diff --git a/frontend/commands.js b/frontend/commands.js
index 8b8bef3..cede8b9 100644
--- a/frontend/commands.js
+++ b/frontend/commands.js
@@ -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);
},
};
};
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index e5baa40..d50db64 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -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; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 365f1f0..fba665a 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -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 = '
' + escapeHtml(raw) + '
' + renderMarkdown(raw) + '
';
}
- /* 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 += 'Some results are temporarily unavailable.
';
else if (String(filter || '').trim().length >= 2 && !remote.length) html += 'No matching issues or pull requests.
';
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);
})();
diff --git a/frontend/index.html b/frontend/index.html
index ac53543..1929542 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -430,6 +430,7 @@
+
diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py
index 9424f6d..67e1787 100644
--- a/src/gitea_proxy.py
+++ b/src/gitea_proxy.py
@@ -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,
}
diff --git a/src/main.py b/src/main.py
index 59918cd..057a9a0 100644
--- a/src/main.py
+++ b/src/main.py
@@ -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:
diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py
index 6fd34ec..c7ad487 100644
--- a/tests/test_command_palette.py
+++ b/tests/test_command_palette.py
@@ -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",
diff --git a/tests/test_global_search.py b/tests/test_global_search.py
index 7b69015..98b93f9 100644
--- a/tests/test_global_search.py
+++ b/tests/test_global_search.py
@@ -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):