// 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 }; 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 ──────────────────────────────────────────────────────── 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, '"'); } // Per-extension icon map → Lucide outline-icon sprite ids. The // actual SVG markup is produced by window.zddc.icons.html(id), // which inlines `` 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) { 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 '' + escapeHtml(node.displayName) + ''; } 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 '' + '' + escapeHtml(parsed.title) + '' + '' + escapeHtml(metaText) + '' + ''; } return '' + escapeHtml(node.name) + ''; } // Render a single tree row as a flat
. 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 = 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 ? '(empty)' : ''; // 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) ? '' + escapeHtml(String(node.ext)) + '' : ''; return '' + '
' + '' + chevronGlyph + '' + '' + iconChar + extChip + '' + labelHtml(node) + virtualHint + '
'; } 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 = ''; 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 += '' + HOME_SVG + ''; var sofar = ''; for (var i = 0; i < parts.length; i++) { sofar += '/' + parts[i]; var isLast = i === parts.length - 1; html += '/'; if (isLast) { html += '' + escapeHtml(parts[i]) + ''; } else { html += '' + escapeHtml(parts[i]) + ''; } } html += '/'; } 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 += '' + HOME_SVG + ''; if (name) { html += '/'; html += '' + escapeHtml(name) + ''; } html += '/'; } 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. // 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 // ".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) return; 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); } } // 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, setSort: function (key) { if (state.sort.key === key) { state.sort.dir = -state.sort.dir; } else { state.sort.key = key; state.sort.dir = 1; } render(); }, // 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 }; })();