Resume addressable mobile Search sessions #782
|
|
@ -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 } } : {}) }; },
|
||||
};
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ FEATURE_SOURCES = {
|
|||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"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/task-overlay-history.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/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",
|
||||
|
|
|
|||
|
|
@ -212,3 +212,88 @@ def test_dashboard_routes_mobile_task_overlays_through_browser_history():
|
|||
assert "taskOverlayHistory.close()" in html
|
||||
assert "saveIssueCaptureDraft();" in html
|
||||
assert "history.replaceState(history.state || {}, '', cleanUrl)" in html
|
||||
|
||||
|
||||
def test_search_history_serializes_bounded_query_and_stable_preview_identity():
|
||||
script = f"""
|
||||
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
|
||||
const pushed = [];
|
||||
const replaced = [];
|
||||
const location = {{ pathname:'/dashboard/', search:'?keep=1', hash:'' }};
|
||||
const history = {{
|
||||
state: {{ page:'dashboard' }},
|
||||
pushState(state, title, url) {{ this.state = state; pushed.push([state, url]); }},
|
||||
replaceState(state, title, url) {{ this.state = state; replaced.push([state, url]); }},
|
||||
back() {{}},
|
||||
}};
|
||||
const controller = createTaskOverlayHistory({{
|
||||
history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
|
||||
}});
|
||||
controller.open('search', {{ query:' release blocker ' }});
|
||||
controller.update({{ query:'release candidate' }});
|
||||
controller.open('search-preview', {{
|
||||
query:'release candidate', preview:{{kind:'issue', repository:'stackchain/api', number:123,
|
||||
title:'must not be serialized', body:'secret'}},
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{pushed, replaced, current:controller.currentState()}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert len(payload["pushed"]) == 2
|
||||
assert len(payload["replaced"]) == 1
|
||||
assert payload["pushed"][0][1] == "/dashboard/?keep=1&search=release+blocker"
|
||||
assert payload["replaced"][0][1] == "/dashboard/?keep=1&search=release+candidate"
|
||||
assert payload["pushed"][1][1] == (
|
||||
"/dashboard/?keep=1&search=release+candidate&preview=issue%3Astackchain%2Fapi%3A123"
|
||||
)
|
||||
assert payload["current"] == {
|
||||
"kind": "search-preview",
|
||||
"query": "release candidate",
|
||||
"preview": {"kind": "issue", "repository": "stackchain/api", "number": 123},
|
||||
}
|
||||
|
||||
|
||||
def test_search_history_restores_direct_url_and_rejects_malformed_or_oversized_state():
|
||||
script = f"""
|
||||
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
|
||||
function restore(search) {{
|
||||
const changes = [];
|
||||
const location = {{ pathname:'/dashboard/', search, hash:'' }};
|
||||
const history = {{ state:null, replaceState(state) {{ this.state = state; }}, back() {{}} }};
|
||||
const controller = createTaskOverlayHistory({{
|
||||
history, location, eventTarget:{{addEventListener() {{}}}},
|
||||
onChange(kind, previous, detail) {{ changes.push([kind, previous, detail]); }},
|
||||
}});
|
||||
controller.start();
|
||||
return {{ state:controller.currentState(), changes }};
|
||||
}}
|
||||
process.stdout.write(JSON.stringify({{
|
||||
valid:restore('?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9'),
|
||||
malformed:restore('?search=ok&preview=issue%3Ainvalid%3A0'),
|
||||
oversized:restore('?search=' + 'x'.repeat(201)),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
expected = {
|
||||
"kind": "search-preview",
|
||||
"query": "release blocker",
|
||||
"preview": {"kind": "pull", "repository": "stackchain/api", "number": 9},
|
||||
}
|
||||
assert payload["valid"]["state"] == expected
|
||||
assert payload["valid"]["changes"] == [["search-preview", None, expected]]
|
||||
assert payload["malformed"]["state"] == {"kind": "search", "query": "ok"}
|
||||
assert payload["oversized"]["state"] == {"kind": None}
|
||||
|
||||
|
||||
def test_dashboard_persists_search_query_and_restores_canonical_preview():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
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 "detail?.query" in html
|
||||
assert "searchPreview.open(detail.preview).catch" in html
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user