diff --git a/zddc/internal/apps/embedded/browse.html b/zddc/internal/apps/embedded/browse.html
index bbc0f5f..294397c 100644
--- a/zddc/internal/apps/embedded/browse.html
+++ b/zddc/internal/apps/embedded/browse.html
@@ -585,6 +585,13 @@ body {
display: flex;
flex-direction: column;
min-height: 0;
+ overflow: hidden;
+}
+
+.browse-table-wrap {
+ flex: 1;
+ overflow: auto;
+ min-height: 0;
}
.toolbar {
@@ -633,7 +640,16 @@ body {
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 {
@@ -775,7 +791,7 @@ body {
ZDDC Browse
- v0.0.12
+ v0.0.13
@@ -796,8 +812,10 @@ body {
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.
@@ -808,17 +826,19 @@ body {
placeholder="Filter by name (substring)..." />
-
-
-
-
Name
-
Size
-
Type
-
Modified
-
-
-
-
+
+
+
+
+
Name
+
Size
+
Type
+
Modified
+
+
+
+
+
@@ -1684,34 +1704,93 @@ body {
}
}
+ // 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 = [];
@@ -1734,6 +1813,8 @@ body {
setChildren: setChildren,
render: render,
toggleFolder: toggleFolder,
+ expandSubtree: expandSubtree,
+ collapseSubtree: collapseSubtree,
setSort: function (key) {
if (state.sort.key === key) {
state.sort.dir = -state.sort.dir;
@@ -1834,21 +1915,36 @@ body {
}
// 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/zddc/internal/apps/embedded/classifier.html b/zddc/internal/apps/embedded/classifier.html
index 8779000..0301de1 100644
--- a/zddc/internal/apps/embedded/classifier.html
+++ b/zddc/internal/apps/embedded/classifier.html
@@ -1376,7 +1376,7 @@ body.help-open .app-header {