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:
parent
d6448159fa
commit
7caf3ecf3f
5 changed files with 148 additions and 41 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 <a> 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 <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).
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -43,8 +43,10 @@
|
|||
<li><b>Local</b> — click <i>Select Directory</i> to pick any folder
|
||||
on your computer (Chromium-based browsers).</li>
|
||||
</ul>
|
||||
<p>Once loaded: click folders to expand, click headers to sort, type
|
||||
in the filter to narrow by name. Click any file to open it.</p>
|
||||
<p>Once loaded: click a folder to expand it, <b>shift-click</b>
|
||||
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>
|
||||
|
||||
|
|
@ -55,17 +57,19 @@
|
|||
placeholder="Filter by name (substring)..." />
|
||||
<span class="toolbar__count" id="entryCount"></span>
|
||||
</div>
|
||||
<table class="browse-table" id="browseTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="name" class="col-name sortable">Name <span class="sort-arrow"></span></th>
|
||||
<th data-sort="size" class="col-size sortable">Size <span class="sort-arrow"></span></th>
|
||||
<th data-sort="ext" class="col-ext sortable">Type <span class="sort-arrow"></span></th>
|
||||
<th data-sort="date" class="col-date sortable">Modified <span class="sort-arrow"></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="browseTbody"></tbody>
|
||||
</table>
|
||||
<div class="browse-table-wrap">
|
||||
<table class="browse-table" id="browseTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="name" class="col-name sortable">Name <span class="sort-arrow"></span></th>
|
||||
<th data-sort="size" class="col-size sortable">Size <span class="sort-arrow"></span></th>
|
||||
<th data-sort="ext" class="col-ext sortable">Type <span class="sort-arrow"></span></th>
|
||||
<th data-sort="date" class="col-date sortable">Modified <span class="sort-arrow"></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="browseTbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue