- Vendor JSZip locally (shared/vendor/jszip.min.js) and bundle into
the browse build instead of CDN-loading. Eliminates the failure
mode where ZIP rows can't expand because the CDN script doesn't
load (CSP, network, etc.). Tool now works fully offline.
- Replace the toolbar filter input + ext multi-select with two
spreadsheet-style auto-filter rows in <thead>:
- 📄 row: file-name filter + extension filter
- 📁 row: folder-name filter
Each input uses shared/zddc-filter syntax (substring/!negate/
^startsWith/$endsWith/regex/| or/space and).
- New visibility model with ancestor-of-match awareness:
- file matches keep their ancestor folders visible (path-to-hit)
- folder match keeps its descendants visible
- filters compose (file ∧ folder ∧ ext) so combinations narrow
Computed model-side; render walks only visible nodes.
- Replace 🏠 emoji breadcrumb-root with an inline outline-stroke SVG
that tints with currentColor.
663 lines
28 KiB
JavaScript
663 lines
28 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++;
|
|
// 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, '>').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 = '<span class="tree-name__label is-folder">'
|
|
+ escapeHtml(node.name) + '</span>';
|
|
} 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 = '<a class="tree-name__label is-file"'
|
|
+ ' href="' + escapeHtml(href) + '"'
|
|
+ ' target="_blank" rel="noopener">' + escapeHtml(node.name) + '</a>';
|
|
}
|
|
return ''
|
|
+ '<tr class="tree-row ' + (node.expanded ? 'expanded' : '')
|
|
+ '" data-id="' + node.id
|
|
+ '" data-isdir="' + node.isDir
|
|
+ '" data-iszip="' + node.isZip + '">'
|
|
+ '<td class="col-name">'
|
|
+ '<span class="tree-name">'
|
|
+ '<span class="tree-name__indent" style="width:' + indent + 'rem;"></span>'
|
|
+ '<span class="' + chevronClass + '"></span>'
|
|
+ '<span class="tree-name__icon">' + iconChar + '</span>'
|
|
+ nameInner
|
|
+ '</span>'
|
|
+ '</td>'
|
|
+ '<td class="col-size">' + (node.isDir ? '' : fmtSize(node.size)) + '</td>'
|
|
+ '<td class="col-ext">' + (node.isDir ? '' : escapeHtml(node.ext)) + '</td>'
|
|
+ '<td class="col-date">' + fmtDate(node.modTime) + '</td>'
|
|
+ '</tr>';
|
|
}
|
|
|
|
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 "<visible> of <total> 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 = '<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;
|
|
}
|
|
|
|
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
|
|
};
|
|
})();
|