292 lines
12 KiB
JavaScript
292 lines
12 KiB
JavaScript
/**
|
|
* Browser history + URL integration for the DSH Web UI.
|
|
*
|
|
* The shell keeps its navigation (selected session, central panel, settings
|
|
* dialog + section) in memory and never touches window.history or the URL.
|
|
* This plugin mirrors that state into the URL hash and into history entries:
|
|
*
|
|
* #/session/<id>[/parent/<id>/mode/<m>][/panel/<id>][/settings/<section>]
|
|
*
|
|
* - every user navigation does history.pushState (so Back/Forward work),
|
|
* changes during app startup use replaceState;
|
|
* - popstate / hashchange / a page load with a hash restores the state;
|
|
* - mouse buttons 4/5 (Back/Forward) map to history.back()/forward().
|
|
*/
|
|
window.__ModuleLoader__.load({
|
|
id: '@local/dsh-history-nav',
|
|
factory() {
|
|
const KEY = 'dshNav';
|
|
const SETTLE_MS = 200; // time the shell gets to settle after a restore
|
|
const BOOT_GRACE_MS = 1500; // un-hashed changes this early are startup, not navigation
|
|
const BOOT_RESTORE_MS = 15000;
|
|
const POLL_MS = 200;
|
|
|
|
const keyOf = (v) => JSON.stringify(v ?? null);
|
|
const enc = encodeURIComponent;
|
|
const dec = (s) => { try { return decodeURIComponent(s); } catch { return s; } };
|
|
|
|
// ---- URL <-> nav ------------------------------------------------------
|
|
// nav = { session: null | {id, parent?, mode?}, panel: null | string, settings: null | string }
|
|
function format(nav) {
|
|
const parts = [];
|
|
if (nav.session) {
|
|
parts.push('session', enc(nav.session.id));
|
|
if (nav.session.parent) parts.push('parent', enc(nav.session.parent), 'mode', enc(nav.session.mode || 'unknown'));
|
|
}
|
|
if (nav.panel) parts.push('panel', enc(nav.panel));
|
|
if (nav.settings != null) parts.push('settings', enc(nav.settings || '_'));
|
|
return '#/' + parts.join('/');
|
|
}
|
|
function parse(hash) {
|
|
const nav = { session: null, panel: null, settings: null };
|
|
const body = (hash || '').replace(/^#\/?/, '');
|
|
if (!body) return nav;
|
|
const seg = body.split('/').map(dec);
|
|
const map = {};
|
|
for (let i = 0; i + 1 < seg.length; i += 2) map[seg[i]] = seg[i + 1];
|
|
if (map.session) {
|
|
nav.session = { id: map.session };
|
|
if (map.parent) { nav.session.parent = map.parent; nav.session.mode = map.mode || 'unknown'; }
|
|
}
|
|
if (map.panel) nav.panel = map.panel;
|
|
if (map.settings != null) nav.settings = map.settings === '_' ? '' : map.settings;
|
|
return nav;
|
|
}
|
|
const isEmpty = (nav) => !nav.session && !nav.panel && nav.settings == null;
|
|
|
|
function start(ctx) {
|
|
const layout = ctx.layout;
|
|
const ws = ctx.uiWorkspace;
|
|
const sessions = ctx.get('sessions');
|
|
const slots = ctx.get('slots');
|
|
const startedAt = Date.now();
|
|
|
|
// ---- settings dialog store (shared instance behind the sidebar.settings entry)
|
|
let settingsStore = null;
|
|
let settingsOff = null;
|
|
function attachSettings() {
|
|
let store = null;
|
|
try {
|
|
const entry = slots && slots.entries('sidebar.settings')[0];
|
|
const decl = entry && entry.store;
|
|
store = decl ? (typeof decl === 'function' ? decl() : decl.create()) : null;
|
|
} catch { store = null; }
|
|
if (store === settingsStore) return;
|
|
if (settingsOff) { settingsOff(); settingsOff = null; }
|
|
settingsStore = store;
|
|
if (store && store.subscribe) settingsOff = store.subscribe(check);
|
|
check();
|
|
}
|
|
function firstSection() {
|
|
try {
|
|
const rows = slots.entries('settings.section')
|
|
.map((e) => ({ id: e.options.id, order: e.options.order ?? 0 }))
|
|
.sort((a, b) => a.order - b.order);
|
|
return rows[0] ? rows[0].id : '';
|
|
} catch { return ''; }
|
|
}
|
|
|
|
// ---- read current state
|
|
const read = () => {
|
|
let sel = {};
|
|
if (ws.selection && ws.selection.getSnapshot) {
|
|
sel = ws.selection.getSnapshot() || {};
|
|
} else if (sessions) {
|
|
const list = sessions.list.getSnapshot();
|
|
const id = list.ids.find((i) => list.byId[i] && list.byId[i].retainedBy && list.byId[i].retainedBy.mainView > 0);
|
|
if (id) sel = { sessionId: id };
|
|
}
|
|
let session = null;
|
|
if (sel.subagentAddress) {
|
|
const a = sel.subagentAddress;
|
|
session = { id: a.childSessionId, parent: a.parentSessionId, mode: a.mode };
|
|
} else if (sel.sessionId) {
|
|
session = { id: sel.sessionId };
|
|
}
|
|
const panel = layout.panelInfo.getSnapshot();
|
|
let settings = null;
|
|
const st = settingsStore && settingsStore.getSnapshot();
|
|
if (st && st.open) settings = st.activeId ?? firstSection();
|
|
return { session, panel: (panel && panel.activePanelId) ?? null, settings };
|
|
};
|
|
|
|
const url = (nav) => location.pathname + location.search + format(nav);
|
|
const entry = (nav, idx) => {
|
|
const base = history.state && typeof history.state === 'object' ? history.state : {};
|
|
return { ...base, [KEY]: { nav, idx } };
|
|
};
|
|
|
|
// ---- apply a target state
|
|
function applyNav(target) {
|
|
const cur = read();
|
|
let sessionChanged = false;
|
|
try {
|
|
if (target.session && keyOf(target.session) !== keyOf(cur.session)) {
|
|
const s = target.session;
|
|
ws.openSession(s.parent
|
|
? { parentSessionId: s.parent, childSessionId: s.id, mode: s.mode || 'unknown' }
|
|
: s.id); // also reveals the conversation (panel -> null)
|
|
sessionChanged = true;
|
|
}
|
|
} catch (error) {
|
|
console.warn('[dsh-history-nav] cannot restore session', error);
|
|
}
|
|
const panelNow = sessionChanged ? null : cur.panel;
|
|
if (keyOf(panelNow) !== keyOf(target.panel)) {
|
|
try { layout.selectPanel(target.panel); } catch (error) {
|
|
console.warn('[dsh-history-nav] cannot restore panel', error);
|
|
}
|
|
}
|
|
const st = settingsStore;
|
|
if (st && st.actions) {
|
|
try {
|
|
if (target.settings == null) {
|
|
if (cur.settings != null) st.actions.close();
|
|
} else if (cur.settings == null || cur.settings !== target.settings) {
|
|
if (target.settings) st.actions.openSection(target.settings); else st.actions.open();
|
|
}
|
|
} catch (error) {
|
|
console.warn('[dsh-history-nav] cannot restore settings', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- boot: restore from the URL hash when present
|
|
let bootTarget = parse(location.hash);
|
|
let booting = !isEmpty(bootTarget); // true while waiting to apply the URL
|
|
let bootTimer = 0;
|
|
const prior = history.state && history.state[KEY];
|
|
let idx = prior && Number.isInteger(prior.idx) ? prior.idx : 0;
|
|
let committed = booting ? bootTarget : read();
|
|
let restoring = false;
|
|
let settleTimer = 0;
|
|
|
|
function finishBoot() {
|
|
clearInterval(bootTimer);
|
|
booting = false;
|
|
committed = read();
|
|
history.replaceState(entry(committed, idx), '', url(committed));
|
|
}
|
|
if (booting) {
|
|
let stable = 0;
|
|
bootTimer = setInterval(() => {
|
|
const age = Date.now() - startedAt;
|
|
const t = { ...bootTarget };
|
|
if (t.session && sessions) {
|
|
const list = sessions.list.getSnapshot();
|
|
const exists = (id) => list.byId[id] !== undefined;
|
|
if (list.phase === 'ready' && !exists(t.session.parent || t.session.id)) t.session = null;
|
|
else if (list.phase !== 'ready' || !exists(t.session.parent || t.session.id)) t.session = undefined; // not yet
|
|
}
|
|
if (t.session === undefined) { // session list not ready yet
|
|
if (age > BOOT_RESTORE_MS) finishBoot();
|
|
return;
|
|
}
|
|
if (t.settings != null && !settingsStore) {
|
|
attachSettings();
|
|
if (!settingsStore && age < BOOT_RESTORE_MS) return;
|
|
if (!settingsStore) t.settings = null;
|
|
}
|
|
// Keep the session the user's URL asked for; unresolved parts fall back to current.
|
|
if (!t.session) t.session = read().session;
|
|
applyNav(t);
|
|
const now = read();
|
|
const ok = keyOf(now.session) === keyOf(t.session)
|
|
&& now.panel === t.panel
|
|
&& (t.settings == null ? now.settings == null : now.settings === t.settings);
|
|
stable = ok ? stable + 1 : 0;
|
|
if (stable >= 2 || age > BOOT_RESTORE_MS) finishBoot();
|
|
}, POLL_MS);
|
|
} else {
|
|
history.replaceState(entry(committed, idx), '', url(committed));
|
|
}
|
|
|
|
// ---- record changes
|
|
function check() {
|
|
if (restoring || booting) return;
|
|
const now = read();
|
|
if (keyOf(now) === keyOf(committed)) return;
|
|
const age = Date.now() - startedAt;
|
|
const startup = age < BOOT_GRACE_MS
|
|
|| (idx === 0 && committed.session == null && now.session != null && age < BOOT_RESTORE_MS);
|
|
if (startup) {
|
|
history.replaceState(entry(now, idx), '', url(now));
|
|
} else {
|
|
idx += 1;
|
|
history.pushState(entry(now, idx), '', url(now));
|
|
}
|
|
committed = now;
|
|
}
|
|
|
|
function restoreFrom(nav) {
|
|
restoring = true;
|
|
committed = nav;
|
|
applyNav(nav);
|
|
clearTimeout(settleTimer);
|
|
settleTimer = setTimeout(() => {
|
|
restoring = false;
|
|
const now = read();
|
|
if (keyOf(now) !== keyOf(committed)) {
|
|
// Target only partly restorable (e.g. archived session): record reality.
|
|
committed = now;
|
|
history.replaceState(entry(now, idx), '', url(now));
|
|
}
|
|
}, SETTLE_MS);
|
|
}
|
|
|
|
function onPopState(event) {
|
|
if (booting) return;
|
|
const data = event.state && event.state[KEY];
|
|
if (data) {
|
|
idx = Number.isInteger(data.idx) ? data.idx : 0;
|
|
restoreFrom(data.nav);
|
|
} else {
|
|
// Entry created by hand-editing the URL / following a link with a hash.
|
|
const nav = parse(location.hash);
|
|
idx += 1;
|
|
history.replaceState(entry(nav, idx), '', location.href);
|
|
restoreFrom(nav);
|
|
}
|
|
}
|
|
|
|
function onMouse(event) {
|
|
if (event.button !== 3 && event.button !== 4) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (event.type !== 'mouseup') return;
|
|
if (event.button === 3) {
|
|
if (idx > 0) history.back(); // never leave the app through Back
|
|
} else {
|
|
history.forward();
|
|
}
|
|
}
|
|
|
|
const unsubs = [];
|
|
if (ws.selection && ws.selection.subscribe) unsubs.push(ws.selection.subscribe(check));
|
|
else if (sessions) unsubs.push(sessions.list.subscribe(check));
|
|
unsubs.push(layout.panelInfo.subscribe(check));
|
|
if (slots && slots.subscribe) unsubs.push(slots.subscribe('sidebar.settings', attachSettings));
|
|
attachSettings();
|
|
window.addEventListener('popstate', onPopState);
|
|
const mouseEvents = ['mousedown', 'mouseup', 'auxclick', 'pointerdown', 'pointerup'];
|
|
for (const type of mouseEvents) window.addEventListener(type, onMouse, true);
|
|
|
|
return () => {
|
|
clearTimeout(settleTimer);
|
|
clearInterval(bootTimer);
|
|
for (const u of unsubs) u();
|
|
if (settingsOff) settingsOff();
|
|
window.removeEventListener('popstate', onPopState);
|
|
for (const type of mouseEvents) window.removeEventListener(type, onMouse, true);
|
|
};
|
|
}
|
|
|
|
return {
|
|
inject: ['layout', 'uiWorkspace', 'slots'],
|
|
apply(ctx) {
|
|
ctx.effect(() => start(ctx), 'dsh-history-nav: browser history');
|
|
},
|
|
};
|
|
},
|
|
});
|