Merge pull request 'Scope mobile Search by type and status' (#794) from timmy/793-scope-mobile-search into main
All checks were successful
CI / lint (push) Successful in 1m42s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s

Merge pull request 'Scope mobile Search by type and status' (#794) from timmy/793-scope-mobile-search into main
This commit is contained in:
timmy 2026-08-14 02:37:38 +00:00
commit 7636e30482
14 changed files with 315 additions and 37 deletions

View File

@ -408,6 +408,12 @@ filter, preserves the selected release lane, shows the current draft count, resp
the device safe area, and moves out of the way while a full-screen task is open. the device safe area, and moves out of the way while a full-screen task is open.
Desktop layout is unchanged. Desktop layout is unchanged.
Mobile **Search** provides touch-sized **Type** and **Status** controls. Operators can
scope results to issues, pull requests, open work, closed work, or all accessible work;
the server applies that scope before pagination. The selected scope is bounded and
addressable, survives preview/back, reload, sharing, and sign-in continuation, and a
scope change cancels obsolete requests before restarting at the first page.
My Work also has an account-synced **Later** queue. **Later today** defers an item for four My Work also has an account-synced **Later** queue. **Later today** defers an item for four
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time** hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**
accepts a valid future local date and time and returns the item at that exact instant; the picker accepts a valid future local date and time and returns the item at that exact instant; the picker

View File

@ -22,7 +22,8 @@
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 }; let scope = { kind:'all', state:'all' };
let state = { status: 'idle', query: '', items: [], more: false, next: 1, scope };
function publish(next) { function publish(next) {
state = next; state = next;
@ -33,7 +34,8 @@
const requestController = new AbortController(); const requestController = new AbortController();
activeController = requestController; activeController = requestController;
try { try {
const result = await search(query, requestController.signal, page); const requestScope = { ...scope };
const result = await search(query, requestController.signal, page, requestScope);
const partial = result.partial === true; const partial = result.partial === true;
const incoming = result.items || result; const incoming = result.items || result;
const combined = append ? state.items.concat(incoming) : incoming; const combined = append ? state.items.concat(incoming) : incoming;
@ -44,12 +46,13 @@
status: 'ready', query, items, partial, status: 'ready', query, items, partial,
more: !!result.has_more, more: !!result.has_more,
next: result.next_page, next: result.next_page,
scope:requestScope,
}); });
} catch (error) { } catch (error) {
if (error && error.name === 'AbortError') return; if (error && error.name === 'AbortError') return;
if (current === generation) publish(append if (current === generation) publish(append
? { ...state, status: 'ready' } ? { ...state, status: 'ready' }
: { status: 'error', query, items: [], error }); : { status: 'error', query, items: [], error, scope:{ ...scope } });
} finally { } finally {
if (activeController === requestController) activeController = null; if (activeController === requestController) activeController = null;
} }
@ -64,12 +67,22 @@
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 }); publish({ status: 'idle', query, items: [], more: false, next: 1, scope:{ ...scope } });
return; return;
} }
publish({ status: 'loading', query, items: [], more: false, next: 1 }); publish({ status: 'loading', query, items: [], more: false, next: 1, scope:{ ...scope } });
timer = setTimeout(() => requestPage(query, 1, current, false), delay); timer = setTimeout(() => requestPage(query, 1, current, false), delay);
}, },
setScope(value) {
const next = {
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
};
if (next.kind === scope.kind && next.state === scope.state) return;
const query = state.query;
scope = next;
this.setQuery(query);
},
loadMore() { loadMore() {
if (state.status !== 'ready' || !state.more || activeController) return; if (state.status !== 'ready' || !state.more || activeController) return;
const current = generation; const current = generation;

View File

@ -114,6 +114,10 @@ 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 { 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-palette.open { display: block; }
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; } .cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
.cmd-search-scope { display:grid; grid-template-columns:auto minmax(120px,1fr) auto minmax(120px,1fr); gap:6px 10px; align-items:center; margin:8px 0 0; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
.cmd-search-scope legend { padding:0 4px; color:#93a4b8; font-size:12px; }
.cmd-search-scope label { font-size:12px; color:#cbd5e1; }
.cmd-search-scope select { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; } #cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
.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 { 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-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
@ -685,6 +689,8 @@ textarea { resize: vertical; min-height: 120px; }
.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, #cmd-load-more { min-height:44px; }
#cmd-input { flex:0 0 auto; min-height:44px; } #cmd-input { flex:0 0 auto; min-height:44px; }
.cmd-search-scope { grid-template-columns:auto minmax(0,1fr); }
#cmd-search-kind, #cmd-search-state { min-height:44px; width:100%; }
#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; }
.pull-sheet-panel { width:100%; border-left:0; padding:14px; } .pull-sheet-panel { width:100%; border-left:0; padding:14px; }

View File

@ -4285,8 +4285,9 @@
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, page = 1, scope = {kind:'all', state:'all'}) {
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&page=' + page +
'&kind=' + encodeURIComponent(scope.kind) + '&state=' + encodeURIComponent(scope.state), {
headers: { Accept:'application/json' }, headers: { Accept:'application/json' },
signal, signal,
}); });
@ -4301,6 +4302,14 @@
renderCommands(state.query); renderCommands(state.query);
}, },
}); });
function currentSearchScope() {
return { kind:qs('#cmd-search-kind').value, state:qs('#cmd-search-state').value };
}
function applySearchScope(scope = {kind:'all', state:'all'}) {
qs('#cmd-search-kind').value = scope.kind;
qs('#cmd-search-state').value = scope.state;
commandSearch.setScope(scope);
}
function safeSearchUrl(value) { function safeSearchUrl(value) {
try { try {
const url = new URL(value); const url = new URL(value);
@ -4393,8 +4402,11 @@
onState: renderSearchPreview, onState: renderSearchPreview,
}); });
function canonicalSearchPreviewUrl() { function canonicalSearchPreviewUrl() {
const { query, preview } = taskOverlayHistory.currentState(); const { query, preview, scope = currentSearchScope() } = taskOverlayHistory.currentState();
const params = new URLSearchParams({ search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number }); const params = new URLSearchParams({
search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number,
search_kind:scope.kind, search_state:scope.state,
});
return new URL('?' + params, window.location.origin + window.location.pathname).href; return new URL('?' + params, window.location.origin + window.location.pathname).href;
} }
function createSearchStart(claim) { function createSearchStart(claim) {
@ -4458,7 +4470,9 @@
mobileSearchViewport.rememberScroll(); mobileSearchViewport.rememberScroll();
searchPreviewReturnKind = 'search'; searchPreviewReturnKind = 'search';
searchPreview.open(item.result).catch(() => {}); searchPreview.open(item.result).catch(() => {});
taskOverlayHistory.open('search-preview', { query:qs('#cmd-input').value, preview:item.result }); taskOverlayHistory.open('search-preview', {
query:qs('#cmd-input').value, scope:currentSearchScope(), preview:item.result,
});
} }
qs('#cmd-palette').classList.remove('open'); qs('#cmd-palette').classList.remove('open');
qs('#cmd-input').setAttribute('aria-expanded', 'false'); qs('#cmd-input').setAttribute('aria-expanded', 'false');
@ -4491,7 +4505,7 @@
} }
function openCommandPalette(navigate = true) { function openCommandPalette(navigate = true) {
if (navigate) { if (navigate) {
taskOverlayHistory.open('search'); taskOverlayHistory.open('search', { scope:currentSearchScope() });
return; return;
} }
qs('#cmd-palette').classList.add('open'); qs('#cmd-palette').classList.add('open');
@ -4541,6 +4555,7 @@
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false); if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false); if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
if (kind === 'search' && previous !== 'search-preview') { if (kind === 'search' && previous !== 'search-preview') {
if (detail?.scope) applySearchScope(detail.scope);
if (detail?.query !== undefined) { if (detail?.query !== undefined) {
qs('#cmd-input').value = detail.query; qs('#cmd-input').value = detail.query;
commandSearch.setQuery(detail.query); commandSearch.setQuery(detail.query);
@ -4548,6 +4563,7 @@
openCommandPalette(false); openCommandPalette(false);
} }
if (kind === 'search-preview' && detail?.preview && previous !== 'search') { if (kind === 'search-preview' && detail?.preview && previous !== 'search') {
if (detail?.scope) applySearchScope(detail.scope);
if (detail?.query !== undefined) qs('#cmd-input').value = detail.query; if (detail?.query !== undefined) qs('#cmd-input').value = detail.query;
searchPreviewReturnKind = 'search'; searchPreviewReturnKind = 'search';
searchPreview.open(detail.preview).catch(() => taskOverlayHistory.close()); searchPreview.open(detail.preview).catch(() => taskOverlayHistory.close());
@ -4560,6 +4576,14 @@
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-load-more').addEventListener('click', () => commandSearch.loadMore());
function changeSearchScope() {
const scope = currentSearchScope();
commandSelection = -1;
taskOverlayHistory.update({ scope });
commandSearch.setScope(scope);
}
qs('#cmd-search-kind').addEventListener('change', changeSearchScope);
qs('#cmd-search-state').addEventListener('change', changeSearchScope);
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 });

View File

@ -429,6 +429,21 @@
<button id="close-command-palette" type="button">Close</button> <button id="close-command-palette" type="button">Close</button>
</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..." />
<fieldset class="cmd-search-scope">
<legend>Filter search results</legend>
<label for="cmd-search-kind">Type</label>
<select id="cmd-search-kind">
<option value="all">All work</option>
<option value="issue">Issues</option>
<option value="pull">Pull requests</option>
</select>
<label for="cmd-search-state">Status</label>
<select id="cmd-search-state">
<option value="all">Any status</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
</fieldset>
<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> <button id="cmd-load-more" hidden>More results</button>
</div> </div>

View File

@ -14,6 +14,13 @@
return query.length <= maxQueryLength ? query : ''; return query.length <= maxQueryLength ? query : '';
} }
function cleanScope(value) {
return {
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
};
}
function cleanPreview(value) { function cleanPreview(value) {
if (!value || !['issue', 'pull'].includes(value.kind)) return null; if (!value || !['issue', 'pull'].includes(value.kind)) return null;
const repository = typeof value.repository === 'string' ? value.repository : ''; const repository = typeof value.repository === 'string' ? value.repository : '';
@ -41,11 +48,12 @@
const kind = allowed.has(state?.taskOverlay) ? state.taskOverlay : null; const kind = allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
if (!searchKinds.has(kind)) return { kind }; if (!searchKinds.has(kind)) return { kind };
const query = cleanQuery(state.searchQuery); const query = cleanQuery(state.searchQuery);
const scope = state.searchScope === undefined ? null : cleanScope(state.searchScope);
const preview = kind === 'search-preview' ? cleanPreview(state.searchPreview) : null; const preview = kind === 'search-preview' ? cleanPreview(state.searchPreview) : null;
if (kind === 'search-preview' && state.searchPreview !== undefined && !preview) { if (kind === 'search-preview' && state.searchPreview !== undefined && !preview) {
return { kind:'search', ...(query ? { query } : {}) }; return { kind:'search', ...(query ? { query } : {}) };
} }
return { kind, ...(query ? { query } : {}), ...(preview ? { preview } : {}) }; return { kind, ...(query ? { query } : {}), ...(scope ? { scope } : {}), ...(preview ? { preview } : {}) };
} }
function urlDetail() { function urlDetail() {
@ -56,9 +64,14 @@
const query = cleanQuery(rawQuery); const query = cleanQuery(rawQuery);
if (rawQuery.trim() && !query) return { kind:null }; if (rawQuery.trim() && !query) return { kind:null };
const preview = parsePreview(params.get('preview')); const preview = parsePreview(params.get('preview'));
const hasScope = params.has('search_kind') || params.has('search_state');
const scope = hasScope ? cleanScope({
kind:params.get('search_kind'), state:params.get('search_state'),
}) : null;
return { return {
kind:preview ? 'search-preview' : 'search', kind:preview ? 'search-preview' : 'search',
...(query ? { query } : {}), ...(query ? { query } : {}),
...(scope ? { scope } : {}),
...(preview ? { preview } : {}), ...(preview ? { preview } : {}),
}; };
} }
@ -67,8 +80,10 @@
const state = { ...(base || {}), taskOverlay:detail.kind }; const state = { ...(base || {}), taskOverlay:detail.kind };
delete state.searchQuery; delete state.searchQuery;
delete state.searchPreview; delete state.searchPreview;
delete state.searchScope;
if (searchKinds.has(detail.kind)) { if (searchKinds.has(detail.kind)) {
if (detail.query) state.searchQuery = detail.query; if (detail.query) state.searchQuery = detail.query;
if (detail.scope) state.searchScope = cleanScope(detail.scope);
if (detail.kind === 'search-preview' && detail.preview) state.searchPreview = detail.preview; if (detail.kind === 'search-preview' && detail.preview) state.searchPreview = detail.preview;
} }
return state; return state;
@ -79,8 +94,14 @@
const params = new URLSearchParams(location.search || ''); const params = new URLSearchParams(location.search || '');
params.delete('search'); params.delete('search');
params.delete('preview'); params.delete('preview');
params.delete('search_kind');
params.delete('search_state');
if (searchKinds.has(detail.kind)) { if (searchKinds.has(detail.kind)) {
params.set('search', detail.query || ''); params.set('search', detail.query || '');
if (detail.scope) {
params.set('search_kind', detail.scope.kind);
params.set('search_state', detail.scope.state);
}
if (detail.kind === 'search-preview' && detail.preview) params.set('preview', previewToken(detail.preview)); if (detail.kind === 'search-preview' && detail.preview) params.set('preview', previewToken(detail.preview));
} }
const query = params.toString(); const query = params.toString();
@ -115,7 +136,11 @@
}, },
open(kind, detail = {}) { open(kind, detail = {}) {
if (!allowed.has(kind)) return false; if (!allowed.has(kind)) return false;
const next = stateDetail(toState({ kind, query:cleanQuery(detail.query), preview:cleanPreview(detail.preview) })); const next = stateDetail(toState({
kind, query:cleanQuery(detail.query),
scope:detail.scope === undefined ? undefined : cleanScope(detail.scope),
preview:cleanPreview(detail.preview),
}));
if (active.kind === next.kind && JSON.stringify(active) === JSON.stringify(next)) return true; if (active.kind === next.kind && JSON.stringify(active) === JSON.stringify(next)) return true;
const previous = active.kind; const previous = active.kind;
history.pushState(toState(next), '', urlFor(next)); history.pushState(toState(next), '', urlFor(next));
@ -128,6 +153,7 @@
const next = stateDetail(toState({ const next = stateDetail(toState({
kind:active.kind, kind:active.kind,
query:detail.query === undefined ? active.query : cleanQuery(detail.query), query:detail.query === undefined ? active.query : cleanQuery(detail.query),
scope:detail.scope === undefined ? active.scope : cleanScope(detail.scope),
preview:detail.preview === undefined ? active.preview : cleanPreview(detail.preview), preview:detail.preview === undefined ? active.preview : cleanPreview(detail.preview),
})); }));
history.replaceState(toState(next), '', urlFor(next)); history.replaceState(toState(next), '', urlFor(next));
@ -146,6 +172,7 @@
delete state.taskOverlay; delete state.taskOverlay;
delete state.searchQuery; delete state.searchQuery;
delete state.searchPreview; delete state.searchPreview;
delete state.searchScope;
active = { kind:null }; active = { kind:null };
history.replaceState(state, '', urlFor(active)); history.replaceState(state, '', urlFor(active));
onChange(null, previous, active); onChange(null, previous, active);

View File

@ -28,7 +28,7 @@ FEATURE_SOURCES = {
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",), "security-center": ("static/security-center.js",),
"today-timer": ( "today-timer": (
"static/task-overlay-history.js", "static/search-preview.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/commands.js", "static/task-overlay-history.js", "static/search-preview.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js", "static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js", "static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",

View File

@ -463,14 +463,27 @@ 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,
page: int = 1,
kind: str = "all",
state: str = "all",
) -> dict:
"""Search accessible issues and pulls concurrently with balanced pagination.""" """Search accessible issues and pulls concurrently with balanced pagination."""
stream_limit = (limit + 1) // 2 item_types = ("issues", "pulls") if kind == "all" else (
"issues" if kind == "issue" else "pulls",
)
stream_limit = (limit + 1) // 2 if kind == "all" else limit
async def load(item_type: str) -> Any: async def load(item_type: str) -> Any:
params = {
"q": query, "type": item_type, "state": state,
"limit": stream_limit, "page": page,
}
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=params,
) )
response.raise_for_status() response.raise_for_status()
payload = response.json() payload = response.json()
@ -478,9 +491,7 @@ async def global_search(query: str, limit: int = 10, page: int = 1) -> dict:
raise ValueError("Gitea global search response was not a list") raise ValueError("Gitea global search response was not a list")
return payload return payload
outcomes = await asyncio.gather( outcomes = await asyncio.gather(*(load(item_type) for item_type in item_types), return_exceptions=True)
load("issues"), load("pulls"), return_exceptions=True
)
for outcome in outcomes: for outcome in outcomes:
if isinstance(outcome, asyncio.CancelledError): if isinstance(outcome, asyncio.CancelledError):
raise outcome raise outcome
@ -489,15 +500,16 @@ async def global_search(query: str, limit: int = 10, page: int = 1) -> dict:
streams: list[list[dict]] = [] streams: list[list[dict]] = []
seen: set[tuple[str, str, int]] = set() seen: set[tuple[str, str, int]] = set()
for outcome, kind in zip(outcomes, ("issue", "pull"), strict=True): result_kinds = tuple("issue" if item_type == "issues" else "pull" for item_type in item_types)
for outcome, result_kind in zip(outcomes, result_kinds, strict=True):
normalized_stream = [] normalized_stream = []
if isinstance(outcome, BaseException): if isinstance(outcome, BaseException):
streams.append(normalized_stream) 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, result_kind)
if normalized is not None: if normalized is not None:
identity = (kind, normalized["repository"], normalized["number"]) identity = (result_kind, normalized["repository"], normalized["number"])
if identity in seen: if identity in seen:
continue continue
seen.add(identity) seen.add(identity)

View File

@ -1006,11 +1006,15 @@ def _share_target_login_redirect(request: Request) -> str:
search_values = request.query_params.getlist("search") search_values = request.query_params.getlist("search")
preview_values = request.query_params.getlist("preview") preview_values = request.query_params.getlist("preview")
if search_values or preview_values: if search_values or preview_values:
allowed = {"search", "preview"} allowed = {"search", "preview", "search_kind", "search_state"}
kind_values = request.query_params.getlist("search_kind")
state_values = request.query_params.getlist("search_state")
if ( if (
set(request.query_params.keys()) - allowed set(request.query_params.keys()) - allowed
or len(search_values) != 1 or len(search_values) != 1
or len(preview_values) != 1 or len(preview_values) != 1
or len(kind_values) > 1
or len(state_values) > 1
or len(search_values[0]) > 200 or len(search_values[0]) > 200
or len(preview_values[0]) > 200 or len(preview_values[0]) > 200
or not re.fullmatch( or not re.fullmatch(
@ -1019,9 +1023,14 @@ def _share_target_login_redirect(request: Request) -> str:
) )
): ):
return "login" return "login"
continuation = urlencode( continuation_values = [
(("search", search_values[0].strip()), ("preview", preview_values[0])) ("search", search_values[0].strip()), ("preview", preview_values[0]),
) ]
if kind_values and kind_values[0] in {"all", "issue", "pull"}:
continuation_values.append(("search_kind", kind_values[0]))
if state_values and state_values[0] in {"all", "open", "closed"}:
continuation_values.append(("search_state", state_values[0]))
continuation = urlencode(continuation_values)
return f"login?{urlencode({'continue': f'./?{continuation}'})}" return f"login?{urlencode({'continue': f'./?{continuation}'})}"
limits = {"title": 200, "text": 8000, "url": 2048} limits = {"title": 200, "text": 8000, "url": 2048}
if any( if any(
@ -2712,13 +2721,15 @@ 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), page: int = Query(default=1, ge=1, le=100),
kind: Literal["all", "issue", "pull"] = Query(default="all"),
state: Literal["all", "open", "closed"] = Query(default="all"),
) -> 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, page, kind, state),
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS, timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
) )
except Exception: except Exception:
@ -2727,7 +2738,7 @@ async def global_search(
status_code=503, status_code=503,
headers={"Retry-After": "1"}, headers={"Retry-After": "1"},
) )
return JSONResponse({"query": query, **result}) return JSONResponse({"query": query, "scope": {"kind": kind, "state": state}, **result})
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview") @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview")

View File

@ -52,6 +52,25 @@ def test_command_script_resolves_inside_dashboard_subpath():
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js" ) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
def test_mobile_search_renders_touch_sized_type_and_status_scope_controls():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
assert '<fieldset class="cmd-search-scope"' in html
assert '<legend>Filter search results</legend>' in html
assert '<label for="cmd-search-kind">Type</label>' in html
assert '<select id="cmd-search-kind"' in html
assert '<option value="pull">Pull requests</option>' in html
assert '<label for="cmd-search-state">Status</label>' in html
assert '<select id="cmd-search-state"' in html
assert '.cmd-search-scope' in css
assert '#cmd-search-kind, #cmd-search-state { min-height:44px;' in css
assert "commandSearch.setScope(scope)" in html
assert "taskOverlayHistory.update({ scope })" in html
assert "'&kind=' + encodeURIComponent(scope.kind)" in html
assert "'&state=' + encodeURIComponent(scope.state)" in html
def test_remote_command_search_ignores_stale_responses(): def test_remote_command_search_ignores_stale_responses():
script = f""" script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))}); const filterCommands = require({json.dumps(str(COMMANDS))});
@ -85,6 +104,37 @@ if (ready[0].query !== 'release' || ready[0].items[0].title !== 'Current result'
) )
def test_remote_command_search_scope_change_aborts_and_restarts_without_stale_results():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
(async () => {{
const pending = [];
const states = [];
const controller = filterCommands.createGlobalSearchController({{
delay: 0,
search: (query, signal, page, scope) => new Promise(resolve => pending.push({{query, signal, page, scope, resolve}})),
onState: state => states.push(state),
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 0));
controller.setScope({{kind:'pull', state:'open'}});
await new Promise(resolve => setTimeout(resolve, 0));
if (!pending[0].signal.aborted) throw new Error('scope change did not abort old request');
if (JSON.stringify(pending[1].scope) !== JSON.stringify({{kind:'pull', state:'open'}})) {{
throw new Error('new request did not receive scope');
}}
pending[1].resolve({{items:[{{kind:'pull',repository:'a/b',number:2}}],has_more:false,next_page:2}});
pending[0].resolve({{items:[{{kind:'issue',repository:'a/b',number:1}}],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.items.length !== 1 || ready.items[0].kind !== 'pull') throw new Error('stale scope result entered list');
if (ready.scope.kind !== 'pull' || ready.scope.state !== 'open') throw new Error('scope missing from state');
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_remote_command_search_aborts_superseded_and_cleared_queries(): def test_remote_command_search_aborts_superseded_and_cleared_queries():
script = f""" script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))}); const filterCommands = require({json.dumps(str(COMMANDS))});
@ -193,7 +243,7 @@ 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, page = 1, scope" 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 'id="cmd-load-more"' in html

View File

@ -918,6 +918,30 @@ async def test_anonymous_search_preview_preserves_only_bounded_canonical_continu
assert oversized.headers["location"] == "login" assert oversized.headers["location"] == "login"
@pytest.mark.anyio
async def test_anonymous_search_preview_preserves_only_valid_search_scope(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
valid = await client.get("/", params={
"search": "release blocker", "preview": "pull:stackchain/api:42",
"search_kind": "pull", "search_state": "open",
})
invalid = await client.get("/", params={
"search": "release blocker", "preview": "pull:stackchain/api:42",
"search_kind": "script", "search_state": "secret",
})
valid_continue = parse_qs(urlsplit(valid.headers["location"]).query)["continue"][0]
invalid_continue = parse_qs(urlsplit(invalid.headers["location"]).query)["continue"][0]
assert valid_continue.endswith(
"search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42&"
"search_kind=pull&search_state=open"
)
assert invalid_continue == (
"./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42"
)
@pytest.mark.anyio @pytest.mark.anyio
async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control): async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control):
transport = httpx.ASGITransport(app=main.app) transport = httpx.ASGITransport(app=main.app)

View File

@ -66,6 +66,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
security_center = first.feature_bundles["security-center"] security_center = first.feature_bundles["security-center"]
assert b"function attachSecurityCenter" not in first.runtime_bytes assert b"function attachSecurityCenter" not in first.runtime_bytes
assert b"function attachSecurityCenter" in security_center.runtime_bytes assert b"function attachSecurityCenter" in security_center.runtime_bytes
assert b"function createGlobalSearchController" not in first.runtime_bytes
assert b"function createGlobalSearchController" in first.feature_bundles["today-timer"].runtime_bytes
assert b"gitea_time_logged" not in first.runtime_bytes assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes assert b"gitea_time_logged" in security_center.runtime_bytes
# Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions. # Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions.

View File

@ -6,12 +6,60 @@ import pytest
from src import gitea_proxy, main from src import gitea_proxy, main
@pytest.mark.anyio
async def test_global_search_endpoint_forwards_valid_type_and_status_scope(monkeypatch):
requested = []
async def search(query, limit, page, kind, state):
requested.append((query, limit, page, kind, state))
return {"items": [], "partial": False, "has_more": False, "next_page": 2}
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&page=1&kind=pull&state=open"
)
assert response.status_code == 200
assert requested == [("mobile", 7, 1, "pull", "open")]
assert response.json()["scope"] == {"kind": "pull", "state": "open"}
@pytest.mark.anyio
async def test_global_search_specific_type_uses_one_full_width_scoped_stream():
requests = []
async def handler(request):
requests.append(dict(request.url.params))
return httpx.Response(200, json=[{
"number": 9,
"title": "Review mobile search",
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": "https://forge.example/stackchain/web/pulls/9",
}])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.global_search(
"mobile", limit=7, page=2, kind="pull", state="open"
)
finally:
await gitea_proxy.stop_client()
assert requests == [{
"q": "mobile", "type": "pulls", "state": "open", "limit": "7", "page": "2"
}]
assert [item["kind"] for item in result["items"]] == ["pull"]
@pytest.mark.anyio @pytest.mark.anyio
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, page, kind, state):
requested.append((query, limit, page)) requested.append((query, limit, page, kind, state))
return { return {
"items": [{ "items": [{
"kind": "issue", "kind": "issue",
@ -31,8 +79,8 @@ async def test_global_search_endpoint_returns_bounded_normalized_results(monkeyp
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, 2, "all", "all")]
assert response.json() == {"query": "mobile", "items": [{ assert response.json() == {"query": "mobile", "scope": {"kind": "all", "state": "all"}, "items": [{
"kind": "issue", "kind": "issue",
"repository": "stackchain/api", "repository": "stackchain/api",
"number": 42, "number": 42,
@ -110,7 +158,12 @@ 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"] == "4"
and request["state"] == "all"
for request in requests
)
assert results == { assert results == {
"items": [{ "items": [{
"kind": "issue", "repository": "stackchain/api", "number": 42, "kind": "issue", "repository": "stackchain/api", "number": 42,

View File

@ -207,7 +207,7 @@ def test_dashboard_routes_mobile_task_overlays_through_browser_history():
assert "createTaskOverlayHistory({" in html assert "createTaskOverlayHistory({" in html
assert "taskOverlayHistory.open('new')" in html assert "taskOverlayHistory.open('new')" in html
assert "taskOverlayHistory.open('find')" in html assert "taskOverlayHistory.open('find')" in html
assert "taskOverlayHistory.open('search')" in html assert "taskOverlayHistory.open('search', { scope:currentSearchScope() })" in html
assert "taskOverlayHistory.open('search-preview')" in html assert "taskOverlayHistory.open('search-preview')" in html
assert "taskOverlayHistory.close()" in html assert "taskOverlayHistory.close()" in html
assert "saveIssueCaptureDraft();" in html assert "saveIssueCaptureDraft();" in html
@ -290,10 +290,45 @@ process.stdout.write(JSON.stringify({{
assert payload["oversized"]["state"] == {"kind": None} assert payload["oversized"]["state"] == {"kind": None}
def test_search_history_round_trips_only_bounded_type_and_status_scope():
script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
function restore(search) {{
const location = {{pathname:'/dashboard/', search, hash:''}};
const history = {{state:null, replaceState(state, title, url) {{this.state=state; this.url=url;}}, back() {{}}}};
const controller = createTaskOverlayHistory({{
history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
}});
return {{state:controller.currentState(), url:history.url}};
}}
const location = {{pathname:'/dashboard/', search:'', hash:''}};
const history = {{state:null, pushState(state,title,url) {{this.state=state; this.url=url;}}, replaceState() {{}}, back() {{}}}};
const controller = createTaskOverlayHistory({{
history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
}});
controller.open('search', {{query:'mobile', scope:{{kind:'pull',state:'open'}}}});
process.stdout.write(JSON.stringify({{
opened:controller.currentState(), url:history.url,
valid:restore('?search=mobile&search_kind=issue&search_state=closed'),
invalid:restore('?search=mobile&search_kind=script&search_state=secret'),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["opened"] == {
"kind": "search", "query": "mobile", "scope": {"kind": "pull", "state": "open"}
}
assert payload["url"] == "/dashboard/?search=mobile&search_kind=pull&search_state=open"
assert payload["valid"]["state"]["scope"] == {"kind": "issue", "state": "closed"}
assert payload["invalid"]["state"]["scope"] == {"kind": "all", "state": "all"}
def test_dashboard_persists_search_query_and_restores_canonical_preview(): def test_dashboard_persists_search_query_and_restores_canonical_preview():
html = dashboard_bundle_text() html = dashboard_bundle_text()
assert "taskOverlayHistory.update({ query:e.target.value })" in html assert "taskOverlayHistory.update({ query:e.target.value })" in html
assert "taskOverlayHistory.open('search-preview', { query:qs('#cmd-input').value, preview:item.result })" in html assert "scope:currentSearchScope(), preview:item.result" in html
assert "detail?.query" in html assert "detail?.query" in html
assert "searchPreview.open(detail.preview).catch" in html assert "searchPreview.open(detail.preview).catch" in html