Compare commits

..

No commits in common. "70281bb56a56851eb3416481d4964d83c2243b08" and "fa3e5e3bf0adeee9205943f5b010ab892f769a6e" have entirely different histories.

8 changed files with 45 additions and 149 deletions

View File

@ -22,38 +22,6 @@
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) {
@ -64,17 +32,25 @@
if (activeController !== null) activeController.abort(); if (activeController !== null) activeController.abort();
activeController = null; activeController = null;
if (query.length < 2) { if (query.length < 2) {
publish({ status: 'idle', query, items: [], more: false, next: 1 }); onState({ status: 'idle', query, items: [] });
return; return;
} }
publish({ status: 'loading', query, items: [], more: false, next: 1 }); onState({ status: 'loading', query, items: [] });
timer = setTimeout(() => requestPage(query, 1, current, false), delay); timer = setTimeout(async () => {
}, const requestController = new AbortController();
loadMore() { activeController = requestController;
if (state.status !== 'ready' || !state.more || activeController) return; try {
const current = generation; const result = await search(query, requestController.signal);
const page = state.next; const items = Array.isArray(result) ? result : result.items;
requestPage(state.query, page, current, true); 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);
}, },
}; };
}; };

View File

@ -119,7 +119,6 @@ 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; }
@ -682,7 +681,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, #cmd-load-more { min-height:44px; } #close-command-palette { 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,6 +4236,7 @@
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;
@ -4257,6 +4258,7 @@
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;
@ -4271,28 +4273,34 @@
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: 'Whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } }, { name: 'Open whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } },
{ name: 'Markdown', run: () => { qs('#md-input').focus(); } }, { name: 'Open markdown widget', run: () => { qs('#md-input').focus(); } },
{ name: 'Refresh', run: load }, { name: 'Refresh now', 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, page = 1) { async function searchGlobalWork(query, signal) {
const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10&page=' + page, { const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10', {
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 payload; return {
items: Array.isArray(payload.items) ? payload.items : [],
partial: payload.partial === true,
};
} }
const commandSearch = filterCommands.createGlobalSearchController({ const commandSearch = filterCommands.createGlobalSearchController({
search: searchGlobalWork, search: searchGlobalWork,
@ -4450,7 +4458,6 @@
} }
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 })) : [];
@ -4469,7 +4476,6 @@
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)]));
}); });
@ -4544,7 +4550,6 @@
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 });
@ -6536,6 +6541,7 @@
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,7 +430,6 @@
</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,14 +463,13 @@ def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
} }
async def global_search(query: str, limit: int = 10, page: int = 1) -> dict: async def global_search(query: str, limit: int = 10) -> dict:
"""Search accessible issues and pulls concurrently with balanced pagination.""" """Search accessible issues and pulls concurrently with a bounded result set."""
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": stream_limit, "page": page}, params={"q": query, "type": item_type, "limit": limit, "page": 1},
) )
response.raise_for_status() response.raise_for_status()
payload = response.json() payload = response.json()
@ -487,12 +486,10 @@ async def global_search(query: str, limit: int = 10, page: int = 1) -> 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]
streams: list[list[dict]] = [] results = []
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)
@ -501,21 +498,10 @@ async def global_search(query: str, limit: int = 10, page: int = 1) -> dict:
if identity in seen: if identity in seen:
continue continue
seen.add(identity) seen.add(identity)
normalized_stream.append(normalized) results.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,14 +2690,13 @@ 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, page), gitea_proxy.global_search(query, limit),
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS, timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
) )
except Exception: except Exception:

View File

@ -142,38 +142,6 @@ 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))});
@ -193,10 +161,9 @@ 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, page = 1)" in html assert "async function searchGlobalWork(query, signal)" 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, page): async def search(query, limit):
requested.append((query, limit, page)) requested.append((query, limit))
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&page=2") response = await client.get("/api/v1/search?q=mobile&limit=7")
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, 2)] assert requested == [("mobile", 7)]
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"] == "4" for request in requests) assert all(request["q"] == "mobile queue" and request["limit"] == "7" 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,8 +122,6 @@ 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,
} }
@ -156,8 +154,6 @@ 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,
} }
@ -184,38 +180,6 @@ 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):