Spaces:
Running
Running
Upload 2 files
Browse files- UI.png +0 -0
- 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 |
+
});
|