From bd054998b23bf23206204fee2417256d784c96d1 Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 14 Aug 2026 02:34:44 +0000 Subject: [PATCH] feat: scope mobile search by type and status (Closes #793) --- README.md | 6 +++ frontend/commands.js | 23 ++++++++--- frontend/dashboard.css | 6 +++ frontend/dashboard.js | 36 ++++++++++++++--- frontend/index.html | 15 +++++++ frontend/task-overlay-history.js | 31 ++++++++++++++- src/frontend_bundle.py | 2 +- src/gitea_proxy.py | 30 +++++++++----- src/main.py | 23 ++++++++--- tests/test_command_palette.py | 52 +++++++++++++++++++++++- tests/test_dashboard_auth.py | 24 ++++++++++++ tests/test_frontend_bundle.py | 2 + tests/test_global_search.py | 63 +++++++++++++++++++++++++++--- tests/test_task_overlay_history.py | 39 +++++++++++++++++- 14 files changed, 315 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 50596b1..79f3df8 100644 --- a/README.md +++ b/README.md @@ -408,6 +408,12 @@ filter, preserves the selected release lane, shows the current draft count, resp the device safe area, and moves out of the way while a full-screen task is open. Desktop layout is unchanged. +Mobile **Search** provides touch-sized **Type** and **Status** controls. Operators can +scope results to issues, pull requests, open work, closed work, or all accessible work; +the server applies that scope before pagination. The selected scope is bounded and +addressable, survives preview/back, reload, sharing, and sign-in continuation, and a +scope change cancels obsolete requests before restarting at the first page. + My Work also has an account-synced **Later** queue. **Later today** defers an item for four hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time** accepts a valid future local date and time and returns the item at that exact instant; the picker diff --git a/frontend/commands.js b/frontend/commands.js index cede8b9..5a693f2 100644 --- a/frontend/commands.js +++ b/frontend/commands.js @@ -22,7 +22,8 @@ let timer = null; let generation = 0; let activeController = null; - let state = { status: 'idle', query: '', items: [], more: false, next: 1 }; + let scope = { kind:'all', state:'all' }; + let state = { status: 'idle', query: '', items: [], more: false, next: 1, scope }; function publish(next) { state = next; @@ -33,7 +34,8 @@ const requestController = new AbortController(); activeController = requestController; try { - const result = await search(query, requestController.signal, page); + const requestScope = { ...scope }; + const result = await search(query, requestController.signal, page, requestScope); const partial = result.partial === true; const incoming = result.items || result; const combined = append ? state.items.concat(incoming) : incoming; @@ -44,12 +46,13 @@ status: 'ready', query, items, partial, more: !!result.has_more, next: result.next_page, + scope:requestScope, }); } catch (error) { if (error && error.name === 'AbortError') return; if (current === generation) publish(append ? { ...state, status: 'ready' } - : { status: 'error', query, items: [], error }); + : { status: 'error', query, items: [], error, scope:{ ...scope } }); } finally { if (activeController === requestController) activeController = null; } @@ -64,12 +67,22 @@ if (activeController !== null) activeController.abort(); activeController = null; if (query.length < 2) { - publish({ status: 'idle', query, items: [], more: false, next: 1 }); + publish({ status: 'idle', query, items: [], more: false, next: 1, scope:{ ...scope } }); return; } - publish({ status: 'loading', query, items: [], more: false, next: 1 }); + publish({ status: 'loading', query, items: [], more: false, next: 1, scope:{ ...scope } }); timer = setTimeout(() => requestPage(query, 1, current, false), delay); }, + setScope(value) { + const next = { + kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all', + state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all', + }; + if (next.kind === scope.kind && next.state === scope.state) return; + const query = state.query; + scope = next; + this.setQuery(query); + }, loadMore() { if (state.status !== 'ready' || !state.more || activeController) return; const current = generation; diff --git a/frontend/dashboard.css b/frontend/dashboard.css index e0505d3..06f806f 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -114,6 +114,10 @@ textarea { resize: vertical; min-height: 120px; } #cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); } #cmd-palette.open { display: block; } .cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; } +.cmd-search-scope { display:grid; grid-template-columns:auto minmax(120px,1fr) auto minmax(120px,1fr); gap:6px 10px; align-items:center; margin:8px 0 0; padding:8px; border:1px solid #1f3a5f; border-radius:8px; } +.cmd-search-scope legend { padding:0 4px; color:#93a4b8; font-size:12px; } +.cmd-search-scope label { font-size:12px; color:#cbd5e1; } +.cmd-search-scope select { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; } #cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; } .cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; } .cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; } @@ -685,6 +689,8 @@ textarea { resize: vertical; min-height: 120px; } .cmd-palette-header { display:flex; flex:0 0 auto; min-height:44px; } #close-command-palette, #cmd-load-more { min-height:44px; } #cmd-input { flex:0 0 auto; min-height:44px; } + .cmd-search-scope { grid-template-columns:auto minmax(0,1fr); } + #cmd-search-kind, #cmd-search-state { min-height:44px; width:100%; } #cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); } .create-issue-panel { width:100%; border-left:0; padding:14px; } .pull-sheet-panel { width:100%; border-left:0; padding:14px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 6a2ef51..b099e8a 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -4285,8 +4285,9 @@ let commandSearchState = { status:'idle', query:'', items:[] }; let commandItems = []; let commandSelection = -1; - async function searchGlobalWork(query, signal, page = 1) { - const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10&page=' + page, { + async function searchGlobalWork(query, signal, page = 1, scope = {kind:'all', state:'all'}) { + const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10&page=' + page + + '&kind=' + encodeURIComponent(scope.kind) + '&state=' + encodeURIComponent(scope.state), { headers: { Accept:'application/json' }, signal, }); @@ -4301,6 +4302,14 @@ renderCommands(state.query); }, }); + function currentSearchScope() { + return { kind:qs('#cmd-search-kind').value, state:qs('#cmd-search-state').value }; + } + function applySearchScope(scope = {kind:'all', state:'all'}) { + qs('#cmd-search-kind').value = scope.kind; + qs('#cmd-search-state').value = scope.state; + commandSearch.setScope(scope); + } function safeSearchUrl(value) { try { const url = new URL(value); @@ -4393,8 +4402,11 @@ onState: renderSearchPreview, }); function canonicalSearchPreviewUrl() { - const { query, preview } = taskOverlayHistory.currentState(); - const params = new URLSearchParams({ search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number }); + const { query, preview, scope = currentSearchScope() } = taskOverlayHistory.currentState(); + const params = new URLSearchParams({ + search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number, + search_kind:scope.kind, search_state:scope.state, + }); return new URL('?' + params, window.location.origin + window.location.pathname).href; } function createSearchStart(claim) { @@ -4458,7 +4470,9 @@ mobileSearchViewport.rememberScroll(); searchPreviewReturnKind = 'search'; searchPreview.open(item.result).catch(() => {}); - taskOverlayHistory.open('search-preview', { query:qs('#cmd-input').value, preview:item.result }); + taskOverlayHistory.open('search-preview', { + query:qs('#cmd-input').value, scope:currentSearchScope(), preview:item.result, + }); } qs('#cmd-palette').classList.remove('open'); qs('#cmd-input').setAttribute('aria-expanded', 'false'); @@ -4491,7 +4505,7 @@ } function openCommandPalette(navigate = true) { if (navigate) { - taskOverlayHistory.open('search'); + taskOverlayHistory.open('search', { scope:currentSearchScope() }); return; } qs('#cmd-palette').classList.add('open'); @@ -4541,6 +4555,7 @@ if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false); if (kind === 'find' && previous !== 'find') openFindWorkSheet(false); if (kind === 'search' && previous !== 'search-preview') { + if (detail?.scope) applySearchScope(detail.scope); if (detail?.query !== undefined) { qs('#cmd-input').value = detail.query; commandSearch.setQuery(detail.query); @@ -4548,6 +4563,7 @@ openCommandPalette(false); } if (kind === 'search-preview' && detail?.preview && previous !== 'search') { + if (detail?.scope) applySearchScope(detail.scope); if (detail?.query !== undefined) qs('#cmd-input').value = detail.query; searchPreviewReturnKind = 'search'; searchPreview.open(detail.preview).catch(() => taskOverlayHistory.close()); @@ -4560,6 +4576,14 @@ qs('#open-palette').addEventListener('click', openCommandPalette); qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close()); qs('#cmd-load-more').addEventListener('click', () => commandSearch.loadMore()); + function changeSearchScope() { + const scope = currentSearchScope(); + commandSelection = -1; + taskOverlayHistory.update({ scope }); + commandSearch.setScope(scope); + } + qs('#cmd-search-kind').addEventListener('change', changeSearchScope); + qs('#cmd-search-state').addEventListener('change', changeSearchScope); qs('#cmd-input').addEventListener('input', (e) => { commandSelection = -1; taskOverlayHistory.update({ query:e.target.value }); diff --git a/frontend/index.html b/frontend/index.html index 5c21e53..e3ccad6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -429,6 +429,21 @@ +
+ Filter search results + + + + +
diff --git a/frontend/task-overlay-history.js b/frontend/task-overlay-history.js index fd28fd4..988ce14 100644 --- a/frontend/task-overlay-history.js +++ b/frontend/task-overlay-history.js @@ -14,6 +14,13 @@ return query.length <= maxQueryLength ? query : ''; } + function cleanScope(value) { + return { + kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all', + state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all', + }; + } + function cleanPreview(value) { if (!value || !['issue', 'pull'].includes(value.kind)) return null; const repository = typeof value.repository === 'string' ? value.repository : ''; @@ -41,11 +48,12 @@ const kind = allowed.has(state?.taskOverlay) ? state.taskOverlay : null; if (!searchKinds.has(kind)) return { kind }; const query = cleanQuery(state.searchQuery); + const scope = state.searchScope === undefined ? null : cleanScope(state.searchScope); const preview = kind === 'search-preview' ? cleanPreview(state.searchPreview) : null; if (kind === 'search-preview' && state.searchPreview !== undefined && !preview) { return { kind:'search', ...(query ? { query } : {}) }; } - return { kind, ...(query ? { query } : {}), ...(preview ? { preview } : {}) }; + return { kind, ...(query ? { query } : {}), ...(scope ? { scope } : {}), ...(preview ? { preview } : {}) }; } function urlDetail() { @@ -56,9 +64,14 @@ const query = cleanQuery(rawQuery); if (rawQuery.trim() && !query) return { kind:null }; const preview = parsePreview(params.get('preview')); + const hasScope = params.has('search_kind') || params.has('search_state'); + const scope = hasScope ? cleanScope({ + kind:params.get('search_kind'), state:params.get('search_state'), + }) : null; return { kind:preview ? 'search-preview' : 'search', ...(query ? { query } : {}), + ...(scope ? { scope } : {}), ...(preview ? { preview } : {}), }; } @@ -67,8 +80,10 @@ const state = { ...(base || {}), taskOverlay:detail.kind }; delete state.searchQuery; delete state.searchPreview; + delete state.searchScope; if (searchKinds.has(detail.kind)) { if (detail.query) state.searchQuery = detail.query; + if (detail.scope) state.searchScope = cleanScope(detail.scope); if (detail.kind === 'search-preview' && detail.preview) state.searchPreview = detail.preview; } return state; @@ -79,8 +94,14 @@ const params = new URLSearchParams(location.search || ''); params.delete('search'); params.delete('preview'); + params.delete('search_kind'); + params.delete('search_state'); if (searchKinds.has(detail.kind)) { params.set('search', detail.query || ''); + if (detail.scope) { + params.set('search_kind', detail.scope.kind); + params.set('search_state', detail.scope.state); + } if (detail.kind === 'search-preview' && detail.preview) params.set('preview', previewToken(detail.preview)); } const query = params.toString(); @@ -115,7 +136,11 @@ }, open(kind, detail = {}) { if (!allowed.has(kind)) return false; - const next = stateDetail(toState({ kind, query:cleanQuery(detail.query), preview:cleanPreview(detail.preview) })); + const next = stateDetail(toState({ + kind, query:cleanQuery(detail.query), + scope:detail.scope === undefined ? undefined : cleanScope(detail.scope), + preview:cleanPreview(detail.preview), + })); if (active.kind === next.kind && JSON.stringify(active) === JSON.stringify(next)) return true; const previous = active.kind; history.pushState(toState(next), '', urlFor(next)); @@ -128,6 +153,7 @@ const next = stateDetail(toState({ kind:active.kind, query:detail.query === undefined ? active.query : cleanQuery(detail.query), + scope:detail.scope === undefined ? active.scope : cleanScope(detail.scope), preview:detail.preview === undefined ? active.preview : cleanPreview(detail.preview), })); history.replaceState(toState(next), '', urlFor(next)); @@ -146,6 +172,7 @@ delete state.taskOverlay; delete state.searchQuery; delete state.searchPreview; + delete state.searchScope; active = { kind:null }; history.replaceState(state, '', urlFor(active)); onChange(null, previous, active); diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 3e660df..77cd107 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/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/commands.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/gitea_proxy.py b/src/gitea_proxy.py index fd96dff..851b308 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -463,14 +463,27 @@ def _normalize_global_search_item(item: Any, kind: str) -> dict | None: } -async def global_search(query: str, limit: int = 10, page: int = 1) -> dict: +async def global_search( + query: str, + limit: int = 10, + page: int = 1, + kind: str = "all", + state: str = "all", +) -> dict: """Search accessible issues and pulls concurrently with balanced pagination.""" - stream_limit = (limit + 1) // 2 + item_types = ("issues", "pulls") if kind == "all" else ( + "issues" if kind == "issue" else "pulls", + ) + stream_limit = (limit + 1) // 2 if kind == "all" else limit async def load(item_type: str) -> Any: + params = { + "q": query, "type": item_type, "state": state, + "limit": stream_limit, "page": page, + } response = await _get_client().get( "/api/v1/repos/issues/search", headers=_auth(), - params={"q": query, "type": item_type, "limit": stream_limit, "page": page}, + params=params, ) response.raise_for_status() payload = response.json() @@ -478,9 +491,7 @@ async def global_search(query: str, limit: int = 10, page: int = 1) -> dict: raise ValueError("Gitea global search response was not a list") return payload - outcomes = await asyncio.gather( - load("issues"), load("pulls"), return_exceptions=True - ) + outcomes = await asyncio.gather(*(load(item_type) for item_type in item_types), return_exceptions=True) for outcome in outcomes: if isinstance(outcome, asyncio.CancelledError): raise outcome @@ -489,15 +500,16 @@ async def global_search(query: str, limit: int = 10, page: int = 1) -> dict: streams: list[list[dict]] = [] seen: set[tuple[str, str, int]] = set() - for outcome, kind in zip(outcomes, ("issue", "pull"), strict=True): + result_kinds = tuple("issue" if item_type == "issues" else "pull" for item_type in item_types) + for outcome, result_kind in zip(outcomes, result_kinds, strict=True): normalized_stream = [] if isinstance(outcome, BaseException): streams.append(normalized_stream) continue for item in outcome: - normalized = _normalize_global_search_item(item, kind) + normalized = _normalize_global_search_item(item, result_kind) if normalized is not None: - identity = (kind, normalized["repository"], normalized["number"]) + identity = (result_kind, normalized["repository"], normalized["number"]) if identity in seen: continue seen.add(identity) diff --git a/src/main.py b/src/main.py index 8e2787b..7c93e37 100644 --- a/src/main.py +++ b/src/main.py @@ -1006,11 +1006,15 @@ 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"} + allowed = {"search", "preview", "search_kind", "search_state"} + kind_values = request.query_params.getlist("search_kind") + state_values = request.query_params.getlist("search_state") if ( set(request.query_params.keys()) - allowed or len(search_values) != 1 or len(preview_values) != 1 + or len(kind_values) > 1 + or len(state_values) > 1 or len(search_values[0]) > 200 or len(preview_values[0]) > 200 or not re.fullmatch( @@ -1019,9 +1023,14 @@ def _share_target_login_redirect(request: Request) -> str: ) ): return "login" - continuation = urlencode( - (("search", search_values[0].strip()), ("preview", preview_values[0])) - ) + continuation_values = [ + ("search", search_values[0].strip()), ("preview", preview_values[0]), + ] + if kind_values and kind_values[0] in {"all", "issue", "pull"}: + continuation_values.append(("search_kind", kind_values[0])) + if state_values and state_values[0] in {"all", "open", "closed"}: + continuation_values.append(("search_state", state_values[0])) + continuation = urlencode(continuation_values) return f"login?{urlencode({'continue': f'./?{continuation}'})}" limits = {"title": 200, "text": 8000, "url": 2048} if any( @@ -2712,13 +2721,15 @@ async def global_search( q: str = Query(min_length=2, max_length=100), limit: int = Query(default=10, ge=1, le=25), page: int = Query(default=1, ge=1, le=100), + kind: Literal["all", "issue", "pull"] = Query(default="all"), + state: Literal["all", "open", "closed"] = Query(default="all"), ) -> JSONResponse: query = q.strip() if len(query) < 2: raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters") try: result = await asyncio.wait_for( - gitea_proxy.global_search(query, limit, page), + gitea_proxy.global_search(query, limit, page, kind, state), timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS, ) except Exception: @@ -2727,7 +2738,7 @@ async def global_search( status_code=503, headers={"Retry-After": "1"}, ) - return JSONResponse({"query": query, **result}) + return JSONResponse({"query": query, "scope": {"kind": kind, "state": state}, **result}) @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview") diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py index 3570899..b1b944b 100644 --- a/tests/test_command_palette.py +++ b/tests/test_command_palette.py @@ -52,6 +52,25 @@ def test_command_script_resolves_inside_dashboard_subpath(): ) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js" +def test_mobile_search_renders_touch_sized_type_and_status_scope_controls(): + html = dashboard_bundle_text() + css = (FRONTEND / "dashboard.css").read_text() + + assert '
Filter search results' in html + assert '' in html + assert ' {{ +const pending = []; +const states = []; +const controller = filterCommands.createGlobalSearchController({{ + delay: 0, + search: (query, signal, page, scope) => new Promise(resolve => pending.push({{query, signal, page, scope, resolve}})), + onState: state => states.push(state), +}}); +controller.setQuery('mobile'); +await new Promise(resolve => setTimeout(resolve, 0)); +controller.setScope({{kind:'pull', state:'open'}}); +await new Promise(resolve => setTimeout(resolve, 0)); +if (!pending[0].signal.aborted) throw new Error('scope change did not abort old request'); +if (JSON.stringify(pending[1].scope) !== JSON.stringify({{kind:'pull', state:'open'}})) {{ + throw new Error('new request did not receive scope'); +}} +pending[1].resolve({{items:[{{kind:'pull',repository:'a/b',number:2}}],has_more:false,next_page:2}}); +pending[0].resolve({{items:[{{kind:'issue',repository:'a/b',number:1}}],has_more:false,next_page:2}}); +await new Promise(resolve => setTimeout(resolve, 0)); +const ready = states.filter(state => state.status === 'ready').at(-1); +if (ready.items.length !== 1 || ready.items[0].kind !== 'pull') throw new Error('stale scope result entered list'); +if (ready.scope.kind !== 'pull' || ready.scope.state !== 'open') throw new Error('scope missing from state'); +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + def test_remote_command_search_aborts_superseded_and_cleared_queries(): script = f""" const filterCommands = require({json.dumps(str(COMMANDS))}); @@ -193,7 +243,7 @@ def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath() assert 'aria-controls="cmd-results"' in html assert 'role="listbox"' in html assert "fetch('api/v1/search?q='" in html - assert "async function searchGlobalWork(query, signal, page = 1)" in html + assert "async function searchGlobalWork(query, signal, page = 1, scope" in html assert "signal," in html assert "Some results are temporarily unavailable." in html assert 'id="cmd-load-more"' in html diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index 4d3a233..a28ed1b 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -918,6 +918,30 @@ async def test_anonymous_search_preview_preserves_only_bounded_canonical_continu assert oversized.headers["location"] == "login" +@pytest.mark.anyio +async def test_anonymous_search_preview_preserves_only_valid_search_scope(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": "pull:stackchain/api:42", + "search_kind": "pull", "search_state": "open", + }) + invalid = await client.get("/", params={ + "search": "release blocker", "preview": "pull:stackchain/api:42", + "search_kind": "script", "search_state": "secret", + }) + + valid_continue = parse_qs(urlsplit(valid.headers["location"]).query)["continue"][0] + invalid_continue = parse_qs(urlsplit(invalid.headers["location"]).query)["continue"][0] + assert valid_continue.endswith( + "search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42&" + "search_kind=pull&search_state=open" + ) + assert invalid_continue == ( + "./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42" + ) + + @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_frontend_bundle.py b/tests/test_frontend_bundle.py index 8c66c1f..b159917 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -66,6 +66,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): security_center = first.feature_bundles["security-center"] assert b"function attachSecurityCenter" not in first.runtime_bytes assert b"function attachSecurityCenter" in security_center.runtime_bytes + assert b"function createGlobalSearchController" not in first.runtime_bytes + assert b"function createGlobalSearchController" in first.feature_bundles["today-timer"].runtime_bytes assert b"gitea_time_logged" not in first.runtime_bytes assert b"gitea_time_logged" in security_center.runtime_bytes # Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions. diff --git a/tests/test_global_search.py b/tests/test_global_search.py index 7428232..b966f1e 100644 --- a/tests/test_global_search.py +++ b/tests/test_global_search.py @@ -6,12 +6,60 @@ import pytest from src import gitea_proxy, main +@pytest.mark.anyio +async def test_global_search_endpoint_forwards_valid_type_and_status_scope(monkeypatch): + requested = [] + + async def search(query, limit, page, kind, state): + requested.append((query, limit, page, kind, state)) + return {"items": [], "partial": False, "has_more": False, "next_page": 2} + + monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/api/v1/search?q=mobile&limit=7&page=1&kind=pull&state=open" + ) + + assert response.status_code == 200 + assert requested == [("mobile", 7, 1, "pull", "open")] + assert response.json()["scope"] == {"kind": "pull", "state": "open"} + + +@pytest.mark.anyio +async def test_global_search_specific_type_uses_one_full_width_scoped_stream(): + requests = [] + + async def handler(request): + requests.append(dict(request.url.params)) + return httpx.Response(200, json=[{ + "number": 9, + "title": "Review mobile search", + "state": "open", + "repository": {"full_name": "stackchain/web"}, + "html_url": "https://forge.example/stackchain/web/pulls/9", + }]) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.global_search( + "mobile", limit=7, page=2, kind="pull", state="open" + ) + finally: + await gitea_proxy.stop_client() + + assert requests == [{ + "q": "mobile", "type": "pulls", "state": "open", "limit": "7", "page": "2" + }] + assert [item["kind"] for item in result["items"]] == ["pull"] + + @pytest.mark.anyio async def test_global_search_endpoint_returns_bounded_normalized_results(monkeypatch): requested = [] - async def search(query, limit, page): - requested.append((query, limit, page)) + async def search(query, limit, page, kind, state): + requested.append((query, limit, page, kind, state)) return { "items": [{ "kind": "issue", @@ -31,8 +79,8 @@ async def test_global_search_endpoint_returns_bounded_normalized_results(monkeyp assert response.status_code == 200 assert response.headers["cache-control"] == "no-store" - assert requested == [("mobile", 7, 2)] - assert response.json() == {"query": "mobile", "items": [{ + assert requested == [("mobile", 7, 2, "all", "all")] + assert response.json() == {"query": "mobile", "scope": {"kind": "all", "state": "all"}, "items": [{ "kind": "issue", "repository": "stackchain/api", "number": 42, @@ -110,7 +158,12 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results() await gitea_proxy.stop_client() assert {request["type"] for request in requests} == {"issues", "pulls"} - assert all(request["q"] == "mobile queue" and request["limit"] == "4" for request in requests) + assert all( + request["q"] == "mobile queue" + and request["limit"] == "4" + and request["state"] == "all" + for request in requests + ) assert results == { "items": [{ "kind": "issue", "repository": "stackchain/api", "number": 42, diff --git a/tests/test_task_overlay_history.py b/tests/test_task_overlay_history.py index 2676048..1271733 100644 --- a/tests/test_task_overlay_history.py +++ b/tests/test_task_overlay_history.py @@ -207,7 +207,7 @@ def test_dashboard_routes_mobile_task_overlays_through_browser_history(): assert "createTaskOverlayHistory({" in html assert "taskOverlayHistory.open('new')" in html assert "taskOverlayHistory.open('find')" in html - assert "taskOverlayHistory.open('search')" in html + assert "taskOverlayHistory.open('search', { scope:currentSearchScope() })" in html assert "taskOverlayHistory.open('search-preview')" in html assert "taskOverlayHistory.close()" in html assert "saveIssueCaptureDraft();" in html @@ -290,10 +290,45 @@ process.stdout.write(JSON.stringify({{ assert payload["oversized"]["state"] == {"kind": None} +def test_search_history_round_trips_only_bounded_type_and_status_scope(): + script = f""" +const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))}); +function restore(search) {{ + const location = {{pathname:'/dashboard/', search, hash:''}}; + const history = {{state:null, replaceState(state, title, url) {{this.state=state; this.url=url;}}, back() {{}}}}; + const controller = createTaskOverlayHistory({{ + history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}}, + }}); + return {{state:controller.currentState(), url:history.url}}; +}} +const location = {{pathname:'/dashboard/', search:'', hash:''}}; +const history = {{state:null, pushState(state,title,url) {{this.state=state; this.url=url;}}, replaceState() {{}}, back() {{}}}}; +const controller = createTaskOverlayHistory({{ + history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}}, +}}); +controller.open('search', {{query:'mobile', scope:{{kind:'pull',state:'open'}}}}); +process.stdout.write(JSON.stringify({{ + opened:controller.currentState(), url:history.url, + valid:restore('?search=mobile&search_kind=issue&search_state=closed'), + invalid:restore('?search=mobile&search_kind=script&search_state=secret'), +}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["opened"] == { + "kind": "search", "query": "mobile", "scope": {"kind": "pull", "state": "open"} + } + assert payload["url"] == "/dashboard/?search=mobile&search_kind=pull&search_state=open" + assert payload["valid"]["state"]["scope"] == {"kind": "issue", "state": "closed"} + assert payload["invalid"]["state"]["scope"] == {"kind": "all", "state": "all"} + + def test_dashboard_persists_search_query_and_restores_canonical_preview(): html = dashboard_bundle_text() assert "taskOverlayHistory.update({ query:e.target.value })" in html - assert "taskOverlayHistory.open('search-preview', { query:qs('#cmd-input').value, preview:item.result })" in html + assert "scope:currentSearchScope(), preview:item.result" in html assert "detail?.query" in html assert "searchPreview.open(detail.preview).catch" in html