From 43163ed9268d00352413513f86d67f4b61623d72 Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 14 Aug 2026 01:04:20 +0000 Subject: [PATCH] feat: share resumable mobile search previews (Closes #787) --- frontend/dashboard.css | 1 + frontend/dashboard.js | 30 +++++++++++--- frontend/index.html | 1 + frontend/login.js | 17 +++++++- frontend/search-preview.js | 18 +++++++- src/frontend_bundle.py | 2 +- src/main.py | 21 ++++++++++ tests/test_command_palette.py | 78 +++++++++++++++++++++++++++++++++++ tests/test_dashboard_auth.py | 27 ++++++++++++ tests/test_login_frontend.py | 47 +++++++++++++++++++++ tests/test_views.py | 2 + 11 files changed, 234 insertions(+), 10 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index d50db64..e0505d3 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -541,6 +541,7 @@ textarea { resize: vertical; min-height: 120px; } .search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; } .search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; } .search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; } +.search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; } .search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; } .markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index d1450cb..b44c651 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -4319,6 +4319,7 @@ const status = qs('#search-preview-status'); const claimButton = qs('#claim-search-result'); const startButton = qs('#start-search-result'); + const shareButton = qs('#share-search-result'); qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness' ? 'Back to blockers' : 'Back to search'; @@ -4331,6 +4332,7 @@ claimButton.disabled = false; startButton.hidden = true; startButton.disabled = false; + shareButton.disabled = true; if (state.status === 'loading') { searchPreviewDetail = null; @@ -4349,6 +4351,7 @@ const detail = state.detail; if (!detail) return; searchPreviewDetail = detail; + shareButton.disabled = state.status === 'sharing'; qs('#search-preview-key').textContent = detail.repository + ' #' + detail.number; qs('#search-preview-title').textContent = detail.title || 'Untitled work item'; qs('#search-preview-meta').textContent = @@ -4366,12 +4369,18 @@ startButton.textContent = detail.reopenable ? 'Reopen & resume' : (detail.assigned_to_me ? 'Start in Today' : 'Assign & start'); startButton.disabled = state.status === 'claiming' || state.status === 'reopening'; - status.textContent = state.status === 'reopening' ? 'Reopening…' : - (state.status === 'claiming' ? 'Assigning this issue to you…' : - (state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' : - (detail.claimable ? 'This issue is open and unassigned.' : - (detail.assigned_to_me ? 'This item is already in My Work.' : - (detail.reopenable ? 'Closed—reopen to resume.' : 'Read-only preview.'))))); + const shareStatus = { + sharing:'Opening share options…', shared:'Search result shared.', copied:'Search result link copied.', + 'share-canceled':'Share canceled.', 'share-error':'Could not share this result. Try again.', + }; + if (shareStatus[state.status]) status.textContent = shareStatus[state.status]; + else if (state.status === 'reopening') status.textContent = 'Reopening…'; + else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…'; + else if (state.status === 'claimed') status.textContent = 'Assignment confirmed. Opening My Work…'; + else if (detail.claimable) status.textContent = 'This issue is open and unassigned.'; + else if (detail.assigned_to_me) status.textContent = 'This item is already in My Work.'; + else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.'; + else status.textContent = 'Read-only preview.'; } const searchPreview = createSearchPreview({ fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }), @@ -4380,8 +4389,14 @@ '/issues/' + encodeURIComponent(detail.number) + '/' + action, { method:'PATCH', headers:{ Accept:'application/json' } } ), + share: url => createWorkRoute.share(url, navigator, navigator.clipboard), onState: renderSearchPreview, }); + function canonicalSearchPreviewUrl() { + const { query, preview } = taskOverlayHistory.currentState(); + const params = new URLSearchParams({ search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number }); + return new URL('?' + params, window.location.origin + window.location.pathname).href; + } function createSearchStart(claim) { return createAssignAndStart({ available: createAndStart.available, @@ -4613,6 +4628,9 @@ } }); qs('#close-search-preview').addEventListener('click', closeSearchPreview); + qs('#share-search-result').addEventListener('click', () => { + searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {}); + }); document.querySelectorAll('.share-work-route').forEach(button => { button.addEventListener('click', async () => { const status = qs('#work-route-share-status'); diff --git a/frontend/index.html b/frontend/index.html index 1929542..5c21e53 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -451,6 +451,7 @@ + Open in Gitea diff --git a/frontend/login.js b/frontend/login.js index 1517d20..9dd4b1c 100644 --- a/frontend/login.js +++ b/frontend/login.js @@ -4,7 +4,7 @@ }(typeof self !== 'undefined' ? self : this, function createLoginController(options) { function validShareContinuation(value) { if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './'; - const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5 }; + const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5, search: 200, preview: 200 }; const params = new URLSearchParams(value.slice(3)); const entries = Array.from(params.entries()); if (!entries.length) return './'; @@ -15,6 +15,14 @@ )); if (invalid) return './'; if (Object.keys(limits).some(name => params.getAll(name).length > 1)) return './'; + const search = params.get('search'); + const preview = params.get('preview'); + if (search !== null || preview !== null) { + if (entries.some(([name]) => !['search', 'preview'].includes(name))) return './'; + if (search === null || preview === null) return './'; + if (!/^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9]\d*$/.test(preview)) return './'; + return value; + } const launch = params.get('launch'); const shared = params.get('shared'); if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './'; @@ -87,7 +95,12 @@ }; } - if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.'; + if (continuation !== './') { + const continuationParams = new URLSearchParams(continuation.slice(3)); + status.textContent = continuationParams.has('preview') + ? 'Sign in to open the shared Search result.' + : 'Sign in to continue your shared capture.'; + } async function showReason(reason) { if (reason === 'session-expired') { diff --git a/frontend/search-preview.js b/frontend/search-preview.js index b362dab..7b662ed 100644 --- a/frontend/search-preview.js +++ b/frontend/search-preview.js @@ -3,10 +3,11 @@ if (typeof module === 'object' && module.exports) module.exports = createSearchPreview; if (root) root.createSearchPreview = createSearchPreview; })(typeof globalThis !== 'undefined' ? globalThis : this, function () { - return function createSearchPreview({ fetchJson, mutate, onState }) { + return function createSearchPreview({ fetchJson, mutate, share, onState }) { let generation = 0; let current = null; let mutationRequest = null; + let shareRequest = null; function run(action, pending, success, detail) { if (mutationRequest) return mutationRequest; @@ -29,6 +30,7 @@ onState({ status: 'loading', item: current }); return fetchJson(current).then(detail => { if (requestGeneration === generation) { + current = { ...current, ...detail }; onState({ status: 'ready', item: current, detail }); } return detail; @@ -44,6 +46,20 @@ current = null; onState({ status: 'closed' }); }, + share(url) { + if (shareRequest) return shareRequest; + if (!current || typeof share !== 'function') return Promise.reject(new Error('Sharing is unavailable.')); + const detail = current; + onState({ status:'sharing', item:current, detail }); + shareRequest = share(url).then(result => { + onState({ status:result, item:current, detail }); + return result; + }).catch(error => { + onState({ status:error?.name === 'AbortError' ? 'share-canceled' : 'share-error', item:current, detail, error }); + throw error; + }).finally(() => { shareRequest = null; }); + return shareRequest; + }, claim(detail) { return run('claim', 'claiming', 'claimed', detail); }, diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 1898242..3e660df 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -28,7 +28,7 @@ FEATURE_SOURCES = { "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), "security-center": ("static/security-center.js",), "today-timer": ( - "static/task-overlay-history.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", + "static/task-overlay-history.js", "static/search-preview.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", "static/today-work.js", "static/pick-work.js", "static/batch-find-work.js", diff --git a/src/main.py b/src/main.py index 057a9a0..8e2787b 100644 --- a/src/main.py +++ b/src/main.py @@ -5,6 +5,7 @@ import hashlib import hmac import math import os +import re import secrets import sqlite3 import time @@ -1002,6 +1003,26 @@ app.include_router(frontend_router) def _share_target_login_redirect(request: Request) -> str: + search_values = request.query_params.getlist("search") + preview_values = request.query_params.getlist("preview") + if search_values or preview_values: + allowed = {"search", "preview"} + if ( + set(request.query_params.keys()) - allowed + or len(search_values) != 1 + or len(preview_values) != 1 + or len(search_values[0]) > 200 + or len(preview_values[0]) > 200 + or not re.fullmatch( + r"(?:issue|pull):[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+:[1-9]\d*", + preview_values[0], + ) + ): + return "login" + continuation = urlencode( + (("search", search_values[0].strip()), ("preview", preview_values[0])) + ) + return f"login?{urlencode({'continue': f'./?{continuation}'})}" limits = {"title": 200, "text": 8000, "url": 2048} if any( len(request.query_params.get(name, "")) > limit diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py index e2e88a6..a884cc4 100644 --- a/tests/test_command_palette.py +++ b/tests/test_command_palette.py @@ -282,6 +282,71 @@ if (!states.some(state => state.status === 'reopened')) throw new Error('reopen subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) +def test_search_preview_shares_canonical_url_without_closing_the_preview(): + script = f""" +const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))}); +(async () => {{ +const calls = []; +const states = []; +const preview = createSearchPreview({{ + fetchJson: item => Promise.resolve(item), + mutate: () => Promise.resolve(), + share: async url => {{ calls.push(url); return 'shared'; }}, + onState: state => states.push(state), +}}); +const detail = {{ repository:'stackchain/api', number:42, kind:'issue' }}; +await preview.open(detail); +const result = await preview.share('https://forge.example/dashboard/?search=release&preview=issue%3Astackchain%2Fapi%3A42'); +process.stdout.write(JSON.stringify({{ result, calls, states }})); +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["result"] == "shared" + assert payload["calls"] == [ + "https://forge.example/dashboard/?search=release&preview=issue%3Astackchain%2Fapi%3A42" + ] + assert payload["states"][-1]["status"] == "shared" + assert payload["states"][-1]["detail"]["number"] == 42 + + +def test_search_preview_share_is_single_flight_and_cancellation_restores_ready_context(): + script = f""" +const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))}); +(async () => {{ +let calls = 0; +let rejectShare; +const states = []; +const preview = createSearchPreview({{ + fetchJson: item => Promise.resolve(item), mutate: () => Promise.resolve(), + share: () => {{ calls += 1; return new Promise((_resolve, reject) => rejectShare = reject); }}, + onState: state => states.push(state), +}}); +await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }}); +const first = preview.share('https://forge.example/dashboard/?search=x'); +const second = preview.share('https://forge.example/dashboard/?search=x'); +const canceled = new Error('canceled'); canceled.name = 'AbortError'; rejectShare(canceled); +await Promise.allSettled([first, second]); +const state = states.at(-1); +process.stdout.write(JSON.stringify({{ calls, same:first === second, status:state.status, + item:state.item, detail:state.detail, errorName:state.error?.name }})); +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "calls": 1, + "same": True, + "status": "share-canceled", + "item": {"repository": "stackchain/api", "number": 42, "kind": "issue"}, + "detail": {"repository": "stackchain/api", "number": 42, "kind": "issue"}, + "errorName": "AbortError", + } + + def test_remote_search_selection_opens_native_preview_without_navigation(): html = dashboard_bundle_text() @@ -294,6 +359,19 @@ def test_remote_search_selection_opens_native_preview_without_navigation(): assert "qs('#cmd-input').value = ''" not in remote_branch +def test_search_preview_share_action_uses_canonical_stackchain_url_and_keeps_context(): + html = dashboard_bundle_text() + + assert "share: url => createWorkRoute.share(url, navigator, navigator.clipboard)" in html + handler = html.split("qs('#share-search-result').addEventListener('click'", 1)[1].split( + "qs('#claim-search-result')", 1 + )[0] + assert "canonicalSearchPreviewUrl()" in handler + assert "searchPreview.share" in handler + assert "closeSearchPreview" not in handler + assert "taskOverlayHistory.close" not in handler + + def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet(): html = dashboard_bundle_text() diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index 20a24c1..4d3a233 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -891,6 +891,33 @@ async def test_anonymous_app_shortcut_preserves_only_a_known_launch_action(acces assert invalid.headers["location"] == "login" +@pytest.mark.anyio +async def test_anonymous_search_preview_preserves_only_bounded_canonical_continuation(access_control): + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + valid = await client.get( + "/", params={"search": "release blocker", "preview": "issue:stackchain/api:42"} + ) + malformed = await client.get( + "/", params={"search": "release blocker", "preview": "issue:stackchain/api:0"} + ) + duplicate = await client.get( + "/?search=release&search=other&preview=issue%3Astackchain%2Fapi%3A42" + ) + oversized = await client.get( + "/", params={"search": "release", "preview": f"issue:{'x' * 201}/api:42"} + ) + + assert parse_qs(urlsplit(valid.headers["location"]).query) == { + "continue": [ + "./?search=release+blocker&preview=issue%3Astackchain%2Fapi%3A42" + ] + } + assert malformed.headers["location"] == "login" + assert duplicate.headers["location"] == "login" + assert oversized.headers["location"] == "login" + + @pytest.mark.anyio async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control): transport = httpx.ASGITransport(app=main.app) diff --git a/tests/test_login_frontend.py b/tests/test_login_frontend.py index 0d902db..d96a877 100644 --- a/tests/test_login_frontend.py +++ b/tests/test_login_frontend.py @@ -243,6 +243,53 @@ const controller = createLoginController({{ ) +def test_successful_login_resumes_only_valid_search_preview_continuation(): + harness = f""" +const createLoginController = require({json.dumps(str(LOGIN_JS))}); +async function destination(continuation) {{ + let replaced = null; + const controller = createLoginController({{ + form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }}, + fetchImpl: async () => new Response('{{}}', {{ status: 200 }}), + location: {{ replace: value => replaced = value }}, continuation, + }}); + await controller.submit('operator-token'); + return replaced; +}} +(async () => process.stdout.write(JSON.stringify({{ + valid:await destination('./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9'), + malformed:await destination('./?search=release&preview=issue%3Astackchain%2Fapi%3A0'), + unknown:await destination('./?search=release&next=https%3A%2F%2Fevil.example'), +}})))().catch(error => {{ console.error(error); process.exit(1); }}); +""" + result = subprocess.run(["node", "-e", harness], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "valid": "./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9", + "malformed": "./", + "unknown": "./", + } + + +def test_search_preview_login_explains_the_resumable_handoff(): + harness = f""" +const createLoginController = require({json.dumps(str(LOGIN_JS))}); +const status = {{ textContent: '' }}; +createLoginController({{ + form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }}, + fetchImpl: async () => new Response('{{}}', {{ status: 200 }}), + location: {{ replace: () => {{}} }}, + continuation: './?search=release&preview=issue%3Astackchain%2Fapi%3A42', +}}); +process.stdout.write(status.textContent); +""" + result = subprocess.run(["node", "-e", harness], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert result.stdout == "Sign in to open the shared Search result." + + def test_passkey_login_uses_web_authentication_without_sending_the_operator_token(): harness = f""" const createLoginController = require({json.dumps(str(LOGIN_JS))}); diff --git a/tests/test_views.py b/tests/test_views.py index d6a9277..03f399f 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -41,8 +41,10 @@ async def test_global_search_preview_is_a_phone_safe_accessible_dialog(): assert 'id="search-preview" role="dialog" aria-modal="true"' in html assert 'aria-labelledby="search-preview-title"' in html assert 'id="claim-search-result"' in html + assert 'id="share-search-result"' in html assert 'id="open-search-result-gitea"' in html assert "static/search-preview.js" in FRONTEND_BUILD.page_sources assert ".search-preview-panel" in css assert "height:100dvh" in css assert "env(safe-area-inset-bottom)" in css + assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css -- 2.43.0