feat: scope mobile search by type and status (Closes #793)
This commit is contained in:
1 parent
3c65bc1b31
commit
bd054998b2
14 files changed
+315
-37
No files matched your search
+18
-5
@@ -22,7 +22,8 @@
|
||||
let timer = null;
|
||||
let generation = 0;
|
||||
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) {
|
||||
state = next;
|
||||
@@ -33,7 +34,8 @@
|
||||
const requestController = new AbortController();
|
||||
activeController = requestController;
|
||||
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 incoming = result.items || result;
|
||||
const combined = append ? state.items.concat(incoming) : incoming;
|
||||
@@ -44,12 +46,13 @@
|
||||
status: 'ready', query, items, partial,
|
||||
more: !!result.has_more,
|
||||
next: result.next_page,
|
||||
scope:requestScope,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error && error.name === 'AbortError') return;
|
||||
if (current === generation) publish(append
|
||||
? { ...state, status: 'ready' }
|
||||
: { status: 'error', query, items: [], error });
|
||||
: { status: 'error', query, items: [], error, scope:{ ...scope } });
|
||||
} finally {
|
||||
if (activeController === requestController) activeController = null;
|
||||
}
|
||||
@@ -64,12 +67,22 @@
|
||||
if (activeController !== null) activeController.abort();
|
||||
activeController = null;
|
||||
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;
|
||||
}
|
||||
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);
|
||||
},
|
||||
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() {
|
||||
if (state.status !== 'ready' || !state.more || activeController) return;
|
||||
const current = generation;
|
||||
|
||||
@@ -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.open { display: block; }
|
||||
.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-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; }
|
||||
@@ -685,6 +689,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.cmd-palette-header { display:flex; flex:0 0 auto; min-height:44px; }
|
||||
#close-command-palette, #cmd-load-more { 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); }
|
||||
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
|
||||
+30
-6
@@ -4285,8 +4285,9 @@
|
||||
let commandSearchState = { status:'idle', query:'', items:[] };
|
||||
let commandItems = [];
|
||||
let commandSelection = -1;
|
||||
async function searchGlobalWork(query, signal, page = 1) {
|
||||
const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10&page=' + page, {
|
||||
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 +
|
||||
'&kind=' + encodeURIComponent(scope.kind) + '&state=' + encodeURIComponent(scope.state), {
|
||||
headers: { Accept:'application/json' },
|
||||
signal,
|
||||
});
|
||||
@@ -4301,6 +4302,14 @@
|
||||
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) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
@@ -4393,8 +4402,11 @@
|
||||
onState: renderSearchPreview,
|
||||
});
|
||||
function canonicalSearchPreviewUrl() {
|
||||
const { query, preview } = taskOverlayHistory.currentState();
|
||||
const params = new URLSearchParams({ search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number });
|
||||
const { query, preview, scope = currentSearchScope() } = taskOverlayHistory.currentState();
|
||||
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;
|
||||
}
|
||||
function createSearchStart(claim) {
|
||||
@@ -4458,7 +4470,9 @@
|
||||
mobileSearchViewport.rememberScroll();
|
||||
searchPreviewReturnKind = 'search';
|
||||
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-input').setAttribute('aria-expanded', 'false');
|
||||
@@ -4491,7 +4505,7 @@
|
||||
}
|
||||
function openCommandPalette(navigate = true) {
|
||||
if (navigate) {
|
||||
taskOverlayHistory.open('search');
|
||||
taskOverlayHistory.open('search', { scope:currentSearchScope() });
|
||||
return;
|
||||
}
|
||||
qs('#cmd-palette').classList.add('open');
|
||||
@@ -4541,6 +4555,7 @@
|
||||
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
|
||||
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
|
||||
if (kind === 'search' && previous !== 'search-preview') {
|
||||
if (detail?.scope) applySearchScope(detail.scope);
|
||||
if (detail?.query !== undefined) {
|
||||
qs('#cmd-input').value = detail.query;
|
||||
commandSearch.setQuery(detail.query);
|
||||
@@ -4548,6 +4563,7 @@
|
||||
openCommandPalette(false);
|
||||
}
|
||||
if (kind === 'search-preview' && detail?.preview && previous !== 'search') {
|
||||
if (detail?.scope) applySearchScope(detail.scope);
|
||||
if (detail?.query !== undefined) qs('#cmd-input').value = detail.query;
|
||||
searchPreviewReturnKind = 'search';
|
||||
searchPreview.open(detail.preview).catch(() => taskOverlayHistory.close());
|
||||
@@ -4560,6 +4576,14 @@
|
||||
qs('#open-palette').addEventListener('click', openCommandPalette);
|
||||
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
|
||||
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) => {
|
||||
commandSelection = -1;
|
||||
taskOverlayHistory.update({ query:e.target.value });
|
||||
|
||||
@@ -429,6 +429,21 @@
|
||||
<button id="close-command-palette" type="button">Close</button>
|
||||
</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..." />
|
||||
<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>
|
||||
<button id="cmd-load-more" hidden>More results</button>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,13 @@
|
||||
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) {
|
||||
if (!value || !['issue', 'pull'].includes(value.kind)) return null;
|
||||
const repository = typeof value.repository === 'string' ? value.repository : '';
|
||||
@@ -41,11 +48,12 @@
|
||||
const kind = allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
|
||||
if (!searchKinds.has(kind)) return { kind };
|
||||
const query = cleanQuery(state.searchQuery);
|
||||
const scope = state.searchScope === undefined ? null : cleanScope(state.searchScope);
|
||||
const preview = kind === 'search-preview' ? cleanPreview(state.searchPreview) : null;
|
||||
if (kind === 'search-preview' && state.searchPreview !== undefined && !preview) {
|
||||
return { kind:'search', ...(query ? { query } : {}) };
|
||||
}
|
||||
return { kind, ...(query ? { query } : {}), ...(preview ? { preview } : {}) };
|
||||
return { kind, ...(query ? { query } : {}), ...(scope ? { scope } : {}), ...(preview ? { preview } : {}) };
|
||||
}
|
||||
|
||||
function urlDetail() {
|
||||
@@ -56,9 +64,14 @@
|
||||
const query = cleanQuery(rawQuery);
|
||||
if (rawQuery.trim() && !query) return { kind:null };
|
||||
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 {
|
||||
kind:preview ? 'search-preview' : 'search',
|
||||
...(query ? { query } : {}),
|
||||
...(scope ? { scope } : {}),
|
||||
...(preview ? { preview } : {}),
|
||||
};
|
||||
}
|
||||
@@ -67,8 +80,10 @@
|
||||
const state = { ...(base || {}), taskOverlay:detail.kind };
|
||||
delete state.searchQuery;
|
||||
delete state.searchPreview;
|
||||
delete state.searchScope;
|
||||
if (searchKinds.has(detail.kind)) {
|
||||
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;
|
||||
}
|
||||
return state;
|
||||
@@ -79,8 +94,14 @@
|
||||
const params = new URLSearchParams(location.search || '');
|
||||
params.delete('search');
|
||||
params.delete('preview');
|
||||
params.delete('search_kind');
|
||||
params.delete('search_state');
|
||||
if (searchKinds.has(detail.kind)) {
|
||||
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));
|
||||
}
|
||||
const query = params.toString();
|
||||
@@ -115,7 +136,11 @@
|
||||
},
|
||||
open(kind, detail = {}) {
|
||||
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;
|
||||
const previous = active.kind;
|
||||
history.pushState(toState(next), '', urlFor(next));
|
||||
@@ -128,6 +153,7 @@
|
||||
const next = stateDetail(toState({
|
||||
kind:active.kind,
|
||||
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),
|
||||
}));
|
||||
history.replaceState(toState(next), '', urlFor(next));
|
||||
@@ -146,6 +172,7 @@
|
||||
delete state.taskOverlay;
|
||||
delete state.searchQuery;
|
||||
delete state.searchPreview;
|
||||
delete state.searchScope;
|
||||
active = { kind:null };
|
||||
history.replaceState(state, '', urlFor(active));
|
||||
onChange(null, previous, active);
|
||||
|
||||
Reference in new issue
Block a user