ZDDC/classifier/js/tree.js
ZDDC 1f8b4e4aaa feat(classifier): zips are single files by default, toggle to expand as a folder
Previously every .zip was auto-expanded into a folder of members (and was even
double-represented as both a file and a folder node). Now a .zip is one
classifiable file by default; right-click it → "Expand as folder" to pull its
members into the fileset, and right-click an expanded archive (or a member) →
"Collapse to single file" to go back. The toggle sits with Exclude in the
context menu.

- scanner: stop creating zip-root nodes during the scan; expandZipAsFolder /
  collapseZipToFile mutate the tree in place (re-reading members from the live
  handle or, for a restored workspace, lazily from the root) and recompute
  subtree totals. Mode is encoded by the tree shape, so it persists in the
  snapshot as-is.
- classify.dropAssignments clears the assignments that cease to exist when a zip
  flips mode (the single-file key on expand; the member keys on collapse).
- copy already handles both: a zip-as-file copies whole; members extract from
  the archive.

Also: a folder whose entire subtree is excluded now renders its name struck
through, mirroring the excluded-file style.

Tests: collapse restores the single .zip + drops member assignments; a
fully-excluded folder gets the struck-through class (48 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:34:25 -05:00

1003 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Folder Tree Module
* Handles folder tree rendering and multi-select
*/
(function() {
'use strict';
// ── Classify & Copy helpers ────────────────────────────────────────────
function classifyOn() {
var c = window.app.modules.classify;
return c && c.isEnabled();
}
// All file objects in a folder's (already-scanned) subtree — group-drag.
function subtreeFiles(folder, out) {
out = out || [];
(folder.files || []).forEach(function (f) { out.push(f); });
(folder.children || []).forEach(function (c) { subtreeFiles(c, out); });
return out;
}
function keysFor(files) {
var c = window.app.modules.classify;
return files.map(function (f) { return c.srcKeyForFile(f); });
}
// A small status dot reflecting a file's classification state.
var STATE_TITLE = {
none: 'unassigned', tracking: 'has tracking number, needs a transmittal',
transmittal: 'in a transmittal, needs a tracking number',
partial: 'placed, but the name is incomplete', done: 'fully classified',
excluded: 'excluded — will not be copied',
};
function stateDot(state) {
var dot = document.createElement('span');
dot.className = 'cl-dot cl-dot--' + state;
dot.title = STATE_TITLE[state] || '';
return dot;
}
// ── Classify-mode source-tree filters ──────────────────────────────────
// The goal in either target tab is to assign-or-exclude every file. Each
// file falls in one bucket FOR THE ACTIVE AXIS — unassigned / assigned /
// excluded — and three "Show …" toggles control which buckets are visible
// (so unchecking Assigned+Excluded leaves only what's left to do). A folder
// whose whole scanned subtree is filtered away is itself hidden.
var showFilters = { unassigned: true, assigned: true, excluded: true };
var showEmpty = true; // show folders that contain no files
function setShowFilters(f) {
showFilters = {
unassigned: f.unassigned !== false,
assigned: f.assigned !== false,
excluded: f.excluded !== false,
};
showEmpty = f.empty !== false;
render();
}
function allFiltersOn() { return showFilters.unassigned && showFilters.assigned && showFilters.excluded; }
function activeAxis() {
var tt = window.app.modules.targetTree;
return (tt && tt.activeAxis) ? tt.activeAxis() : 'tracking';
}
// Bucket a file relative to the active axis: 'excluded' | 'assigned' | 'unassigned'.
function fileCategory(file) {
var c = window.app.modules.classify;
var a = c.getAssignment(c.srcKeyForFile(file));
if (a && a.excluded) return 'excluded';
var assigned = a && (activeAxis() === 'transmittal' ? a.transmittalNodeId : a.trackingNodeId);
return assigned ? 'assigned' : 'unassigned';
}
function classifyAllows(file) { return !classifyOn() || !!showFilters[fileCategory(file)]; }
// ── name filter (the autofilter box above the tree) ────────────────────
// Live substring search over each file's full path+name (and folder names),
// ANDing space-separated terms. Matches reveal their whole folder hierarchy.
var nameFilter = '', filterTerms = [];
function setNameFilter(q) {
nameFilter = (q || '').trim();
filterTerms = nameFilter.toLowerCase().split(/\s+/).filter(Boolean);
render();
}
function filterActive() { return filterTerms.length > 0; }
function nameHit(text) {
if (!filterTerms.length) return true;
var t = String(text || '').toLowerCase();
for (var i = 0; i < filterTerms.length; i++) { if (t.indexOf(filterTerms[i]) === -1) return false; }
return true;
}
// Anything narrowing the tree (a name search, a show-filter off, or hiding empties).
function anyFilter() { return filterActive() || (classifyOn() && (!allFiltersOn() || !showEmpty)); }
// One pass → the set of folder paths + file keys to render. A file shows when
// it passes the show-filters AND (no name search, OR an ancestor folder
// matched, OR its own path/name matches). A folder shows when it (or an
// ancestor) matches, or anything inside it shows — so the path to a hit is
// always revealed.
var visible = null; // { folders, files } while filtering, else null
function computeVisible() {
var c = window.app.modules.classify;
var folders = Object.create(null), files = Object.create(null), open = Object.create(null);
var nf = filterActive();
function walk(folder, ancMatched) {
var selfMatch = nf && nameHit(folder.path || folder.name);
var matched = ancMatched || selfMatch;
var show = false, hasFile = false, descMatch = false;
(folder.children || []).forEach(function (ch) {
var r = walk(ch, matched);
if (r.show) show = true;
if (r.hasFile) hasFile = true;
if (r.subtreeMatch) descMatch = true; // a child leads to a match
});
(folder.files || []).forEach(function (f) {
hasFile = true;
if (!classifyAllows(f)) return;
var fileMatch = nf && nameHit(c.srcKeyForFile(f));
if (!nf || matched || fileMatch) { files[c.srcKeyForFile(f)] = true; show = true; }
if (fileMatch) descMatch = true; // a match sits directly in this folder
});
if (matched) show = true;
// "Show Empty" off → hide folders whose whole subtree holds no files.
if (!hasFile && !showEmpty && !matched) show = false;
if (show) folders[folder.path] = true;
// Auto-open ONLY the connector folders on the path down to a match —
// never the matched node itself. Terminal matches and everything
// off-path keep their real collapse state; the root's expand-all
// covers the rest. (Search reveals where hits are; it doesn't reshape
// the tree.)
if (nf && descMatch) open[folder.path] = true;
return { show: show, hasFile: hasFile, subtreeMatch: descMatch || selfMatch };
}
(window.app.folderTree || []).forEach(function (root) { walk(root, false); });
return { folders: folders, files: files, open: open };
}
// True only for folders the search needs opened to expose a hit beneath them.
function autoOpen(folder) { return !!(visible && visible.open && visible.open[folder.path]); }
function folderShown(folder) { return !visible || !!visible.folders[folder.path]; }
function fileShown(file) {
if (!classifyAllows(file)) return false;
return !visible || !!visible.files[window.app.modules.classify.srcKeyForFile(file)];
}
// All scanned files (for the per-bucket counts on the filter checkboxes).
function allClassifyFiles() {
var out = [];
(window.app.folderTree || []).forEach(function (f) { subtreeFiles(f, out); });
return out;
}
function updateFilterCounts() {
if (!classifyOn()) return;
var n = { unassigned: 0, assigned: 0, excluded: 0 };
allClassifyFiles().forEach(function (f) { n[fileCategory(f)]++; });
['unassigned', 'assigned', 'excluded'].forEach(function (k) {
var el = document.getElementById('show' + k.charAt(0).toUpperCase() + k.slice(1) + 'Count');
if (el) el.textContent = '(' + n[k] + ')';
});
}
/**
* Render the folder tree
*/
function render() {
const container = window.app.dom.folderTree;
// Preserve scroll across re-render — toggling a Show filter shouldn't
// jump the view back to the top.
const prevScroll = container.scrollTop;
wireClassifyInteractions();
container.innerHTML = '';
updateFilterCounts();
visible = anyFilter() ? computeVisible() : null;
if (window.app.folderTree.length === 0) {
container.innerHTML = '<div class="tree-empty">No folders found</div>';
return;
}
window.app.folderTree.forEach(folder => {
if (!folderShown(folder)) return;
const element = createFolderElement(folder);
container.appendChild(element);
});
if (!container.children.length) {
container.innerHTML = '<div class="tree-empty">'
+ (filterActive() ? 'No files match “' + nameFilter + '”.' : 'Nothing matches the current filters.') + '</div>';
}
updateSelectedCount();
container.scrollTop = prevScroll;
}
/**
* Populate a folder row's count element with "direct+total" counts, e.g.
* "(2+10 folders, 15+300 files)" — direct (immediate children) shows as
* soon as the folder's own directory is read; the total (whole subtree)
* grows and flashes grey until the subtree is fully scanned, then goes
* solid. The "+total" part is omitted once scanning is done and there's
* nothing deeper (direct == total).
*/
function populateCount(el, folder) {
el.textContent = '';
el.classList.remove('done');
const st = folder.scanState;
if (st === 'pending') return;
if (st === 'zip-pending') { el.textContent = '(zip — open to scan)'; return; }
if (st === 'scanning') { el.textContent = 'scanning…'; return; }
const done = st === 'done';
// When fully scanned both numbers are blue; .done turns the labels blue too.
if (done) el.classList.add('done');
const dDir = folder.subdirCount || 0, tDir = folder.runDirs || 0;
const dFile = folder.fileCount || 0, tFile = folder.runFiles || 0;
const frag = document.createDocumentFragment();
frag.appendChild(document.createTextNode('('));
if (dDir > 0 || tDir > 0) {
appendPair(frag, dDir, tDir, done);
appendLabel(frag, tDir === 1 ? ' folder, ' : ' folders, ');
}
appendPair(frag, dFile, tFile, done);
appendLabel(frag, tFile === 1 ? ' file)' : ' files)');
el.appendChild(frag);
}
// The "folders"/"files" word labels — blue only once the row is .done.
function appendLabel(frag, text) {
const s = document.createElement('span');
s.className = 'ct-label';
s.textContent = text;
frag.appendChild(s);
}
// Append "<direct>" (always a completed/blue number) and, when there's a
// subtree (or scanning is ongoing), "+<total>" with the total in a span
// that greys + pulses until final, then turns blue.
function appendPair(frag, direct, total, done) {
const d = document.createElement('span');
d.className = 'ct-direct';
d.textContent = String(direct);
frag.appendChild(d);
if (!done || total > direct) {
frag.appendChild(document.createTextNode('+'));
const t = document.createElement('span');
t.className = 'ct-total' + (done ? '' : ' pending');
t.textContent = String(total);
frag.appendChild(t);
}
}
/**
* Create a folder element
*/
function createFolderElement(folder, level = 0) {
const div = document.createElement('div');
const item = document.createElement('div');
item.className = 'folder-item';
// Grey the row until its subtree is fully scanned (scanState 'done');
// 'scanning' rows also get a subtle pulse via CSS.
if (folder.scanState && folder.scanState !== 'done') {
item.classList.add('scanning');
}
item.dataset.path = folder.path;
item.style.paddingLeft = `${level * 1.5}rem`;
// Check if selected
if (window.app.selectedFolders.has(folder.path)) {
item.classList.add('selected');
}
// Classify mode: the folder row is a drag source for a group-drag of
// every file in its subtree.
if (classifyOn()) {
item.draggable = true;
item.addEventListener('dragstart', function (e) {
e.stopPropagation();
var files = subtreeFiles(folder);
if (!files.length) { e.preventDefault(); return; }
window.app.modules.dnd.setDrag(keysFor(files), e);
});
}
// Toggle button: shown when the folder has children OR hasn't been
// scanned yet (it might have children — expanding triggers its scan).
const toggle = document.createElement('span');
toggle.className = 'folder-toggle';
const mightHaveChildren = (folder.children && folder.children.length > 0)
|| folder.scanState === 'pending'
|| folder.scanState === 'zip-pending'
// Classify mode: a folder with files (even none of subfolders) is
// expandable so its files can be revealed and dragged.
|| (classifyOn() && folder.files && folder.files.length > 0);
if (mightHaveChildren) {
toggle.textContent = (folder.expanded || autoOpen(folder)) ? '▼' : '▶';
toggle.addEventListener('click', (e) => {
e.stopPropagation();
const recursive = e.ctrlKey || e.metaKey;
toggleFolder(folder, recursive);
});
} else {
toggle.textContent = ' ';
}
item.appendChild(toggle);
// Folder icon (different for ZIP files)
const icon = document.createElement('span');
icon.className = 'folder-icon';
if (folder.isZipRoot) {
icon.innerHTML = '&#128230;'; // 📦
} else if (folder.isVirtualDir) {
icon.innerHTML = '&#128194;'; // 📂
} else {
icon.innerHTML = '&#128193;'; // 📁
}
item.appendChild(icon);
// Classify mode: an aggregate state dot for the folder's subtree, and a
// struck-through name when the WHOLE subtree is excluded (mirrors files).
if (classifyOn()) {
const agg = aggregateState(subtreeFiles(folder));
if (agg) item.appendChild(stateDot(agg));
if (agg === 'excluded') item.classList.add('excluded');
}
// Folder name
const name = document.createElement('span');
name.className = 'folder-name';
name.textContent = folder.name;
item.appendChild(name);
// Subfolder / file counts (immediate). Greyed via the row's .scanning
// class until the subtree is fully scanned.
const count = document.createElement('span');
count.className = 'folder-count';
populateCount(count, folder);
item.appendChild(count);
// Extract button for ZIP roots
if (folder.isZipRoot) {
const extractBtn = document.createElement('button');
extractBtn.className = 'btn btn-sm zip-extract-btn';
extractBtn.textContent = '📤 Extract';
extractBtn.title = 'Extract ZIP contents to folder';
extractBtn.addEventListener('click', async (e) => {
e.stopPropagation();
await handleExtractZip(folder);
});
item.appendChild(extractBtn);
}
// Extract All button for folders with ZIP descendants (but not ZIP roots themselves)
if (!folder.isZipRoot && !folder.isVirtualDir) {
const zipCount = countZipDescendants(folder);
if (zipCount > 0) {
const extractAllBtn = document.createElement('button');
extractAllBtn.className = 'btn btn-sm zip-extract-all-btn';
extractAllBtn.textContent = `📤 Extract All (${zipCount})`;
extractAllBtn.title = `Extract all ${zipCount} ZIP file(s) in this folder`;
extractAllBtn.addEventListener('click', async (e) => {
e.stopPropagation();
await handleExtractAllZips(folder);
});
item.appendChild(extractAllBtn);
}
}
// Click handler for selection
item.addEventListener('click', (e) => {
handleFolderClick(folder, e);
});
div.appendChild(item);
// Children — when expanded, or opened on the path to a search hit below.
// The Show toggles never force-expand; search opens only connector folders.
if ((folder.expanded || autoOpen(folder)) && folder.children && folder.children.length > 0) {
const childrenDiv = document.createElement('div');
childrenDiv.className = 'folder-children';
folder.children.forEach(child => {
if (!folderShown(child)) return;
const childElement = createFolderElement(child, level + 1);
childrenDiv.appendChild(childElement);
});
div.appendChild(childrenDiv);
}
// Classify mode: list this folder's own files (draggable leaves) when
// expanded (or opened to reveal a search hit), so they can be dropped.
if (classifyOn() && (folder.expanded || autoOpen(folder)) && folder.files && folder.files.length > 0) {
const filesDiv = document.createElement('div');
filesDiv.className = 'folder-children folder-files';
folder.files.forEach(function (file) {
if (!fileShown(file)) return;
filesDiv.appendChild(createFileElement(file, level + 1));
});
div.appendChild(filesDiv);
}
return div;
}
/**
* Create a draggable source-file row (classify mode only).
*/
function createFileElement(file, level) {
const c = window.app.modules.classify;
const item = document.createElement('div');
item.className = 'file-item';
item.style.paddingLeft = `${level * 1.5}rem`;
item.draggable = true;
item.title = 'Click to preview · drag onto a tracking folder or transmittal to assign';
const key = c.srcKeyForFile(file);
item.dataset.key = key;
const st = c.fileState(file);
if (st === 'excluded') item.classList.add('excluded');
item.appendChild(stateDot(st));
const icon = document.createElement('span');
icon.className = 'file-icon';
icon.innerHTML = '&#128196;'; // 📄
item.appendChild(icon);
const name = document.createElement('span');
name.className = 'file-name';
name.textContent = zddc.joinExtension(file.originalFilename, file.extension);
item.appendChild(name);
item.addEventListener('dragstart', function (e) {
e.stopPropagation();
window.app.modules.dnd.setDrag([key], e);
});
return item;
}
/**
* Handle folder click with multi-select support
*/
function handleFolderClick(folder, event) {
if (event.ctrlKey || event.metaKey) {
// Ctrl+Click: Toggle selection
if (window.app.selectedFolders.has(folder.path)) {
window.app.selectedFolders.delete(folder.path);
} else {
window.app.selectedFolders.add(folder.path);
}
} else if (event.shiftKey) {
// Shift+Click: Range selection
const visibleFolders = getVisibleFolders();
const currentIndex = visibleFolders.findIndex(f => f.path === folder.path);
if (currentIndex >= 0 && window.app.lastSelectedFolderPath) {
const lastIndex = visibleFolders.findIndex(f => f.path === window.app.lastSelectedFolderPath);
if (lastIndex >= 0) {
const start = Math.min(currentIndex, lastIndex);
const end = Math.max(currentIndex, lastIndex);
// Select range
for (let i = start; i <= end; i++) {
window.app.selectedFolders.add(visibleFolders[i].path);
}
}
} else {
window.app.selectedFolders.add(folder.path);
}
} else {
// Normal click: Single selection
window.app.selectedFolders.clear();
window.app.selectedFolders.add(folder.path);
}
// Remember last selected for shift-click
window.app.lastSelectedFolderPath = folder.path;
// Re-render tree
render();
// Load files from selected folders
loadFilesFromSelectedFolders();
}
/**
* Handle ZIP extraction
*/
async function handleExtractZip(folder) {
if (!folder.isZipRoot || !folder.zipPath) return;
try {
const confirmed = confirm(`Extract "${folder.name}" to a new folder?\n\nThis will create a folder named "${folder.name.replace(/\.zip$/i, '')}" with the ZIP contents.`);
if (!confirmed) return;
// Show extracting state
const btn = document.querySelector(`.folder-item[data-path="${folder.path}"] .zip-extract-btn`);
if (btn) {
btn.textContent = '⏳ Extracting...';
btn.disabled = true;
}
await window.app.modules.scanner.extractZip(folder.zipPath);
// Auto-refresh preserving tree state
await window.app.modules.scanner.scanDirectory(window.app.rootHandle, true);
} catch (err) {
console.error('Error extracting ZIP:', err);
alert('Error extracting ZIP: ' + err.message);
// Reset button
const btn = document.querySelector(`.folder-item[data-path="${folder.path}"] .zip-extract-btn`);
if (btn) {
btn.textContent = '📤 Extract';
btn.disabled = false;
}
}
}
/**
* Count ZIP descendants in a folder
*/
function countZipDescendants(folder) {
let count = 0;
if (folder.children) {
for (const child of folder.children) {
if (child.isZipRoot) {
count++;
}
count += countZipDescendants(child);
}
}
return count;
}
/**
* Get all ZIP folders as flat list
*/
function getZipDescendants(folder, zips = []) {
if (folder.children) {
for (const child of folder.children) {
if (child.isZipRoot) {
zips.push(child);
}
getZipDescendants(child, zips);
}
}
return zips;
}
/**
* Handle extracting all ZIPs in a folder
*/
async function handleExtractAllZips(folder) {
const zips = getZipDescendants(folder);
if (zips.length === 0) return;
const confirmed = confirm(`Extract ${zips.length} ZIP file(s)?\n\nThis will create folders for each ZIP with their contents.`);
if (!confirmed) return;
try {
// Show extracting state on button
const btn = document.querySelector(`.folder-item[data-path="${folder.path}"] .zip-extract-all-btn`);
if (btn) {
btn.textContent = '⏳ Extracting...';
btn.disabled = true;
}
// Extract all ZIPs
for (const zip of zips) {
if (zip.zipPath) {
await window.app.modules.scanner.extractZip(zip.zipPath);
}
}
// Auto-refresh preserving tree state
await window.app.modules.scanner.scanDirectory(window.app.rootHandle, true);
} catch (err) {
console.error('Error extracting ZIPs:', err);
alert('Error extracting ZIPs: ' + err.message);
// Reset button
const btn = document.querySelector(`.folder-item[data-path="${folder.path}"] .zip-extract-all-btn`);
if (btn) {
btn.textContent = `📤 Extract All (${zips.length})`;
btn.disabled = false;
}
}
}
/**
* Toggle folder expansion
*/
function toggleFolder(folder, recursive = false) {
folder.expanded = !folder.expanded;
if (recursive && folder.children) {
// Recursively expand/collapse all children
const newState = folder.expanded;
function setAllExpanded(f) {
f.expanded = newState;
if (f.children) {
f.children.forEach(setAllExpanded);
}
}
folder.children.forEach(setAllExpanded);
}
render();
// Opening a not-yet-complete folder jumps its subtree to the front of
// the scan so its contents are complete on open (re-renders as it
// fills in). Background scanning continues for everything else.
if (folder.expanded && folder.scanState !== 'done'
&& window.app.modules.scanner && window.app.modules.scanner.ensureScanned) {
window.app.modules.scanner.ensureScanned(folder).then(render).catch(() => {});
}
}
/**
* Load files from all selected folders
*/
async function loadFilesFromSelectedFolders() {
// Use store to manage files
window.app.modules.store.setSelectedFolders(Array.from(window.app.selectedFolders));
}
/**
* Find folder by path in tree
*/
function findFolderByPath(path) {
function search(folders) {
for (const folder of folders) {
if (folder.path === path) {
return folder;
}
if (folder.children) {
const found = search(folder.children);
if (found) return found;
}
}
return null;
}
return search(window.app.folderTree);
}
/**
* Update selected folders count
*/
function updateSelectedCount() {
const el = window.app.dom.selectedFoldersCount;
if (!el) return; // count no longer shown in the folder-tree header
const count = window.app.selectedFolders.size;
el.textContent = `${count} folder${count !== 1 ? 's' : ''} selected`;
}
/**
* Build folder tree from scanned data
*/
function buildTree(rootHandle, foldersMap) {
const tree = [];
// Convert flat map to tree structure
function buildNode(handle, path) {
// For virtual folders, look up by path string; for real folders, use handle
let files;
if (handle.isVirtualDir || handle.isZipRoot) {
files = foldersMap.get(handle.virtualPath || handle.zipPath) || [];
} else {
files = foldersMap.get(handle) || [];
}
// Add folderPath to each file for folder highlighting (filter out null files)
files.filter(file => file !== null).forEach(file => {
if (!file.isDirectory) {
file.folderPath = path;
}
});
// Filter out null files for the node
const validFiles = files.filter(f => f !== null);
const node = {
name: handle.name,
path: path,
handle: handle,
files: validFiles,
fileCount: validFiles.length,
children: [],
expanded: false
};
// Mark ZIP-related nodes
if (handle.isZipRoot) {
node.isZipRoot = true;
node.zipPath = handle.zipPath;
}
if (handle.isVirtualDir) {
node.isVirtualDir = true;
node.zipPath = handle.zipPath;
}
return node;
}
// Recursively build tree
function addChildren(node) {
// Get subdirectories (filter out null files first)
// For virtual folders, look up by path string
let files;
if (node.handle.isVirtualDir || node.handle.isZipRoot) {
files = foldersMap.get(node.handle.virtualPath || node.handle.zipPath) || [];
} else {
files = foldersMap.get(node.handle) || [];
}
const validFiles = files.filter(f => f !== null);
const subdirs = validFiles.filter(f => f.isDirectory);
subdirs.forEach(subdir => {
const childPath = node.path + '/' + subdir.handle.name;
const childNode = buildNode(subdir.handle, childPath);
addChildren(childNode);
node.children.push(childNode);
});
// Update file count to exclude directories and null files
node.files = validFiles.filter(f => !f.isDirectory);
node.fileCount = node.files.length;
}
// Build root
const root = buildNode(rootHandle, rootHandle.name);
addChildren(root);
// Expand root by default
root.expanded = true;
tree.push(root);
return tree;
}
/**
* Get all currently visible folders (expanded tree)
*/
function getVisibleFolders() {
const visible = [];
function traverse(folders) {
for (const folder of folders) {
visible.push(folder);
if (folder.expanded && folder.children) {
traverse(folder.children);
}
}
}
traverse(window.app.folderTree);
return visible;
}
/**
* Select all visible folders
*/
function selectAllVisible() {
const visible = getVisibleFolders();
window.app.selectedFolders.clear();
visible.forEach(f => window.app.selectedFolders.add(f.path));
render();
loadFilesFromSelectedFolders();
}
/**
* Expand all folders in tree
*/
function expandAll() {
function setAllExpanded(folder) {
folder.expanded = true;
if (folder.children) {
folder.children.forEach(setAllExpanded);
}
}
window.app.folderTree.forEach(setAllExpanded);
render();
}
/**
* Select all folders in tree
*/
function selectAll() {
function collectAllPaths(folders, paths = []) {
folders.forEach(folder => {
paths.push(folder.path);
if (folder.children) {
collectAllPaths(folder.children, paths);
}
});
return paths;
}
const allPaths = collectAllPaths(window.app.folderTree);
allPaths.forEach(path => window.app.selectedFolders.add(path));
render();
loadFilesFromSelectedFolders();
}
/**
* Set up keyboard shortcuts for folder tree
*/
function setupKeyboardShortcuts() {
const container = window.app.dom.folderTree;
container.addEventListener('keydown', (e) => {
// Ctrl+A: Select all visible
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
e.preventDefault();
selectAllVisible();
}
});
// Make container focusable
container.tabIndex = 0;
}
// ── Classify interactions (exclude menu, cross-tree reveal) ─────────────
var classifyWired = false;
function wireClassifyInteractions() {
if (classifyWired) return;
classifyWired = true;
var ft = window.app.dom.folderTree;
if (!ft) { classifyWired = false; return; }
ft.addEventListener('contextmenu', onContextMenu);
// Single-click a source file → preview it (the "look at it, then assign"
// half of the workflow). Drag still assigns; right-click excludes.
ft.addEventListener('click', function (e) {
if (!classifyOn()) return;
var fe = e.target.closest('.file-item');
if (!fe || !fe.dataset.key) return;
var file = findFileByKey(fe.dataset.key);
if (file && window.app.modules.preview && window.app.modules.preview.previewFile) {
window.app.modules.preview.previewFile(file);
}
});
}
// Aggregate classification state across a folder's loaded subtree files.
function aggregateState(files) {
if (!files.length) return null;
var c = window.app.modules.classify;
var ex = 0, done = 0, placed = 0;
files.forEach(function (f) {
var s = c.fileState(f);
if (s === 'excluded') ex++;
else if (s === 'done') done++;
else if (s !== 'none') placed++;
});
if (ex === files.length) return 'excluded';
var active = files.length - ex;
if (active > 0 && done === active) return 'done';
if (done > 0 || placed > 0) return 'partial';
return 'none';
}
function findFolderByPath(path) {
var hit = null;
(function walk(nodes) {
(nodes || []).forEach(function (n) {
if (hit) return;
if (n.path === path) { hit = n; return; }
walk(n.children);
});
})(window.app.folderTree);
return hit;
}
function findFileByKey(key) {
var c = window.app.modules.classify, hit = null;
(function walk(nodes) {
(nodes || []).forEach(function (n) {
if (hit) return;
(n.files || []).forEach(function (f) { if (!hit && c.srcKeyForFile(f) === key) hit = f; });
walk(n.children);
});
})(window.app.folderTree);
return hit;
}
function expandToPath(folderPath) {
(function walk(nodes) {
(nodes || []).forEach(function (n) {
if (n.path === folderPath || folderPath.indexOf(n.path + '/') === 0) {
n.expanded = true;
walk(n.children);
}
});
})(window.app.folderTree);
}
// Reveal a source file (target → source). Expands its folder chain, renders,
// scrolls + flashes the row.
function revealFile(key) {
var file = findFileByKey(key);
if (!file) return;
expandToPath(file.folderPath);
render();
var rows = window.app.dom.folderTree.querySelectorAll('.file-item');
var row = Array.prototype.filter.call(rows, function (r) { return r.dataset.key === key; })[0];
if (row) {
row.scrollIntoView({ block: 'center' });
row.classList.add('match-highlight');
setTimeout(function () { row.classList.remove('match-highlight'); }, 1500);
}
}
// ── per-zip mode toggle (single file ⇄ expandable folder) ───────────────
function persistTreeChange() {
var ws = window.app.modules.workspace;
if (ws && ws.onRescanned) ws.onRescanned();
}
async function expandZip(file) {
if (!file.handle && !window.app.rootHandle) {
if (window.zddc) window.zddc.toast('Connect the source directory first to expand this archive.', 'warning');
return;
}
try {
var node = await window.app.modules.scanner.expandZipAsFolder(file);
if (node) { render(); persistTreeChange(); }
} catch (e) {
if (window.zddc) window.zddc.toast('Couldnt expand the archive — ' + (e.message || e), 'error');
}
}
function collapseZip(zipNode) {
if (!zipNode) return;
window.app.modules.scanner.collapseZipToFile(zipNode);
render();
persistTreeChange();
}
// ── context menu (exclude / include / clear / zip mode) ─────────────────
var menuEl = null;
function hideMenu() { if (menuEl) { menuEl.remove(); menuEl = null; } }
function showMenu(x, y, items) {
hideMenu();
menuEl = document.createElement('div');
menuEl.className = 'cl-menu';
items.forEach(function (it) {
var b = document.createElement('button');
b.className = 'cl-menu__item';
b.textContent = it.label;
b.addEventListener('click', function () { hideMenu(); it.fn(); });
menuEl.appendChild(b);
});
menuEl.style.left = x + 'px';
menuEl.style.top = y + 'px';
document.body.appendChild(menuEl);
setTimeout(function () {
document.addEventListener('click', hideMenu, { once: true });
document.addEventListener('scroll', hideMenu, { once: true, capture: true });
}, 0);
}
function onContextMenu(e) {
if (!classifyOn()) return;
var c = window.app.modules.classify;
var fileEl = e.target.closest('.file-item');
var folderEl = e.target.closest('.folder-item');
if (!fileEl && !folderEl) return;
e.preventDefault();
var items = [];
if (fileEl) {
var key = fileEl.dataset.key;
var a = c.getAssignment(key);
var excluded = !!(a && a.excluded);
items.push({ label: excluded ? 'Include in copy' : 'Exclude from copy', fn: function () { c.setExcluded([key], !excluded); } });
if (a && (a.trackingNodeId || a.transmittalNodeId)) {
if (a.trackingNodeId) items.push({ label: 'Clear tracking', fn: function () { c.place([key], null, 'tracking'); } });
if (a.transmittalNodeId) items.push({ label: 'Clear transmittal', fn: function () { c.place([key], null, 'transmittal'); } });
}
var file = findFileByKey(key);
if (file && file.isVirtual) {
items.push({ label: 'Collapse archive to single file', fn: function () { collapseZip(findFolderByPath(file.zipPath)); } });
} else if (file && file.extension === 'zip') {
items.push({ label: 'Expand as folder', fn: function () { expandZip(file); } });
}
} else {
var folder = findFolderByPath(folderEl.dataset.path);
if (folder && folder.isZipRoot) {
items.push({ label: 'Collapse to single file', fn: function () { collapseZip(folder); } });
}
var keys = keysFor(subtreeFiles(folder || { files: [], children: [] }));
if (keys.length) {
var allExcl = keys.every(function (k) { var a = c.getAssignment(k); return a && a.excluded; });
items.push({
label: (allExcl ? 'Include' : 'Exclude') + ' folder (' + keys.length + ' file' + (keys.length === 1 ? '' : 's') + ')',
fn: function () { c.setExcluded(keys, !allExcl); },
});
}
if (!items.length) return;
}
showMenu(e.clientX, e.clientY, items);
}
// Export module
window.app.modules.tree = {
render,
buildTree,
loadFilesFromSelectedFolders,
setupKeyboardShortcuts,
expandAll,
selectAll,
revealFile,
setShowFilters,
setNameFilter
};
})();