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>
554 lines
18 KiB
JavaScript
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 = '📦'; // 📦
|
|
} else if (folder.isVirtualDir) {
|
|
icon.innerHTML = '📂'; // 📂
|
|
} else {
|
|
icon.innerHTML = '📁'; // 📁
|
|
}
|
|
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
|
|
};
|
|
})();
|