ZDDC/browse/js/tree.js
ZDDC 7d4d2dc9a2 feat(browse): two-pane shell + markdown plugin + grid mode (Phases A/B/C/D)
Reshape browse from "tree-as-table with popup preview" into a unified
file-experience tool with three layered behaviors:

  Phase A — Two-pane shell
  Phase B — Markdown plugin (Toast UI inline)
  Phase C — Grid mode (classifier workflow)
  Phase D — Deprecation banners on standalone classifier + mdedit

= Phase A: two-pane shell + lightweight preview plugins =

Browse's table view becomes a tree-pane on the left + preview-pane on
the right with a draggable resizer. Click a folder → expand inline.
Click a file → render in the right pane. The previous popup window
becomes an explicit "⤴ Pop out" button in the right-pane header for
users with a second monitor.

Preview rendering reuses shared/preview-lib.js (PDF iframe, image
<img>, TIFF, ZIP listing, text <pre>). Unknown types show a download
link. browse/js/preview.js refactored into renderInline (default) +
renderInPopup (Pop out button); both share the same plugin
dispatch logic.

Filter rows were already removed earlier this session. Sort columns
likewise — the tree is alphabetical by default; the underlying
setSort API still exists for future re-introduction.

= Phase B: markdown plugin =

New browse/js/preview-markdown.js: when a .md or .markdown file is
clicked, the right pane mounts a Toast UI editor (initial-value =
file contents) with a small toolbar containing Save + dirty indicator
+ status text. Save sends PUT through the file API for server-mode
files; non-server sources are read-only for now (deferred to a
follow-up that wires zddc-source.js writes too). Ctrl+S / Cmd+S
inside the editor saves.

Toast UI Editor (~700 KB JS + ~160 KB CSS) was previously bundled
only in mdedit/vendor/. Moved to shared/vendor/ so browse and mdedit
both pull from one location.

= Phase C: grid mode =

View-mode toggle [Browse | Grid] in the toolbar. Grid mode loads the
classifier tool as an iframe scoped to the current directory (server
mode at working/staging/incoming locations) — classifier's full
bulk-rename workflow without leaving browse. v1 implementation; a
future iteration could bundle classifier's modules directly into
browse for tighter integration. Hostile cases (file:// origin, paths
outside working/staging/incoming) show a friendly explanation
instead of a blank iframe.

new browse/js/grid.js handles the activation logic.

= Phase D: deprecation banners =

mdedit and classifier standalones gain a "this tool is being absorbed
into Browse" advisory banner. Both standalones remain fully
functional and continue to ship — they're useful for offline single-
file editing and air-gapped environments. The banner just points
users toward the unified browse experience.

= Files =

  + browse/js/preview-markdown.js   (markdown plugin)
  + browse/js/grid.js               (grid-mode plugin)
  M browse/template.html            (two-pane layout, view toggle, banners)
  M browse/css/tree.css             (two-pane CSS, replaces table styles)
  M browse/js/init.js               (state additions: selectedId, viewMode)
  M browse/js/tree.js               (rowHtml: <tr>+<td> → <div>)
  M browse/js/preview.js            (renderInline / renderInPopup split)
  M browse/js/events.js             (toggle wiring, resizer, click handlers
                                     adapted from <table> to <div>)
  M browse/build.sh                 (Toast UI vendor + new modules)
  R mdedit/vendor/toastui-*         → shared/vendor/  (one bundle, two tools)
  M mdedit/build.sh                 (paths)
  M mdedit/template.html            (deprecation banner)
  M classifier/template.html        (deprecation banner)
  M tests/browse.spec.js            (selectors updated for new layout +
                                     new "click file → preview" test)

Bundle sizes after this commit:
  browse:     ~1020 KB  (was ~290 KB; added Toast UI ~700 KB)
  classifier: ~1470 KB  (unchanged from prior baseline)
  mdedit:     ~2140 KB  (unchanged; vendor location moved but not added)

What's deferred:
  - TOC + front-matter pane in browse's markdown plugin (mdedit has
    these; browse v1 uses just the editor).
  - FS-API writes from browse's markdown plugin (server PUT works).
  - Classifier modules bundled directly into browse (v1 uses iframe).
  - Sort UI in the new tree (model still supports it; no widget yet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:46:51 -05:00

518 lines
21 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++;
// ZIP files are treated as folders for tree purposes — the
// chevron lets the user expand them inline. The actual
// contents are loaded on first expand via JSZip.
var isZip = !raw.isDir && raw.ext === 'zip';
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: [],
isZip: isZip,
zipFile: null, // cached JSZip instance
zipPath: raw.zipPath || null, // path within zip (for virtual children)
zipParentId: raw.zipParentId || null // ancestor zip's node id (for nested entries)
};
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 nodes in render order. Skips the children of a collapsed
// expandable.
function visibleIds() {
var out = [];
function walk(ids) {
for (var i = 0; i < ids.length; i++) {
var n = state.nodes.get(ids[i]);
if (!n) continue;
out.push(ids[i]);
if ((n.isDir || n.isZip) && 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.isZip) && 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;');
}
// Render a single tree row as a flat <div>. Indentation via
// padding-left so the row's hover background spans the full
// pane width. Files are rendered as plain rows (no anchor) —
// the preview pane handles click navigation, and a Ctrl/Cmd-
// click can fall back to opening the file's url in a new tab
// via the events.js click handler (it sees the modifier key).
function rowHtml(node) {
var indent = 0.4 + node.depth * 1.0;
var expandable = node.isDir || node.isZip;
var iconChar = node.isDir ? '📁' : (node.isZip ? '🗜️' : '📄');
var chevronClass = 'tree-name__chevron'
+ (expandable ? '' : ' tree-name__chevron--leaf');
var selected = state.selectedId === node.id ? ' is-selected' : '';
return ''
+ '<div class="tree-row ' + (node.expanded ? 'expanded' : '') + selected
+ '" data-id="' + node.id
+ '" data-isdir="' + node.isDir
+ '" data-iszip="' + node.isZip + '"'
+ ' style="padding-left:' + indent + 'rem"'
+ ' role="treeitem" tabindex="-1">'
+ '<span class="' + chevronClass + '"></span>'
+ '<span class="tree-name__icon">' + iconChar + '</span>'
+ '<span class="tree-name__label" title="' + escapeHtml(node.name) + '">'
+ escapeHtml(node.name) + '</span>'
+ '</div>';
}
function render() {
var body = document.getElementById('treeBody');
if (!body) return;
var ids = visibleIds();
var html = '';
for (var i = 0; i < ids.length; i++) {
html += rowHtml(state.nodes.get(ids[i]));
}
body.innerHTML = html;
updateCount();
renderBreadcrumbs();
}
// Count nodes that render at the root + every expanded subtree.
function expandedSetSize() {
var n = 0;
function walk(ids) {
for (var i = 0; i < ids.length; i++) {
n++;
var node = state.nodes.get(ids[i]);
if (node && (node.isDir || node.isZip) && node.expanded) {
walk(node.childIds);
}
}
}
walk(state.rootIds);
return n;
}
function updateCount() {
var el = document.getElementById('entryCount');
if (!el) return;
var total = expandedSetSize();
el.textContent = total + ' item' + (total === 1 ? '' : 's');
}
// ── Breadcrumbs ──────────────────────────────────────────────────────
// Inline outline home icon. Stroke-based so it tints with the
// current text color rather than depending on emoji rendering.
var HOME_SVG = '<svg class="bc-home-icon" xmlns="http://www.w3.org/2000/svg" '
+ 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
+ '<path d="M3 12l9-9 9 9"/>'
+ '<path d="M5 10v10h14V10"/>'
+ '<path d="M10 20v-6h4v6"/></svg>';
function renderBreadcrumbs() {
var el = document.getElementById('breadcrumbs');
if (!el) return;
var html = '';
if (state.source === 'server') {
// Server mode: every segment links to its directory URL.
// The browser navigates → server returns embedded browse →
// the new instance auto-loads that directory's listing.
var path = state.currentPath || '/';
var parts = path.split('/').filter(Boolean);
html += '<a class="bc-link bc-root" href="/" title="Site root">'
+ HOME_SVG + '</a>';
var sofar = '';
for (var i = 0; i < parts.length; i++) {
sofar += '/' + parts[i];
var isLast = i === parts.length - 1;
html += '<span class="bc-sep">/</span>';
if (isLast) {
html += '<span class="bc-link bc-link--current">'
+ escapeHtml(parts[i]) + '</span>';
} else {
html += '<a class="bc-link" href="' + escapeHtml(sofar + '/') + '">'
+ escapeHtml(parts[i]) + '</a>';
}
}
html += '<span class="bc-sep">/</span>';
} else if (state.source === 'fs') {
// FS-API mode: ancestor handles weren't retained when the
// user picked the root, so we can't navigate up. Show the
// root icon + handle name without links.
var name = state.rootHandle ? state.rootHandle.name : '';
html += '<span class="bc-link bc-root" title="Local directory">'
+ HOME_SVG + '</span>';
if (name) {
html += '<span class="bc-sep">/</span>';
html += '<span class="bc-link bc-link--current">' + escapeHtml(name) + '</span>';
}
html += '<span class="bc-sep">/</span>';
}
el.innerHTML = html;
}
// Sort headers no longer exist in the DOM (the tree replaced the
// table); the tree.setSort() method still works but only via
// programmatic callers — there's no UI for changing sort yet.
// Load a folder's children (lazy; idempotent re-loads). Dispatches
// by node kind:
// - regular folder → server JSON listing OR FS-API enumeration
// - zip file → fetch+JSZip; entries become virtual children
// - zip child dir → already-listed entries from the parent zip
// (zips are enumerated whole, so child dirs
// are pre-populated when the zip expands)
async function loadChildren(node) {
if (node.loaded) return;
try {
if (node.isZip) {
await loadZipChildren(node);
} else if (node._zipSyntheticDir) {
// Synthetic dir node materialized when a zip's entry
// list referenced "a/b/file" but had no "a/" entry.
// Re-walk the owning zip's flat entry list with the
// dir's full prefix.
var owner = state.nodes.get(node.zipParentId);
if (!owner || !owner.zipEntries) {
throw new Error('zip parent not loaded');
}
setZipDirChildren(node, owner, node.zipPath + '/');
} else if (node.isDir) {
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);
}
}
// Fetch a zip's bytes, parse with JSZip, and materialize its
// entries as a tree of virtual nodes. JSZip's entry list is flat
// (full paths); we reconstruct the directory hierarchy on top.
async function loadZipChildren(zipNode) {
await loader.ensureJSZip();
var arrayBuffer;
if (state.source === 'server' && zipNode.url) {
var resp = await fetch(zipNode.url);
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + zipNode.url);
arrayBuffer = await resp.arrayBuffer();
} else if (zipNode.handle) {
// FS-API: top-level zip in a local folder.
var f = await zipNode.handle.getFile();
arrayBuffer = await f.arrayBuffer();
} else if (zipNode.zipParentId != null) {
// Nested zip inside another zip — read from parent JSZip.
var parent = state.nodes.get(zipNode.zipParentId);
if (!parent || !parent.zipFile) {
throw new Error('parent zip not loaded');
}
arrayBuffer = await parent.zipFile.file(zipNode.zipPath).async('arraybuffer');
} else {
throw new Error('cannot fetch zip bytes (no source)');
}
var zip = await window.JSZip.loadAsync(arrayBuffer);
zipNode.zipFile = zip;
// Build a path → raw-entry map. Entry paths are
// "dir/sub/file.ext" or "dir/" for directories. We slice
// to immediate children of zipNode (i.e. zero slashes after
// a leading prefix). For nested directories, we synthesize
// folder nodes that lazy-expand to the next level via the
// same raw-entry list — keep it on the zipNode for replay.
zipNode.zipEntries = []; // for re-walk on expand of subdirs
zip.forEach(function (relPath, entry) {
zipNode.zipEntries.push({
path: relPath.replace(/\/$/, ''),
isDir: entry.dir,
size: (entry._data && entry._data.uncompressedSize) || 0,
modTime: entry.date instanceof Date ? entry.date : null,
rawPath: relPath
});
});
// Now seed top-level children of the zip itself.
setZipDirChildren(zipNode, zipNode, '');
}
// Populate node's childIds with the entries directly under
// pathPrefix (relative to the owning zip). Directory entries
// become folder nodes whose own children are seeded on first
// expand by this same function (recursively descending zipPath).
function setZipDirChildren(node, zipOwner, pathPrefix) {
var seen = new Map(); // immediate child name → raw entry
zipOwner.zipEntries.forEach(function (e) {
if (!e.path.startsWith(pathPrefix)) return;
var rest = e.path.substring(pathPrefix.length);
if (rest === '') return;
// Take the FIRST segment of the remaining path
var slash = rest.indexOf('/');
var firstSeg = slash === -1 ? rest : rest.substring(0, slash);
var isImmediateFile = !e.isDir && slash === -1;
var isImmediateDir = e.isDir && slash === -1;
// For deeply-nested entries (rest contains a slash), we
// surface only the first segment as a synthetic folder
// entry. For immediate entries, we emit the entry as-is.
if (isImmediateFile || isImmediateDir) {
// Immediate entry — use the real metadata.
seen.set(firstSeg, {
name: firstSeg,
isDir: e.isDir,
size: e.size,
modTime: e.modTime,
ext: e.isDir ? '' : loader.splitExt(firstSeg),
url: null,
handle: null,
zipPath: e.path,
zipParentId: zipOwner.id
});
} else if (slash !== -1 && !seen.has(firstSeg)) {
// Deeper entry, no explicit dir entry yet — synthesize.
seen.set(firstSeg, {
name: firstSeg,
isDir: true,
size: 0,
modTime: null,
ext: '',
url: null,
handle: null,
zipPath: pathPrefix + firstSeg,
zipParentId: zipOwner.id
});
}
});
// Drop existing children (re-load case)
node.childIds.forEach(function (id) { state.nodes.delete(id); });
node.childIds = [];
seen.forEach(function (raw) {
var n = newNode(raw, node.id, node.depth + 1);
// Synthetic dir nodes inside zip don't have a dedicated
// load path — they re-walk zipEntries on expand. Mark
// them so the dispatcher knows.
if (raw.isDir && !n.isZip) {
n._zipSyntheticDir = true;
}
node.childIds.push(n.id);
});
sortNodes(node.childIds);
node.loaded = true;
}
// Toggle a folder's expanded state. Loads children on first expand.
// Treats "expandable" as either a real directory OR a zip file
// (zip files act like folders for tree purposes — the chevron
// expands them and the contents come from JSZip).
async function toggleFolder(nodeId) {
var n = state.nodes.get(nodeId);
if (!n || !(n.isDir || n.isZip)) return;
if (!n.expanded && !n.loaded) {
await loadChildren(n);
if (!n.loaded) return; // load failed (statusError already set)
}
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
// expandable node (folder OR zip) is loaded and marked expanded.
// Skips zip-EXPANSION recursion to avoid auto-loading every
// archive in the tree (those can be huge); plain folders only.
async function expandSubtree(nodeId) {
var root = state.nodes.get(nodeId);
if (!root || !(root.isDir || root.isZip)) return;
var status = window.app.modules.events.statusInfo;
status('Expanding subtree…');
var processed = 0;
var queue = [root];
while (queue.length) {
var batch = queue;
queue = [];
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]);
// Recurse into plain folders only — don't auto-
// expand zip archives during a subtree expand
// (they can be very large).
if (c && c.isDir && !c.isZip) queue.push(c);
}
}
render();
status('Expanding subtree… (' + processed + ' folders loaded)');
}
status('Expanded ' + processed + ' folder' + (processed === 1 ? '' : 's'));
}
function collapseSubtree(nodeId) {
var root = state.nodes.get(nodeId);
if (!root || !(root.isDir || root.isZip)) 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 || c.isZip)) 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();
},
pathFor: pathFor
};
})();