feat(browse): persist selection + show-hidden in URL

The browse SPA's URL bar now reflects the currently-selected node and
the show-hidden toggle, so:

  - bookmarking / copy-pasting the URL re-opens the same view
  - reload (forced by the admin-mode toggle, which has to reload to
    pick up the elevation cookie) lands the user back on the same
    selection with intermediates expanded
  - browser back/forward walks history correctly, re-applying both
    the scope AND the file/hidden state at each step

Implementation:
  events.js: syncURLToSelection() — builds <scope>/?file=<rel>&hidden=1
    via URLSearchParams (with %2F → '/' so the URL bar reads cleanly)
    and history.replaceState's it. Called from every selectedId set
    site (single-click, arrow-key nav, right-click), from the show-
    hidden toggle, and after rescopeServer's scope pushState so the
    new scope keeps the hidden flag.
  app.js bootstrap: reads ?hidden=1 in addition to the existing
    auto-flip-on-dotfile logic, so an explicit hidden toggle survives
    reload.
  app.js popstate: re-walks ?file= via openDeepLink so back/forward
    restore not just the scope but the selection + expansion path.
    Also re-applies hidden=1.

Choice: replaceState (not pushState) on selection changes — the only
"intentional" navigation step is the scope rescope (already pushState).
A long click sequence shouldn't pollute history.

What this doesn't cover: sibling folders the user expanded that aren't
on the path-to-selection. Persisting that needs sessionStorage; for
"as much as possible without overcomplicating" the URL-only state
captures scope, selected node, and the path-to-selection (auto-
expanded by the deep-link walker) — the most common case.

FS-API mode (offline / file://) is a no-op — no shareable URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-18 17:00:20 -05:00
parent 8aebb0c346
commit c240bf30a5
2 changed files with 68 additions and 2 deletions

View file

@ -81,6 +81,10 @@
// initial listing fetch so dotfiles appear in the tree. // initial listing fetch so dotfiles appear in the tree.
var qs = new URLSearchParams(location.search); var qs = new URLSearchParams(location.search);
var deepFile = qs.get('file'); var deepFile = qs.get('file');
// Explicit ?hidden=1 in the URL: restore the show-hidden toggle
// on reload (the URL is the persistence layer for this flag —
// see events.js syncURLToSelection).
if (qs.get('hidden') === '1') state.showHidden = true;
if (deepFile) { if (deepFile) {
var segs = deepFile.split('/').filter(Boolean); var segs = deepFile.split('/').filter(Boolean);
for (var si = 0; si < segs.length; si++) { for (var si = 0; si < segs.length; si++) {
@ -125,6 +129,9 @@
if (window.app.state.source !== 'server') return; if (window.app.state.source !== 'server') return;
var path = location.pathname; var path = location.pathname;
if (!path.endsWith('/')) path += '/'; if (!path.endsWith('/')) path += '/';
var popQS = new URLSearchParams(location.search);
if (popQS.get('hidden') === '1') window.app.state.showHidden = true;
else window.app.state.showHidden = false;
try { try {
var es = await loader.fetchServerChildren(path); var es = await loader.fetchServerChildren(path);
window.app.state.currentPath = path; window.app.state.currentPath = path;
@ -138,6 +145,10 @@
if (previewTitle) previewTitle.textContent = 'No file selected'; if (previewTitle) previewTitle.textContent = 'No file selected';
// Reapply view mode for the new URL (incoming/ → grid, etc). // Reapply view mode for the new URL (incoming/ → grid, etc).
if (events.applyResolvedViewMode) events.applyResolvedViewMode(); if (events.applyResolvedViewMode) events.applyResolvedViewMode();
// Re-walk ?file= so back/forward restores selection +
// expansion, not just scope.
var popFile = popQS.get('file');
if (popFile) await openDeepLink(popFile);
} catch (_e) { /* swallow — leave the tree as-is */ } } catch (_e) { /* swallow — leave the tree as-is */ }
}); });
} }

View file

@ -87,6 +87,49 @@
} }
} }
// syncURLToSelection reflects the current scope + selected node +
// show-hidden flag into the URL bar via history.replaceState, so:
// - bookmarks / copy-paste of the URL re-open the same view
// - reload (e.g. after toggling admin mode, which forces a hard
// reload to pick up the elevated cookie) lands the user back
// on the same selection
//
// Uses replaceState (not pushState) so a long click sequence doesn't
// pollute browser history. Scope changes (rescopeServer) still
// pushState — that's the only "intentional" navigation step in the
// SPA, and back/forward should walk between scopes, not selections.
//
// FS-API mode has no shareable URL, so this is a no-op there.
function syncURLToSelection() {
if (state.source !== 'server') return;
var scope = state.currentPath || '/';
if (!scope.endsWith('/')) scope += '/';
var params = new URLSearchParams();
var node = state.selectedId != null ? state.nodes.get(state.selectedId) : null;
if (node) {
var abs = tree.pathFor(node);
var prefix = scope.replace(/\/$/, '');
var rel = abs;
if (prefix && abs.indexOf(prefix + '/') === 0) {
rel = abs.slice(prefix.length + 1);
}
// Directory selections get a trailing slash so the URL
// round-trips as a navigable folder reference.
if (node.isDir && rel && !rel.endsWith('/')) rel += '/';
if (rel) params.set('file', rel);
}
if (state.showHidden) params.set('hidden', '1');
// URLSearchParams percent-encodes '/' to %2F; the server doesn't
// care, but the URL bar reads better with raw slashes.
var qs = params.toString().replace(/%2F/g, '/');
var url = scope + (qs ? '?' + qs : '');
try {
history.replaceState({ zddcBrowse: true, path: url }, '', url);
} catch (_e) { /* private browsing edge cases */ }
}
async function refreshListing() { async function refreshListing() {
// Snapshot expanded paths + selection BEFORE setRoot clears the // Snapshot expanded paths + selection BEFORE setRoot clears the
// tree, then re-apply after the new root is in place. Keeps // tree, then re-apply after the new root is in place. Keeps
@ -273,6 +316,7 @@
state.selectedId = id; state.selectedId = id;
state.lastPreviewedNodeId = id; state.lastPreviewedNodeId = id;
tree.render(); // refresh selection highlight tree.render(); // refresh selection highlight
syncURLToSelection();
var p = previewMod(); var p = previewMod();
if (p) p.showFilePreview(node); if (p) p.showFilePreview(node);
}); });
@ -396,6 +440,7 @@
state.selectedId = nextId; state.selectedId = nextId;
var nextNode = state.nodes.get(nextId); var nextNode = state.nodes.get(nextId);
tree.render(); tree.render();
syncURLToSelection();
// Auto-preview files as the keyboard cursor lands on them // Auto-preview files as the keyboard cursor lands on them
// so the right pane keeps up with selection. Folders are // so the right pane keeps up with selection. Folders are
// selection-only; their preview is "expand to see inside". // selection-only; their preview is "expand to see inside".
@ -423,6 +468,7 @@
if (!node) return; if (!node) return;
state.selectedId = id; state.selectedId = id;
tree.render(); tree.render();
syncURLToSelection();
window.zddc.menu.open({ window.zddc.menu.open({
x: e.clientX, x: e.clientX,
y: e.clientY, y: e.clientY,
@ -720,7 +766,10 @@
statusInfo('Deleted ' + node.name); statusInfo('Deleted ' + node.name);
// Clear selection / preview when they pointed at the // Clear selection / preview when they pointed at the
// now-gone node, so the right pane doesn't keep a ghost. // now-gone node, so the right pane doesn't keep a ghost.
if (state.selectedId === node.id) state.selectedId = null; if (state.selectedId === node.id) {
state.selectedId = null;
syncURLToSelection();
}
if (state.lastPreviewedNodeId === node.id) { if (state.lastPreviewedNodeId === node.id) {
state.lastPreviewedNodeId = null; state.lastPreviewedNodeId = null;
var pb = document.getElementById('previewBody'); var pb = document.getElementById('previewBody');
@ -978,6 +1027,7 @@
checked: function () { return !!state.showHidden; }, checked: function () { return !!state.showHidden; },
action: function () { action: function () {
state.showHidden = !state.showHidden; state.showHidden = !state.showHidden;
syncURLToSelection();
refreshListing(); refreshListing();
} } } }
]; ];
@ -1022,6 +1072,7 @@
checked: function () { return !!state.showHidden; }, checked: function () { return !!state.showHidden; },
action: function () { action: function () {
state.showHidden = !state.showHidden; state.showHidden = !state.showHidden;
syncURLToSelection();
refreshListing(); refreshListing();
} } } }
]; ];
@ -1129,10 +1180,14 @@
if (previewMeta) previewMeta.textContent = ''; if (previewMeta) previewMeta.textContent = '';
// pushState so the URL bar reflects the new scope. A real // pushState so the URL bar reflects the new scope. A real
// reload would re-load browse at this URL (trailing slash → // reload would re-load browse at this URL (trailing slash →
// ServeDirectory → embedded browse SPA). // ServeDirectory → embedded browse SPA). Then immediately
// replaceState via syncURLToSelection so the new URL also
// carries ?hidden=1 if the toggle is on (selection is null
// at the new scope; the query gets only `hidden`).
try { try {
history.pushState({ zddcBrowse: true, path: url }, '', url); history.pushState({ zddcBrowse: true, path: url }, '', url);
} catch (_e) { /* private browsing edge cases */ } } catch (_e) { /* private browsing edge cases */ }
syncURLToSelection();
statusInfo('Entered ' + displayName); statusInfo('Entered ' + displayName);
// The new scope may have a different default view (grid inside // The new scope may have a different default view (grid inside
// incoming/, browse elsewhere). Re-resolve from the URL now // incoming/, browse elsewhere). Re-resolve from the URL now