fix(browse): listing fetch + row height + recursive expand/collapse

Three issues from initial v0.0.12 dev/prod testing:

  1. Online listings empty.
     directory.go was missing Vary: Accept on its responses, so
     browser/CDN cached the HTML response (the embedded browse.html)
     and served it again when browse's JS later fetched the same URL
     with Accept: application/json. JSON parse failed, autoDetect
     returned null, empty state showed. Adds Vary: Accept on both
     branches and changes browse.html cache-control to no-cache so
     deployed updates land immediately.

  2. Top-level folder rows tall, shrink as subtree expands.
     The .browse-table had flex:1 in a flex column. <table> in flex
     doesn't reliably distribute height across rows — with few rows,
     each row stretched. Wrap the table in a div with overflow:auto
     and drop flex:1 from the table itself.

  3. Recursive expand/collapse.
     Shift-click (or alt-click) on a folder now expand-all or
     collapse-all its subtree. Plain click still toggles just that
     folder. Implementation: tree.expandSubtree() walks BFS, loading
     each level's children in parallel, re-rendering between levels
     so the user sees progress. tree.collapseSubtree() recursively
     marks the subtree collapsed (children stay loaded for instant
     re-expand).
This commit is contained in:
ZDDC 2026-05-03 20:20:54 -05:00
parent d6448159fa
commit 7caf3ecf3f
5 changed files with 148 additions and 41 deletions

View file

@ -5,6 +5,13 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
overflow: hidden;
}
.browse-table-wrap {
flex: 1;
overflow: auto;
min-height: 0;
} }
.toolbar { .toolbar {
@ -53,7 +60,16 @@
border-collapse: collapse; border-collapse: collapse;
font-size: 0.9rem; font-size: 0.9rem;
background: var(--bg); 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 { .browse-table thead th {

View file

@ -80,21 +80,36 @@
} }
// Tree-row clicks (event delegation on tbody). // 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 <a> tag's natural target=_blank do its
// job — don't intercept.
var tbody = document.getElementById('browseTbody'); var tbody = document.getElementById('browseTbody');
if (tbody) { if (tbody) {
tbody.addEventListener('click', function (e) { tbody.addEventListener('click', function (e) {
var row = e.target.closest('tr.tree-row'); var row = e.target.closest('tr.tree-row');
if (!row) return; if (!row) return;
var isDir = row.dataset.isdir === 'true'; var isDir = row.dataset.isdir === 'true';
if (!isDir) { if (!isDir) return;
// Let the <a> 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).
e.preventDefault(); 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);
}
}); });
} }
} }

View file

@ -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. // Toggle a folder's expanded state. Loads children on first expand.
async function toggleFolder(nodeId) { async function toggleFolder(nodeId) {
var n = state.nodes.get(nodeId); var n = state.nodes.get(nodeId);
if (!n || !n.isDir) return; if (!n || !n.isDir) return;
if (!n.expanded && !n.loaded) { if (!n.expanded && !n.loaded) {
try { await loadChildren(n);
var raw; if (!n.loaded) return; // load failed
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;
}
} }
n.expanded = !n.expanded; n.expanded = !n.expanded;
render(); 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. // Compute the URL/path for a node by walking parents.
function pathFor(node) { function pathFor(node) {
var parts = []; var parts = [];
@ -269,6 +328,8 @@
setChildren: setChildren, setChildren: setChildren,
render: render, render: render,
toggleFolder: toggleFolder, toggleFolder: toggleFolder,
expandSubtree: expandSubtree,
collapseSubtree: collapseSubtree,
setSort: function (key) { setSort: function (key) {
if (state.sort.key === key) { if (state.sort.key === key) {
state.sort.dir = -state.sort.dir; state.sort.dir = -state.sort.dir;

View file

@ -43,8 +43,10 @@
<li><b>Local</b> — click <i>Select Directory</i> to pick any folder <li><b>Local</b> — click <i>Select Directory</i> to pick any folder
on your computer (Chromium-based browsers).</li> on your computer (Chromium-based browsers).</li>
</ul> </ul>
<p>Once loaded: click folders to expand, click headers to sort, type <p>Once loaded: click a folder to expand it, <b>shift-click</b>
in the filter to narrow by name. Click any file to open it.</p> 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.</p>
</div> </div>
</div> </div>
@ -55,6 +57,7 @@
placeholder="Filter by name (substring)..." /> placeholder="Filter by name (substring)..." />
<span class="toolbar__count" id="entryCount"></span> <span class="toolbar__count" id="entryCount"></span>
</div> </div>
<div class="browse-table-wrap">
<table class="browse-table" id="browseTable"> <table class="browse-table" id="browseTable">
<thead> <thead>
<tr> <tr>
@ -67,6 +70,7 @@
<tbody id="browseTbody"></tbody> <tbody id="browseTbody"></tbody>
</table> </table>
</div> </div>
</div>
</main> </main>
<div id="statusBar" class="status-bar"></div> <div id="statusBar" class="status-bar"></div>

View file

@ -80,6 +80,13 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
return 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") { if strings.Contains(accept, "application/json") {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache") 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("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-ZDDC-Source", "embedded:browse") 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) _, _ = w.Write(body)
} }