Spaces:
Running
Running
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 = | |
'<div class="upload-message">Select a directory to view its contents</div>'; | |
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 = ` | |
<div class="tree-node" style="margin-left: ${indent}px" data-path="${node.path}"> | |
<div class="tree-node-content"> | |
${ | |
node.isTextFile !== false | |
? ` | |
<input | |
type="checkbox" | |
class="tree-checkbox" | |
data-path="${node.path}" | |
${selState.checked ? 'checked' : ''} | |
data-indeterminate="${selState.indeterminate}" | |
> | |
` | |
: '' | |
} | |
<span class="tree-node-icon">${icon}</span> | |
<span class="tree-node-name">${node.name}${ | |
node.size ? ` (${this.formatSize(node.size)})` : '' | |
}</span> | |
</div> | |
</div> | |
`; | |
if (node.isDir && state.expandedNodes.has(node.path) && node.children) { | |
const sortedChildren = [...node.children].sort((a, b) => { | |
if (a.isDir === b.isDir) return a.name.localeCompare(b.name); | |
return b.isDir - a.isDir; | |
}); | |
sortedChildren.forEach((child) => { | |
html += this.renderNode(child, selectionMap, level + 1); | |
}); | |
} | |
return html; | |
} | |
formatSize(bytes) { | |
const units = ['B', 'KB', 'MB', 'GB']; | |
let size = bytes; | |
let unitIndex = 0; | |
while (size >= 1024 && unitIndex < units.length - 1) { | |
size /= 1024; | |
unitIndex++; | |
} | |
return `${size.toFixed(1)} ${units[unitIndex]}`; | |
} | |
handleNodeClick(event) { | |
const checkbox = event.target.closest('.tree-checkbox'); | |
const content = event.target.closest('.tree-node-content'); | |
if (!content) return; | |
const path = content.closest('.tree-node').dataset.path; | |
const node = this.findNode(path); | |
if (!node) return; | |
if (checkbox) { | |
this.toggleNodeSelection(node); | |
} else if (node.isDir) { | |
// If directory, expand/collapse | |
this.store.dispatch(actions.toggleExpanded(node.path)); | |
} | |
} | |
toggleNodeSelection(node) { | |
const state = this.store.getState(); | |
// We'll do a recursive approach in a single pass, then one dispatch | |
const pathsToSelect = []; | |
const pathsToDeselect = []; | |
// Instead of repeated dispatches, gather everything first | |
const recurse = (currentNode) => { | |
if (!currentNode.isDir && currentNode.isTextFile) { | |
// Check if currently selected or not | |
const isCurrentlySelected = state.selectedPaths.has(currentNode.path); | |
if (isCurrentlySelected) { | |
// We'll mark for deselect | |
pathsToDeselect.push(currentNode.path); | |
} else { | |
// We'll mark for select | |
pathsToSelect.push(currentNode.path); | |
} | |
} | |
currentNode.children?.forEach(recurse); | |
}; | |
if (node.isDir) { | |
// For a folder, we see if it is fully selected | |
// (meaning all text files are selected) | |
// or partially/none selected -> then we do the opposite. | |
const { totalFiles, selectedFiles } = this.countFiles(node, state.selectedPaths); | |
const isFullySelected = totalFiles > 0 && selectedFiles === totalFiles; | |
if (isFullySelected) { | |
// Deselect everything under it | |
const collectAll = (n) => { | |
if (!n.isDir && n.isTextFile) { | |
pathsToDeselect.push(n.path); | |
} | |
n.children?.forEach(collectAll); | |
}; | |
collectAll(node); | |
} else { | |
// Select everything under it | |
const collectAll = (n) => { | |
if (!n.isDir && n.isTextFile) { | |
pathsToSelect.push(n.path); | |
} | |
n.children?.forEach(collectAll); | |
}; | |
collectAll(node); | |
} | |
} else { | |
// It's a file | |
const isSelected = state.selectedPaths.has(node.path); | |
if (isSelected) { | |
pathsToDeselect.push(node.path); | |
} else { | |
pathsToSelect.push(node.path); | |
} | |
} | |
// Now one dispatch for all changes | |
this.store.dispatch(actions.bulkSelectPaths(pathsToSelect, pathsToDeselect)); | |
this.store.dispatch(actions.updateStats()); | |
} | |
findNode(path, node = this.store.getState().root) { | |
if (!node) return null; | |
if (node.path === path) return node; | |
if (!node.children) return null; | |
for (const child of node.children) { | |
const found = this.findNode(path, child); | |
if (found) return found; | |
} | |
return null; | |
} | |
setupEventListeners() { | |
const directoryInput = document.getElementById('directoryInput'); | |
directoryInput.addEventListener('change', (e) => this.handleFileSelect(e)); | |
document.getElementById('expandAllButton').addEventListener('click', () => this.toggleAll(true)); | |
document.getElementById('collapseAllButton').addEventListener('click', () => | |
this.toggleAll(false) | |
); | |
document.getElementById('selectAllButton').addEventListener('click', () => this.selectAll(true)); | |
document.getElementById('deselectAllButton').addEventListener('click', () => | |
this.selectAll(false) | |
); | |
document.getElementById('clearButton').addEventListener('click', () => this.clearAll()); | |
document.getElementById('copyButton').addEventListener('click', () => this.copyToClipboard()); | |
this.container.addEventListener('click', this.handleNodeClick.bind(this)); | |
} | |
// Instead of dispatching for every node, we do one pass through the tree | |
// and then dispatch a single bulk update. | |
toggleAll(expand) { | |
const state = this.store.getState(); | |
const pathsToExpand = []; | |
const pathsToCollapse = []; | |
const gather = (node) => { | |
if (node.isDir) { | |
if (expand) { | |
pathsToExpand.push(node.path); | |
} else { | |
pathsToCollapse.push(node.path); | |
} | |
node.children?.forEach(gather); | |
} | |
}; | |
if (state.root) { | |
gather(state.root); | |
// Single dispatch for all changes | |
this.store.dispatch(actions.bulkSetExpanded(pathsToExpand, pathsToCollapse)); | |
} | |
} | |
// Single pass for selectAll or deselectAll | |
selectAll(select) { | |
const state = this.store.getState(); | |
const pathsToSelect = []; | |
const pathsToDeselect = []; | |
const gather = (node) => { | |
if (!node.isDir && node.isTextFile) { | |
const isSelected = state.selectedPaths.has(node.path); | |
if (select && !isSelected) { | |
pathsToSelect.push(node.path); | |
} else if (!select && isSelected) { | |
pathsToDeselect.push(node.path); | |
} | |
} | |
node.children?.forEach(gather); | |
}; | |
if (state.root) { | |
gather(state.root); | |
// Single dispatch | |
this.store.dispatch(actions.bulkSelectPaths(pathsToSelect, pathsToDeselect)); | |
this.store.dispatch(actions.updateStats()); | |
} | |
} | |
clearAll() { | |
this.store.dispatch(actions.reset()); | |
document.getElementById('directoryInput').value = ''; | |
document.getElementById('selectedFilesContent').textContent = ''; | |
this.renderTree(); | |
this.updateUI(); | |
// delete the local storage key | |
localStorage.removeItem(LOCAL_STORAGE_KEY); | |
} | |
copyToClipboard() { | |
const content = this.generateSelectedContent(); | |
navigator.clipboard.writeText(content).then(() => { | |
const button = document.getElementById('copyButton'); | |
button.textContent = 'Copied!'; | |
setTimeout(() => (button.textContent = 'Copy to Clipboard'), 2000); | |
}); | |
} | |
generateSelectedContent() { | |
const state = this.store.getState(); | |
if (!state.root) return ''; | |
const content = []; | |
content.push(`<folder-structure>\n${this.generateAsciiTree()}\n</folder-structure>`); | |
for (const path of state.selectedPaths) { | |
const text = state.fileContents[path]; | |
if (text) { | |
content.push(`<document path="${path}">\n${text}\n</document>`); | |
} | |
} | |
return content.join('\n\n'); | |
} | |
generateAsciiTree() { | |
const state = this.store.getState(); | |
if (!state.root) return ''; | |
const generateBranch = (node, prefix = '', isLast = true) => { | |
// If neither this node nor its descendants are selected, skip | |
const nodeSelected = state.selectedPaths.has(node.path); | |
const descendantSelected = this.hasSelectedDescendant(node, state.selectedPaths); | |
if (!nodeSelected && !descendantSelected) { | |
return ''; | |
} | |
const connector = isLast ? 'βββ ' : 'βββ '; | |
const childPrefix = isLast ? ' ' : 'β '; | |
let result = prefix + connector + node.name + '\n'; | |
if (node.children) { | |
const visibleChildren = node.children.filter( | |
(child) => state.selectedPaths.has(child.path) || this.hasSelectedDescendant(child, state.selectedPaths) | |
); | |
visibleChildren.forEach((child, index) => { | |
result += generateBranch( | |
child, | |
prefix + childPrefix, | |
index === visibleChildren.length - 1 | |
); | |
}); | |
} | |
return result; | |
}; | |
return generateBranch(state.root); | |
} | |
hasSelectedDescendant(node, selectedPaths) { | |
if (!node.children) return false; | |
return node.children.some( | |
(child) => selectedPaths.has(child.path) || this.hasSelectedDescendant(child, selectedPaths) | |
); | |
} | |
// Utility to count how many text files are under this node and | |
// how many are selected, so we can decide if it's "fully" selected or not. | |
countFiles(node, selectedPaths) { | |
let total = 0; | |
let selected = 0; | |
const recurse = (currentNode) => { | |
if (!currentNode.isDir && currentNode.isTextFile) { | |
total++; | |
if (selectedPaths.has(currentNode.path)) { | |
selected++; | |
} | |
} | |
currentNode.children?.forEach(recurse); | |
}; | |
recurse(node); | |
return { totalFiles: total, selectedFiles: selected }; | |
} | |
handleStateChange(state) { | |
this.renderTree(); | |
this.updateUI(); | |
document.getElementById('selectedFilesContent').textContent = this.generateSelectedContent(); | |
document.getElementById('selectedCount').textContent = state.stats.selectedCount; | |
document.getElementById('estimatedTokens').textContent = state.stats.totalTokens; | |
} | |
updateUI() { | |
const state = this.store.getState(); | |
document.getElementById('selectedFilesContent').textContent = this.generateSelectedContent(); | |
document.getElementById('selectedCount').textContent = state.stats.selectedCount; | |
document.getElementById('estimatedTokens').textContent = state.stats.totalTokens; | |
} | |
} | |
function calculateTokens(fileContents, selectedPaths) { | |
let totalChars = 0; | |
for (const path of selectedPaths) { | |
const content = fileContents[path]; | |
if (content) { | |
totalChars += content.length; | |
} | |
} | |
// Estimate 1 token per 4 characters as a rough approximation | |
return Math.ceil(totalChars / 4); | |
} | |
// Initialize the app | |
document.addEventListener('DOMContentLoaded', () => { | |
const store = Store.getInstance(); | |
const viewer = new FileTreeViewer(store); | |
// If we have existing state, render it | |
if (store.getState().root) { | |
viewer.renderTree(); | |
viewer.updateUI(); | |
} | |
}); | |