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:
parent
8aebb0c346
commit
c240bf30a5
2 changed files with 68 additions and 2 deletions
|
|
@ -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 */ }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue