All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
A new HTML tool — browse — that lists the contents of any directory.
Designed for ZDDC archives but no ZDDC-specific filtering; just a
straight folder browser with expand/collapse, sort, and name filter.
Modes (auto-detected at page load):
- Online: when served by zddc-server at a folder URL, queries
the same URL with Accept: application/json to load the listing
and renders it. Auto-served as the default at any directory
under ZDDC_ROOT without an index.html (replacing the previous
minimal-HTML stub from directory.go).
- Local: 'Select Directory' button uses FileSystemAccessAPI to
pick any folder on disk; works in Chromium-based browsers.
Features (Phase 1 — what's in this commit):
- Tree view with lazy-loaded folders (children fetched on first
expand).
- Sort by name / size / extension / date (column header click).
- Filter by name substring (toolbar input).
- File click opens in a new tab — for server-backed pages,
routes through zddc-server's normal handler so .archive
redirects + apps cascade overrides + ACL all apply.
Phase 2 deferred:
- ZIP files inline expansion (treat archive entries as virtual
children).
- File preview popup (reuse shared/preview-lib.js).
- Extension multi-select filter.
Wiring:
- browse/ added to top-level ./build's per-tool list, embed
block, versions.txt, and the lockstep release commit + tag set.
All seven tools (archive, transmittal, classifier, mdedit,
landing, form, browse) advance together on stable cuts.
- shared/build-lib.sh: browse added to ZDDC_RELEASE_TOOLS and
verify_channel_links's per-tool loop.
- zddc/internal/apps/embed.go: //go:embed browse.html +
EmbeddedBytes("browse") case.
- zddc/internal/apps/availability.go: browse available at every
directory (same as archive).
- zddc/internal/apps/handler.go: MatchAppHTML routes
/<dir>/browse.html → 'browse'.
- zddc/internal/handler/directory.go: when a directory request
arrives with Accept: text/html and no index.html exists,
serve the embedded browse.html bytes (with a JSON-fallback
if the embedded slot is empty during bootstrap).
288 lines
11 KiB
JavaScript
288 lines
11 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');
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
n.expanded = !n.expanded;
|
|
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,
|
|
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
|
|
};
|
|
})();
|