feat: resume addressable mobile search sessions (Closes #781)
This commit is contained in:
1 parent
741c27eacf
commit
bc0d47e8e7
4 files changed
+221
-26
No files matched your search
+15
-3
@@ -4451,7 +4451,7 @@
|
||||
mobileSearchViewport.rememberScroll();
|
||||
searchPreviewReturnKind = 'search';
|
||||
searchPreview.open(item.result).catch(() => {});
|
||||
taskOverlayHistory.open('search-preview');
|
||||
taskOverlayHistory.open('search-preview', { query:qs('#cmd-input').value, preview:item.result });
|
||||
}
|
||||
qs('#cmd-palette').classList.remove('open');
|
||||
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
||||
@@ -4496,7 +4496,7 @@
|
||||
const taskOverlayHistory = createTaskOverlayHistory({
|
||||
history: window.history,
|
||||
eventTarget: window,
|
||||
onChange(kind, previous) {
|
||||
onChange(kind, previous, detail) {
|
||||
if (previous === 'new' && kind !== 'new') {
|
||||
if (!suppressCreateDraftOnHistoryClose) saveIssueCaptureDraft();
|
||||
suppressCreateDraftOnHistoryClose = false;
|
||||
@@ -4531,7 +4531,18 @@
|
||||
}
|
||||
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
|
||||
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
|
||||
if (kind === 'search' && previous !== 'search-preview') openCommandPalette(false);
|
||||
if (kind === 'search' && previous !== 'search-preview') {
|
||||
if (detail?.query !== undefined) {
|
||||
qs('#cmd-input').value = detail.query;
|
||||
commandSearch.setQuery(detail.query);
|
||||
}
|
||||
openCommandPalette(false);
|
||||
}
|
||||
if (kind === 'search-preview' && detail?.preview && previous !== 'search') {
|
||||
if (detail?.query !== undefined) qs('#cmd-input').value = detail.query;
|
||||
searchPreviewReturnKind = 'search';
|
||||
searchPreview.open(detail.preview).catch(() => taskOverlayHistory.close());
|
||||
}
|
||||
if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);
|
||||
if (kind === 'today-readiness' && previous !== 'today-readiness') renderTodayReadiness(todayReadiness.snapshot());
|
||||
},
|
||||
@@ -4541,6 +4552,7 @@
|
||||
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
|
||||
qs('#cmd-input').addEventListener('input', (e) => {
|
||||
commandSelection = -1;
|
||||
taskOverlayHistory.update({ query:e.target.value });
|
||||
renderCommands(e.target.value);
|
||||
commandSearch.setQuery(e.target.value);
|
||||
});
|
||||
|
||||
@@ -6,21 +6,104 @@
|
||||
'use strict';
|
||||
|
||||
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview', 'today-readiness']);
|
||||
const searchKinds = new Set(['search', 'search-preview']);
|
||||
const maxQueryLength = 200;
|
||||
|
||||
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
|
||||
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
|
||||
function cleanQuery(value) {
|
||||
const query = typeof value === 'string' ? value.trim() : '';
|
||||
return query.length <= maxQueryLength ? query : '';
|
||||
}
|
||||
|
||||
function cleanPreview(value) {
|
||||
if (!value || !['issue', 'pull'].includes(value.kind)) return null;
|
||||
const repository = typeof value.repository === 'string' ? value.repository : '';
|
||||
const parts = repository.split('/');
|
||||
const validPart = part => /^[A-Za-z0-9_.-]+$/.test(part);
|
||||
const number = Number(value.number);
|
||||
if (parts.length !== 2 || !parts.every(validPart) || !Number.isInteger(number) || number < 1) return null;
|
||||
return { kind:value.kind, repository, number };
|
||||
}
|
||||
|
||||
function previewToken(preview) {
|
||||
return preview ? `${preview.kind}:${preview.repository}:${preview.number}` : '';
|
||||
}
|
||||
|
||||
function parsePreview(value) {
|
||||
const match = /^(issue|pull):([^/:]+\/[^/:]+):(\d+)$/.exec(value || '');
|
||||
return match ? cleanPreview({ kind:match[1], repository:match[2], number:Number(match[3]) }) : null;
|
||||
}
|
||||
|
||||
return function createTaskOverlayHistory({ history, location, eventTarget, onChange }) {
|
||||
let started = false;
|
||||
let restoredFromUrl = false;
|
||||
|
||||
function stateKind(state = history.state) {
|
||||
return allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
|
||||
function stateDetail(state = history.state) {
|
||||
const kind = allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
|
||||
if (!searchKinds.has(kind)) return { kind };
|
||||
const query = cleanQuery(state.searchQuery);
|
||||
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 } : {}) };
|
||||
}
|
||||
|
||||
function urlDetail() {
|
||||
if (!location || typeof URLSearchParams === 'undefined') return { kind:null };
|
||||
const params = new URLSearchParams(location.search || '');
|
||||
const rawQuery = params.get('search');
|
||||
if (rawQuery === null) return { kind:null };
|
||||
const query = cleanQuery(rawQuery);
|
||||
if (rawQuery.trim() && !query) return { kind:null };
|
||||
const preview = parsePreview(params.get('preview'));
|
||||
return {
|
||||
kind:preview ? 'search-preview' : 'search',
|
||||
...(query ? { query } : {}),
|
||||
...(preview ? { preview } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toState(detail, base = history.state) {
|
||||
const state = { ...(base || {}), taskOverlay:detail.kind };
|
||||
delete state.searchQuery;
|
||||
delete state.searchPreview;
|
||||
if (searchKinds.has(detail.kind)) {
|
||||
if (detail.query) state.searchQuery = detail.query;
|
||||
if (detail.kind === 'search-preview' && detail.preview) state.searchPreview = detail.preview;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function urlFor(detail) {
|
||||
if (!location || typeof URLSearchParams === 'undefined') return undefined;
|
||||
const params = new URLSearchParams(location.search || '');
|
||||
params.delete('search');
|
||||
params.delete('preview');
|
||||
if (searchKinds.has(detail.kind)) {
|
||||
params.set('search', detail.query || '');
|
||||
if (detail.kind === 'search-preview' && detail.preview) params.set('preview', previewToken(detail.preview));
|
||||
}
|
||||
const query = params.toString();
|
||||
return `${location.pathname || ''}${query ? `?${query}` : ''}${location.hash || ''}`;
|
||||
}
|
||||
|
||||
let initial = stateDetail();
|
||||
if (!initial.kind) {
|
||||
const fromUrl = urlDetail();
|
||||
if (fromUrl.kind) {
|
||||
initial = fromUrl;
|
||||
restoredFromUrl = true;
|
||||
history.replaceState(toState(fromUrl), '', urlFor(fromUrl));
|
||||
}
|
||||
}
|
||||
let active = initial;
|
||||
|
||||
function apply(state) {
|
||||
const next = stateKind(state);
|
||||
if (next === active) return;
|
||||
const previous = active;
|
||||
const next = stateDetail(state);
|
||||
if (JSON.stringify(next) === JSON.stringify(active)) return;
|
||||
const previous = active.kind;
|
||||
active = next;
|
||||
onChange(next, previous);
|
||||
onChange(next.kind, previous, next);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -28,33 +111,48 @@
|
||||
if (started) return;
|
||||
started = true;
|
||||
eventTarget.addEventListener('popstate', event => apply(event.state));
|
||||
if (restoredFromUrl) onChange(active.kind, null, active);
|
||||
},
|
||||
open(kind) {
|
||||
open(kind, detail = {}) {
|
||||
if (!allowed.has(kind)) return false;
|
||||
if (active === kind) return true;
|
||||
const previous = active;
|
||||
const state = { ...(history.state || {}), taskOverlay: kind };
|
||||
history.pushState(state, '');
|
||||
active = kind;
|
||||
onChange(kind, previous);
|
||||
const next = stateDetail(toState({ kind, query:cleanQuery(detail.query), 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));
|
||||
active = next;
|
||||
onChange(next.kind, previous, next);
|
||||
return true;
|
||||
},
|
||||
update(detail = {}) {
|
||||
if (!searchKinds.has(active.kind)) return false;
|
||||
const next = stateDetail(toState({
|
||||
kind:active.kind,
|
||||
query:detail.query === undefined ? active.query : cleanQuery(detail.query),
|
||||
preview:detail.preview === undefined ? active.preview : cleanPreview(detail.preview),
|
||||
}));
|
||||
history.replaceState(toState(next), '', urlFor(next));
|
||||
active = next;
|
||||
return true;
|
||||
},
|
||||
close() {
|
||||
if (!active) return false;
|
||||
if (!active.kind) return false;
|
||||
history.back();
|
||||
return true;
|
||||
},
|
||||
leave() {
|
||||
if (!active) return false;
|
||||
const previous = active;
|
||||
if (!active.kind) return false;
|
||||
const previous = active.kind;
|
||||
const state = { ...(history.state || {}) };
|
||||
delete state.taskOverlay;
|
||||
history.replaceState(state, '');
|
||||
active = null;
|
||||
onChange(null, previous);
|
||||
delete state.searchQuery;
|
||||
delete state.searchPreview;
|
||||
active = { kind:null };
|
||||
history.replaceState(state, '', urlFor(active));
|
||||
onChange(null, previous, active);
|
||||
return true;
|
||||
},
|
||||
current() { return active; },
|
||||
current() { return active.kind; },
|
||||
currentState() { return { ...active, ...(active.preview ? { preview:{ ...active.preview } } : {}) }; },
|
||||
};
|
||||
};
|
||||
});
|
||||
Reference in new issue
Block a user