ZDDC/classifier/js/tree.js
ZDDC ecb0a270cc feat(classifier): incremental scan — status, top-levels-first, per-folder state
Replaces the full depth-first "scan everything, then render once + expandAll +
selectAll" walk (which looked stalled and was a render bomb on a large network
drive) with a progressive, breadth-first scan:

- Walks level-by-level behind a bounded worker pool (6), rendering as it goes —
  the top folder levels appear immediately, deeper levels fill in the
  background. Workers await between directories so the UI stays responsive.
- Live status line under the tree header: "Scanning… N folders · M files —
  <current path>", ending "Scanned … in Ts."
- Per-folder state machine (pending → scanning → children → done) with
  immediate subfolder/file counts; the row is greyed (with a faint pulse) until
  its whole subtree is scanned, then turns solid — the at-a-glance signal.
- Opening a folder jumps its subtree to the front of the scan (ensureScanned),
  so an opened folder always shows complete contents; idempotent vs the
  background walk.
- No more auto-expand/auto-select-all (that loaded the entire drive up front);
  the root is selected so the grid shows its files immediately.
- ZIPs stay expandable, scanned inline into virtual nodes (already in memory
  once read); whole zip subtree marked done at once.

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

554 lines
18 KiB
JavaScript

/**
* Folder Tree Module
* Handles folder tree rendering and multi-select
*/
(function() {
'use strict';
/**
* Render the folder tree
*/
function render() {
const container = window.app.dom.folderTree;
container.innerHTML = '';
if (window.app.folderTree.length === 0) {
container.innerHTML = '<div class="tree-empty">No folders found</div>';
return;
}
window.app.folderTree.forEach(folder => {
const element = createFolderElement(folder);
container.appendChild(element);
});
updateSelectedCount();
}
/**
* Count label for a folder row. While the folder is still being scanned
* its counts are unknown; once its own directory has been read we show the
* immediate subfolder/file counts (greyed until the whole subtree is done).
*/
function folderCountLabel(folder) {
const st = folder.scanState;
if (st === 'pending') return '';
if (st === 'scanning') return 'scanning…';
const d = folder.subdirCount || 0;
const f = folder.fileCount || 0;
const parts = [];
if (d) parts.push(d + (d === 1 ? ' folder' : ' folders'));
parts.push(f + (f === 1 ? ' file' : ' files'));
return '(' + parts.join(', ') + ')';
}
/**
* 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');
}
// 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';
if (mightHaveChildren) {
toggle.textContent = folder.expanded ? '▼' : '▶';
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);
// 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';
count.textContent = folderCountLabel(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 (if expanded)
if (folder.expanded && folder.children && folder.children.length > 0) {
const childrenDiv = document.createElement('div');
childrenDiv.className = 'folder-children';
folder.children.forEach(child => {
const childElement = createFolderElement(child, level + 1);
childrenDiv.appendChild(childElement);
});
div.appendChild(childrenDiv);
}
return div;
}
/**
* 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 count = window.app.selectedFolders.size;
window.app.dom.selectedFoldersCount.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;
}
// Export module
window.app.modules.tree = {
render,
buildTree,
loadFilesFromSelectedFolders,
setupKeyboardShortcuts,
expandAll,
selectAll
};
})();