Search global work from the command palette #196

Merged
rockachopa merged 1 commits from timmy/195-global-work-search into main 2026-08-07 12:29:11 +00:00
8 changed files with 359 additions and 17 deletions

View File

@ -45,8 +45,12 @@ uvicorn src.main:app --host 127.0.0.1 --port 8000
Open `http://127.0.0.1:8000/` for the dashboard. To verify the backend and its
Gitea connection directly, request
`http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing
`user`, `repos`, `issues`, and `pull_requests`. Never commit the token or place
it in a tracked configuration file.
`user`, `repos`, `issues`, and `pull_requests`. Press `Ctrl/Cmd+K` in the dashboard
to search commands plus issues and pull requests across every repository visible to
the configured Gitea token. Remote search starts after two characters, is debounced,
and keeps local commands usable if Gitea search is unavailable. The bounded API is
also available at `GET /api/v1/search?q=<query>&limit=<1-25>`. Never commit the token
or place it in a tracked configuration file.
For service monitoring, GET `/healthz` is a liveness check that confirms the
API process is running and does not contact Gitea. GET `/readyz` is the

View File

@ -3,8 +3,47 @@
if (typeof module === 'object' && module.exports) module.exports = filterCommands;
if (root) root.filterCommands = filterCommands;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function filterCommands(commands, filter) {
function filterCommands(commands, filter) {
const term = String(filter || '').toLowerCase();
return commands.filter(command => command.name.toLowerCase().includes(term));
}
filterCommands.nextSelection = function nextSelection(current, key, count) {
if (count < 1) return -1;
if (key === 'ArrowDown') return (current + 1 + count) % count;
if (key === 'ArrowUp') return (current - 1 + count) % count;
return current;
};
filterCommands.createGlobalSearchController = function createGlobalSearchController(options) {
const search = options.search;
const onState = options.onState;
const delay = options.delay === undefined ? 250 : options.delay;
let timer = null;
let generation = 0;
return {
setQuery(value) {
const query = String(value || '').trim();
generation += 1;
const current = generation;
if (timer !== null) clearTimeout(timer);
if (query.length < 2) {
onState({ status: 'idle', query, items: [] });
return;
}
onState({ status: 'loading', query, items: [] });
timer = setTimeout(async () => {
try {
const items = await search(query);
if (current === generation) onState({ status: 'ready', query, items });
} catch (error) {
if (current === generation) onState({ status: 'error', query, items: [], error });
}
}, delay);
},
};
};
return filterCommands;
});

View File

@ -40,8 +40,11 @@ input[type=\"text\"], textarea { width: 100%; padding: 8px; border-radius: 8px;
textarea { resize: vertical; min-height: 120px; }
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
#cmd-palette.open { display: block; }
.cmd-item { padding: 10px; cursor: pointer; border-radius: 10px; color:#e5e7eb; }
.cmd-item:hover { background: #10233a; }
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
.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; }
.modal { background: #0b1526; border: 1px solid #2a496e; border-radius: 14px; padding: 14px; width: min(1100px, 94vw); max-height: 92vh; overflow: auto; }
@ -332,8 +335,8 @@ textarea { resize: vertical; min-height: 120px; }
</main>
<div id="cmd-palette" role="dialog" aria-label="Command palette">
<input id="cmd-input" type="text" placeholder="Type a command, e.g. whiteboard, markdown..." />
<div id="cmd-results" class="stack" style="margin-top:8px;"></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" style="margin-top:8px;max-height:min(65vh,520px);overflow-y:auto;"></div>
</div>
<div id="whiteboard-modal">
@ -1759,17 +1762,95 @@ textarea { resize: vertical; min-height: 120px; }
{ name: 'Refresh now', run: load },
{ name: 'Scroll issues', run: () => qs('#work').scrollIntoView({ behavior:'smooth', block:'start' }) },
];
let commandSearchState = { status:'idle', query:'', items:[] };
let commandItems = [];
let commandSelection = -1;
async function searchGlobalWork(query) {
const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10', {
headers: { Accept:'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Search is temporarily unavailable.');
return Array.isArray(payload.items) ? payload.items : [];
}
const commandSearch = filterCommands.createGlobalSearchController({
search: searchGlobalWork,
onState: state => {
commandSearchState = state;
renderCommands(state.query);
},
});
function safeSearchUrl(value) {
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
} catch (_) { return ''; }
}
function runCommandItem(item) {
if (item.command) item.command.run();
else {
const url = safeSearchUrl(item.result.url);
if (url) window.location.assign(url);
}
qs('#cmd-palette').classList.remove('open');
qs('#cmd-input').setAttribute('aria-expanded', 'false');
qs('#cmd-input').value = '';
}
function renderCommands(filter) {
const el = qs('#cmd-results');
const items = filterCommands(commands, filter);
el.innerHTML = items.map((c, idx) => '<div class="cmd-item" data-idx="' + idx + '">' + escapeHtml(c.name) + '</div>').join('');
const local = filterCommands(commands, filter).map(command => ({ command }));
const remote = commandSearchState.query === String(filter || '').trim()
? commandSearchState.items.map(result => ({ result })) : [];
commandItems = local.concat(remote);
if (commandSelection >= commandItems.length) commandSelection = -1;
let html = local.length ? '<div class="cmd-group">Commands</div>' : '';
html += local.map((item, idx) => '<div class="cmd-item' + (idx === commandSelection ? ' selected' : '') + '" role="option" aria-selected="' + (idx === commandSelection) + '" data-idx="' + idx + '"><span>' + escapeHtml(item.command.name) + '</span><span class="cmd-meta">Command</span></div>').join('');
if (remote.length) html += '<div class="cmd-group">Issues and pull requests</div>';
html += remote.map((item, remoteIdx) => {
const idx = local.length + remoteIdx;
const result = item.result;
return '<div class="cmd-item' + (idx === commandSelection ? ' selected' : '') + '" role="option" aria-selected="' + (idx === commandSelection) + '" data-idx="' + idx + '"><span>' + escapeHtml(result.title) + '</span><span class="cmd-meta">' + escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' + escapeHtml(result.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + escapeHtml(result.state) + '</span></div>';
}).join('');
if (commandSearchState.status === 'loading') html += '<div class="cmd-status">Searching accessible work…</div>';
else if (commandSearchState.status === 'error') html += '<div class="cmd-status">Search unavailable. Keep typing or retry.</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.querySelectorAll('.cmd-item').forEach((item) => {
item.addEventListener('click', () => { items[Number(item.dataset.idx)].run(); qs('#cmd-palette').classList.remove('open'); qs('#cmd-input').value=''; });
item.addEventListener('click', () => runCommandItem(commandItems[Number(item.dataset.idx)]));
});
}
qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); });
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
function openCommandPalette() {
qs('#cmd-palette').classList.add('open');
qs('#cmd-input').setAttribute('aria-expanded', 'true');
qs('#cmd-input').focus();
commandSelection = -1;
renderCommands(qs('#cmd-input').value);
}
qs('#open-palette').addEventListener('click', openCommandPalette);
qs('#cmd-input').addEventListener('input', (e) => {
commandSelection = -1;
renderCommands(e.target.value);
commandSearch.setQuery(e.target.value);
});
qs('#cmd-input').addEventListener('keydown', event => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
commandSelection = filterCommands.nextSelection(commandSelection, event.key, commandItems.length);
renderCommands(event.currentTarget.value);
const selected = qs('#cmd-results .selected');
if (selected) selected.scrollIntoView({ block:'nearest' });
} else if (event.key === 'Enter' && commandSelection >= 0) {
event.preventDefault();
runCommandItem(commandItems[commandSelection]);
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && qs('#cmd-palette').classList.contains('open')) {
e.preventDefault();
qs('#cmd-palette').classList.remove('open');
qs('#cmd-input').setAttribute('aria-expanded', 'false');
return;
}
if (e.key === 'Escape' && findingWork) {
e.preventDefault();
closeFindWorkSheet();
@ -1793,11 +1874,10 @@ textarea { resize: vertical; min-height: 120px; }
}
if ((e.metaKey||e.ctrlKey) && e.key==='k') {
e.preventDefault();
qs('#cmd-palette').classList.toggle('open');
if (qs('#cmd-palette').classList.contains('open')) {
qs('#cmd-input').focus();
renderCommands('');
}
qs('#cmd-palette').classList.remove('open');
qs('#cmd-input').setAttribute('aria-expanded', 'false');
} else openCommandPalette();
}
});
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));

View File

@ -183,6 +183,60 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
}
def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
if not isinstance(item, dict):
return None
repository = item.get("repository")
repository = repository if isinstance(repository, dict) else {}
url = _safe_web_url(item.get("html_url"))
if not (
isinstance(item.get("number"), int)
and isinstance(item.get("title"), str)
and isinstance(item.get("state"), str)
and isinstance(repository.get("full_name"), str)
and repository.get("full_name")
and url
):
return None
return {
"kind": kind,
"repository": repository["full_name"],
"number": item["number"],
"title": item["title"],
"state": item["state"],
"url": url,
}
async def global_search(query: str, limit: int = 10) -> list[dict]:
"""Search accessible issues and pulls concurrently with a bounded result set."""
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},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea global search response was not a list")
return payload
issues_payload, pulls_payload = await asyncio.gather(load("issues"), load("pulls"))
results = []
seen: set[tuple[str, str, int]] = set()
for payload, kind in ((issues_payload, "issue"), (pulls_payload, "pull")):
for item in payload:
normalized = _normalize_global_search_item(item, kind)
if normalized is not None:
identity = (kind, normalized["repository"], normalized["number"])
if identity in seen:
continue
seen.add(identity)
results.append(normalized)
return results
def _normalize_available_issue(item: Any) -> dict | None:
if (
not isinstance(item, dict)

View File

@ -66,6 +66,7 @@ AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES = 256
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
WORK_PAGE_TIMEOUT_SECONDS = 5.0
GLOBAL_SEARCH_TIMEOUT_SECONDS = 3.0
NOTIFICATION_DETAIL_TIMEOUT_SECONDS = 5.0
BULK_NOTIFICATION_CONCURRENCY = 5
BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0
@ -364,7 +365,7 @@ app.include_router(frontend_router)
@app.middleware("http")
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues"} or request.url.path.startswith("/api/v1/work/") or (
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search"} or request.url.path.startswith("/api/v1/work/") or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/review")
) or request.url.path.startswith("/api/v1/notifications") or (
@ -471,6 +472,28 @@ async def context() -> JSONResponse:
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
@app.get("/api/v1/search")
async def global_search(
q: str = Query(min_length=2, max_length=100),
limit: int = Query(default=10, ge=1, le=25),
) -> JSONResponse:
query = q.strip()
if len(query) < 2:
raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters")
try:
items = await asyncio.wait_for(
gitea_proxy.global_search(query, limit),
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
)
except Exception:
return JSONResponse(
{"error": "Search is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse({"query": query, "items": items})
@app.get("/api/v1/work/{stream}")
async def paged_work(
stream: Literal["issue", "pull", "review"],

View File

@ -19,5 +19,6 @@ def test_api_requests_resolve_inside_dashboard_subpath():
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/work/",
}

View File

@ -46,3 +46,61 @@ def test_command_script_resolves_inside_dashboard_subpath():
assert urljoin(
"https://forge.alexanderwhitestone.com/dashboard/", command_source
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
def test_remote_command_search_ignores_stale_responses():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
(async () => {{
const pending = new Map();
const states = [];
const controller = filterCommands.createGlobalSearchController({{
delay: 0,
search: query => new Promise(resolve => pending.set(query, resolve)),
onState: state => states.push(state),
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 0));
controller.setQuery('release');
await new Promise(resolve => setTimeout(resolve, 0));
pending.get('release')([{{ title: 'Current result' }}]);
await new Promise(resolve => setTimeout(resolve, 0));
pending.get('mobile')([{{ title: 'Stale result' }}]);
await new Promise(resolve => setTimeout(resolve, 0));
const ready = states.filter(state => state.status === 'ready');
if (ready.length !== 1) throw new Error(`expected one ready state, got ${{ready.length}}`);
if (ready[0].query !== 'release' || ready[0].items[0].title !== 'Current result') {{
throw new Error(`stale response replaced current state: ${{JSON.stringify(ready)}}`);
}}
}})().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))});
if (commands.nextSelection(-1, 'ArrowDown', 3) !== 0) throw new Error('should select first');
if (commands.nextSelection(2, 'ArrowDown', 3) !== 0) throw new Error('should wrap down');
if (commands.nextSelection(0, 'ArrowUp', 3) !== 2) throw new Error('should wrap up');
if (commands.nextSelection(1, 'Enter', 3) !== 1) throw new Error('other keys should not move');
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath():
html = (FRONTEND / "index.html").read_text()
assert 'role="combobox"' in html
assert 'aria-controls="cmd-results"' in html
assert 'role="listbox"' in html
assert "fetch('api/v1/search?q='" in html
assert urljoin(
"https://forge.alexanderwhitestone.com/dashboard/",
"api/v1/search?q=mobile",
) == "https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=mobile"

View File

@ -0,0 +1,83 @@
import httpx
import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_global_search_endpoint_returns_bounded_normalized_results(monkeypatch):
requested = []
async def search(query, limit):
requested.append((query, limit))
return [{
"kind": "issue",
"repository": "stackchain/api",
"number": 42,
"title": "Repair mobile queue",
"state": "open",
"url": "https://forge.example/stackchain/api/issues/42",
}]
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")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert requested == [("mobile", 7)]
assert response.json() == {"query": "mobile", "items": [{
"kind": "issue",
"repository": "stackchain/api",
"number": 42,
"title": "Repair mobile queue",
"state": "open",
"url": "https://forge.example/stackchain/api/issues/42",
}]}
@pytest.mark.anyio
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
requests = []
async def handler(request):
requests.append(dict(request.url.params))
kind = request.url.params["type"]
if kind == "issues":
return httpx.Response(200, json=[{
"id": 4, "number": 42, "title": "Repair queue", "state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/issues/42",
}, {
"id": 4, "number": 42, "title": "Repair queue", "state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/issues/42",
}, {
"id": 5, "number": 43, "title": "Unsafe", "state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "javascript:alert(1)",
}])
return httpx.Response(200, json=[{
"id": 8, "number": 9, "title": "Improve search", "state": "closed",
"repository": {"full_name": "stackchain/web"},
"html_url": "https://forge.example/stackchain/web/pulls/9",
}])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
results = await gitea_proxy.global_search("mobile queue", 7)
finally:
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 results == [{
"kind": "issue", "repository": "stackchain/api", "number": 42,
"title": "Repair queue", "state": "open",
"url": "https://forge.example/stackchain/api/issues/42",
}, {
"kind": "pull", "repository": "stackchain/web", "number": 9,
"title": "Improve search", "state": "closed",
"url": "https://forge.example/stackchain/web/pulls/9",
}]