// 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) // 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 }; 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, '&').replace(//g, '>').replace(/"/g, '"'); } // 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 = node.isDir ? '📁' : (node.isZip ? '🗜️' : '📄'); var chevronClass = 'tree-name__chevron' + (expandable ? '' : ' tree-name__chevron--leaf'); var selected = state.selectedId === node.id ? ' is-selected' : ''; var virtualCls = node.virtual ? ' tree-row--virtual' : ''; var virtualHint = node.virtual ? '(empty)' : ''; return '' + '
' + '' + '' + iconChar + '' + '' + escapeHtml(node.name) + '' + 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; 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 = ''; 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. // 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(); }, // 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 }; })();