diff --git a/browse/css/tree.css b/browse/css/tree.css index 5cab6f9..617eb6b 100644 --- a/browse/css/tree.css +++ b/browse/css/tree.css @@ -5,6 +5,13 @@ display: flex; flex-direction: column; min-height: 0; + overflow: hidden; +} + +.browse-table-wrap { + flex: 1; + overflow: auto; + min-height: 0; } .toolbar { @@ -53,7 +60,16 @@ border-collapse: collapse; font-size: 0.9rem; background: var(--bg); - flex: 1; + /* No flex:1 — tables don't reliably distribute extra height across + rows the way flex columns do. With few rows we'd get tall rows + that shrink as more children are loaded. The wrap div handles + scrolling instead. */ +} + +.browse-table tbody tr { + /* Pin rows to a deterministic height so table layout never + redistributes vertical space across them. */ + line-height: 1.4; } .browse-table thead th { diff --git a/browse/js/events.js b/browse/js/events.js index 49a5644..6a8a893 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -80,21 +80,36 @@ } // Tree-row clicks (event delegation on tbody). + // Click semantics on a folder row: + // - plain click → toggle just this folder + // - shift-click → recursive expand/collapse of the whole + // subtree (matches common file-explorer + // convention; e.g. Finder, VSCode tree, + // Windows Explorer) + // - alt-click → ALSO recursive (alt is sometimes the + // expand-all key on Linux DEs; bind both + // so muscle memory works either way) + // File rows: let the tag's natural target=_blank do its + // job — don't intercept. var tbody = document.getElementById('browseTbody'); if (tbody) { tbody.addEventListener('click', function (e) { var row = e.target.closest('tr.tree-row'); if (!row) return; var isDir = row.dataset.isdir === 'true'; - if (!isDir) { - // Let the tag's natural target=_blank handle file - // clicks. Don't intercept. - return; - } - // Folder: toggle on chevron OR anywhere on the row except - // the file link (no link in folder rows). + if (!isDir) return; e.preventDefault(); - tree.toggleFolder(parseInt(row.dataset.id, 10)); + var id = parseInt(row.dataset.id, 10); + if (e.shiftKey || e.altKey) { + var node = state.nodes.get(id); + if (node && node.expanded) { + tree.collapseSubtree(id); + } else { + tree.expandSubtree(id); + } + } else { + tree.toggleFolder(id); + } }); } } diff --git a/browse/js/tree.js b/browse/js/tree.js index dddf6ac..f500e17 100644 --- a/browse/js/tree.js +++ b/browse/js/tree.js @@ -219,34 +219,93 @@ } } + // Load a folder's children (lazy; idempotent re-loads). + async function loadChildren(node) { + if (node.loaded) return; + try { + var raw; + if (state.source === 'server') { + raw = await loader.fetchServerChildren(pathFor(node) + '/'); + } else if (state.source === 'fs') { + raw = await loader.fetchFsChildren(node.handle); + } else { + return; + } + setChildren(node.id, raw); + } catch (e) { + window.app.modules.events.statusError( + 'Failed to load ' + node.name + ': ' + e.message); + } + } + // Toggle a folder's expanded state. Loads children on first expand. async function toggleFolder(nodeId) { var n = state.nodes.get(nodeId); if (!n || !n.isDir) return; if (!n.expanded && !n.loaded) { - try { - var raw; - if (state.source === 'server') { - var childPath = state.currentPath - + n.name + '/'; // server URLs are relative paths - // Walk up the parent chain to build the full path. - childPath = pathFor(n) + '/'; - raw = await loader.fetchServerChildren(childPath); - } else if (state.source === 'fs') { - raw = await loader.fetchFsChildren(n.handle); - } else { - return; - } - window.app.modules.tree.setChildren(nodeId, raw); - } catch (e) { - window.app.modules.events.statusError('Failed to load folder: ' + e.message); - return; - } + await loadChildren(n); + if (!n.loaded) return; // load failed } n.expanded = !n.expanded; render(); } + // Recursive expand: load + expand all descendants of nodeId. Used + // for Shift-click on a folder. Walks breadth-first, fanning out + // through children, grand-children, etc. until every reachable + // folder is loaded and marked expanded. Status bar shows progress + // because deeply-nested trees can take a while. + // + // Parallelism: kept conservative (per-level fan-out) to avoid + // hammering zddc-server with hundreds of concurrent listing + // fetches. Browsers also throttle per-origin concurrency, but + // queuing politely is friendlier than fighting that. + async function expandSubtree(nodeId) { + var root = state.nodes.get(nodeId); + if (!root || !root.isDir) return; + var status = window.app.modules.events.statusInfo; + status('Expanding subtree…'); + var processed = 0; + var queue = [root]; + while (queue.length) { + var batch = queue; + queue = []; + // Load this level's children in parallel (Promise.all). + await Promise.all(batch.map(function (n) { return loadChildren(n); })); + for (var i = 0; i < batch.length; i++) { + var n = batch[i]; + n.expanded = true; + processed++; + for (var j = 0; j < n.childIds.length; j++) { + var c = state.nodes.get(n.childIds[j]); + if (c && c.isDir) queue.push(c); + } + } + // Re-render after each level so the user sees progress + // rather than a long pause then a sudden full-tree dump. + render(); + status('Expanding subtree… (' + processed + ' folders loaded)'); + } + status('Expanded ' + processed + ' folder' + (processed === 1 ? '' : 's')); + } + + // Recursive collapse: mark this node and every descendant as + // collapsed. Doesn't unload — if the user re-expands later, the + // children are still in memory and re-render is instant. + function collapseSubtree(nodeId) { + var root = state.nodes.get(nodeId); + if (!root || !root.isDir) return; + function walk(n) { + n.expanded = false; + for (var i = 0; i < n.childIds.length; i++) { + var c = state.nodes.get(n.childIds[i]); + if (c && c.isDir) walk(c); + } + } + walk(root); + render(); + } + // Compute the URL/path for a node by walking parents. function pathFor(node) { var parts = []; @@ -269,6 +328,8 @@ setChildren: setChildren, render: render, toggleFolder: toggleFolder, + expandSubtree: expandSubtree, + collapseSubtree: collapseSubtree, setSort: function (key) { if (state.sort.key === key) { state.sort.dir = -state.sort.dir; diff --git a/browse/template.html b/browse/template.html index fd878ce..3b0d002 100644 --- a/browse/template.html +++ b/browse/template.html @@ -43,8 +43,10 @@
  • Local — click Select Directory to pick any folder on your computer (Chromium-based browsers).
  • -

    Once loaded: click folders to expand, click headers to sort, type - in the filter to narrow by name. Click any file to open it.

    +

    Once loaded: click a folder to expand it, shift-click + to expand its entire subtree (or collapse it again), + click column headers to sort, type in the filter to narrow + by name. Click any file to open it.

    @@ -55,17 +57,19 @@ placeholder="Filter by name (substring)..." /> - - - - - - - - - - -
    Name Size Type Modified
    +
    + + + + + + + + + + +
    Name Size Type Modified
    +
    diff --git a/zddc/internal/handler/directory.go b/zddc/internal/handler/directory.go index fdc584c..169c924 100644 --- a/zddc/internal/handler/directory.go +++ b/zddc/internal/handler/directory.go @@ -80,6 +80,13 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) { return } + // Vary: Accept is critical — the same URL serves either the JSON + // listing or the embedded browse.html depending on the Accept + // header. Without Vary, browsers/CDNs cache one response and + // serve it for the other Accept value, breaking browse.html's + // auto-detect (which fetches the same URL with Accept: JSON). + w.Header().Set("Vary", "Accept") + if strings.Contains(accept, "application/json") { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-cache") @@ -109,6 +116,10 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("X-ZDDC-Source", "embedded:browse") - w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate") + // no-cache here too — browse.html has session-tied content (the + // directory listing it loads via fetch), and we want browser to + // always re-validate so deployed-binary updates appear immediately + // rather than after a 5-minute cache window. + w.Header().Set("Cache-Control", "no-cache") _, _ = w.Write(body) }