// 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 visible nodes in render order. Excludes nodes whose // node.visible is false (filter-hidden) and skips the children of // a collapsed expandable. Filter visibility is computed by // recomputeVisibility() before this is called from render(). 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 (n.visible === false) 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, '"'); } function rowHtml(node) { var indent = node.depth * 1.2; var expandable = node.isDir || node.isZip; var iconChar = node.isDir ? '📁' : (node.isZip ? '🗜️' : '📄'); var chevronClass = 'tree-name__chevron' + (expandable ? '' : ' tree-name__chevron--leaf'); var nameInner; if (node.isDir) { nameInner = '' + escapeHtml(node.name) + ''; } else { // File / zip: clickable. Plain click → preview popup. // Modifier-click (ctrl/cmd) and middle-click → open in // new tab (browser default for the href). Server mode // gets the real URL (so right-click → save-link-as also // works); FS mode and zip-virtual children get '#'. var href = node.url || '#'; nameInner = '' + escapeHtml(node.name) + ''; } return '' + '' + '' + '' + '' + '' + '' + iconChar + '' + nameInner + '' + '' + '' + (node.isDir ? '' : fmtSize(node.size)) + '' + '' + (node.isDir ? '' : escapeHtml(node.ext)) + '' + '' + fmtDate(node.modTime) + '' + ''; } function render() { var tbody = document.getElementById('browseTbody'); if (!tbody) return; recomputeVisibility(); var ids = visibleIds(); var html = ''; for (var i = 0; i < ids.length; i++) { html += rowHtml(state.nodes.get(ids[i])); } tbody.innerHTML = html; updateCount(); updateSortHeaders(); renderBreadcrumbs(); } // Compute model-level visibility per node based on the three // filter ASTs: // - fileFilter → matches a file's basename // - folderFilter→ matches a folder's basename // - extFilter → matches a file's extension (no leading dot) // // Visibility rules: // 1. A FILE is "self-matched" when it passes both file+ext filter. // 2. A FOLDER is "self-matched" when it passes the folder filter. // 3. A file is "in-scope" when either no folder filter is active, // OR at least one ancestor folder is folder-self-matched. // 4. A file is VISIBLE when self-matched AND in-scope. // 5. A folder is VISIBLE when: // - any descendant is visible (so the path to a hit is // always shown), OR // - the folder itself is folder-self-matched AND no file // filter is active (when a file filter is set, we hide // folders that have no matching files inside — keeps // the result list focused). // // Pure model walk; the renderer just consumes node.visible. Hidden // expandable nodes get their `expanded` flag respected even though // they're not in the DOM, so toggling filters preserves the user's // expand state. function recomputeVisibility() { var fileAst = state.filters.file.ast; var folderAst = state.filters.folder.ast; var extAst = state.filters.ext.ast; var hasFile = !!(state.filters.file.raw); var hasFolder = !!(state.filters.folder.raw); var hasExt = !!(state.filters.ext.raw); var anyActive = hasFile || hasFolder || hasExt; // Fast path: nothing filtered → everything visible. if (!anyActive) { state.nodes.forEach(function (n) { n.visible = true; }); return; } var f = window.zddc && window.zddc.filter; // Walk top-down to propagate folder scope, then bottom-up to // propagate descendant visibility. Done in one DFS recursion. // ZIPs are hybrids — they match FILE filter (their name is a // filename) AND can be matched by FOLDER filter (they're // container-like — clicking expands them like a folder). function visit(nodeId, ancestorMatchesFolder) { var n = state.nodes.get(nodeId); if (!n) return false; if (!(n.isDir || n.isZip)) { // Plain file. Visible iff its name+ext pass file/ext // filters AND it's inside the folder-filter scope. var nameOk = f.matches(n.name, fileAst); var extOk = f.matches(n.ext || '', extAst); n.visible = nameOk && extOk && ancestorMatchesFolder; return n.visible; } // Folder or zip — has childIds and contributes to scope. // Folder self-match: the folder/zip name passes folder // filter. A folder match also opens the file-filter scope // for descendants. var asFolderMatch = f.matches(n.name, folderAst); // A zip can also match the FILE filter (it's a file too). // Typing a zip name into file filter surfaces the zip. // Gate on hasFile||hasExt — when neither is active, the // empty filter matches every name and would falsely // surface every zip regardless of the active folder filter. var asFileMatch = n.isZip && (hasFile || hasExt) && f.matches(n.name, fileAst) && f.matches(n.ext || '', extAst); var nextAncestorScope = ancestorMatchesFolder || asFolderMatch || asFileMatch; var anyChildVisible = false; for (var i = 0; i < n.childIds.length; i++) { if (visit(n.childIds[i], nextAncestorScope)) anyChildVisible = true; } // Visible if: // - any descendant is visible (path-to-hit visibility), or // - self-folder-match with no file/ext filter active // (let the folder surface even if it's empty/unloaded), or // - self-file-match (for zips, where the user is searching // for the archive by name in the file filter). n.visible = anyChildVisible || (asFolderMatch && !hasFile && !hasExt) || asFileMatch; return n.visible; } // Initial ancestor scope = folder filter empty (so files don't // require ancestor matches when there's no folder filter). var initialScope = !hasFolder; for (var i = 0; i < state.rootIds.length; i++) { visit(state.rootIds[i], initialScope); } } // Count nodes that would render if no filter were active // (i.e. anything at the root, or under an expanded ancestor). // Used to express " of shown" while a filter is on. 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 visible = visibleIds().length; var total = expandedSetSize(); var anyFilter = state.filters.file.raw || state.filters.folder.raw || state.filters.ext.raw; el.textContent = anyFilter ? visible + ' of ' + total + ' shown' : 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; } 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'); } } } // 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(); }, // Update one of the three column filters and re-render. `which` // is 'file' | 'folder' | 'ext'. Empty raw → AST cleared. setFilter: function (which, raw) { var slot = state.filters[which]; if (!slot) return; slot.raw = raw || ''; slot.ast = slot.raw && window.zddc && window.zddc.filter ? window.zddc.filter.parse(slot.raw) : null; render(); }, pathFor: pathFor }; })();