Merge pull request 'Load complete, balanced results in mobile Search' (#784) from timmy/783-paginated-balanced-mobile-search into main
This commit is contained in:
commit
70281bb56a
|
|
@ -22,6 +22,38 @@
|
|||
let timer = null;
|
||||
let generation = 0;
|
||||
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 {
|
||||
setQuery(value) {
|
||||
|
|
@ -32,25 +64,17 @@
|
|||
if (activeController !== null) activeController.abort();
|
||||
activeController = null;
|
||||
if (query.length < 2) {
|
||||
onState({ status: 'idle', query, items: [] });
|
||||
publish({ status: 'idle', query, items: [], more: false, next: 1 });
|
||||
return;
|
||||
}
|
||||
onState({ status: 'loading', query, items: [] });
|
||||
timer = setTimeout(async () => {
|
||||
const requestController = new AbortController();
|
||||
activeController = requestController;
|
||||
try {
|
||||
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);
|
||||
publish({ status: 'loading', query, items: [], more: false, next: 1 });
|
||||
timer = setTimeout(() => requestPage(query, 1, current, false), delay);
|
||||
},
|
||||
loadMore() {
|
||||
if (state.status !== 'ready' || !state.more || activeController) return;
|
||||
const current = generation;
|
||||
const page = state.next;
|
||||
requestPage(state.query, page, current, true);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
|
||||
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
|
||||
.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; }
|
||||
#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; }
|
||||
|
|
@ -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.open { display:flex; flex-direction:column; }
|
||||
.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-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; }
|
||||
|
|
|
|||
|
|
@ -4236,7 +4236,6 @@
|
|||
function openModal(id) { qs('#' + id).classList.add('open'); }
|
||||
function closeModal(id) { qs('#' + id).classList.remove('open'); }
|
||||
|
||||
/* Creative ambient background */
|
||||
(function bg(){
|
||||
const c=qs('#bg'),ctx=c.getContext('2d');
|
||||
let w,h,time=0;
|
||||
|
|
@ -4258,7 +4257,6 @@
|
|||
draw();
|
||||
})();
|
||||
|
||||
/* Whiteboard */
|
||||
function initWhiteboard() {
|
||||
const canvas = qs('#wb'), ctx = canvas.getContext('2d');
|
||||
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(); });
|
||||
}
|
||||
|
||||
/* Markdown */
|
||||
qs('#md-input').addEventListener('input', renderMD);
|
||||
function renderMD() {
|
||||
const raw = qs('#md-input').value || '';
|
||||
qs('#md-preview').innerHTML = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + renderMarkdown(raw) + '</div>';
|
||||
}
|
||||
|
||||
/* Commands */
|
||||
const commands = [
|
||||
{ name: 'Open whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } },
|
||||
{ name: 'Open markdown widget', run: () => { qs('#md-input').focus(); } },
|
||||
{ name: 'Refresh now', run: load },
|
||||
{ name: 'Scroll issues', run: () => qs('#work').scrollIntoView({ behavior:'smooth', block:'start' }) },
|
||||
{ name: 'Whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } },
|
||||
{ name: 'Markdown', run: () => { qs('#md-input').focus(); } },
|
||||
{ name: 'Refresh', run: load },
|
||||
];
|
||||
let commandSearchState = { status:'idle', query:'', items:[] };
|
||||
let commandItems = [];
|
||||
let commandSelection = -1;
|
||||
async function searchGlobalWork(query, signal) {
|
||||
const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10', {
|
||||
async function searchGlobalWork(query, signal, page = 1) {
|
||||
const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10&page=' + page, {
|
||||
headers: { Accept:'application/json' },
|
||||
signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || 'Search is temporarily unavailable.');
|
||||
return {
|
||||
items: Array.isArray(payload.items) ? payload.items : [],
|
||||
partial: payload.partial === true,
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
const commandSearch = filterCommands.createGlobalSearchController({
|
||||
search: searchGlobalWork,
|
||||
|
|
@ -4458,6 +4450,7 @@
|
|||
}
|
||||
function renderCommands(filter) {
|
||||
const el = qs('#cmd-results');
|
||||
const loadMore = qs('#cmd-load-more');
|
||||
const local = filterCommands(commands, filter).map(command => ({ command }));
|
||||
const remote = commandSearchState.query === String(filter || '').trim()
|
||||
? 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 (String(filter || '').trim().length >= 2 && !remote.length) html += '<div class="cmd-status">No matching issues or pull requests.</div>';
|
||||
el.innerHTML = html;
|
||||
loadMore.hidden = !commandSearchState.more;
|
||||
el.querySelectorAll('.cmd-item').forEach((item) => {
|
||||
item.addEventListener('click', () => runCommandItem(commandItems[Number(item.dataset.idx)]));
|
||||
});
|
||||
|
|
@ -4550,6 +4544,7 @@
|
|||
taskOverlayHistory.start();
|
||||
qs('#open-palette').addEventListener('click', openCommandPalette);
|
||||
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
|
||||
qs('#cmd-load-more').addEventListener('click', () => commandSearch.loadMore());
|
||||
qs('#cmd-input').addEventListener('input', (e) => {
|
||||
commandSelection = -1;
|
||||
taskOverlayHistory.update({ query:e.target.value });
|
||||
|
|
@ -6541,7 +6536,6 @@
|
|||
if (!document.hidden) deviceSetup?.render();
|
||||
});
|
||||
|
||||
/* Widgets */
|
||||
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
|
||||
setInterval(widgetTick, 1000);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -430,6 +430,7 @@
|
|||
</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..." />
|
||||
<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 class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""Search accessible issues and pulls concurrently with a bounded result set."""
|
||||
async def global_search(query: str, limit: int = 10, page: int = 1) -> dict:
|
||||
"""Search accessible issues and pulls concurrently with balanced pagination."""
|
||||
stream_limit = (limit + 1) // 2
|
||||
async def load(item_type: str) -> Any:
|
||||
response = await _get_client().get(
|
||||
"/api/v1/repos/issues/search",
|
||||
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()
|
||||
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):
|
||||
raise outcomes[0]
|
||||
|
||||
results = []
|
||||
streams: list[list[dict]] = []
|
||||
seen: set[tuple[str, str, int]] = set()
|
||||
for outcome, kind in zip(outcomes, ("issue", "pull"), strict=True):
|
||||
normalized_stream = []
|
||||
if isinstance(outcome, BaseException):
|
||||
streams.append(normalized_stream)
|
||||
continue
|
||||
for item in outcome:
|
||||
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:
|
||||
continue
|
||||
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 {
|
||||
"items": results[:limit],
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2690,13 +2690,14 @@ async def background_identity() -> JSONResponse:
|
|||
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),
|
||||
) -> 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),
|
||||
gitea_proxy.global_search(query, limit, page),
|
||||
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
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():
|
||||
script = f"""
|
||||
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 'role="listbox"' 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 "Some results are temporarily unavailable." in html
|
||||
assert 'id="cmd-load-more"' in html
|
||||
assert urljoin(
|
||||
"https://forge.alexanderwhitestone.com/dashboard/",
|
||||
"api/v1/search?q=mobile",
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ from src import gitea_proxy, main
|
|||
async def test_global_search_endpoint_returns_bounded_normalized_results(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def search(query, limit):
|
||||
requested.append((query, limit))
|
||||
async def search(query, limit, page):
|
||||
requested.append((query, limit, page))
|
||||
return {
|
||||
"items": [{
|
||||
"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)
|
||||
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")
|
||||
response = await client.get("/api/v1/search?q=mobile&limit=7&page=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert requested == [("mobile", 7)]
|
||||
assert requested == [("mobile", 7, 2)]
|
||||
assert response.json() == {"query": "mobile", "items": [{
|
||||
"kind": "issue",
|
||||
"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()
|
||||
|
||||
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 == {
|
||||
"items": [{
|
||||
"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",
|
||||
}],
|
||||
"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",
|
||||
}],
|
||||
"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
|
||||
|
||||
|
||||
@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
|
||||
async def test_global_search_propagates_cancellation_to_stop_obsolete_work():
|
||||
async def handler(request):
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user