const LOCAL_STORAGE_KEY = 'treeState'; // State Management const Store = (function () { let instance; let subscribers = new Set(); // We'll keep an initialState here const initialState = { root: null, selectedPaths: new Set(), fileContents: {}, expandedNodes: new Set(), stats: { selectedCount: 0, totalTokens: 0, }, }; function createInstance() { let state = Object.freeze({ ...initialState, ...loadState() }); return { getState() { // Return a shallow clone or structured clone: // but in a big app, you might just return references // to reduce overhead. For now, keep a safe copy. return structuredClone(state); }, dispatch(action) { // Instead of multiple calls, we expect a single "mutation function" const draft = structuredClone(state); action(draft); // Freeze next state to maintain immutability const nextState = Object.freeze(draft); if (nextState !== state) { state = nextState; this.notify(); saveState(state); } }, subscribe(callback) { subscribers.add(callback); return () => subscribers.delete(callback); }, notify() { subscribers.forEach((callback) => callback(state)); }, }; } function saveState(state) { const serializedState = { ...state, selectedPaths: Array.from(state.selectedPaths), expandedNodes: Array.from(state.expandedNodes), }; localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(serializedState)); } function loadState() { const saved = localStorage.getItem(LOCAL_STORAGE_KEY); if (!saved) return {}; const state = JSON.parse(saved); return { ...state, selectedPaths: new Set(state.selectedPaths), expandedNodes: new Set(state.expandedNodes), }; } return { getInstance() { if (!instance) { instance = createInstance(); } return instance; }, }; })(); // Actions const actions = { setRoot: (root) => (state) => { state.root = root; }, // Instead of toggling each item repeatedly, we accept an array or set of paths // to add or remove in a single batch. bulkSelectPaths: (pathsToSelect = [], pathsToDeselect = []) => (state) => { for (const path of pathsToSelect) { state.selectedPaths.add(path); } for (const path of pathsToDeselect) { state.selectedPaths.delete(path); } }, // Toggling a single path is still allowed, but we generally encourage // single dispatch usage with "bulkSelectPaths". toggleSelected: (path, selected) => (state) => { if (selected) { state.selectedPaths.add(path); } else { state.selectedPaths.delete(path); } }, toggleExpanded: (path) => (state) => { if (state.expandedNodes.has(path)) { state.expandedNodes.delete(path); } else { state.expandedNodes.add(path); } }, setExpanded: (path, expand) => (state) => { if (expand) { state.expandedNodes.add(path); } else { state.expandedNodes.delete(path); } }, setFileContents: (path, content) => (state) => { state.fileContents[path] = content; }, updateStats: () => (state) => { state.stats.selectedCount = state.selectedPaths.size; state.stats.totalTokens = calculateTokens(state.fileContents, state.selectedPaths); }, reset: () => (state) => { // Re-initialize everything to the original initialState state.root = null; state.selectedPaths = new Set(); state.fileContents = {}; state.expandedNodes = new Set(); state.stats.selectedCount = 0; state.stats.totalTokens = 0; }, bulkSetExpanded: (pathsToExpand = [], pathsToCollapse = []) => (state) => { for (const path of pathsToExpand) { state.expandedNodes.add(path); } for (const path of pathsToCollapse) { state.expandedNodes.delete(path); } }, }; // Helper: determine if a file is a spreadsheet function isSpreadsheet(filename) { if (!filename) return false; const spreadsheetExtensions = [ '.xls', '.xlsx', '.xlsm', '.xlsb', '.xlt', '.ods', '.fods', '.numbers', ]; const lower = filename.toLowerCase(); return spreadsheetExtensions.some((ext) => lower.endsWith(ext)); } // Helper: determine if a file is a PDF function isPDF(filename) { if (!filename) return false; return filename.toLowerCase().endsWith('.pdf'); } // Parse PDF file async function parsePDFFile(file) { try { const arrayBuffer = await file.arrayBuffer(); const typedArray = new Uint8Array(arrayBuffer); const loadingTask = pdfjsLib.getDocument({ data: typedArray }); const pdf = await loadingTask.promise; let textOutput = ''; for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { const page = await pdf.getPage(pageNum); const textContent = await page.getTextContent(); // Filter out empty strings and join with proper spacing const pageText = textContent.items .filter((item) => item.str.trim().length > 0) .map((item) => { // Handle different types of spaces and line breaks if (item.hasEOL) return item.str + '\n'; return item.str + ' '; }) .join('') .replace(/\s+/g, ' ') .trim(); if (pageText) { textOutput += pageText + '\n\n'; } } return textOutput.trim(); } catch (err) { console.error('PDF parsing error:', err); throw new Error(`Failed to parse PDF: ${err.message}`); } } // Parse spreadsheet file async function parseSpreadsheetFile(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => { try { const data = new Uint8Array(e.target.result); const workbook = XLSX.read(data, { type: 'array' }); let textOutput = ''; // Convert each sheet in the workbook to CSV and append workbook.SheetNames.forEach((sheetName) => { const worksheet = workbook.Sheets[sheetName]; const csv = XLSX.utils.sheet_to_csv(worksheet); textOutput += `Sheet: ${sheetName}\n${csv}\n\n`; }); resolve(textOutput.trim()); } catch (err) { reject(err); } }; reader.onerror = (err) => reject(err); reader.readAsArrayBuffer(file); }); } class FileTreeViewer { constructor(store) { this.store = store; this.container = document.getElementById('fileTree'); // Adjust these ignored paths as needed this.IGNORED_DIRECTORIES = ['node_modules', 'venv', '.git', '__pycache__', '.idea', '.vscode']; this.IGNORED_FILES = [ '.DS_Store', 'Thumbs.db', '.env', '.pyc', '.jpg', '.jpeg', '.png', '.gif', '.mp4', '.mov', '.avi', '.webp', '.mkv', '.wmv', '.flv', '.svg', '.zip', '.tar', '.gz', '.rar', '.exe', '.bin', '.iso', '.dll', '.psd', '.ai', '.eps', '.tiff', '.woff', '.woff2', '.ttf', '.otf', '.flac', '.m4a', '.aac', '.mov', '.3gp', ]; // Add LIKELY_TEXT_FILES list this.LIKELY_TEXT_FILES = [ '.txt', '.md', '.markdown', '.json', '.js', '.ts', '.jsx', '.tsx', '.css', '.scss', '.sass', '.less', '.html', '.htm', '.xml', '.yaml', '.yml', '.ini', '.conf', '.cfg', '.config', '.py', '.rb', '.php', '.java', '.c', '.cpp', '.h', '.hpp', '.cs', '.go', '.rs', '.swift', '.kt', '.kts', '.sh', '.bash', '.zsh', '.fish', '.sql', '.graphql', '.vue', '.svelte', '.astro', '.env.example', '.gitignore', '.dockerignore', '.editorconfig', '.eslintrc', '.prettierrc', '.babelrc', 'LICENSE', 'README', 'CHANGELOG', 'TODO', '.csv', '.tsv' ]; // Subscribe to store updates this.store.subscribe(this.handleStateChange.bind(this)); this.setupEventListeners(); } async isTextFile(file) { // First check known text extensions if (this.LIKELY_TEXT_FILES.some(ext => file.name.toLowerCase().endsWith(ext.toLowerCase()) )) { return true; } // Then check spreadsheets and PDFs if (isSpreadsheet(file.name) || isPDF(file.name)) { return true; } // Fall back to content analysis for unknown extensions const slice = file.slice(0, 4096); const text = await slice.text(); const printableChars = text.match(/[\x20-\x7E\n\r\t\u00A0-\u02AF\u0370-\u1CFF]/g); return printableChars && printableChars.length / text.length > 0.7; } async handleFileSelect(event) { const files = Array.from(event.target.files || []).filter( (file) => !this.IGNORED_DIRECTORIES.some((dir) => file.webkitRelativePath.split('/').includes(dir)) && !this.IGNORED_FILES.some((ignoredFile) => { // If ignoredFile starts with a dot, treat it as an extension if (ignoredFile.startsWith('.')) { return file.name.toLowerCase().endsWith(ignoredFile.toLowerCase()); } // Otherwise, do an exact filename match return file.name === ignoredFile; }) ); if (!files.length) return; // Determine if each file is text const fileTypeMap = new Map(); for (const file of files) { fileTypeMap.set(file.webkitRelativePath, await this.isTextFile(file)); } // Build the root tree structure const root = this.buildFileTree(files, fileTypeMap); this.store.dispatch(actions.setRoot(root)); // Parse file contents in batch for (const file of files) { if (!fileTypeMap.get(file.webkitRelativePath)) { continue; // skip binary or unsupported } let text = ''; if (isSpreadsheet(file.name)) { text = await parseSpreadsheetFile(file); } else if (isPDF(file.name)) { text = await parsePDFFile(file); } else { text = await file.text(); } this.store.dispatch(actions.setFileContents(file.webkitRelativePath, text)); } this.store.dispatch(actions.updateStats()); event.target.value = ''; } buildFileTree(files, fileTypeMap) { // The first part (index 0) is the root folder name // This is a naive approach if multiple top-level folders are possible // but usually there's one main folder from the input. const root = { name: files[0].webkitRelativePath.split('/')[0], path: files[0].webkitRelativePath.split('/')[0], isDir: true, children: [], }; files.forEach((file) => { const pathParts = file.webkitRelativePath.split('/'); let currentNode = root; pathParts.forEach((part, index) => { if (index === 0) return; const currentPath = pathParts.slice(0, index + 1).join('/'); if (index === pathParts.length - 1) { const isTextFile = fileTypeMap.get(file.webkitRelativePath); currentNode.children.push({ name: part, path: currentPath, isDir: false, size: file.size, isTextFile, }); } else { let childNode = currentNode.children.find((n) => n.name === part); if (!childNode) { childNode = { name: part, path: currentPath, isDir: true, children: [], }; currentNode.children.push(childNode); } currentNode = childNode; } }); }); return root; } renderTree() { const state = this.store.getState(); if (!state.root) { this.container.innerHTML = '
'; return; } // We'll do a single pass to compute each node's selection state // so we don't repeatedly call expensive functions in `renderNode`. const selectionMap = this.computeSelectionStates(state); // Render the tree HTML this.container.innerHTML = this.renderNode(state.root, selectionMap); // After the container has been populated, set `indeterminate` on each checkbox const allCheckboxes = this.container.querySelectorAll('.tree-checkbox'); allCheckboxes.forEach((checkbox) => { const isIndeterminate = checkbox.getAttribute('data-indeterminate') === 'true'; checkbox.indeterminate = isIndeterminate; }); } // Single pass to compute each node's "checked" and "indeterminate" state: computeSelectionStates(state) { // We'll store a map of path -> { checked: bool, indeterminate: bool } const selectionMap = {}; // Recursive function that returns { totalFiles, selectedFiles } // so we can compute folder selection state in one pass. const computeStateForNode = (node) => { if (!node.isDir) { if (node.isTextFile && state.selectedPaths.has(node.path)) { // 1 selected file selectionMap[node.path] = { checked: true, indeterminate: false }; return { totalFiles: 1, selectedFiles: 1 }; } else { selectionMap[node.path] = { checked: false, indeterminate: false }; return { totalFiles: node.isTextFile ? 1 : 0, selectedFiles: 0 }; } } let total = 0; let selected = 0; node.children?.forEach((child) => { const result = computeStateForNode(child); total += result.totalFiles; selected += result.selectedFiles; }); if (total > 0 && selected === total) { selectionMap[node.path] = { checked: true, indeterminate: false }; } else if (selected > 0 && selected < total) { selectionMap[node.path] = { checked: false, indeterminate: true }; } else { selectionMap[node.path] = { checked: false, indeterminate: false }; } return { totalFiles: total, selectedFiles: selected }; }; // Start with root computeStateForNode(state.root); return selectionMap; } renderNode(node, selectionMap, level = 0) { const state = this.store.getState(); const indent = level * 20; const icon = node.isDir ? state.expandedNodes.has(node.path) ? '📂' : '📁' : node.isTextFile ? '📄' : '📦'; const selState = selectionMap[node.path] || { checked: false, indeterminate: false }; let html = `