feat: sync saved mobile search views (Closes #821)
CI / lint (pull_request) Successful in 1m46s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-14 10:46:13 +00:00
1 parent 6314efb82b
commit 19772b5797
13 files changed
+593 -4

No files matched your search

+12
View File
@@ -121,6 +121,15 @@ textarea { resize: vertical; min-height: 120px; }
.cmd-search-scope select { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
.cmd-search-scope input { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
.cmd-repository-status { grid-column:2 / -1; min-height:16px; color:#93a4b8; font-size:12px; }
.saved-searches { margin-top:8px; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
.saved-search-header, .saved-search-create, .saved-search-row, .saved-search-row-actions { display:flex; align-items:center; gap:8px; }
.saved-search-header { justify-content:space-between; }
.saved-search-create { margin-top:6px; }
.saved-search-create input { flex:1; min-width:0; }
.saved-search-list { display:grid; gap:4px; margin-top:6px; }
.saved-search-row { justify-content:space-between; border-top:1px solid #1f3a5f; padding-top:4px; }
.saved-search-open { flex:1; min-width:0; text-align:left; }
.saved-search-action { min-height:44px; }
#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; }
@@ -733,6 +742,9 @@ textarea { resize: vertical; min-height: 120px; }
.cmd-search-scope { grid-template-columns:auto minmax(0,1fr); }
#cmd-search-kind, #cmd-search-state { min-height:44px; width:100%; }
#cmd-search-repository { min-height:44px; width:100%; }
.saved-search-create { align-items:stretch; }
.saved-search-create input, .saved-search-open, .saved-search-row-actions button { min-height:44px; }
.saved-search-row { align-items:stretch; }
#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; }
+3
View File
@@ -4613,6 +4613,9 @@
},
});
taskOverlayHistory.start();
createSavedSearches.mount(
document, fetch, commandSearch, applySearchScope, taskOverlayHistory
).load();
qs('#open-palette').addEventListener('click', openCommandPalette);
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
qs('#cmd-load-more').addEventListener('click', () => commandSearch.loadMore());
+13
View File
@@ -451,6 +451,18 @@
<datalist id="cmd-search-repositories"></datalist>
<span id="cmd-repository-status" class="cmd-repository-status" aria-live="polite"></span>
</fieldset>
<section class="saved-searches" aria-labelledby="saved-searches-heading">
<div class="saved-search-header">
<strong id="saved-searches-heading">Saved searches</strong>
<span id="saved-search-status" class="small" aria-live="polite"></span>
</div>
<div class="saved-search-create">
<label for="saved-search-name" class="sr-only">Saved search name</label>
<input id="saved-search-name" type="text" maxlength="60" autocomplete="off" placeholder="Name this search" />
<button id="save-current-search" class="saved-search-action" type="button">Save search</button>
</div>
<div id="saved-search-list" class="saved-search-list" aria-label="Saved searches"></div>
</section>
<div id="cmd-search-action-status" class="small" aria-live="polite"></div>
<button id="resume-search-batch" class="search-batch-recovery" type="button" hidden>Resume batch</button>
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div>
@@ -1107,6 +1119,7 @@
<script src="static/security-center.js"></script>
<script src="static/markdown.js"></script>
<script src="static/commands.js"></script>
<script src="static/saved-searches.js"></script>
<script src="static/search-preview.js"></script>
<script src="static/search-defer.js"></script>
<script src="static/widgets.js"></script>
+159
View File
@@ -0,0 +1,159 @@
(function (root, factory) {
const createSavedSearches = factory();
if (typeof module === 'object' && module.exports) module.exports = createSavedSearches;
if (root) root.createSavedSearches = createSavedSearches;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
function createSavedSearches(options) {
const fetchJson = options.fetchJson;
const createId = options.createId || (() => crypto.randomUUID().replace(/-/g, ''));
const onOpen = options.onOpen;
const onState = options.onState || (() => {});
let current = { revision:0, views:[] };
function snapshot() {
return { revision:current.revision, views:current.views.map(view => ({ ...view })) };
}
function publish(status, message) {
onState({ ...snapshot(), status, ...(message ? { message } : {}) });
}
function normalizeSearch(name, search, id) {
const cleanName = String(name || '').trim();
const query = String(search?.query || '').trim();
if (!cleanName || cleanName.length > 60) throw new Error('Name must be between 1 and 60 characters.');
if (query.length < 2 || query.length > 200) throw new Error('Search query must be between 2 and 200 characters.');
return {
id, name:cleanName, query,
kind:['all', 'issue', 'pull'].includes(search?.kind) ? search.kind : 'all',
state:['all', 'open', 'closed'].includes(search?.state) ? search.state : 'all',
repository:String(search?.repository || '').trim(),
};
}
async function replace(views) {
publish('saving');
try {
current = await fetchJson('api/v1/saved-searches', {
method:'PUT', headers:{ 'Content-Type':'application/json' },
body:JSON.stringify({ revision:current.revision, views }),
});
publish('ready');
return snapshot();
} catch (error) {
const conflict = error?.status === 409 && error?.payload?.detail?.snapshot;
if (conflict) {
current = conflict;
publish('conflict', 'Saved searches changed on another device.');
} else {
publish('error', 'Saved searches could not sync. Search still works.');
}
throw error;
}
}
return {
async load() {
publish('loading');
try {
current = await fetchJson('api/v1/saved-searches');
publish('ready');
} catch (error) {
publish('error', 'Saved searches could not load. Search still works.');
}
return snapshot();
},
save(name, search) {
if (current.views.length >= 20) return Promise.reject(new Error('Saved searches are limited to 20.'));
const created = normalizeSearch(name, search, createId());
return replace(current.views.concat(created));
},
rename(id, name) {
const existing = current.views.find(view => view.id === id);
if (!existing) return Promise.reject(new Error('Saved search no longer exists.'));
const changed = normalizeSearch(name, existing, existing.id);
return replace(current.views.map(view => view.id === id ? changed : view));
},
remove(id) {
if (!current.views.some(view => view.id === id)) return Promise.resolve(snapshot());
return replace(current.views.filter(view => view.id !== id));
},
open(id) {
const selected = current.views.find(view => view.id === id);
if (selected) onOpen({ ...selected });
},
snapshot,
};
}
createSavedSearches.mount = function mountSavedSearches(document, fetch, search, applyScope, history) {
const query = selector => document.querySelector(selector);
const input = query('#cmd-input');
const request = async (url, init) => {
const response = await fetch(url, init);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.detail?.message || payload.detail || 'Saved Search request failed.');
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
};
let controller;
function render(state) {
const list = query('#saved-search-list');
const status = query('#saved-search-status');
list.replaceChildren();
status.textContent = ({loading:'Loading…', saving:'Syncing…', ready:'',
conflict:state.message, error:state.message})[state.status] || '';
state.views.forEach(view => {
const row = document.createElement('div');
row.className = 'saved-search-row';
const open = document.createElement('button');
open.type = 'button'; open.className = 'saved-search-open saved-search-action';
open.textContent = view.name; open.title = view.query;
open.addEventListener('click', () => controller.open(view.id));
const actions = document.createElement('div');
actions.className = 'saved-search-row-actions';
for (const action of ['Rename', 'Delete']) {
const button = document.createElement('button');
button.type = 'button'; button.className = 'saved-search-action'; button.textContent = action;
button.setAttribute('aria-label', action + ' ' + view.name);
button.addEventListener('click', async () => {
if (action === 'Rename') {
const name = globalThis.prompt('Rename saved search', view.name);
if (name !== null && name.trim()) await controller.rename(view.id, name).catch(() => {});
} else if (globalThis.confirm('Delete saved search “' + view.name + '”?')) {
await controller.remove(view.id).catch(() => {});
}
});
actions.append(button);
}
row.append(open, actions); list.append(row);
});
}
controller = createSavedSearches({ fetchJson:request, onState:render, onOpen:view => {
search.setQuery('');
input.value = view.query;
applyScope(view);
search.setQuery(view.query);
history.update({query:view.query, scope:view});
input.focus();
}});
query('#save-current-search').addEventListener('click', async () => {
const name = query('#saved-search-name').value.trim();
const current = {query:input.value.trim(), kind:query('#cmd-search-kind').value,
state:query('#cmd-search-state').value, repository:query('#cmd-search-repository').value.trim()};
if (!name || current.query.length < 2) {
query('#saved-search-status').textContent = 'Enter a name and at least 2 search characters.';
return;
}
await controller.save(name, current).then(() => { query('#saved-search-name').value = ''; })
.catch(error => { query('#saved-search-status').textContent = error.message; });
});
return controller;
};
return createSavedSearches;
});
+1
View File
@@ -17,6 +17,7 @@ const SHELL = [
BASE + 'static/security-center.js',
BASE + 'static/markdown.js',
BASE + 'static/commands.js',
BASE + 'static/saved-searches.js',
BASE + 'static/search-preview.js',
BASE + 'static/search-defer.js',
BASE + 'static/widgets.js',