(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; });