From 7b736bc4c6d92a6906a54b1bfd24e58aeda1fffe Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 14 Aug 2026 00:04:06 +0000 Subject: [PATCH] feat: paginate balanced mobile search results (Closes #783) --- frontend/commands.js | 58 +++++++++++++++++++++++++---------- frontend/dashboard.css | 3 +- frontend/dashboard.js | 24 ++++++--------- frontend/index.html | 1 + src/gitea_proxy.py | 24 ++++++++++++--- src/main.py | 3 +- tests/test_command_palette.py | 35 ++++++++++++++++++++- tests/test_global_search.py | 46 ++++++++++++++++++++++++--- 8 files changed, 149 insertions(+), 45 deletions(-) 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 @@
+