Load complete, balanced results in mobile Search #784

Merged
timmy merged 1 commits from timmy/783-paginated-balanced-mobile-search into main 2026-08-14 00:06:25 +00:00
8 changed files with 149 additions and 45 deletions

View File

@ -22,6 +22,38 @@
let timer = null; let timer = null;
let generation = 0; let generation = 0;
let activeController = null; let activeController = null;
let state = { status: 'idle', query: '', items: [], more: false, next: 1 };
function publish(next) {
state = next;
onState(next);
}
async function requestPage(query, page, current, append) {
const requestController = new AbortController();
activeController = requestController;
try {
const result = await search(query, requestController.signal, page);
const partial = result.partial === true;
const incoming = result.items || result;
const combined = append ? state.items.concat(incoming) : incoming;
const items = [...new Map((combined || []).map(item =>
[`${item.kind}:${item.repository}:${item.number}`, item]
)).values()];
if (current === generation) publish({
status: 'ready', query, items, partial,
more: !!result.has_more,
next: result.next_page,
});
} catch (error) {
if (error && error.name === 'AbortError') return;
if (current === generation) publish(append
? { ...state, status: 'ready' }
: { status: 'error', query, items: [], error });
} finally {
if (activeController === requestController) activeController = null;
}
}
return { return {
setQuery(value) { setQuery(value) {
@ -32,25 +64,17 @@
if (activeController !== null) activeController.abort(); if (activeController !== null) activeController.abort();
activeController = null; activeController = null;
if (query.length < 2) { if (query.length < 2) {
onState({ status: 'idle', query, items: [] }); publish({ status: 'idle', query, items: [], more: false, next: 1 });
return; return;
} }
onState({ status: 'loading', query, items: [] }); publish({ status: 'loading', query, items: [], more: false, next: 1 });
timer = setTimeout(async () => { timer = setTimeout(() => requestPage(query, 1, current, false), delay);
const requestController = new AbortController(); },
activeController = requestController; loadMore() {
try { if (state.status !== 'ready' || !state.more || activeController) return;
const result = await search(query, requestController.signal); const current = generation;
const items = Array.isArray(result) ? result : result.items; const page = state.next;
const partial = !Array.isArray(result) && result.partial === true; requestPage(state.query, page, current, 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);
}, },
}; };
}; };

View File

@ -119,6 +119,7 @@ textarea { resize: vertical; min-height: 120px; }
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; } .cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; } .cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
.cmd-status { padding:10px; color:#93a4b8; font-size:13px; } .cmd-status { padding:10px; color:#93a4b8; font-size:13px; }
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; } .cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); } #whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
#whiteboard-modal.open, #markdown-modal.open { display: flex; } #whiteboard-modal.open, #markdown-modal.open { display: flex; }
@ -681,7 +682,7 @@ textarea { resize: vertical; min-height: 120px; }
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; } #cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
#cmd-palette.open { display:flex; flex-direction:column; } #cmd-palette.open { display:flex; flex-direction:column; }
.cmd-palette-header { display:flex; flex:0 0 auto; min-height:44px; } .cmd-palette-header { display:flex; flex:0 0 auto; min-height:44px; }
#close-command-palette { min-height:44px; } #close-command-palette, #cmd-load-more { min-height:44px; }
#cmd-input { flex:0 0 auto; min-height:44px; } #cmd-input { flex:0 0 auto; min-height:44px; }
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); } #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; } .create-issue-panel { width:100%; border-left:0; padding:14px; }

View File

@ -4236,7 +4236,6 @@
function openModal(id) { qs('#' + id).classList.add('open'); } function openModal(id) { qs('#' + id).classList.add('open'); }
function closeModal(id) { qs('#' + id).classList.remove('open'); } function closeModal(id) { qs('#' + id).classList.remove('open'); }
/* Creative ambient background */
(function bg(){ (function bg(){
const c=qs('#bg'),ctx=c.getContext('2d'); const c=qs('#bg'),ctx=c.getContext('2d');
let w,h,time=0; let w,h,time=0;
@ -4258,7 +4257,6 @@
draw(); draw();
})(); })();
/* Whiteboard */
function initWhiteboard() { function initWhiteboard() {
const canvas = qs('#wb'), ctx = canvas.getContext('2d'); const canvas = qs('#wb'), ctx = canvas.getContext('2d');
let drawing = false; let drawing = false;
@ -4273,34 +4271,28 @@
qs('#wb-save').addEventListener('click', () => { const a=document.createElement('a'); a.href=canvas.toDataURL(); a.download='whiteboard.png'; a.click(); }); qs('#wb-save').addEventListener('click', () => { const a=document.createElement('a'); a.href=canvas.toDataURL(); a.download='whiteboard.png'; a.click(); });
} }
/* Markdown */
qs('#md-input').addEventListener('input', renderMD); qs('#md-input').addEventListener('input', renderMD);
function renderMD() { function renderMD() {
const raw = qs('#md-input').value || ''; const raw = qs('#md-input').value || '';
qs('#md-preview').innerHTML = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + renderMarkdown(raw) + '</div>'; qs('#md-preview').innerHTML = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + renderMarkdown(raw) + '</div>';
} }
/* Commands */
const commands = [ const commands = [
{ name: 'Open whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } }, { name: 'Whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } },
{ name: 'Open markdown widget', run: () => { qs('#md-input').focus(); } }, { name: 'Markdown', run: () => { qs('#md-input').focus(); } },
{ name: 'Refresh now', run: load }, { name: 'Refresh', run: load },
{ name: 'Scroll issues', run: () => qs('#work').scrollIntoView({ behavior:'smooth', block:'start' }) },
]; ];
let commandSearchState = { status:'idle', query:'', items:[] }; let commandSearchState = { status:'idle', query:'', items:[] };
let commandItems = []; let commandItems = [];
let commandSelection = -1; let commandSelection = -1;
async function searchGlobalWork(query, signal) { async function searchGlobalWork(query, signal, page = 1) {
const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10', { const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10&page=' + page, {
headers: { Accept:'application/json' }, headers: { Accept:'application/json' },
signal, signal,
}); });
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Search is temporarily unavailable.'); if (!response.ok) throw new Error(payload.error || 'Search is temporarily unavailable.');
return { return payload;
items: Array.isArray(payload.items) ? payload.items : [],
partial: payload.partial === true,
};
} }
const commandSearch = filterCommands.createGlobalSearchController({ const commandSearch = filterCommands.createGlobalSearchController({
search: searchGlobalWork, search: searchGlobalWork,
@ -4458,6 +4450,7 @@
} }
function renderCommands(filter) { function renderCommands(filter) {
const el = qs('#cmd-results'); const el = qs('#cmd-results');
const loadMore = qs('#cmd-load-more');
const local = filterCommands(commands, filter).map(command => ({ command })); const local = filterCommands(commands, filter).map(command => ({ command }));
const remote = commandSearchState.query === String(filter || '').trim() const remote = commandSearchState.query === String(filter || '').trim()
? commandSearchState.items.map(result => ({ result })) : []; ? commandSearchState.items.map(result => ({ result })) : [];
@ -4476,6 +4469,7 @@
else if (commandSearchState.partial) html += '<div class="cmd-status">Some results are temporarily unavailable.</div>'; else if (commandSearchState.partial) html += '<div class="cmd-status">Some results are temporarily unavailable.</div>';
else if (String(filter || '').trim().length >= 2 && !remote.length) html += '<div class="cmd-status">No matching issues or pull requests.</div>'; else if (String(filter || '').trim().length >= 2 && !remote.length) html += '<div class="cmd-status">No matching issues or pull requests.</div>';
el.innerHTML = html; el.innerHTML = html;
loadMore.hidden = !commandSearchState.more;
el.querySelectorAll('.cmd-item').forEach((item) => { el.querySelectorAll('.cmd-item').forEach((item) => {
item.addEventListener('click', () => runCommandItem(commandItems[Number(item.dataset.idx)])); item.addEventListener('click', () => runCommandItem(commandItems[Number(item.dataset.idx)]));
}); });
@ -4550,6 +4544,7 @@
taskOverlayHistory.start(); taskOverlayHistory.start();
qs('#open-palette').addEventListener('click', openCommandPalette); qs('#open-palette').addEventListener('click', openCommandPalette);
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close()); qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
qs('#cmd-load-more').addEventListener('click', () => commandSearch.loadMore());
qs('#cmd-input').addEventListener('input', (e) => { qs('#cmd-input').addEventListener('input', (e) => {
commandSelection = -1; commandSelection = -1;
taskOverlayHistory.update({ query:e.target.value }); taskOverlayHistory.update({ query:e.target.value });
@ -6541,7 +6536,6 @@
if (!document.hidden) deviceSetup?.render(); if (!document.hidden) deviceSetup?.render();
}); });
/* Widgets */
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); } function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
setInterval(widgetTick, 1000); setInterval(widgetTick, 1000);
})(); })();

View File

@ -430,6 +430,7 @@
</div> </div>
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." /> <input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div> <div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div>
<button id="cmd-load-more" hidden>More results</button>
</div> </div>
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title"> <div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">

View File

@ -463,13 +463,14 @@ def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
} }
async def global_search(query: str, limit: int = 10) -> dict: async def global_search(query: str, limit: int = 10, page: int = 1) -> dict:
"""Search accessible issues and pulls concurrently with a bounded result set.""" """Search accessible issues and pulls concurrently with balanced pagination."""
stream_limit = (limit + 1) // 2
async def load(item_type: str) -> Any: async def load(item_type: str) -> Any:
response = await _get_client().get( response = await _get_client().get(
"/api/v1/repos/issues/search", "/api/v1/repos/issues/search",
headers=_auth(), headers=_auth(),
params={"q": query, "type": item_type, "limit": limit, "page": 1}, params={"q": query, "type": item_type, "limit": stream_limit, "page": page},
) )
response.raise_for_status() response.raise_for_status()
payload = response.json() payload = response.json()
@ -486,10 +487,12 @@ async def global_search(query: str, limit: int = 10) -> dict:
if all(isinstance(outcome, BaseException) for outcome in outcomes): if all(isinstance(outcome, BaseException) for outcome in outcomes):
raise outcomes[0] raise outcomes[0]
results = [] streams: list[list[dict]] = []
seen: set[tuple[str, str, int]] = set() seen: set[tuple[str, str, int]] = set()
for outcome, kind in zip(outcomes, ("issue", "pull"), strict=True): for outcome, kind in zip(outcomes, ("issue", "pull"), strict=True):
normalized_stream = []
if isinstance(outcome, BaseException): if isinstance(outcome, BaseException):
streams.append(normalized_stream)
continue continue
for item in outcome: for item in outcome:
normalized = _normalize_global_search_item(item, kind) normalized = _normalize_global_search_item(item, kind)
@ -498,10 +501,21 @@ async def global_search(query: str, limit: int = 10) -> dict:
if identity in seen: if identity in seen:
continue continue
seen.add(identity) seen.add(identity)
results.append(normalized) normalized_stream.append(normalized)
streams.append(normalized_stream)
results = []
for index in range(max((len(stream) for stream in streams), default=0)):
for stream in streams:
if index < len(stream):
results.append(stream[index])
return { return {
"items": results[:limit], "items": results[:limit],
"partial": any(isinstance(outcome, BaseException) for outcome in outcomes), "partial": any(isinstance(outcome, BaseException) for outcome in outcomes),
"has_more": any(
not isinstance(outcome, BaseException) and len(outcome) >= stream_limit
for outcome in outcomes
),
"next_page": page + 1,
} }

View File

@ -2690,13 +2690,14 @@ async def background_identity() -> JSONResponse:
async def global_search( async def global_search(
q: str = Query(min_length=2, max_length=100), q: str = Query(min_length=2, max_length=100),
limit: int = Query(default=10, ge=1, le=25), limit: int = Query(default=10, ge=1, le=25),
page: int = Query(default=1, ge=1, le=100),
) -> JSONResponse: ) -> JSONResponse:
query = q.strip() query = q.strip()
if len(query) < 2: if len(query) < 2:
raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters") raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters")
try: try:
result = await asyncio.wait_for( result = await asyncio.wait_for(
gitea_proxy.global_search(query, limit), gitea_proxy.global_search(query, limit, page),
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS, timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
) )
except Exception: except Exception:

View File

@ -142,6 +142,38 @@ if (!ready || ready.items.length !== 1 || ready.partial !== true) {{
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_remote_command_search_appends_deduplicated_pages_and_ignores_stale_load_more():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
(async () => {{
const pending = new Map();
const states = [];
const controller = filterCommands.createGlobalSearchController({{
delay: 0,
search: (query, signal, page) => new Promise(resolve => pending.set(query + ':' + page, resolve)),
onState: state => states.push(state),
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 0));
pending.get('mobile:1')({{ items:[{{kind:'issue',repository:'a/b',number:1}}], has_more:true, next_page:2 }});
await new Promise(resolve => setTimeout(resolve, 0));
controller.loadMore();
await new Promise(resolve => setTimeout(resolve, 0));
controller.setQuery('release');
await new Promise(resolve => setTimeout(resolve, 0));
pending.get('mobile:2')({{ items:[{{kind:'issue',repository:'a/b',number:1}},{{kind:'pull',repository:'a/b',number:2}}], has_more:false, next_page:3 }});
pending.get('release:1')({{ items:[{{kind:'issue',repository:'a/b',number:3}}], 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.query !== 'release' || ready.items.length !== 1 || ready.items[0].number !== 3) {{
throw new Error('stale page entered current query: ' + 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(): def test_command_selection_wraps_for_arrow_keys():
script = f""" script = f"""
const commands = require({json.dumps(str(COMMANDS))}); const commands = require({json.dumps(str(COMMANDS))});
@ -161,9 +193,10 @@ def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath()
assert 'aria-controls="cmd-results"' in html assert 'aria-controls="cmd-results"' in html
assert 'role="listbox"' in html assert 'role="listbox"' in html
assert "fetch('api/v1/search?q='" in html assert "fetch('api/v1/search?q='" in html
assert "async function searchGlobalWork(query, signal)" in html assert "async function searchGlobalWork(query, signal, page = 1)" in html
assert "signal," in html assert "signal," in html
assert "Some results are temporarily unavailable." in html assert "Some results are temporarily unavailable." in html
assert 'id="cmd-load-more"' in html
assert urljoin( assert urljoin(
"https://forge.alexanderwhitestone.com/dashboard/", "https://forge.alexanderwhitestone.com/dashboard/",
"api/v1/search?q=mobile", "api/v1/search?q=mobile",

View File

@ -10,8 +10,8 @@ from src import gitea_proxy, main
async def test_global_search_endpoint_returns_bounded_normalized_results(monkeypatch): async def test_global_search_endpoint_returns_bounded_normalized_results(monkeypatch):
requested = [] requested = []
async def search(query, limit): async def search(query, limit, page):
requested.append((query, limit)) requested.append((query, limit, page))
return { return {
"items": [{ "items": [{
"kind": "issue", "kind": "issue",
@ -27,11 +27,11 @@ async def test_global_search_endpoint_returns_bounded_normalized_results(monkeyp
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False) monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
transport = httpx.ASGITransport(app=main.app) transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/search?q=mobile&limit=7") response = await client.get("/api/v1/search?q=mobile&limit=7&page=2")
assert response.status_code == 200 assert response.status_code == 200
assert response.headers["cache-control"] == "no-store" assert response.headers["cache-control"] == "no-store"
assert requested == [("mobile", 7)] assert requested == [("mobile", 7, 2)]
assert response.json() == {"query": "mobile", "items": [{ assert response.json() == {"query": "mobile", "items": [{
"kind": "issue", "kind": "issue",
"repository": "stackchain/api", "repository": "stackchain/api",
@ -110,7 +110,7 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
await gitea_proxy.stop_client() await gitea_proxy.stop_client()
assert {request["type"] for request in requests} == {"issues", "pulls"} assert {request["type"] for request in requests} == {"issues", "pulls"}
assert all(request["q"] == "mobile queue" and request["limit"] == "7" for request in requests) assert all(request["q"] == "mobile queue" and request["limit"] == "4" for request in requests)
assert results == { assert results == {
"items": [{ "items": [{
"kind": "issue", "repository": "stackchain/api", "number": 42, "kind": "issue", "repository": "stackchain/api", "number": 42,
@ -122,6 +122,8 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
"url": "https://forge.example/stackchain/web/pulls/9", "url": "https://forge.example/stackchain/web/pulls/9",
}], }],
"partial": False, "partial": False,
"has_more": False,
"next_page": 2,
} }
@ -154,6 +156,8 @@ async def test_global_search_returns_healthy_stream_when_other_stream_fails():
"url": "https://forge.example/stackchain/web/pulls/9", "url": "https://forge.example/stackchain/web/pulls/9",
}], }],
"partial": True, "partial": True,
"has_more": False,
"next_page": 2,
} }
@ -180,6 +184,38 @@ async def test_global_search_caps_combined_results_to_requested_limit():
assert result["partial"] is False assert result["partial"] is False
@pytest.mark.anyio
async def test_global_search_pages_each_stream_and_balances_results():
requests = []
async def handler(request):
params = dict(request.url.params)
requests.append(params)
kind = params["type"]
page = int(params["page"])
items = [{
"number": page * 10 + number,
"title": f"{kind} page {page} result {number}",
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": f"https://forge.example/stackchain/web/{kind}/{page * 10 + number}",
} for number in (1, 2, 3, 4)]
return httpx.Response(200, json=items)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.global_search("search", limit=4, page=2)
finally:
await gitea_proxy.stop_client()
assert {(request["type"], request["page"]) for request in requests} == {
("issues", "2"), ("pulls", "2")
}
assert [item["kind"] for item in result["items"]] == ["issue", "pull", "issue", "pull"]
assert result["next_page"] == 3
assert result["has_more"] is True
@pytest.mark.anyio @pytest.mark.anyio
async def test_global_search_propagates_cancellation_to_stop_obsolete_work(): async def test_global_search_propagates_cancellation_to_stop_obsolete_work():
async def handler(request): async def handler(request):