ZDDC/browse/js/tree.js
2026-06-11 13:32:31 -05:00

718 lines
33 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++;
// A .zip file is treated as a folder for tree purposes — the
// chevron expands it. On expand, server mode fetches the
// server's "<…>.zip/" virtual-directory listing; offline mode
// opens the zip with JSZip behind a ZipDirectoryHandle. Either
// way the zip's members become ordinary directory/file nodes.
var isZip = !raw.isDir && raw.ext === 'zip';
var node = {
id: id,
name: raw.name,
// displayName is the rendered label when set by the parent
// .zddc display: map. Sort + lookup continues to use .name
// (the on-disk basename) so URL composition stays canonical.
displayName: raw.displayName || '',
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,
_zipDirHandle: null, // cached ZipDirectoryHandle (offline / nested zips)
// True when this entry was synthesized client-side (e.g.
// canonical project folders that don't exist on disk yet).
// Rendered with a muted style + an "(empty)" hint.
virtual: !!raw.virtual,
// Server-computed write authority. Editors (preview-yaml,
// preview-markdown) consult this via canSave() to decide
// whether to mount read-only. Dropping the field here
// silently makes every node read-only — the actual root
// cause behind "I'm admin but the editor says read-only".
writable: !!raw.writable,
// Server-computed verb set (canonical "rwcda" subset).
// Per-entry permission gating reads this via
// zddc.cap.has(node, verb). Three states:
// "rw…" — zddc-server explicit grant
// "" — zddc-server explicit zero grant
// undefined — Caddy / FS-API listings (no verbs field).
// Per-entry gates skip the cascade check
// and fall back to canMutate / writable.
verbs: typeof raw.verbs === 'string' ? raw.verbs : undefined,
// Cascade default tool for a directory entry. When "tables"
// (mdl/rsk/ssr), the node is a TABLE LEAF: rendered without a
// chevron and, on click, opens the tables tool in the preview
// pane instead of expanding/navigating. See isTableLeaf().
defaultTool: raw.defaultTool || ''
};
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. When state.filterAST is set, also skips nodes that
// don't match (files) or whose subtree has no matches (folders),
// and force-walks into folders that have matching descendants so
// those matches are visible even when the user hadn't expanded
// the folder. The user's actual node.expanded flag stays untouched
// so clearing the filter restores their original layout.
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;
if (state.filterAST && !passesFilter(n)) continue;
out.push(ids[i]);
if (n.isDir || n.isZip) {
var forceWalk = !!state.filterAST;
if (forceWalk || 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;
}
// ── Filter ─────────────────────────────────────────────────────────────
// Build the haystack string we run the filter AST against. We
// concatenate every searchable field — name, displayName, plus any
// ZDDC parts the basename parses to — so users can type a tracking
// number, a status code, a date, or a piece of the title.
function filterHaystack(node) {
var parts = [node.name];
if (node.displayName) parts.push(node.displayName);
var z = window.zddc;
if (z) {
var parsed = node.isDir ? z.parseFolder(node.name)
: z.parseFilename(node.name);
if (parsed && parsed.valid) {
if (parsed.trackingNumber) parts.push(parsed.trackingNumber);
if (parsed.title) parts.push(parsed.title);
if (parsed.status) parts.push(parsed.status);
if (parsed.revision) parts.push(parsed.revision);
if (parsed.date) parts.push(parsed.date);
}
}
return parts.join(' ');
}
function nodeMatchesFilter(node) {
if (!state.filterAST) return true;
return window.zddc.filter.matches(filterHaystack(node), state.filterAST);
}
// True when this node should appear in the filtered view: either
// the node itself matches, or it's an expandable with at least
// one matching descendant (so we keep the path to a match visible).
function passesFilter(node) {
if (!state.filterAST) return true;
if (nodeMatchesFilter(node)) return true;
if (!(node.isDir || node.isZip)) return false;
if (!node.loaded) return false; // unloaded subtrees aren't searched
for (var i = 0; i < node.childIds.length; i++) {
var child = state.nodes.get(node.childIds[i]);
if (child && passesFilter(child)) return true;
}
return false;
}
// Is this folder being "forced open" by an active filter because
// a descendant matches? Used by rowHtml to render the chevron as
// expanded without mutating node.expanded.
function filterForcesOpen(node) {
if (!state.filterAST) return false;
if (!(node.isDir || node.isZip)) return false;
return passesFilter(node) && !nodeMatchesFilter(node);
}
// ── Rendering ────────────────────────────────────────────────────────
var fmtSize = window.app.modules.util.fmtSize;
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());
}
var escapeHtml = window.app.modules.util.escapeHtml;
// Per-extension icon map → Lucide outline-icon sprite ids. The
// actual SVG markup is produced by window.zddc.icons.html(id),
// which inlines `<svg><use href="#id"/></svg>` so the page CSS
// can size and tint via currentColor.
//
// book-marked PDF file-pen markdown
// file-text word / txt file-spreadsheet spreadsheet
// presentation slides file-image image
// file-video video file-audio audio
// ruler CAD / drawing globe web
// file-cog config / .zddc file-code source code
// file-archive non-nav archive folder-archive .zip (navigable)
// file generic folder directory
var ICON_BY_EXT = {
pdf: 'icon-book-marked',
md: 'icon-file-pen', markdown: 'icon-file-pen',
doc: 'icon-file-text', docx: 'icon-file-text', rtf: 'icon-file-text', odt: 'icon-file-text',
xls: 'icon-file-spreadsheet', xlsx: 'icon-file-spreadsheet',
csv: 'icon-file-spreadsheet', ods: 'icon-file-spreadsheet', tsv: 'icon-file-spreadsheet',
ppt: 'icon-presentation', pptx: 'icon-presentation', odp: 'icon-presentation',
txt: 'icon-file-text', log: 'icon-file-text',
jpg: 'icon-file-image', jpeg: 'icon-file-image', png: 'icon-file-image',
gif: 'icon-file-image', webp: 'icon-file-image', svg: 'icon-file-image',
bmp: 'icon-file-image', tif: 'icon-file-image', tiff: 'icon-file-image',
ico: 'icon-file-image', heic: 'icon-file-image',
mp4: 'icon-file-video', mov: 'icon-file-video', avi: 'icon-file-video',
mkv: 'icon-file-video', webm: 'icon-file-video', m4v: 'icon-file-video',
mp3: 'icon-file-audio', wav: 'icon-file-audio', flac: 'icon-file-audio',
ogg: 'icon-file-audio', m4a: 'icon-file-audio', aac: 'icon-file-audio',
dwg: 'icon-ruler', dxf: 'icon-ruler', step: 'icon-ruler',
stp: 'icon-ruler', iges: 'icon-ruler', igs: 'icon-ruler',
html: 'icon-globe', htm: 'icon-globe',
yaml: 'icon-file-cog', yml: 'icon-file-cog', json: 'icon-file-cog',
toml: 'icon-file-cog', ini: 'icon-file-cog', xml: 'icon-file-cog',
conf: 'icon-file-cog', cfg: 'icon-file-cog',
'7z': 'icon-file-archive', rar: 'icon-file-archive', tar: 'icon-file-archive',
gz: 'icon-file-archive', tgz: 'icon-file-archive',
bz2: 'icon-file-archive', xz: 'icon-file-archive',
// Code — share one glyph across languages so users build the
// "this is source" pattern. Distinguishing per language would
// be visual noise without much added signal.
js: 'icon-file-code', mjs: 'icon-file-code', cjs: 'icon-file-code',
ts: 'icon-file-code', tsx: 'icon-file-code', jsx: 'icon-file-code',
py: 'icon-file-code', go: 'icon-file-code', rs: 'icon-file-code',
c: 'icon-file-code', cc: 'icon-file-code', cpp: 'icon-file-code',
h: 'icon-file-code', hpp: 'icon-file-code', java: 'icon-file-code',
rb: 'icon-file-code', php: 'icon-file-code', sh: 'icon-file-code',
bash: 'icon-file-code', zsh: 'icon-file-code', lua: 'icon-file-code',
swift: 'icon-file-code', kt: 'icon-file-code', kts: 'icon-file-code',
css: 'icon-file-code', scss: 'icon-file-code', less: 'icon-file-code'
};
function symbolForNode(node) {
// Table-leaf dirs (mdl/rsk/ssr) read as a table, not a folder.
if (window.app.modules.util.isTableLeaf(node)) return 'icon-file-spreadsheet';
if (node.isDir) return 'icon-folder';
if (node.isZip) return 'icon-folder-archive';
// `.zddc` (no extension) is the cascade config — same family
// as yaml. Match the literal basename before falling through
// to the extension table.
if (node.name === '.zddc') return 'icon-file-cog';
var ext = (node.ext || '').toLowerCase();
return ICON_BY_EXT[ext] || 'icon-file';
}
function iconForNode(node) {
return window.zddc.icons.html(symbolForNode(node));
}
// Render the label cell for a row. When the basename parses as a
// ZDDC-conformant filename (files) or transmittal folder name
// (directories), split into a two-line layout:
// top — trackingNumber · [revision · ]status (small, muted)
// bot — title (normal weight)
// Otherwise fall back to a single line.
//
// .zddc `display:` overrides always render as a single line — the
// operator chose that string for a reason; we don't try to second-
// guess it by parsing for ZDDC structure.
function labelHtml(node) {
// No native title="…" — the rich hovercard (browse/js/hovercard.js)
// replaces the browser tooltip with a metadata view that's
// both more informative and styled to match the rest of the UI.
if (node.displayName) {
return '<span class="tree-name__label">'
+ escapeHtml(node.displayName)
+ '</span>';
}
var z = window.zddc;
var parsed = null;
if (z) {
parsed = node.isDir
? z.parseFolder(node.name)
: z.parseFilename(node.name);
}
if (parsed && parsed.valid) {
// Folders carry a date (no revision); files carry a
// revision (no date). Status is present on both.
var parts;
if (node.isDir) {
parts = [parsed.date, parsed.trackingNumber, parsed.status];
} else {
parts = [parsed.trackingNumber, parsed.revision, parsed.status];
}
var metaText = parts.filter(Boolean).join(' · ');
// Title-first: primary content on the top line so the row
// reads like a normal file manager / mail list. Meta sits
// below as the supporting "subtitle" — same hierarchy
// pattern as Gmail, Linear, Notion file rows.
return '<span class="tree-name__label tree-name__label--zddc">'
+ '<span class="tree-name__title">'
+ escapeHtml(parsed.title)
+ '</span>'
+ '<span class="tree-name__meta">'
+ escapeHtml(metaText)
+ '</span>'
+ '</span>';
}
return '<span class="tree-name__label">'
+ escapeHtml(node.name)
+ '</span>';
}
// 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;
// Table-leaf dirs render like a file: no chevron, click opens the
// table in the preview pane (handled by events.js / preview.js).
var tableLeaf = window.app.modules.util.isTableLeaf(node);
var expandable = (node.isDir || node.isZip) && !tableLeaf;
var iconChar = iconForNode(node);
var chevronClass = 'tree-name__chevron'
+ (expandable ? '' : ' tree-name__chevron--leaf');
// Outline Lucide chevron — single sprite glyph, rotated 90°
// via CSS for the expanded state. Leaf rows ship an empty
// chevron span so the icon column stays aligned.
var chevronGlyph = expandable
? window.zddc.icons.html('icon-chevron-right')
: '';
// While a filter is active, folders that contain a matching
// descendant are rendered as visually expanded so the user
// can see the match — even if node.expanded is still false.
// The actual flag stays untouched so clearing the filter
// restores the user's original tree shape.
var visuallyExpanded = node.expanded || filterForcesOpen(node);
var selected = state.selectedId === node.id ? ' is-selected' : '';
var virtualCls = node.virtual ? ' tree-row--virtual' : '';
// No native title — the hovercard surfaces a dedicated
// "Virtual: Not yet created on disk" row for these nodes.
var virtualHint = node.virtual
? '<span class="tree-name__hint">(empty)</span>'
: '';
// Extension chip stacked under the file icon. Files with a
// non-empty ext get a small uppercase label; folders / zips
// skip it (the chevron + icon glyph carries enough info).
var extChip = (!node.isDir && !node.isZip && node.ext)
? '<span class="tree-name__ext">' + escapeHtml(String(node.ext)) + '</span>'
: '';
return ''
+ '<div class="tree-row ' + (visuallyExpanded ? 'expanded' : '') + selected + virtualCls
+ '" data-id="' + node.id
+ '" data-isdir="' + node.isDir
+ '" data-iszip="' + node.isZip + '"'
+ (tableLeaf ? ' data-tableleaf="true"' : '')
+ (node.virtual ? ' data-virtual="true"' : '')
+ ' style="padding-left:' + indent + 'rem"'
+ ' role="treeitem" tabindex="-1">'
+ '<span class="' + chevronClass + '">' + chevronGlyph + '</span>'
+ '<span class="tree-name__icon">' + iconChar + extChip + '</span>'
+ labelHtml(node)
+ virtualHint
// Kebab (⋯) — visible affordance that the row has actions; opens
// the same context menu. Revealed on hover/selection/focus (CSS).
// tabindex -1 keeps it out of the tab order (roving tabindex on
// the rows); reachable via right-click / the keyboard menu key.
+ '<button type="button" class="tree-row__kebab" tabindex="-1"'
+ ' aria-label="Row actions">'
+ window.zddc.icons.html('icon-ellipsis')
+ '</button>'
+ '</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;
renderBreadcrumbs();
}
// ── 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;
}
// True when this .zip node lives inside another zip, so its bytes
// can't be fetched as a standalone server resource: we read them
// through the containing handle (offline / nested) or by fetching
// the inner-zip member URL. In server mode a zip-inside-a-zip's URL
// contains ".zip/"; offline it has a handle that is itself a zip
// entry.
function zipNestedInsideZip(node) {
if (state.source === 'server') {
return pathFor(node).toLowerCase().indexOf('.zip/') !== -1;
}
return !!(node.handle && node.handle.isZipEntry);
}
// Open a .zip node as a directory handle (a ZipDirectoryHandle over
// a JSZip instance), cached on the node. Bytes come from a real
// FileSystemFileHandle / ZipFileHandle when present (offline, or a
// zip nested in a zip), else from a server URL — zddc-server returns
// the raw .zip for "<…>.zip" and the inner-zip bytes for
// "<outer>.zip/inner.zip".
async function zipDirHandle(node) {
if (node._zipDirHandle) return node._zipDirHandle;
await loader.ensureJSZip();
var zh;
if (node.handle) {
zh = await window.zddc.zip.fromFileHandle(node.handle);
} else if (node.url) {
var resp = await fetch(node.url, { credentials: 'same-origin' });
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + node.url);
zh = await window.zddc.zip.fromBlob(await resp.arrayBuffer(), node.name);
} else {
throw new Error('cannot open zip ' + node.name + ' (no handle or URL)');
}
node._zipDirHandle = zh;
return zh;
}
// Load a folder's children (lazy; idempotent re-loads). Dispatches
// by node kind:
// - regular folder → server JSON listing OR FS-API entries
// - top-level .zip, server mode → the server's "<…>.zip/" virtual-
// directory listing (no whole-zip
// download — zddc-server extracts a
// member only when one is requested)
// - .zip otherwise (offline, or a zip nested in a zip)
// → open it with JSZip and enumerate
// it as a directory handle; members
// become ordinary dir/file nodes
async function loadChildren(node) {
if (node.loaded || node.loading) return;
// In-flight guard: a folder can be (re)toggled while its first
// load is still pending — rapid Enter/ArrowRight key-repeat, or a
// double-click landing during a single-click's load. Without this,
// both calls pass the !loaded check and fire duplicate fetches that
// race in setChildren. The flag serializes per-node so the second
// caller is a no-op until the first resolves.
node.loading = true;
try {
if (node.isZip && state.source === 'server' && !zipNestedInsideZip(node)) {
setChildren(node.id, await loader.fetchServerChildren(pathFor(node) + '/'));
} else if (node.isZip) {
setChildren(node.id, await loader.fetchFsChildren(await zipDirHandle(node)));
} 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);
} finally {
node.loading = false;
}
}
// 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('/');
}
// ── State snapshot / restore ───────────────────────────────────────────
//
// Used by refresh + show-hidden so the user doesn't lose their
// tree layout when the listing reloads. The key is the absolute
// path of each node, computed by pathFor; on restore we walk the
// new tree and re-apply expansion + selection to nodes whose
// paths match.
function snapshotState() {
var expanded = {};
var selectedPath = null;
var previewPath = null;
state.nodes.forEach(function (n) {
if ((n.isDir || n.isZip) && n.expanded) {
expanded[pathFor(n)] = true;
}
if (n.id === state.selectedId) selectedPath = pathFor(n);
if (n.id === state.lastPreviewedNodeId) previewPath = pathFor(n);
});
return {
expanded: expanded,
selectedPath: selectedPath,
previewPath: previewPath
};
}
// Walk the current tree (already populated by setRoot) and re-
// load + expand every folder whose path appears in snapshot.expanded.
// Sets selectedId and lastPreviewedNodeId by matching the snapshot
// paths to the freshly-issued node IDs.
async function restoreState(snap) {
if (!snap) return;
async function walk(ids) {
for (var i = 0; i < ids.length; i++) {
var n = state.nodes.get(ids[i]);
if (!n) continue;
var p = pathFor(n);
if (snap.selectedPath && p === snap.selectedPath) {
state.selectedId = n.id;
}
if (snap.previewPath && p === snap.previewPath) {
state.lastPreviewedNodeId = n.id;
}
if ((n.isDir || n.isZip) && snap.expanded[p]) {
await loadChildren(n);
if (n.loaded) {
n.expanded = true;
await walk(n.childIds);
}
}
}
}
await walk(state.rootIds);
}
// Public API
window.app.modules.tree = {
setRoot: setRoot,
setChildren: setChildren,
render: render,
toggleFolder: toggleFolder,
expandSubtree: expandSubtree,
collapseSubtree: collapseSubtree,
loadChildren: loadChildren,
snapshotState: snapshotState,
restoreState: restoreState,
// Set both key and direction explicitly. dir: 1 (asc) or -1 (desc).
// Used by the toolbar's sort dropdown.
setSortExplicit: function (key, dir) {
state.sort.key = key;
state.sort.dir = (dir === -1 ? -1 : 1);
render();
},
pathFor: pathFor,
visibleIds: visibleIds
};
})();