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)
}