The black-completed vs grey-flashing distinction was too subtle. Completed numbers (the direct count, always; the +total once final) now render in var(--primary) — theme-aware blue in both light and dark. While a subtree is still scanning its +total stays muted grey + pulses, so blue = done, grey = in progress. Once both numbers are blue the row's folders/files labels turn blue too (.folder-count.done .ct-label). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
596 lines
20 KiB
JavaScript
596 lines
20 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();
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
}
|
|
|
|
// 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';
|
|
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';
|
|
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 (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
|
|
};
|
|
})();
|