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).
349 lines
13 KiB
JavaScript
349 lines
13 KiB
JavaScript
// tree.js — in-memory tree model + DOM rendering.
|
|
//
|
|
// Nodes are stored flat in state.nodes (Map by id). The visible
|
|
// render is a depth-first walk starting from state.rootIds, skipping
|
|
// children of unexpanded folders. This decouples model from DOM and
|
|
// keeps re-renders linear in the visible-row count.
|
|
(function () {
|
|
'use strict';
|
|
|
|
var state = window.app.state;
|
|
var loader = window.app.modules.loader;
|
|
|
|
// ── Model helpers ────────────────────────────────────────────────────
|
|
|
|
function newNode(raw, parentId, depth) {
|
|
var id = state.nextId++;
|
|
var node = {
|
|
id: id,
|
|
name: raw.name,
|
|
isDir: raw.isDir,
|
|
size: raw.size,
|
|
modTime: raw.modTime,
|
|
ext: raw.ext,
|
|
url: raw.url,
|
|
handle: raw.handle,
|
|
depth: depth,
|
|
parentId: parentId,
|
|
expanded: false,
|
|
loaded: false,
|
|
childIds: []
|
|
};
|
|
state.nodes.set(id, node);
|
|
return node;
|
|
}
|
|
|
|
function clearTree() {
|
|
state.nodes.clear();
|
|
state.rootIds = [];
|
|
state.nextId = 1;
|
|
}
|
|
|
|
// Sort an array of nodes by current sort key. Folders always come
|
|
// first within a level (mimics common file managers).
|
|
function sortNodes(ids) {
|
|
var key = state.sort.key;
|
|
var dir = state.sort.dir;
|
|
ids.sort(function (a, b) {
|
|
var na = state.nodes.get(a);
|
|
var nb = state.nodes.get(b);
|
|
// Folders before files
|
|
if (na.isDir !== nb.isDir) return na.isDir ? -1 : 1;
|
|
var av, bv;
|
|
switch (key) {
|
|
case 'size':
|
|
av = na.size; bv = nb.size; break;
|
|
case 'ext':
|
|
av = na.ext; bv = nb.ext; break;
|
|
case 'date':
|
|
av = na.modTime ? na.modTime.getTime() : 0;
|
|
bv = nb.modTime ? nb.modTime.getTime() : 0;
|
|
break;
|
|
default:
|
|
av = na.name.toLowerCase();
|
|
bv = nb.name.toLowerCase();
|
|
}
|
|
if (av < bv) return -1 * dir;
|
|
if (av > bv) return 1 * dir;
|
|
return na.name.toLowerCase().localeCompare(nb.name.toLowerCase());
|
|
});
|
|
}
|
|
|
|
// Populate state with the root listing.
|
|
function setRoot(rawEntries) {
|
|
clearTree();
|
|
rawEntries.forEach(function (raw) {
|
|
var n = newNode(raw, null, 0);
|
|
state.rootIds.push(n.id);
|
|
});
|
|
sortNodes(state.rootIds);
|
|
}
|
|
|
|
// Populate a folder's children. Caller passes raw entries in any order.
|
|
function setChildren(parentId, rawEntries) {
|
|
var parent = state.nodes.get(parentId);
|
|
if (!parent) return;
|
|
// Drop any existing children first (re-load case).
|
|
parent.childIds.forEach(function (id) { state.nodes.delete(id); });
|
|
parent.childIds = [];
|
|
rawEntries.forEach(function (raw) {
|
|
var n = newNode(raw, parentId, parent.depth + 1);
|
|
parent.childIds.push(n.id);
|
|
});
|
|
sortNodes(parent.childIds);
|
|
parent.loaded = true;
|
|
}
|
|
|
|
// Walk visible nodes in render order.
|
|
function visibleIds() {
|
|
var out = [];
|
|
function walk(ids) {
|
|
for (var i = 0; i < ids.length; i++) {
|
|
out.push(ids[i]);
|
|
var n = state.nodes.get(ids[i]);
|
|
if (n.isDir && n.expanded) walk(n.childIds);
|
|
}
|
|
}
|
|
// Re-sort everything at all levels so a sort change reorders
|
|
// already-loaded children consistently.
|
|
sortNodes(state.rootIds);
|
|
state.nodes.forEach(function (n) {
|
|
if (n.isDir && n.loaded) sortNodes(n.childIds);
|
|
});
|
|
walk(state.rootIds);
|
|
return out;
|
|
}
|
|
|
|
// ── Rendering ────────────────────────────────────────────────────────
|
|
|
|
function fmtSize(bytes) {
|
|
if (bytes == null) return '';
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
|
}
|
|
|
|
function fmtDate(d) {
|
|
if (!d) return '';
|
|
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
|
|
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
|
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function rowHtml(node) {
|
|
var indent = node.depth * 1.2;
|
|
var iconChar = node.isDir ? '📁' : '📄';
|
|
var labelClass = node.isDir ? 'is-folder' : 'is-file';
|
|
var chevronClass = 'tree-name__chevron' + (node.isDir ? '' : ' tree-name__chevron--leaf');
|
|
var nameInner;
|
|
if (node.isDir) {
|
|
nameInner = '<span class="tree-name__label is-folder">'
|
|
+ escapeHtml(node.name) + '</span>';
|
|
} else {
|
|
// File: clickable link. In server mode, href is a real URL
|
|
// that opens the file. In FS mode, click handler reads the
|
|
// file via the handle and triggers a download (Phase 2).
|
|
var href = node.url || '#';
|
|
nameInner = '<a class="tree-name__label is-file"'
|
|
+ ' href="' + escapeHtml(href) + '"'
|
|
+ ' target="_blank" rel="noopener">' + escapeHtml(node.name) + '</a>';
|
|
}
|
|
return ''
|
|
+ '<tr class="tree-row ' + (node.expanded ? 'expanded' : '')
|
|
+ '" data-id="' + node.id + '" data-isdir="' + node.isDir + '">'
|
|
+ '<td class="col-name">'
|
|
+ '<span class="tree-name">'
|
|
+ '<span class="tree-name__indent" style="width:' + indent + 'rem;"></span>'
|
|
+ '<span class="' + chevronClass + '"></span>'
|
|
+ '<span class="tree-name__icon">' + iconChar + '</span>'
|
|
+ nameInner
|
|
+ '</span>'
|
|
+ '</td>'
|
|
+ '<td class="col-size">' + (node.isDir ? '' : fmtSize(node.size)) + '</td>'
|
|
+ '<td class="col-ext">' + (node.isDir ? '' : escapeHtml(node.ext)) + '</td>'
|
|
+ '<td class="col-date">' + fmtDate(node.modTime) + '</td>'
|
|
+ '</tr>';
|
|
}
|
|
|
|
function render() {
|
|
var tbody = document.getElementById('browseTbody');
|
|
if (!tbody) return;
|
|
var ids = visibleIds();
|
|
var html = '';
|
|
for (var i = 0; i < ids.length; i++) {
|
|
html += rowHtml(state.nodes.get(ids[i]));
|
|
}
|
|
tbody.innerHTML = html;
|
|
applyFilter();
|
|
updateCount();
|
|
updateSortHeaders();
|
|
}
|
|
|
|
// Filter is purely DOM-level: hide rows whose name doesn't match.
|
|
// Cheap, immediate, no model rebuild.
|
|
function applyFilter() {
|
|
var f = state.filterText;
|
|
var rows = document.querySelectorAll('#browseTbody tr.tree-row');
|
|
for (var i = 0; i < rows.length; i++) {
|
|
var row = rows[i];
|
|
var n = state.nodes.get(parseInt(row.dataset.id, 10));
|
|
if (!n) continue;
|
|
var match = !f || n.name.toLowerCase().indexOf(f) !== -1;
|
|
row.classList.toggle('tree-row--filtered', !match);
|
|
}
|
|
}
|
|
|
|
function updateCount() {
|
|
var el = document.getElementById('entryCount');
|
|
if (!el) return;
|
|
var rows = document.querySelectorAll('#browseTbody tr.tree-row:not(.tree-row--filtered)');
|
|
var total = document.querySelectorAll('#browseTbody tr.tree-row').length;
|
|
el.textContent = state.filterText
|
|
? rows.length + ' of ' + total + ' shown'
|
|
: total + ' item' + (total === 1 ? '' : 's');
|
|
}
|
|
|
|
function updateSortHeaders() {
|
|
var ths = document.querySelectorAll('#browseTable thead th.sortable');
|
|
for (var i = 0; i < ths.length; i++) {
|
|
ths[i].classList.remove('sort-asc', 'sort-desc');
|
|
if (ths[i].dataset.sort === state.sort.key) {
|
|
ths[i].classList.add(state.sort.dir > 0 ? 'sort-asc' : 'sort-desc');
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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 = [];
|
|
var cur = node;
|
|
while (cur) {
|
|
parts.unshift(cur.name);
|
|
cur = cur.parentId == null ? null : state.nodes.get(cur.parentId);
|
|
}
|
|
if (state.source === 'server') {
|
|
// currentPath is the dir containing rootIds — root nodes
|
|
// sit DIRECTLY under it.
|
|
return state.currentPath.replace(/\/$/, '') + '/' + parts.join('/');
|
|
}
|
|
return parts.join('/');
|
|
}
|
|
|
|
// Public API
|
|
window.app.modules.tree = {
|
|
setRoot: setRoot,
|
|
setChildren: setChildren,
|
|
render: render,
|
|
toggleFolder: toggleFolder,
|
|
expandSubtree: expandSubtree,
|
|
collapseSubtree: collapseSubtree,
|
|
setSort: function (key) {
|
|
if (state.sort.key === key) {
|
|
state.sort.dir = -state.sort.dir;
|
|
} else {
|
|
state.sort.key = key;
|
|
state.sort.dir = 1;
|
|
}
|
|
render();
|
|
},
|
|
setFilter: function (s) {
|
|
state.filterText = (s || '').toLowerCase();
|
|
applyFilter();
|
|
updateCount();
|
|
},
|
|
pathFor: pathFor
|
|
};
|
|
})();
|