Initial commit: dsh-history-nav DSH plugin with docs
This commit is contained in:
commit
78ff986584
6 files changed
+384
No files matched your search
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 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');
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user