mokecome commited on
Commit
a79d5e4
Β·
verified Β·
1 Parent(s): 52b8265

Upload 2 files

Browse files
Files changed (2) hide show
  1. UI.png +0 -0
  2. static/js/app.js +847 -0
UI.png ADDED
static/js/app.js ADDED
@@ -0,0 +1,847 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const LOCAL_STORAGE_KEY = 'treeState';
2
+
3
+ // State Management
4
+ const Store = (function () {
5
+ let instance;
6
+ let subscribers = new Set();
7
+
8
+ // We'll keep an initialState here
9
+ const initialState = {
10
+ root: null,
11
+ selectedPaths: new Set(),
12
+ fileContents: {},
13
+ expandedNodes: new Set(),
14
+ stats: {
15
+ selectedCount: 0,
16
+ totalTokens: 0,
17
+ },
18
+ };
19
+
20
+ function createInstance() {
21
+ let state = Object.freeze({ ...initialState, ...loadState() });
22
+
23
+ return {
24
+ getState() {
25
+ // Return a shallow clone or structured clone:
26
+ // but in a big app, you might just return references
27
+ // to reduce overhead. For now, keep a safe copy.
28
+ return structuredClone(state);
29
+ },
30
+
31
+ dispatch(action) {
32
+ // Instead of multiple calls, we expect a single "mutation function"
33
+ const draft = structuredClone(state);
34
+ action(draft);
35
+
36
+ // Freeze next state to maintain immutability
37
+ const nextState = Object.freeze(draft);
38
+
39
+ if (nextState !== state) {
40
+ state = nextState;
41
+ this.notify();
42
+ saveState(state);
43
+ }
44
+ },
45
+
46
+ subscribe(callback) {
47
+ subscribers.add(callback);
48
+ return () => subscribers.delete(callback);
49
+ },
50
+
51
+ notify() {
52
+ subscribers.forEach((callback) => callback(state));
53
+ },
54
+ };
55
+ }
56
+
57
+ function saveState(state) {
58
+ const serializedState = {
59
+ ...state,
60
+ selectedPaths: Array.from(state.selectedPaths),
61
+ expandedNodes: Array.from(state.expandedNodes),
62
+ };
63
+ localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(serializedState));
64
+ }
65
+
66
+ function loadState() {
67
+ const saved = localStorage.getItem(LOCAL_STORAGE_KEY);
68
+ if (!saved) return {};
69
+
70
+ const state = JSON.parse(saved);
71
+ return {
72
+ ...state,
73
+ selectedPaths: new Set(state.selectedPaths),
74
+ expandedNodes: new Set(state.expandedNodes),
75
+ };
76
+ }
77
+
78
+ return {
79
+ getInstance() {
80
+ if (!instance) {
81
+ instance = createInstance();
82
+ }
83
+ return instance;
84
+ },
85
+ };
86
+ })();
87
+
88
+ // Actions
89
+ const actions = {
90
+ setRoot: (root) => (state) => {
91
+ state.root = root;
92
+ },
93
+
94
+ // Instead of toggling each item repeatedly, we accept an array or set of paths
95
+ // to add or remove in a single batch.
96
+ bulkSelectPaths: (pathsToSelect = [], pathsToDeselect = []) => (state) => {
97
+ for (const path of pathsToSelect) {
98
+ state.selectedPaths.add(path);
99
+ }
100
+ for (const path of pathsToDeselect) {
101
+ state.selectedPaths.delete(path);
102
+ }
103
+ },
104
+
105
+ // Toggling a single path is still allowed, but we generally encourage
106
+ // single dispatch usage with "bulkSelectPaths".
107
+ toggleSelected: (path, selected) => (state) => {
108
+ if (selected) {
109
+ state.selectedPaths.add(path);
110
+ } else {
111
+ state.selectedPaths.delete(path);
112
+ }
113
+ },
114
+
115
+ toggleExpanded: (path) => (state) => {
116
+ if (state.expandedNodes.has(path)) {
117
+ state.expandedNodes.delete(path);
118
+ } else {
119
+ state.expandedNodes.add(path);
120
+ }
121
+ },
122
+
123
+ setExpanded: (path, expand) => (state) => {
124
+ if (expand) {
125
+ state.expandedNodes.add(path);
126
+ } else {
127
+ state.expandedNodes.delete(path);
128
+ }
129
+ },
130
+
131
+ setFileContents: (path, content) => (state) => {
132
+ state.fileContents[path] = content;
133
+ },
134
+
135
+ updateStats: () => (state) => {
136
+ state.stats.selectedCount = state.selectedPaths.size;
137
+ state.stats.totalTokens = calculateTokens(state.fileContents, state.selectedPaths);
138
+ },
139
+
140
+ reset: () => (state) => {
141
+ // Re-initialize everything to the original initialState
142
+ state.root = null;
143
+ state.selectedPaths = new Set();
144
+ state.fileContents = {};
145
+ state.expandedNodes = new Set();
146
+ state.stats.selectedCount = 0;
147
+ state.stats.totalTokens = 0;
148
+ },
149
+
150
+ bulkSetExpanded: (pathsToExpand = [], pathsToCollapse = []) => (state) => {
151
+ for (const path of pathsToExpand) {
152
+ state.expandedNodes.add(path);
153
+ }
154
+ for (const path of pathsToCollapse) {
155
+ state.expandedNodes.delete(path);
156
+ }
157
+ },
158
+ };
159
+
160
+ // Helper: determine if a file is a spreadsheet
161
+ function isSpreadsheet(filename) {
162
+ if (!filename) return false;
163
+
164
+ const spreadsheetExtensions = [
165
+ '.xls', '.xlsx', '.xlsm', '.xlsb',
166
+ '.xlt', '.ods', '.fods', '.numbers',
167
+ ];
168
+ const lower = filename.toLowerCase();
169
+ return spreadsheetExtensions.some((ext) => lower.endsWith(ext));
170
+ }
171
+
172
+ // Helper: determine if a file is a PDF
173
+ function isPDF(filename) {
174
+ if (!filename) return false;
175
+ return filename.toLowerCase().endsWith('.pdf');
176
+ }
177
+
178
+ // Parse PDF file
179
+ async function parsePDFFile(file) {
180
+ try {
181
+ const arrayBuffer = await file.arrayBuffer();
182
+ const typedArray = new Uint8Array(arrayBuffer);
183
+ const loadingTask = pdfjsLib.getDocument({ data: typedArray });
184
+ const pdf = await loadingTask.promise;
185
+ let textOutput = '';
186
+
187
+ for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
188
+ const page = await pdf.getPage(pageNum);
189
+ const textContent = await page.getTextContent();
190
+
191
+ // Filter out empty strings and join with proper spacing
192
+ const pageText = textContent.items
193
+ .filter((item) => item.str.trim().length > 0)
194
+ .map((item) => {
195
+ // Handle different types of spaces and line breaks
196
+ if (item.hasEOL) return item.str + '\n';
197
+ return item.str + ' ';
198
+ })
199
+ .join('')
200
+ .replace(/\s+/g, ' ')
201
+ .trim();
202
+
203
+ if (pageText) {
204
+ textOutput += pageText + '\n\n';
205
+ }
206
+ }
207
+
208
+ return textOutput.trim();
209
+ } catch (err) {
210
+ console.error('PDF parsing error:', err);
211
+ throw new Error(`Failed to parse PDF: ${err.message}`);
212
+ }
213
+ }
214
+
215
+ // Parse spreadsheet file
216
+ async function parseSpreadsheetFile(file) {
217
+ return new Promise((resolve, reject) => {
218
+ const reader = new FileReader();
219
+ reader.onload = (e) => {
220
+ try {
221
+ const data = new Uint8Array(e.target.result);
222
+ const workbook = XLSX.read(data, { type: 'array' });
223
+ let textOutput = '';
224
+
225
+ // Convert each sheet in the workbook to CSV and append
226
+ workbook.SheetNames.forEach((sheetName) => {
227
+ const worksheet = workbook.Sheets[sheetName];
228
+ const csv = XLSX.utils.sheet_to_csv(worksheet);
229
+ textOutput += `Sheet: ${sheetName}\n${csv}\n\n`;
230
+ });
231
+
232
+ resolve(textOutput.trim());
233
+ } catch (err) {
234
+ reject(err);
235
+ }
236
+ };
237
+ reader.onerror = (err) => reject(err);
238
+ reader.readAsArrayBuffer(file);
239
+ });
240
+ }
241
+
242
+ class FileTreeViewer {
243
+ constructor(store) {
244
+ this.store = store;
245
+ this.container = document.getElementById('fileTree');
246
+
247
+ // Adjust these ignored paths as needed
248
+ this.IGNORED_DIRECTORIES = ['node_modules', 'venv', '.git', '__pycache__', '.idea', '.vscode'];
249
+ this.IGNORED_FILES = [
250
+ '.DS_Store',
251
+ 'Thumbs.db',
252
+ '.env',
253
+ '.pyc',
254
+ '.jpg',
255
+ '.jpeg',
256
+ '.png',
257
+ '.gif',
258
+ '.mp4',
259
+ '.mov',
260
+ '.avi',
261
+ '.webp',
262
+ '.mkv',
263
+ '.wmv',
264
+ '.flv',
265
+ '.svg',
266
+ '.zip',
267
+ '.tar',
268
+ '.gz',
269
+ '.rar',
270
+ '.exe',
271
+ '.bin',
272
+ '.iso',
273
+ '.dll',
274
+ '.psd',
275
+ '.ai',
276
+ '.eps',
277
+ '.tiff',
278
+ '.woff',
279
+ '.woff2',
280
+ '.ttf',
281
+ '.otf',
282
+ '.flac',
283
+ '.m4a',
284
+ '.aac',
285
+ '.mov',
286
+ '.3gp',
287
+ ];
288
+
289
+ // Add LIKELY_TEXT_FILES list
290
+ this.LIKELY_TEXT_FILES = [
291
+ '.txt', '.md', '.markdown', '.json', '.js', '.ts', '.jsx', '.tsx',
292
+ '.css', '.scss', '.sass', '.less', '.html', '.htm', '.xml', '.yaml',
293
+ '.yml', '.ini', '.conf', '.cfg', '.config', '.py', '.rb', '.php',
294
+ '.java', '.c', '.cpp', '.h', '.hpp', '.cs', '.go', '.rs', '.swift',
295
+ '.kt', '.kts', '.sh', '.bash', '.zsh', '.fish', '.sql', '.graphql',
296
+ '.vue', '.svelte', '.astro', '.env.example', '.gitignore', '.dockerignore',
297
+ '.editorconfig', '.eslintrc', '.prettierrc', '.babelrc', 'LICENSE',
298
+ 'README', 'CHANGELOG', 'TODO', '.csv', '.tsv'
299
+ ];
300
+
301
+ // Subscribe to store updates
302
+ this.store.subscribe(this.handleStateChange.bind(this));
303
+ this.setupEventListeners();
304
+ }
305
+
306
+ async isTextFile(file) {
307
+ // First check known text extensions
308
+ if (this.LIKELY_TEXT_FILES.some(ext =>
309
+ file.name.toLowerCase().endsWith(ext.toLowerCase())
310
+ )) {
311
+ return true;
312
+ }
313
+
314
+ // Then check spreadsheets and PDFs
315
+ if (isSpreadsheet(file.name) || isPDF(file.name)) {
316
+ return true;
317
+ }
318
+
319
+ // Fall back to content analysis for unknown extensions
320
+ const slice = file.slice(0, 4096);
321
+ const text = await slice.text();
322
+ const printableChars = text.match(/[\x20-\x7E\n\r\t\u00A0-\u02AF\u0370-\u1CFF]/g);
323
+ return printableChars && printableChars.length / text.length > 0.7;
324
+ }
325
+
326
+ async handleFileSelect(event) {
327
+ const files = Array.from(event.target.files || []).filter(
328
+ (file) =>
329
+ !this.IGNORED_DIRECTORIES.some((dir) => file.webkitRelativePath.split('/').includes(dir)) &&
330
+ !this.IGNORED_FILES.some((ignoredFile) => {
331
+ // If ignoredFile starts with a dot, treat it as an extension
332
+ if (ignoredFile.startsWith('.')) {
333
+ return file.name.toLowerCase().endsWith(ignoredFile.toLowerCase());
334
+ }
335
+ // Otherwise, do an exact filename match
336
+ return file.name === ignoredFile;
337
+ })
338
+ );
339
+
340
+ if (!files.length) return;
341
+
342
+ // Determine if each file is text
343
+ const fileTypeMap = new Map();
344
+ for (const file of files) {
345
+ fileTypeMap.set(file.webkitRelativePath, await this.isTextFile(file));
346
+ }
347
+
348
+ // Build the root tree structure
349
+ const root = this.buildFileTree(files, fileTypeMap);
350
+ this.store.dispatch(actions.setRoot(root));
351
+
352
+ // Parse file contents in batch
353
+ for (const file of files) {
354
+ if (!fileTypeMap.get(file.webkitRelativePath)) {
355
+ continue; // skip binary or unsupported
356
+ }
357
+
358
+ let text = '';
359
+ if (isSpreadsheet(file.name)) {
360
+ text = await parseSpreadsheetFile(file);
361
+ } else if (isPDF(file.name)) {
362
+ text = await parsePDFFile(file);
363
+ } else {
364
+ text = await file.text();
365
+ }
366
+
367
+ this.store.dispatch(actions.setFileContents(file.webkitRelativePath, text));
368
+ }
369
+
370
+ this.store.dispatch(actions.updateStats());
371
+ event.target.value = '';
372
+ }
373
+
374
+ buildFileTree(files, fileTypeMap) {
375
+ // The first part (index 0) is the root folder name
376
+ // This is a naive approach if multiple top-level folders are possible
377
+ // but usually there's one main folder from the input.
378
+ const root = {
379
+ name: files[0].webkitRelativePath.split('/')[0],
380
+ path: files[0].webkitRelativePath.split('/')[0],
381
+ isDir: true,
382
+ children: [],
383
+ };
384
+
385
+ files.forEach((file) => {
386
+ const pathParts = file.webkitRelativePath.split('/');
387
+ let currentNode = root;
388
+
389
+ pathParts.forEach((part, index) => {
390
+ if (index === 0) return;
391
+
392
+ const currentPath = pathParts.slice(0, index + 1).join('/');
393
+
394
+ if (index === pathParts.length - 1) {
395
+ const isTextFile = fileTypeMap.get(file.webkitRelativePath);
396
+ currentNode.children.push({
397
+ name: part,
398
+ path: currentPath,
399
+ isDir: false,
400
+ size: file.size,
401
+ isTextFile,
402
+ });
403
+ } else {
404
+ let childNode = currentNode.children.find((n) => n.name === part);
405
+ if (!childNode) {
406
+ childNode = {
407
+ name: part,
408
+ path: currentPath,
409
+ isDir: true,
410
+ children: [],
411
+ };
412
+ currentNode.children.push(childNode);
413
+ }
414
+ currentNode = childNode;
415
+ }
416
+ });
417
+ });
418
+
419
+ return root;
420
+ }
421
+
422
+ renderTree() {
423
+ const state = this.store.getState();
424
+ if (!state.root) {
425
+ this.container.innerHTML =
426
+ '<div class="upload-message">Select a directory to view its contents</div>';
427
+ return;
428
+ }
429
+
430
+ // We'll do a single pass to compute each node's selection state
431
+ // so we don't repeatedly call expensive functions in `renderNode`.
432
+ const selectionMap = this.computeSelectionStates(state);
433
+
434
+ // Render the tree HTML
435
+ this.container.innerHTML = this.renderNode(state.root, selectionMap);
436
+
437
+ // After the container has been populated, set `indeterminate` on each checkbox
438
+ const allCheckboxes = this.container.querySelectorAll('.tree-checkbox');
439
+ allCheckboxes.forEach((checkbox) => {
440
+ const isIndeterminate = checkbox.getAttribute('data-indeterminate') === 'true';
441
+ checkbox.indeterminate = isIndeterminate;
442
+ });
443
+ }
444
+
445
+ // Single pass to compute each node's "checked" and "indeterminate" state:
446
+ computeSelectionStates(state) {
447
+ // We'll store a map of path -> { checked: bool, indeterminate: bool }
448
+ const selectionMap = {};
449
+
450
+ // Recursive function that returns { totalFiles, selectedFiles }
451
+ // so we can compute folder selection state in one pass.
452
+ const computeStateForNode = (node) => {
453
+ if (!node.isDir) {
454
+ if (node.isTextFile && state.selectedPaths.has(node.path)) {
455
+ // 1 selected file
456
+ selectionMap[node.path] = { checked: true, indeterminate: false };
457
+ return { totalFiles: 1, selectedFiles: 1 };
458
+ } else {
459
+ selectionMap[node.path] = { checked: false, indeterminate: false };
460
+ return { totalFiles: node.isTextFile ? 1 : 0, selectedFiles: 0 };
461
+ }
462
+ }
463
+
464
+ let total = 0;
465
+ let selected = 0;
466
+ node.children?.forEach((child) => {
467
+ const result = computeStateForNode(child);
468
+ total += result.totalFiles;
469
+ selected += result.selectedFiles;
470
+ });
471
+
472
+ if (total > 0 && selected === total) {
473
+ selectionMap[node.path] = { checked: true, indeterminate: false };
474
+ } else if (selected > 0 && selected < total) {
475
+ selectionMap[node.path] = { checked: false, indeterminate: true };
476
+ } else {
477
+ selectionMap[node.path] = { checked: false, indeterminate: false };
478
+ }
479
+ return { totalFiles: total, selectedFiles: selected };
480
+ };
481
+
482
+ // Start with root
483
+ computeStateForNode(state.root);
484
+ return selectionMap;
485
+ }
486
+
487
+ renderNode(node, selectionMap, level = 0) {
488
+ const state = this.store.getState();
489
+ const indent = level * 20;
490
+ const icon = node.isDir
491
+ ? state.expandedNodes.has(node.path)
492
+ ? 'πŸ“‚'
493
+ : 'πŸ“'
494
+ : node.isTextFile
495
+ ? 'πŸ“„'
496
+ : 'πŸ“¦';
497
+
498
+ const selState = selectionMap[node.path] || { checked: false, indeterminate: false };
499
+
500
+ let html = `
501
+ <div class="tree-node" style="margin-left: ${indent}px" data-path="${node.path}">
502
+ <div class="tree-node-content">
503
+ ${
504
+ node.isTextFile !== false
505
+ ? `
506
+ <input
507
+ type="checkbox"
508
+ class="tree-checkbox"
509
+ data-path="${node.path}"
510
+ ${selState.checked ? 'checked' : ''}
511
+ data-indeterminate="${selState.indeterminate}"
512
+ >
513
+ `
514
+ : ''
515
+ }
516
+ <span class="tree-node-icon">${icon}</span>
517
+ <span class="tree-node-name">${node.name}${
518
+ node.size ? ` (${this.formatSize(node.size)})` : ''
519
+ }</span>
520
+ </div>
521
+ </div>
522
+ `;
523
+
524
+ if (node.isDir && state.expandedNodes.has(node.path) && node.children) {
525
+ const sortedChildren = [...node.children].sort((a, b) => {
526
+ if (a.isDir === b.isDir) return a.name.localeCompare(b.name);
527
+ return b.isDir - a.isDir;
528
+ });
529
+
530
+ sortedChildren.forEach((child) => {
531
+ html += this.renderNode(child, selectionMap, level + 1);
532
+ });
533
+ }
534
+
535
+ return html;
536
+ }
537
+
538
+ formatSize(bytes) {
539
+ const units = ['B', 'KB', 'MB', 'GB'];
540
+ let size = bytes;
541
+ let unitIndex = 0;
542
+ while (size >= 1024 && unitIndex < units.length - 1) {
543
+ size /= 1024;
544
+ unitIndex++;
545
+ }
546
+ return `${size.toFixed(1)} ${units[unitIndex]}`;
547
+ }
548
+
549
+ handleNodeClick(event) {
550
+ const checkbox = event.target.closest('.tree-checkbox');
551
+ const content = event.target.closest('.tree-node-content');
552
+ if (!content) return;
553
+
554
+ const path = content.closest('.tree-node').dataset.path;
555
+ const node = this.findNode(path);
556
+ if (!node) return;
557
+
558
+ if (checkbox) {
559
+ this.toggleNodeSelection(node);
560
+ } else if (node.isDir) {
561
+ // If directory, expand/collapse
562
+ this.store.dispatch(actions.toggleExpanded(node.path));
563
+ }
564
+ }
565
+
566
+ toggleNodeSelection(node) {
567
+ const state = this.store.getState();
568
+ // We'll do a recursive approach in a single pass, then one dispatch
569
+ const pathsToSelect = [];
570
+ const pathsToDeselect = [];
571
+
572
+ // Instead of repeated dispatches, gather everything first
573
+ const recurse = (currentNode) => {
574
+ if (!currentNode.isDir && currentNode.isTextFile) {
575
+ // Check if currently selected or not
576
+ const isCurrentlySelected = state.selectedPaths.has(currentNode.path);
577
+ if (isCurrentlySelected) {
578
+ // We'll mark for deselect
579
+ pathsToDeselect.push(currentNode.path);
580
+ } else {
581
+ // We'll mark for select
582
+ pathsToSelect.push(currentNode.path);
583
+ }
584
+ }
585
+ currentNode.children?.forEach(recurse);
586
+ };
587
+
588
+ if (node.isDir) {
589
+ // For a folder, we see if it is fully selected
590
+ // (meaning all text files are selected)
591
+ // or partially/none selected -> then we do the opposite.
592
+ const { totalFiles, selectedFiles } = this.countFiles(node, state.selectedPaths);
593
+ const isFullySelected = totalFiles > 0 && selectedFiles === totalFiles;
594
+
595
+ if (isFullySelected) {
596
+ // Deselect everything under it
597
+ const collectAll = (n) => {
598
+ if (!n.isDir && n.isTextFile) {
599
+ pathsToDeselect.push(n.path);
600
+ }
601
+ n.children?.forEach(collectAll);
602
+ };
603
+ collectAll(node);
604
+ } else {
605
+ // Select everything under it
606
+ const collectAll = (n) => {
607
+ if (!n.isDir && n.isTextFile) {
608
+ pathsToSelect.push(n.path);
609
+ }
610
+ n.children?.forEach(collectAll);
611
+ };
612
+ collectAll(node);
613
+ }
614
+ } else {
615
+ // It's a file
616
+ const isSelected = state.selectedPaths.has(node.path);
617
+ if (isSelected) {
618
+ pathsToDeselect.push(node.path);
619
+ } else {
620
+ pathsToSelect.push(node.path);
621
+ }
622
+ }
623
+
624
+ // Now one dispatch for all changes
625
+ this.store.dispatch(actions.bulkSelectPaths(pathsToSelect, pathsToDeselect));
626
+ this.store.dispatch(actions.updateStats());
627
+ }
628
+
629
+ findNode(path, node = this.store.getState().root) {
630
+ if (!node) return null;
631
+ if (node.path === path) return node;
632
+ if (!node.children) return null;
633
+
634
+ for (const child of node.children) {
635
+ const found = this.findNode(path, child);
636
+ if (found) return found;
637
+ }
638
+ return null;
639
+ }
640
+
641
+ setupEventListeners() {
642
+ const directoryInput = document.getElementById('directoryInput');
643
+ directoryInput.addEventListener('change', (e) => this.handleFileSelect(e));
644
+
645
+ document.getElementById('expandAllButton').addEventListener('click', () => this.toggleAll(true));
646
+ document.getElementById('collapseAllButton').addEventListener('click', () =>
647
+ this.toggleAll(false)
648
+ );
649
+ document.getElementById('selectAllButton').addEventListener('click', () => this.selectAll(true));
650
+ document.getElementById('deselectAllButton').addEventListener('click', () =>
651
+ this.selectAll(false)
652
+ );
653
+ document.getElementById('clearButton').addEventListener('click', () => this.clearAll());
654
+ document.getElementById('copyButton').addEventListener('click', () => this.copyToClipboard());
655
+
656
+ this.container.addEventListener('click', this.handleNodeClick.bind(this));
657
+ }
658
+
659
+ // Instead of dispatching for every node, we do one pass through the tree
660
+ // and then dispatch a single bulk update.
661
+ toggleAll(expand) {
662
+ const state = this.store.getState();
663
+ const pathsToExpand = [];
664
+ const pathsToCollapse = [];
665
+
666
+ const gather = (node) => {
667
+ if (node.isDir) {
668
+ if (expand) {
669
+ pathsToExpand.push(node.path);
670
+ } else {
671
+ pathsToCollapse.push(node.path);
672
+ }
673
+ node.children?.forEach(gather);
674
+ }
675
+ };
676
+
677
+ if (state.root) {
678
+ gather(state.root);
679
+ // Single dispatch for all changes
680
+ this.store.dispatch(actions.bulkSetExpanded(pathsToExpand, pathsToCollapse));
681
+ }
682
+ }
683
+
684
+ // Single pass for selectAll or deselectAll
685
+ selectAll(select) {
686
+ const state = this.store.getState();
687
+ const pathsToSelect = [];
688
+ const pathsToDeselect = [];
689
+
690
+ const gather = (node) => {
691
+ if (!node.isDir && node.isTextFile) {
692
+ const isSelected = state.selectedPaths.has(node.path);
693
+ if (select && !isSelected) {
694
+ pathsToSelect.push(node.path);
695
+ } else if (!select && isSelected) {
696
+ pathsToDeselect.push(node.path);
697
+ }
698
+ }
699
+ node.children?.forEach(gather);
700
+ };
701
+
702
+ if (state.root) {
703
+ gather(state.root);
704
+ // Single dispatch
705
+ this.store.dispatch(actions.bulkSelectPaths(pathsToSelect, pathsToDeselect));
706
+ this.store.dispatch(actions.updateStats());
707
+ }
708
+ }
709
+
710
+ clearAll() {
711
+ this.store.dispatch(actions.reset());
712
+ document.getElementById('directoryInput').value = '';
713
+ document.getElementById('selectedFilesContent').textContent = '';
714
+ this.renderTree();
715
+ this.updateUI();
716
+ // delete the local storage key
717
+ localStorage.removeItem(LOCAL_STORAGE_KEY);
718
+ }
719
+
720
+ copyToClipboard() {
721
+ const content = this.generateSelectedContent();
722
+ navigator.clipboard.writeText(content).then(() => {
723
+ const button = document.getElementById('copyButton');
724
+ button.textContent = 'Copied!';
725
+ setTimeout(() => (button.textContent = 'Copy to Clipboard'), 2000);
726
+ });
727
+ }
728
+
729
+ generateSelectedContent() {
730
+ const state = this.store.getState();
731
+ if (!state.root) return '';
732
+
733
+ const content = [];
734
+ content.push(`<folder-structure>\n${this.generateAsciiTree()}\n</folder-structure>`);
735
+
736
+ for (const path of state.selectedPaths) {
737
+ const text = state.fileContents[path];
738
+ if (text) {
739
+ content.push(`<document path="${path}">\n${text}\n</document>`);
740
+ }
741
+ }
742
+
743
+ return content.join('\n\n');
744
+ }
745
+
746
+ generateAsciiTree() {
747
+ const state = this.store.getState();
748
+ if (!state.root) return '';
749
+
750
+ const generateBranch = (node, prefix = '', isLast = true) => {
751
+ // If neither this node nor its descendants are selected, skip
752
+ const nodeSelected = state.selectedPaths.has(node.path);
753
+ const descendantSelected = this.hasSelectedDescendant(node, state.selectedPaths);
754
+ if (!nodeSelected && !descendantSelected) {
755
+ return '';
756
+ }
757
+
758
+ const connector = isLast ? '└── ' : 'β”œβ”€β”€ ';
759
+ const childPrefix = isLast ? ' ' : 'β”‚ ';
760
+ let result = prefix + connector + node.name + '\n';
761
+
762
+ if (node.children) {
763
+ const visibleChildren = node.children.filter(
764
+ (child) => state.selectedPaths.has(child.path) || this.hasSelectedDescendant(child, state.selectedPaths)
765
+ );
766
+
767
+ visibleChildren.forEach((child, index) => {
768
+ result += generateBranch(
769
+ child,
770
+ prefix + childPrefix,
771
+ index === visibleChildren.length - 1
772
+ );
773
+ });
774
+ }
775
+
776
+ return result;
777
+ };
778
+
779
+ return generateBranch(state.root);
780
+ }
781
+
782
+ hasSelectedDescendant(node, selectedPaths) {
783
+ if (!node.children) return false;
784
+ return node.children.some(
785
+ (child) => selectedPaths.has(child.path) || this.hasSelectedDescendant(child, selectedPaths)
786
+ );
787
+ }
788
+
789
+ // Utility to count how many text files are under this node and
790
+ // how many are selected, so we can decide if it's "fully" selected or not.
791
+ countFiles(node, selectedPaths) {
792
+ let total = 0;
793
+ let selected = 0;
794
+
795
+ const recurse = (currentNode) => {
796
+ if (!currentNode.isDir && currentNode.isTextFile) {
797
+ total++;
798
+ if (selectedPaths.has(currentNode.path)) {
799
+ selected++;
800
+ }
801
+ }
802
+ currentNode.children?.forEach(recurse);
803
+ };
804
+ recurse(node);
805
+
806
+ return { totalFiles: total, selectedFiles: selected };
807
+ }
808
+
809
+ handleStateChange(state) {
810
+ this.renderTree();
811
+ this.updateUI();
812
+ document.getElementById('selectedFilesContent').textContent = this.generateSelectedContent();
813
+ document.getElementById('selectedCount').textContent = state.stats.selectedCount;
814
+ document.getElementById('estimatedTokens').textContent = state.stats.totalTokens;
815
+ }
816
+
817
+ updateUI() {
818
+ const state = this.store.getState();
819
+ document.getElementById('selectedFilesContent').textContent = this.generateSelectedContent();
820
+ document.getElementById('selectedCount').textContent = state.stats.selectedCount;
821
+ document.getElementById('estimatedTokens').textContent = state.stats.totalTokens;
822
+ }
823
+ }
824
+
825
+ function calculateTokens(fileContents, selectedPaths) {
826
+ let totalChars = 0;
827
+ for (const path of selectedPaths) {
828
+ const content = fileContents[path];
829
+ if (content) {
830
+ totalChars += content.length;
831
+ }
832
+ }
833
+ // Estimate 1 token per 4 characters as a rough approximation
834
+ return Math.ceil(totalChars / 4);
835
+ }
836
+
837
+ // Initialize the app
838
+ document.addEventListener('DOMContentLoaded', () => {
839
+ const store = Store.getInstance();
840
+ const viewer = new FileTreeViewer(store);
841
+
842
+ // If we have existing state, render it
843
+ if (store.getState().root) {
844
+ viewer.renderTree();
845
+ viewer.updateUI();
846
+ }
847
+ });