// 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++; 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: [] }; 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. function visibleIds() { var out = []; function walk(ids) { for (var i = 0; i < ids.length; i++) { out.push(ids[i]); var n = state.nodes.get(ids[i]); if (n.isDir && 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.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 iconChar = node.isDir ? '📁' : '📄'; var labelClass = node.isDir ? 'is-folder' : 'is-file'; var chevronClass = 'tree-name__chevron' + (node.isDir ? '' : ' tree-name__chevron--leaf'); var nameInner; if (node.isDir) { nameInner = '' + escapeHtml(node.name) + ''; } else { // File: clickable link. In server mode, href is a real URL // that opens the file. In FS mode, click handler reads the // file via the handle and triggers a download (Phase 2). 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; var ids = visibleIds(); var html = ''; for (var i = 0; i < ids.length; i++) { html += rowHtml(state.nodes.get(ids[i])); } tbody.innerHTML = html; applyFilter(); updateCount(); updateSortHeaders(); } // Filter is purely DOM-level: hide rows whose name doesn't match. // Cheap, immediate, no model rebuild. function applyFilter() { var f = state.filterText; var rows = document.querySelectorAll('#browseTbody tr.tree-row'); for (var i = 0; i < rows.length; i++) { var row = rows[i]; var n = state.nodes.get(parseInt(row.dataset.id, 10)); if (!n) continue; var match = !f || n.name.toLowerCase().indexOf(f) !== -1; row.classList.toggle('tree-row--filtered', !match); } } function updateCount() { var el = document.getElementById('entryCount'); if (!el) return; var rows = document.querySelectorAll('#browseTbody tr.tree-row:not(.tree-row--filtered)'); var total = document.querySelectorAll('#browseTbody tr.tree-row').length; el.textContent = state.filterText ? rows.length + ' of ' + total + ' shown' : total + ' item' + (total === 1 ? '' : 's'); } 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). async function loadChildren(node) { if (node.loaded) return; try { 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. async function toggleFolder(nodeId) { var n = state.nodes.get(nodeId); if (!n || !n.isDir) return; if (!n.expanded && !n.loaded) { await loadChildren(n); if (!n.loaded) return; // load failed } 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 // folder is loaded and marked expanded. Status bar shows progress // because deeply-nested trees can take a while. // // Parallelism: kept conservative (per-level fan-out) to avoid // hammering zddc-server with hundreds of concurrent listing // fetches. Browsers also throttle per-origin concurrency, but // queuing politely is friendlier than fighting that. async function expandSubtree(nodeId) { var root = state.nodes.get(nodeId); if (!root || !root.isDir) return; var status = window.app.modules.events.statusInfo; status('Expanding subtree…'); var processed = 0; var queue = [root]; while (queue.length) { var batch = queue; queue = []; // Load this level's children in parallel (Promise.all). 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]); if (c && c.isDir) queue.push(c); } } // Re-render after each level so the user sees progress // rather than a long pause then a sudden full-tree dump. render(); status('Expanding subtree… (' + processed + ' folders loaded)'); } status('Expanded ' + processed + ' folder' + (processed === 1 ? '' : 's')); } // Recursive collapse: mark this node and every descendant as // collapsed. Doesn't unload — if the user re-expands later, the // children are still in memory and re-render is instant. function collapseSubtree(nodeId) { var root = state.nodes.get(nodeId); if (!root || !root.isDir) 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) 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(); }, setFilter: function (s) { state.filterText = (s || '').toLowerCase(); applyFilter(); updateCount(); }, pathFor: pathFor }; })();