ZDDC/browse/js/tree.js
ZDDC fb13ff4fd8
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
feat(browse): generic directory listing tool — default at folder URLs
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).
2026-05-03 19:56:51 -05:00

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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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
};
})();