diff --git a/browse/js/app.js b/browse/js/app.js index 014bff3..5a73237 100644 --- a/browse/js/app.js +++ b/browse/js/app.js @@ -81,6 +81,10 @@ // initial listing fetch so dotfiles appear in the tree. var qs = new URLSearchParams(location.search); 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) { var segs = deepFile.split('/').filter(Boolean); for (var si = 0; si < segs.length; si++) { @@ -125,6 +129,9 @@ if (window.app.state.source !== 'server') return; var path = location.pathname; 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 { var es = await loader.fetchServerChildren(path); window.app.state.currentPath = path; @@ -138,6 +145,10 @@ if (previewTitle) previewTitle.textContent = 'No file selected'; // Reapply view mode for the new URL (incoming/ → grid, etc). 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 */ } }); } diff --git a/browse/js/events.js b/browse/js/events.js index 6872d0a..c868967 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -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() { // Snapshot expanded paths + selection BEFORE setRoot clears the // tree, then re-apply after the new root is in place. Keeps @@ -273,6 +316,7 @@ state.selectedId = id; state.lastPreviewedNodeId = id; tree.render(); // refresh selection highlight + syncURLToSelection(); var p = previewMod(); if (p) p.showFilePreview(node); }); @@ -396,6 +440,7 @@ state.selectedId = nextId; var nextNode = state.nodes.get(nextId); tree.render(); + syncURLToSelection(); // Auto-preview files as the keyboard cursor lands on them // so the right pane keeps up with selection. Folders are // selection-only; their preview is "expand to see inside". @@ -423,6 +468,7 @@ if (!node) return; state.selectedId = id; tree.render(); + syncURLToSelection(); window.zddc.menu.open({ x: e.clientX, y: e.clientY, @@ -720,7 +766,10 @@ statusInfo('Deleted ' + node.name); // Clear selection / preview when they pointed at the // 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) { state.lastPreviewedNodeId = null; var pb = document.getElementById('previewBody'); @@ -978,6 +1027,7 @@ checked: function () { return !!state.showHidden; }, action: function () { state.showHidden = !state.showHidden; + syncURLToSelection(); refreshListing(); } } ]; @@ -1022,6 +1072,7 @@ checked: function () { return !!state.showHidden; }, action: function () { state.showHidden = !state.showHidden; + syncURLToSelection(); refreshListing(); } } ]; @@ -1129,10 +1180,14 @@ if (previewMeta) previewMeta.textContent = ''; // pushState so the URL bar reflects the new scope. A real // 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 { history.pushState({ zddcBrowse: true, path: url }, '', url); } catch (_e) { /* private browsing edge cases */ } + syncURLToSelection(); statusInfo('Entered ' + displayName); // The new scope may have a different default view (grid inside // incoming/, browse elsewhere). Re-resolve from the URL now