diff --git a/frontend/commands.js b/frontend/commands.js index c815df9..8b8bef3 100644 --- a/frontend/commands.js +++ b/frontend/commands.js @@ -21,6 +21,7 @@ const delay = options.delay === undefined ? 250 : options.delay; let timer = null; let generation = 0; + let activeController = null; return { setQuery(value) { @@ -28,17 +29,26 @@ generation += 1; const current = generation; if (timer !== null) clearTimeout(timer); + if (activeController !== null) activeController.abort(); + activeController = null; if (query.length < 2) { onState({ status: 'idle', query, items: [] }); return; } onState({ status: 'loading', query, items: [] }); timer = setTimeout(async () => { + const requestController = new AbortController(); + activeController = requestController; try { - const items = await search(query); - if (current === generation) onState({ status: 'ready', query, items }); + const result = await search(query, requestController.signal); + const items = Array.isArray(result) ? result : result.items; + const partial = !Array.isArray(result) && result.partial === true; + if (current === generation) onState({ status: 'ready', query, items, partial }); } catch (error) { + if (error && error.name === 'AbortError') return; if (current === generation) onState({ status: 'error', query, items: [], error }); + } finally { + if (activeController === requestController) activeController = null; } }, delay); }, diff --git a/frontend/index.html b/frontend/index.html index c2871f4..9d57284 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1794,13 +1794,17 @@ textarea { resize: vertical; min-height: 120px; } let commandSearchState = { status:'idle', query:'', items:[] }; let commandItems = []; let commandSelection = -1; - async function searchGlobalWork(query) { + async function searchGlobalWork(query, signal) { const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10', { headers: { Accept:'application/json' }, + signal, }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error || 'Search is temporarily unavailable.'); - return Array.isArray(payload.items) ? payload.items : []; + return { + items: Array.isArray(payload.items) ? payload.items : [], + partial: payload.partial === true, + }; } const commandSearch = filterCommands.createGlobalSearchController({ search: searchGlobalWork, @@ -1920,6 +1924,7 @@ textarea { resize: vertical; min-height: 120px; } }).join(''); if (commandSearchState.status === 'loading') html += '
Searching accessible work…
'; else if (commandSearchState.status === 'error') html += '
Search unavailable. Keep typing or retry.
'; + else if (commandSearchState.partial) html += '
Some results are temporarily unavailable.
'; else if (String(filter || '').trim().length >= 2 && !remote.length) html += '
No matching issues or pull requests.
'; el.innerHTML = html; el.querySelectorAll('.cmd-item').forEach((item) => { diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index c9b50c3..b59fe27 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -208,7 +208,7 @@ def _normalize_global_search_item(item: Any, kind: str) -> dict | None: } -async def global_search(query: str, limit: int = 10) -> list[dict]: +async def global_search(query: str, limit: int = 10) -> dict: """Search accessible issues and pulls concurrently with a bounded result set.""" async def load(item_type: str) -> Any: response = await _get_client().get( @@ -222,11 +222,21 @@ async def global_search(query: str, limit: int = 10) -> list[dict]: raise ValueError("Gitea global search response was not a list") return payload - issues_payload, pulls_payload = await asyncio.gather(load("issues"), load("pulls")) + outcomes = await asyncio.gather( + load("issues"), load("pulls"), return_exceptions=True + ) + for outcome in outcomes: + if isinstance(outcome, asyncio.CancelledError): + raise outcome + if all(isinstance(outcome, BaseException) for outcome in outcomes): + raise outcomes[0] + results = [] seen: set[tuple[str, str, int]] = set() - for payload, kind in ((issues_payload, "issue"), (pulls_payload, "pull")): - for item in payload: + for outcome, kind in zip(outcomes, ("issue", "pull"), strict=True): + if isinstance(outcome, BaseException): + continue + for item in outcome: normalized = _normalize_global_search_item(item, kind) if normalized is not None: identity = (kind, normalized["repository"], normalized["number"]) @@ -234,7 +244,10 @@ async def global_search(query: str, limit: int = 10) -> list[dict]: continue seen.add(identity) results.append(normalized) - return results + return { + "items": results[:limit], + "partial": any(isinstance(outcome, BaseException) for outcome in outcomes), + } async def work_preview(repository: str, kind: str, number: int) -> dict: diff --git a/src/main.py b/src/main.py index 143c5de..2eaf9d9 100644 --- a/src/main.py +++ b/src/main.py @@ -481,7 +481,7 @@ async def global_search( if len(query) < 2: raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters") try: - items = await asyncio.wait_for( + result = await asyncio.wait_for( gitea_proxy.global_search(query, limit), timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS, ) @@ -491,7 +491,7 @@ async def global_search( status_code=503, headers={"Retry-After": "1"}, ) - return JSONResponse({"query": query, "items": items}) + return JSONResponse({"query": query, **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 be40084..9b6c64e 100644 --- a/tests/test_command_palette.py +++ b/tests/test_command_palette.py @@ -82,6 +82,63 @@ if (ready[0].query !== 'release' || ready[0].items[0].title !== 'Current result' ) +def test_remote_command_search_aborts_superseded_and_cleared_queries(): + script = f""" +const filterCommands = require({json.dumps(str(COMMANDS))}); +(async () => {{ +const signals = []; +const states = []; +const controller = filterCommands.createGlobalSearchController({{ + delay: 0, + search: (query, signal) => {{ + signals.push(signal); + return new Promise((resolve, reject) => signal.addEventListener('abort', () => {{ + const error = new Error('aborted'); + error.name = 'AbortError'; + reject(error); + }})); + }}, + onState: state => states.push(state), +}}); +controller.setQuery('mobile'); +await new Promise(resolve => setTimeout(resolve, 0)); +controller.setQuery('release'); +if (!signals[0].aborted) throw new Error('superseded request was not aborted'); +await new Promise(resolve => setTimeout(resolve, 0)); +controller.setQuery(''); +if (!signals[1].aborted) throw new Error('cleared query request was not aborted'); +await new Promise(resolve => setTimeout(resolve, 0)); +if (states.some(state => state.status === 'error')) throw new Error('abort rendered an error'); +if (states.at(-1).status !== 'idle') throw new Error('clear did not restore idle 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_preserves_partial_result_status(): + script = f""" +const filterCommands = require({json.dumps(str(COMMANDS))}); +(async () => {{ +const states = []; +const controller = filterCommands.createGlobalSearchController({{ + delay: 0, + search: () => Promise.resolve({{ items:[{{ title:'Useful result' }}], partial:true }}), + onState: state => states.push(state), +}}); +controller.setQuery('mobile'); +await new Promise(resolve => setTimeout(resolve, 0)); +await new Promise(resolve => setTimeout(resolve, 0)); +const ready = states.find(state => state.status === 'ready'); +if (!ready || ready.items.length !== 1 || ready.partial !== true) {{ + throw new Error('partial result metadata was lost: ' + JSON.stringify(states)); +}} +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + def test_command_selection_wraps_for_arrow_keys(): script = f""" const commands = require({json.dumps(str(COMMANDS))}); @@ -101,6 +158,9 @@ 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)" in html + assert "signal," in html + assert "Some results are temporarily unavailable." in html assert urljoin( "https://forge.alexanderwhitestone.com/dashboard/", "api/v1/search?q=mobile", diff --git a/tests/test_global_search.py b/tests/test_global_search.py index e467615..a0a5c71 100644 --- a/tests/test_global_search.py +++ b/tests/test_global_search.py @@ -1,3 +1,5 @@ +import asyncio + import httpx import pytest @@ -10,14 +12,17 @@ async def test_global_search_endpoint_returns_bounded_normalized_results(monkeyp async def search(query, limit): requested.append((query, limit)) - return [{ - "kind": "issue", - "repository": "stackchain/api", - "number": 42, - "title": "Repair mobile queue", - "state": "open", - "url": "https://forge.example/stackchain/api/issues/42", - }] + return { + "items": [{ + "kind": "issue", + "repository": "stackchain/api", + "number": 42, + "title": "Repair mobile queue", + "state": "open", + "url": "https://forge.example/stackchain/api/issues/42", + }], + "partial": False, + } monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False) transport = httpx.ASGITransport(app=main.app) @@ -34,7 +39,7 @@ async def test_global_search_endpoint_returns_bounded_normalized_results(monkeyp "title": "Repair mobile queue", "state": "open", "url": "https://forge.example/stackchain/api/issues/42", - }]} + }], "partial": False} @pytest.mark.anyio @@ -106,15 +111,88 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results() assert {request["type"] for request in requests} == {"issues", "pulls"} assert all(request["q"] == "mobile queue" and request["limit"] == "7" for request in requests) - assert results == [{ - "kind": "issue", "repository": "stackchain/api", "number": 42, - "title": "Repair queue", "state": "open", - "url": "https://forge.example/stackchain/api/issues/42", - }, { - "kind": "pull", "repository": "stackchain/web", "number": 9, - "title": "Improve search", "state": "closed", - "url": "https://forge.example/stackchain/web/pulls/9", - }] + assert results == { + "items": [{ + "kind": "issue", "repository": "stackchain/api", "number": 42, + "title": "Repair queue", "state": "open", + "url": "https://forge.example/stackchain/api/issues/42", + }, { + "kind": "pull", "repository": "stackchain/web", "number": 9, + "title": "Improve search", "state": "closed", + "url": "https://forge.example/stackchain/web/pulls/9", + }], + "partial": False, + } + + +@pytest.mark.anyio +async def test_global_search_returns_healthy_stream_when_other_stream_fails(): + async def handler(request): + if request.url.params["type"] == "issues": + return httpx.Response(503, json={"message": "upstream details must stay private"}) + return httpx.Response(200, json=[{ + "number": 9, + "title": "Improve 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("search", 10) + finally: + await gitea_proxy.stop_client() + + assert result == { + "items": [{ + "kind": "pull", + "repository": "stackchain/web", + "number": 9, + "title": "Improve search", + "state": "open", + "url": "https://forge.example/stackchain/web/pulls/9", + }], + "partial": True, + } + + +@pytest.mark.anyio +async def test_global_search_caps_combined_results_to_requested_limit(): + async def handler(request): + kind = request.url.params["type"] + items = [{ + "number": number, + "title": f"{kind} {number}", + "state": "open", + "repository": {"full_name": "stackchain/web"}, + "html_url": f"https://forge.example/stackchain/web/{kind}/{number}", + } for number in (1, 2)] + return httpx.Response(200, json=items) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.global_search("search", 2) + finally: + await gitea_proxy.stop_client() + + assert len(result["items"]) == 2 + assert result["partial"] is False + + +@pytest.mark.anyio +async def test_global_search_propagates_cancellation_to_stop_obsolete_work(): + async def handler(request): + if request.url.params["type"] == "issues": + raise asyncio.CancelledError() + return httpx.Response(200, json=[]) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + with pytest.raises(asyncio.CancelledError): + await gitea_proxy.global_search("obsolete", 10) + finally: + await gitea_proxy.stop_client() @pytest.mark.anyio