├── .gitignore ├── UI.png ├── Profile.jpg ├── static ├── logo.png ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── site.webmanifest └── js │ ├── main.js │ ├── actions.js │ ├── helpers.js │ ├── store.js │ ├── indexing.js │ ├── viewer.js │ ├── tree-view.js │ └── app.js ├── Dockerfile ├── .dockerignore ├── LICENSE ├── README.md ├── help.html ├── index.html └── lib └── localforage.min.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store -------------------------------------------------------------------------------- /UI.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hello-nerdo/CodebaseToPrompt/HEAD/UI.png -------------------------------------------------------------------------------- /Profile.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hello-nerdo/CodebaseToPrompt/HEAD/Profile.jpg -------------------------------------------------------------------------------- /static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hello-nerdo/CodebaseToPrompt/HEAD/static/logo.png -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hello-nerdo/CodebaseToPrompt/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hello-nerdo/CodebaseToPrompt/HEAD/static/favicon-16x16.png -------------------------------------------------------------------------------- /static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hello-nerdo/CodebaseToPrompt/HEAD/static/favicon-32x32.png -------------------------------------------------------------------------------- /static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hello-nerdo/CodebaseToPrompt/HEAD/static/apple-touch-icon.png -------------------------------------------------------------------------------- /static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hello-nerdo/CodebaseToPrompt/HEAD/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hello-nerdo/CodebaseToPrompt/HEAD/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:alpine 2 | 3 | WORKDIR /usr/share/nginx/html 4 | 5 | COPY . . 6 | 7 | EXPOSE 80 8 | 9 | CMD ["nginx", "-g", "daemon off;"] 10 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore hidden system files 2 | .DS_Store 3 | Thumbs.db 4 | 5 | # Ignore Git and IDE folders 6 | .git 7 | .gitignore 8 | .idea 9 | .vscode 10 | 11 | # Node-related 12 | node_modules/ 13 | 14 | # Local environment or coverage artifacts (if any) 15 | .env 16 | coverage/ 17 | *.log 18 | 19 | # If you have any build artifacts or caches you want excluded, add them here -------------------------------------------------------------------------------- /static/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "CodebaseToPrompt", 3 | "short_name": "CodebaseToPrompt", 4 | "icons": [ 5 | { 6 | "src": "static/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "static/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "display": "standalone" 17 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 pathfinder 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /static/js/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * main.js 3 | * 4 | * Orchestrates the UI: sets up the FileTreeViewer, wires DOM events, 5 | * and initializes from the store. 6 | */ 7 | import { Store } from './store.js'; 8 | import { FileTreeViewer } from './viewer.js'; 9 | 10 | document.addEventListener('DOMContentLoaded', async () => { 11 | // 1) Load the store from IndexedDB 12 | const store = await Store.getInstance(); 13 | 14 | // 2) Create the viewer 15 | const viewer = new FileTreeViewer(store); 16 | 17 | // 3) Subscribe so that whenever the store changes, we re-render etc. 18 | store.subscribe((newState) => { 19 | viewer.handleStateChange(newState); 20 | }); 21 | 22 | // 4) If there's already a root in the store, show it 23 | const currentState = store.getState(); 24 | if (currentState.root) { 25 | viewer.handleStateChange(currentState); 26 | } 27 | }); 28 | 29 | // Add this with your other event listeners 30 | document.getElementById('selectTextButton').addEventListener('click', () => { 31 | const content = document.getElementById('selectedFilesContent'); 32 | const range = document.createRange(); 33 | range.selectNodeContents(content); 34 | const selection = window.getSelection(); 35 | selection.removeAllRanges(); 36 | selection.addRange(range); 37 | }); 38 | -------------------------------------------------------------------------------- /static/js/actions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * actions.js 3 | * 4 | * Exports a set of "action creators" that mutate the state draft. 5 | * Also writes file contents to localforage if needed. 6 | */ 7 | import { contentsDB, calculateTokens } from './helpers.js'; 8 | 9 | export const actions = { 10 | setRoot: (root) => async (state) => { 11 | state.root = root; 12 | }, 13 | 14 | bulkSelectPaths: (pathsToSelect = [], pathsToDeselect = []) => async (state) => { 15 | for (const p of pathsToSelect) { 16 | state.selectedPaths.add(p); 17 | } 18 | for (const p of pathsToDeselect) { 19 | state.selectedPaths.delete(p); 20 | } 21 | }, 22 | 23 | toggleSelected: (path, selected) => async (state) => { 24 | if (selected) { 25 | state.selectedPaths.add(path); 26 | } else { 27 | state.selectedPaths.delete(path); 28 | } 29 | }, 30 | 31 | toggleExpanded: (path) => async (state) => { 32 | if (state.expandedNodes.has(path)) { 33 | state.expandedNodes.delete(path); 34 | } else { 35 | state.expandedNodes.add(path); 36 | } 37 | }, 38 | 39 | setExpanded: (path, expand) => async (state) => { 40 | if (expand) { 41 | state.expandedNodes.add(path); 42 | } else { 43 | state.expandedNodes.delete(path); 44 | } 45 | }, 46 | 47 | /** 48 | * Immediately store file content in memory + localforage. 49 | */ 50 | setFileContents: (path, content) => async (state) => { 51 | state.fileContents[path] = content; 52 | await contentsDB.setItem(path, content); 53 | }, 54 | 55 | updateStats: () => async (state) => { 56 | state.stats.selectedCount = state.selectedPaths.size; 57 | state.stats.totalTokens = calculateTokens(state.fileContents, state.selectedPaths); 58 | }, 59 | 60 | reset: () => async (state) => { 61 | // Clear everything 62 | state.root = null; 63 | state.selectedPaths = new Set(); 64 | state.fileContents = {}; 65 | state.expandedNodes = new Set(); 66 | state.stats.selectedCount = 0; 67 | state.stats.totalTokens = 0; 68 | 69 | // Also clear the contents DB 70 | const keys = await contentsDB.keys(); 71 | for (const k of keys) { 72 | await contentsDB.removeItem(k); 73 | } 74 | }, 75 | 76 | bulkSetExpanded: (pathsToExpand = [], pathsToCollapse = []) => async (state) => { 77 | for (const p of pathsToExpand) { 78 | state.expandedNodes.add(p); 79 | } 80 | for (const p of pathsToCollapse) { 81 | state.expandedNodes.delete(p); 82 | } 83 | }, 84 | }; 85 | -------------------------------------------------------------------------------- /static/js/helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * helpers.js 3 | * 4 | * - localForage instances for metadata / contents 5 | * - load/save metadata 6 | * - load contents for selected paths 7 | * - token calculation 8 | * - text-likelihood check 9 | */ 10 | 11 | export const metadataDB = localforage.createInstance({ 12 | name: 'CodebaseToPrompt', 13 | storeName: 'fileMetadata', 14 | }); 15 | 16 | export const contentsDB = localforage.createInstance({ 17 | name: 'CodebaseToPrompt', 18 | storeName: 'fileContents', 19 | }); 20 | 21 | /** 22 | * Load the entire metadata object from IndexedDB 23 | */ 24 | export async function loadAllMetadata() { 25 | const saved = await metadataDB.getItem('treeState'); 26 | return saved || {}; 27 | } 28 | 29 | /** 30 | * Save the current metadata object (root, selectedPaths, expandedNodes, stats). 31 | */ 32 | export async function saveAllMetadata(state) { 33 | const serializable = { 34 | root: state.root, 35 | selectedPaths: Array.from(state.selectedPaths), 36 | expandedNodes: Array.from(state.expandedNodes), 37 | stats: state.stats, 38 | }; 39 | await metadataDB.setItem('treeState', serializable); 40 | } 41 | 42 | /** 43 | * Only load contents for the currently selected paths (avoid loading everything). 44 | */ 45 | export async function loadAllContents(selectedPaths) { 46 | const out = {}; 47 | for (const path of selectedPaths) { 48 | const content = await contentsDB.getItem(path); 49 | if (content) { 50 | out[path] = content; 51 | } 52 | } 53 | return out; 54 | } 55 | 56 | /** 57 | * Estimate tokens by a rough 4-chars = 1 token approach. 58 | */ 59 | export function calculateTokens(fileContents, selectedPaths) { 60 | let totalChars = 0; 61 | for (const path of selectedPaths) { 62 | const content = fileContents[path]; 63 | if (content) { 64 | totalChars += content.length; 65 | } 66 | } 67 | // approximate 4 chars per token 68 | return Math.ceil(totalChars / 4); 69 | } 70 | 71 | /** 72 | * Attempt to read ~4KB and see how many "printable" characters exist 73 | * to guess if it's text or binary. 74 | */ 75 | export async function isTextLikely(file) { 76 | const slice = file.slice(0, 4096); 77 | const text = await slice.text(); 78 | const printableChars = text.match(/[\x20-\x7E\n\r\t\u00A0-\u02AF\u0370-\u1CFF]/g); 79 | return printableChars && printableChars.length / text.length > 0.7; 80 | } 81 | -------------------------------------------------------------------------------- /static/js/store.js: -------------------------------------------------------------------------------- 1 | /** 2 | * store.js 3 | * 4 | * Manages global app state using localforage for persistent storage: 5 | * - fileMetadata: store overall tree, selected paths, expanded nodes, etc. 6 | * - fileContents: store actual text of files keyed by path. 7 | */ 8 | import { actions } from './actions.js'; 9 | import { loadAllMetadata, saveAllMetadata, loadAllContents } from './helpers.js'; 10 | 11 | /** 12 | * A standard initial in-memory layout. 13 | */ 14 | const initialState = { 15 | root: null, 16 | selectedPaths: new Set(), 17 | fileContents: {}, // in-memory cache 18 | expandedNodes: new Set(), 19 | stats: { 20 | selectedCount: 0, 21 | totalTokens: 0, 22 | }, 23 | }; 24 | 25 | // We keep a singleton store reference 26 | let instance = null; 27 | let subscribers = new Set(); 28 | 29 | export class Store { 30 | /** 31 | * Private constructor: Use static getInstance() instead. 32 | */ 33 | constructor() { 34 | this.state = null; // Will be loaded asynchronously 35 | } 36 | 37 | static async getInstance() { 38 | if (!instance) { 39 | instance = new Store(); 40 | await instance._init(); 41 | } 42 | return instance; 43 | } 44 | 45 | async _init() { 46 | // 1) Load metadata from IndexedDB 47 | const loadedMeta = await loadAllMetadata(); 48 | const mergedState = { 49 | ...initialState, 50 | ...loadedMeta, 51 | selectedPaths: new Set(loadedMeta.selectedPaths || []), 52 | expandedNodes: new Set(loadedMeta.expandedNodes || []), 53 | }; 54 | // 2) Load file contents for selected paths 55 | const contents = await loadAllContents(mergedState.selectedPaths); 56 | 57 | // Merge in-memory 58 | this.state = Object.freeze({ 59 | ...mergedState, 60 | fileContents: contents, 61 | }); 62 | } 63 | 64 | getState() { 65 | // Return a structured clone to avoid accidental direct mutation 66 | return structuredClone(this.state); 67 | } 68 | 69 | /** 70 | * The "dispatch" method: it takes an async action function that modifies 71 | * a draft of state, then we freeze & store it. 72 | */ 73 | async dispatch(actionFn) { 74 | const draft = structuredClone(this.state); 75 | await actionFn(draft); 76 | const nextState = Object.freeze(draft); 77 | 78 | if (nextState !== this.state) { 79 | this.state = nextState; 80 | this._notify(); 81 | // Save metadata (file tree, selected paths, etc.) 82 | await saveAllMetadata(this.state); 83 | } 84 | } 85 | 86 | subscribe(callback) { 87 | subscribers.add(callback); 88 | return () => { 89 | subscribers.delete(callback); 90 | }; 91 | } 92 | 93 | _notify() { 94 | for (const cb of subscribers) { 95 | cb(this.state); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /static/js/indexing.js: -------------------------------------------------------------------------------- 1 | /** 2 | * indexing.js 3 | * 4 | * Functions to parse PDFs, spreadsheets, etc. Also determines 5 | * file type (PDF, XLSX) by extension. 6 | */ 7 | 8 | // <-- This line makes sure we can use pdfjs from the global scope in our ES module: 9 | const pdfjs = window.pdfjsLib; 10 | 11 | /** 12 | * Check if a file is PDF by extension 13 | */ 14 | export function isPDF(filename) { 15 | if (!filename) return false; 16 | return filename.toLowerCase().endsWith('.pdf'); 17 | } 18 | 19 | /** 20 | * Parse a PDF file to text using pdf.js 21 | */ 22 | export async function parsePDFFile(file) { 23 | const arrayBuffer = await file.arrayBuffer(); 24 | const typedArray = new Uint8Array(arrayBuffer); 25 | 26 | // Use `pdfjs` (pointing to window.pdfjsLib) 27 | const loadingTask = pdfjs.getDocument({ data: typedArray }); 28 | const pdf = await loadingTask.promise; 29 | let textOutput = ''; 30 | 31 | for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { 32 | const page = await pdf.getPage(pageNum); 33 | const textContent = await page.getTextContent(); 34 | const pageText = textContent.items 35 | .filter((item) => item.str.trim().length > 0) 36 | .map((item) => { 37 | return item.hasEOL ? item.str + '\n' : item.str + ' '; 38 | }) 39 | .join('') 40 | .replace(/\s+/g, ' ') 41 | .trim(); 42 | if (pageText) { 43 | textOutput += pageText + '\n\n'; 44 | } 45 | } 46 | 47 | return textOutput.trim(); 48 | } 49 | 50 | /** 51 | * Check if a file is a spreadsheet 52 | */ 53 | export function isSpreadsheet(filename) { 54 | if (!filename) return false; 55 | const spreadsheetExtensions = [ 56 | '.xls', 57 | '.xlsx', 58 | '.xlsm', 59 | '.xlsb', 60 | '.xlt', 61 | '.ods', 62 | '.fods', 63 | '.numbers', 64 | ]; 65 | const lower = filename.toLowerCase(); 66 | return spreadsheetExtensions.some((ext) => lower.endsWith(ext)); 67 | } 68 | 69 | /** 70 | * Parse a spreadsheet file to CSV text using SheetJS 71 | */ 72 | export async function parseSpreadsheetFile(file) { 73 | return new Promise((resolve, reject) => { 74 | const reader = new FileReader(); 75 | reader.onload = (e) => { 76 | try { 77 | const data = new Uint8Array(e.target.result); 78 | const workbook = XLSX.read(data, { type: 'array' }); 79 | let textOutput = ''; 80 | 81 | workbook.SheetNames.forEach((sheetName) => { 82 | const worksheet = workbook.Sheets[sheetName]; 83 | const csv = XLSX.utils.sheet_to_csv(worksheet); 84 | textOutput += `Sheet: ${sheetName}\n${csv}\n\n`; 85 | }); 86 | 87 | resolve(textOutput.trim()); 88 | } catch (err) { 89 | reject(err); 90 | } 91 | }; 92 | reader.onerror = (err) => reject(err); 93 | reader.readAsArrayBuffer(file); 94 | }); 95 | } 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CodebaseToPrompt 2 | 3 | A simple tool that converts your local directory into a structured prompt for Large Language Models (LLMs). It helps you pick which files to include or ignore, then outputs everything in a format you can copy directly into an LLM for code review, analysis, or documentation. 4 | 5 | [![User Interface](UI.png)](https://hello-nerdo.github.io/CodebaseToPrompt/) 6 | 7 | Try it here: [CodebaseToPrompt Web App](https://hello-nerdo.github.io/CodebaseToPrompt/) 8 | 9 | --- 10 | 11 | ## Overview 12 | 13 | **CodebaseToPrompt** scans your chosen folder right in the browser (no files are uploaded anywhere) and builds a file tree. You can expand folders, see which files are text or code, and select only what you need. The selected files are then compiled into a snippet of structured text, which you can copy for use with an LLM. 14 | 15 | --- 16 | 17 | ## Features 18 | 19 | - **Interactive File Tree** 20 | Explore and expand your local folders in a simple interface. 21 | 22 | - **File Filtering** 23 | Automatically ignores system or binary files (e.g., `.DS_Store`, `node_modules`, images, videos). 24 | 25 | - **Local Storage** 26 | Your selections are remembered so you can pick up where you left off. 27 | 28 | - **LLM-Ready Output** 29 | Generates a format that's easy to paste into chatbots and other AI tools. 30 | 31 | - **Token Count Estimate** 32 | Provides a rough calculation of how many tokens the selected content might use. 33 | 34 | - **Flexible Text Selection** 35 | Choose between selecting text or copying directly to clipboard, ensuring compatibility with both HTTP and HTTPS environments. 36 | 37 | --- 38 | 39 | ## How to Use 40 | 41 | 1. **Open the App** 42 | Clone this repository, then open `index.html` in a modern browser (Chrome or Firefox recommended). 43 | 44 | ```bash 45 | git clone https://github.com/hello-nerdo/CodebaseToPrompt.git 46 | ``` 47 | 48 | 2. **Select Your Folder** 49 | Click “Select Directory” to choose the folder you want to analyze. 50 | 51 | 3. **Pick Files** 52 | Expand or collapse directories. Check or uncheck files to decide what gets included. 53 | 54 | 4. **Copy or Select Text** 55 | View or copy your selected files in the generated prompt format by clicking “Copy to Clipboard” or “Select Text” (the latter is useful in HTTP-only environments). 56 | 57 | --- 58 | 59 | ## Docker Quickstart 60 | 61 | For a self-contained deployment, you can use the included **Dockerfile**: 62 | 63 | ```bash 64 | # Build the Docker image 65 | docker build -t codebasetoprompt . 66 | 67 | # Run the container (maps container port 80 to host port 8080) 68 | docker run -d -p 8080:80 codebasetoprompt 69 | 70 | # Then open http://localhost:8080 71 | ``` 72 | 73 | > **Note:** This simply serves the static files using `nginx:alpine`. If you prefer GitHub Pages or other hosting, you can ignore Docker. 74 | 75 | --- 76 | 77 | ## Use Cases 78 | 79 | - Creating context for AI-based code review or Q&A 80 | - Quickly extracting only the important parts of a large project for analysis 81 | - Preparing short or large code snippets for LLM debugging 82 | - Generating reference material for new developers or documentation 83 | 84 | --- 85 | 86 | ## Configuration 87 | 88 | Certain folders and file types are automatically ignored (e.g., `node_modules`, `.git`, `venv`, common binary files). You can modify the lists inside the JavaScript code (`app.js`) if you need more control. 89 | 90 | --- 91 | 92 | ## Browser Support 93 | 94 | - Chrome/Chromium (recommended) 95 | - Edge 96 | - Firefox 97 | - Safari (basic directory selection may vary) 98 | 99 | --- 100 | 101 | ## Contributing 102 | 103 | Contributions are welcome! Feel free to open issues for bugs or requests. For major changes, create an issue to discuss them first. 104 | 105 | 1. Fork and clone the repo 106 | 2. Make your changes in a branch 107 | 3. Open a Pull Request describing your updates 108 | 109 | --- 110 | 111 | ## License 112 | 113 | [MIT](LICENSE) 114 | 115 | --- 116 | 117 | ## Acknowledgments 118 | 119 | - Built with vanilla JavaScript and Web APIs 120 | - Uses [SheetJS](https://sheetjs.com/) for spreadsheet parsing 121 | - Uses [PDF.js](https://mozilla.github.io/pdf.js/) for PDF parsing 122 | 123 | --- 124 | 125 | ## Contact 126 | 127 | - Open an issue on GitHub for ideas or bug reports 128 | - Submit a Pull Request for direct contributions 129 | - Reach out via the repo's issues page if you have questions -------------------------------------------------------------------------------- /help.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 21 | CodebaseToPrompt Help 22 | 66 | 67 | 68 |
69 |

How to Use CodebaseToPrompt

70 |

71 | This page explains how to use the tool to convert your local directory into a structured prompt 72 | for Large Language Models (LLMs). 73 |

74 | 75 | UI Overview 76 | 77 |

Overview

78 |

79 | CodebaseToPrompt scans your chosen folder right in the browser (files are only loaded to your 80 | local browser) and builds a file tree. You can expand folders, see which files are text or code, 81 | and select only what you need. 82 |

83 | 84 |

Features

85 | 111 | 112 |

How to Use

113 |

Follow these steps to get started:

114 |
    115 |
  1. 116 | Open the App: Clone this repository, then open index.html in a 117 | modern browser. 118 |
  2. 119 |
  3. 120 | Select Your Folder: Click “Select Directory” to choose the folder you want to 121 | analyze. 122 |
  4. 123 |
  5. 124 | Pick Files: Expand or collapse directories. Check or uncheck files to decide 125 | what gets included. 126 |
  6. 127 |
  7. 128 | Copy Output: View or copy your selected files in the generated prompt format 129 | by clicking “Copy to Clipboard.” 130 |
  8. 131 |
132 | 133 |

134 | Back to Main Page 135 |

136 |
137 | 138 | 139 | -------------------------------------------------------------------------------- /static/js/viewer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * viewer.js 3 | * 4 | * Contains the FileTreeViewer class which now delegates actual tree rendering 5 | * to our new web component. We only handle: 6 | * - Building the in-memory "tree data" 7 | * - Toggling expansions / selections in the Store 8 | * - Listening for events from and updating the Store 9 | * - "Expand All" / "Collapse All" / "Select All" / "Deselect All" / "Clear" in the UI 10 | */ 11 | 12 | import { actions } from './actions.js'; 13 | import { isSpreadsheet, parseSpreadsheetFile, isPDF, parsePDFFile } from './indexing.js'; 14 | import { isTextLikely } from './helpers.js'; 15 | 16 | // We import our web component (tree-view.js) in index.html or main.js, so we can also import here if needed: 17 | import './tree-view.js'; 18 | 19 | export class FileTreeViewer { 20 | constructor(store) { 21 | this.store = store; 22 | // The custom element 23 | this.container = document.getElementById('fileTree'); 24 | 25 | // Listen for events from : 26 | this.container.addEventListener('selection-changed', (evt) => { 27 | // The web component has new selectedPaths 28 | this.store.dispatch(async (draft) => { 29 | draft.selectedPaths = new Set(evt.detail.selectedPaths); 30 | }).then(() => { 31 | this.store.dispatch(actions.updateStats()); 32 | }); 33 | }); 34 | 35 | this.container.addEventListener('expansion-changed', (evt) => { 36 | this.store.dispatch(async (draft) => { 37 | draft.expandedNodes = new Set(evt.detail.expandedPaths); 38 | }); 39 | }); 40 | 41 | // Set up the top-level UI buttons 42 | document.getElementById('expandAllButton').addEventListener('click', () => { 43 | this.expandAll(); 44 | }); 45 | document.getElementById('collapseAllButton').addEventListener('click', () => { 46 | this.collapseAll(); 47 | }); 48 | document.getElementById('selectAllButton').addEventListener('click', () => { 49 | this.selectAll(); 50 | }); 51 | document.getElementById('deselectAllButton').addEventListener('click', () => { 52 | this.deselectAll(); 53 | }); 54 | document.getElementById('clearButton').addEventListener('click', () => { 55 | this.clearAll(); 56 | }); 57 | 58 | // Copy selected content 59 | document.getElementById('copyButton').addEventListener('click', () => { 60 | this.copyToClipboard(); 61 | }); 62 | 63 | // Directory input 64 | document.getElementById('directoryInput').addEventListener('change', (e) => { 65 | const files = Array.from(e.target.files || []); 66 | this.handleFileSelect(files); 67 | e.target.value = ''; // reset 68 | }); 69 | } 70 | 71 | /** 72 | * handleFileSelect - scans the user-chosen files, filters out ignored ones, 73 | * figures out text vs binary, builds a root object, loads file content, etc. 74 | */ 75 | async handleFileSelect(files) { 76 | // Filter out ignored directories/files 77 | files = files.filter((file) => { 78 | const parts = file.webkitRelativePath.split('/'); 79 | // skip if any directory is ignored 80 | if (['node_modules','venv','.git','__pycache__','.idea','.vscode'].some((dir) => parts.includes(dir))) { 81 | return false; 82 | } 83 | // skip if file matches an ignored filename or extension 84 | if ([ 85 | '.DS_Store','Thumbs.db','.env','.pyc','.jpg','.jpeg','.png','.gif', 86 | '.mp4','.mov','.avi','.webp','.mkv','.wmv','.flv','.svg','.zip','.tar','.gz', 87 | '.rar','.exe','.bin','.iso','.dll','.psd','.ai','.eps','.tiff','.woff', 88 | '.woff2','.ttf','.otf','.flac','.m4a','.aac','.mov','.3gp' 89 | ].some((ignored) => { 90 | if (ignored.startsWith('.')) { 91 | return file.name.toLowerCase().endsWith(ignored.toLowerCase()); 92 | } 93 | return file.name === ignored; 94 | })) { 95 | return false; 96 | } 97 | return true; 98 | }); 99 | 100 | if (!files.length) return; 101 | 102 | // Decide which are text 103 | const fileTypeMap = new Map(); 104 | for (const file of files) { 105 | const lower = file.name.toLowerCase(); 106 | if ( 107 | [ 108 | '.txt','.md','.markdown','.json','.js','.ts','.jsx','.tsx','.css','.scss','.sass', 109 | '.less','.html','.htm','.xml','.yaml','.yml','.ini','.conf','.cfg','.config','.py', 110 | '.rb','.php','.java','.c','.cpp','.h','.hpp','.cs','.go','.rs','.swift','.kt','.kts', 111 | '.sh','.bash','.zsh','.fish','.sql','.graphql','.vue','.svelte','.astro','.env.example', 112 | '.gitignore','.dockerignore','.editorconfig','.eslintrc','.prettierrc','.babelrc','LICENSE', 113 | 'README','CHANGELOG','TODO','.csv','.tsv' 114 | ].some((ext) => lower.endsWith(ext)) || 115 | isSpreadsheet(file.name) || isPDF(file.name) 116 | ) { 117 | fileTypeMap.set(file.webkitRelativePath, true); 118 | } else { 119 | const textLikely = await isTextLikely(file); 120 | fileTypeMap.set(file.webkitRelativePath, textLikely); 121 | } 122 | } 123 | 124 | // Build root object 125 | const root = this.buildFileTree(files, fileTypeMap); 126 | 127 | // Save to store 128 | await this.store.dispatch(actions.setRoot(root)); 129 | 130 | // Then load contents 131 | for (const file of files) { 132 | if (!fileTypeMap.get(file.webkitRelativePath)) continue; 133 | let text = ''; 134 | if (isSpreadsheet(file.name)) { 135 | text = await parseSpreadsheetFile(file); 136 | } else if (isPDF(file.name)) { 137 | text = await parsePDFFile(file); 138 | } else { 139 | text = await file.text(); 140 | } 141 | await this.store.dispatch(actions.setFileContents(file.webkitRelativePath, text)); 142 | } 143 | 144 | await this.store.dispatch(actions.updateStats()); 145 | } 146 | 147 | buildFileTree(files, fileTypeMap) { 148 | if (!files.length) return null; 149 | const basePath = files[0].webkitRelativePath.split('/')[0]; 150 | const root = { 151 | name: basePath, 152 | path: basePath, 153 | isDir: true, 154 | children: [], 155 | }; 156 | 157 | files.forEach((file) => { 158 | const parts = file.webkitRelativePath.split('/'); 159 | let current = root; 160 | parts.forEach((part, idx) => { 161 | if (idx === 0) return; // skip the top-level folder 162 | const pathSoFar = parts.slice(0, idx + 1).join('/'); 163 | if (idx === parts.length - 1) { 164 | // file 165 | current.children.push({ 166 | name: part, 167 | path: pathSoFar, 168 | isDir: false, 169 | size: file.size, 170 | isTextFile: fileTypeMap.get(file.webkitRelativePath), 171 | }); 172 | } else { 173 | // directory 174 | let childDir = current.children.find((c) => c.name === part && c.isDir); 175 | if (!childDir) { 176 | childDir = { 177 | name: part, 178 | path: pathSoFar, 179 | isDir: true, 180 | children: [], 181 | }; 182 | current.children.push(childDir); 183 | } 184 | current = childDir; 185 | } 186 | }); 187 | }); 188 | return root; 189 | } 190 | 191 | /** 192 | * Called whenever store state changes. 193 | * We pass the new data into . 194 | */ 195 | handleStateChange(state) { 196 | // If no root, do nothing 197 | if (!state.root) { 198 | this.container.data = null; 199 | return; 200 | } 201 | // re-bind data 202 | this.container.data = state.root; 203 | // these can remain sets 204 | this.container.selectedPaths = state.selectedPaths; 205 | this.container.expandedPaths = state.expandedNodes; 206 | 207 | // Also update the "Selected Files" panel, token stats, etc. 208 | this.updateUI(state); 209 | } 210 | 211 | updateUI(state) { 212 | // Show stats 213 | document.getElementById('selectedCount').textContent = state.stats.selectedCount; 214 | document.getElementById('estimatedTokens').textContent = state.stats.totalTokens; 215 | 216 | // Show the selected files in the
 area
217 |     const lines = [];
218 |     // Add an ASCII tree of selected only
219 |     lines.push(`\n${this.generateAsciiTree(state.root, state.selectedPaths)}\n`);
220 |     // Then each selected file as 
221 |     for (const path of state.selectedPaths) {
222 |       const content = state.fileContents[path];
223 |       if (content) {
224 |         lines.push(`\n${content}\n`);
225 |       }
226 |     }
227 |     document.getElementById('selectedFilesContent').textContent = lines.join('\n\n');
228 |   }
229 | 
230 |   /**
231 |    * Simple ASCII-tree for selected items
232 |    */
233 |   generateAsciiTree(node, selectedPaths, prefix = '', isLast = true) {
234 |     const nodeSelected = selectedPaths.has(node.path);
235 |     const childSelected = node.children?.some(
236 |       (ch) => selectedPaths.has(ch.path) || (ch.isDir && this.anyChildSelected(ch, selectedPaths))
237 |     );
238 |     if (!nodeSelected && !childSelected) {
239 |       return '';
240 |     }
241 |     const connector = isLast ? '└── ' : '├── ';
242 |     const childPrefix = isLast ? '    ' : '│   ';
243 |     let result = prefix + connector + node.name + '\n';
244 | 
245 |     if (node.children) {
246 |       const visible = node.children.filter(
247 |         (ch) => selectedPaths.has(ch.path) || (ch.isDir && this.anyChildSelected(ch, selectedPaths))
248 |       );
249 |       visible.forEach((child, index) => {
250 |         const lastChild = index === visible.length - 1;
251 |         result += this.generateAsciiTree(child, selectedPaths, prefix + childPrefix, lastChild);
252 |       });
253 |     }
254 |     return result;
255 |   }
256 |   anyChildSelected(node, selectedPaths) {
257 |     if (!node.children) return false;
258 |     return node.children.some((ch) => selectedPaths.has(ch.path) || (ch.isDir && this.anyChildSelected(ch, selectedPaths)));
259 |   }
260 | 
261 |   /**
262 |    * Expand / collapse / select / etc. all files
263 |    */
264 |   async expandAll() {
265 |     const state = this.store.getState();
266 |     if (!state.root) return;
267 |     const pathsToExpand = [];
268 |     this.walk(state.root, (node) => {
269 |       if (node.isDir) pathsToExpand.push(node.path);
270 |     });
271 |     await this.store.dispatch(actions.bulkSetExpanded(pathsToExpand, []));
272 |   }
273 | 
274 |   async collapseAll() {
275 |     const state = this.store.getState();
276 |     if (!state.root) return;
277 |     const pathsToCollapse = [];
278 |     this.walk(state.root, (node) => {
279 |       if (node.isDir) pathsToCollapse.push(node.path);
280 |     });
281 |     await this.store.dispatch(actions.bulkSetExpanded([], pathsToCollapse));
282 |   }
283 | 
284 |   async selectAll() {
285 |     const state = this.store.getState();
286 |     if (!state.root) return;
287 |     const pathsToSelect = [];
288 |     this.walk(state.root, (node) => {
289 |       if (!node.isDir && node.isTextFile) {
290 |         pathsToSelect.push(node.path);
291 |       }
292 |     });
293 |     await this.store.dispatch(actions.bulkSelectPaths(pathsToSelect, []));
294 |     await this.store.dispatch(actions.updateStats());
295 |   }
296 | 
297 |   async deselectAll() {
298 |     const state = this.store.getState();
299 |     if (!state.root) return;
300 |     const pathsToDeselect = [];
301 |     this.walk(state.root, (node) => {
302 |       if (!node.isDir && node.isTextFile) {
303 |         pathsToDeselect.push(node.path);
304 |       }
305 |     });
306 |     await this.store.dispatch(actions.bulkSelectPaths([], pathsToDeselect));
307 |     await this.store.dispatch(actions.updateStats());
308 |   }
309 | 
310 |   async clearAll() {
311 |     await this.store.dispatch(actions.reset());
312 |     document.getElementById('directoryInput').value = '';
313 |     // the UI will update once store triggers handleStateChange
314 |   }
315 | 
316 |   async copyToClipboard() {
317 |     const text = document.getElementById('selectedFilesContent').textContent || '';
318 |     await navigator.clipboard.writeText(text);
319 |     const btn = document.getElementById('copyButton');
320 |     btn.textContent = 'Copied!';
321 |     setTimeout(() => {
322 |       btn.textContent = 'Copy to Clipboard';
323 |     }, 1500);
324 |   }
325 | 
326 |   walk(node, fn) {
327 |     fn(node);
328 |     if (node.children) {
329 |       for (const child of node.children) {
330 |         this.walk(child, fn);
331 |       }
332 |     }
333 |   }
334 | }
335 | 


--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
  1 | 
  2 | 
  3 |   
  4 |     
  5 |     
  6 |     
  7 |     
  8 |     
  9 |     
 10 | 
 11 |     
 12 |     
 13 |     
 21 | 
 22 |     
 23 |     
 27 |     
 28 |     
 32 | 
 33 |     
 34 |     
 35 | 
 36 |     
 37 |     Convert Directory to LLM XML prompt
 38 | 
 39 |     
322 |     
326 |     
327 |     
331 |   
332 |   
333 |     
334 |
335 | ⚠ This tool is optimized for larger screens. Mobile functionality is basic. 336 |
337 |
338 |
339 |
340 |
341 | 342 | 350 | Help 351 |
352 | 355 | 358 |
359 |
360 | 363 | 366 |
367 |
368 |
369 | 372 |
373 |
374 |
375 | 376 |
377 | 378 | 379 | 380 |
381 |
382 |

Selected Files

383 |
384 | 385 | 386 |
387 |
388 |

389 |           
390 |
391 | 392 |
393 | Selected Files: 0 | Estimated Tokens: 394 | 0 395 |
396 |
397 |
398 | 399 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | -------------------------------------------------------------------------------- /static/js/tree-view.js: -------------------------------------------------------------------------------- 1 | class TreeView extends HTMLElement { 2 | constructor() { 3 | super(); 4 | this.attachShadow({ mode: 'open' }); 5 | 6 | // Add ARIA attributes to root 7 | this.setAttribute('role', 'tree'); 8 | this.setAttribute('aria-multiselectable', 'true'); 9 | this.setAttribute('aria-label', 'File tree'); // Or use aria-labelledby 10 | 11 | // Initialize state 12 | this._data = null; 13 | this._selectedPaths = new Set(); 14 | this._expandedPaths = new Set(); 15 | this._focusedPath = null; 16 | this._searchString = ''; 17 | this._searchTimeout = null; 18 | 19 | // Bind methods 20 | this._handleClick = this._handleClick.bind(this); 21 | this._handleKeyDown = this._handleKeyDown.bind(this); 22 | 23 | // Create and attach styles 24 | const style = document.createElement('style'); 25 | style.textContent = ` 26 | :host { 27 | display: block; 28 | font-family: sans-serif; 29 | height: 100%; 30 | min-height: 0; 31 | position: relative; 32 | overflow: hidden; 33 | } 34 | :host > div { 35 | position: absolute; 36 | top: 0; 37 | left: 0; 38 | right: 0; 39 | bottom: 0; 40 | overflow-y: auto; 41 | overflow-x: hidden; 42 | } 43 | .upload-message { 44 | text-align: center; 45 | color: #6b7280; 46 | padding: 2rem; 47 | } 48 | .tree-node { 49 | display: block; 50 | margin: 0; 51 | padding: 0px 0px; 52 | } 53 | .node-content { 54 | border-bottom: 1px solid #e5e7eb; 55 | height: 32px; 56 | display: flex; 57 | align-items: center; 58 | cursor: pointer; 59 | padding: 6px 8px; 60 | border-radius: 4px; 61 | transition: all 0.2s ease; 62 | } 63 | .node-content:hover { 64 | background-color: #e8f0fe; 65 | } 66 | .checkbox { 67 | margin-right: 8px; 68 | cursor: pointer; 69 | width: 16px; 70 | height: 16px; 71 | } 72 | .folder-icon, 73 | .file-icon { 74 | margin-right: 8px; 75 | font-size: 1.1em; 76 | width: 20px; 77 | text-align: center; 78 | } 79 | .folder-icon { 80 | color: #c19a6b; 81 | } 82 | .file-icon { 83 | color: #555; 84 | } 85 | .node-content:focus { 86 | outline: 2px solid #2563eb; 87 | background-color: #e8f0fe; 88 | } 89 | .node-content[aria-selected="true"] { 90 | background-color: #e8f0fe; 91 | } 92 | `; 93 | this.shadowRoot.appendChild(style); 94 | 95 | // Create main container 96 | this._container = document.createElement('div'); 97 | this.shadowRoot.appendChild(this._container); 98 | 99 | // Add event delegation 100 | this._container.addEventListener('click', this._handleClick); 101 | this._container.addEventListener('keydown', this._handleKeyDown); 102 | } 103 | 104 | // Getters/Setters for properties 105 | get data() { 106 | return this._data; 107 | } 108 | 109 | set data(value) { 110 | this._data = value; 111 | this._render(); 112 | } 113 | 114 | get selectedPaths() { 115 | return this._selectedPaths; 116 | } 117 | 118 | set selectedPaths(value) { 119 | this._selectedPaths = new Set(value); 120 | this._render(); 121 | } 122 | 123 | get expandedPaths() { 124 | return this._expandedPaths; 125 | } 126 | 127 | set expandedPaths(value) { 128 | this._expandedPaths = new Set(value); 129 | this._render(); 130 | } 131 | 132 | // Main render method 133 | _render() { 134 | if (!this._data) { 135 | this._container.innerHTML = 136 | '
Select a directory to view its contents
'; 137 | return; 138 | } 139 | 140 | const fragment = document.createDocumentFragment(); 141 | this._renderNode(this._data, 0, fragment); 142 | 143 | // Clear and update container 144 | this._container.innerHTML = ''; 145 | this._container.appendChild(fragment); 146 | } 147 | 148 | // Recursive node rendering 149 | _renderNode(node, level, parent) { 150 | const isExpanded = this._expandedPaths.has(node.path); 151 | const { checked, indeterminate } = this._computeCheckboxState(node); 152 | 153 | // Create node container 154 | const nodeDiv = document.createElement('div'); 155 | nodeDiv.className = 'tree-node'; 156 | 157 | // Create node content 158 | const contentDiv = document.createElement('div'); 159 | contentDiv.className = 'node-content'; 160 | contentDiv.style.marginLeft = `${level * 20}px`; 161 | contentDiv.dataset.path = node.path; 162 | contentDiv.dataset.type = node.isDir ? 'dir' : 'file'; 163 | 164 | // Add proper ARIA roles and states 165 | contentDiv.setAttribute('role', 'treeitem'); 166 | contentDiv.setAttribute('aria-level', level + 1); 167 | 168 | // Add checkbox if needed 169 | if (node.isDir || node.isTextFile) { 170 | const checkbox = document.createElement('input'); 171 | checkbox.type = 'checkbox'; 172 | checkbox.className = 'checkbox'; 173 | checkbox.checked = checked; 174 | checkbox.indeterminate = indeterminate; 175 | contentDiv.appendChild(checkbox); 176 | contentDiv.setAttribute('aria-selected', 177 | this._selectedPaths.has(node.path) ? 'true' : 'false'); 178 | } 179 | 180 | // Add icon 181 | const icon = document.createElement('span'); 182 | icon.className = 'folder-icon'; 183 | icon.textContent = node.isDir ? (isExpanded ? '📂' : '📁') : node.isTextFile ? '📄' : '📦'; 184 | contentDiv.appendChild(icon); 185 | 186 | // Add name and size 187 | const nameSpan = document.createElement('span'); 188 | nameSpan.textContent = node.name + (node.size ? ` (${this._formatSize(node.size)})` : ''); 189 | contentDiv.appendChild(nameSpan); 190 | 191 | nodeDiv.appendChild(contentDiv); 192 | 193 | // Render children if expanded directory 194 | if (node.isDir && isExpanded && node.children) { 195 | const sortedChildren = [...node.children].sort((a, b) => { 196 | if (a.isDir === b.isDir) return a.name.localeCompare(b.name); 197 | return b.isDir - a.isDir; 198 | }); 199 | 200 | // Create group container for children 201 | const groupDiv = document.createElement('div'); 202 | groupDiv.setAttribute('role', 'group'); 203 | nodeDiv.appendChild(groupDiv); 204 | 205 | // Move children rendering into group 206 | sortedChildren.forEach(child => { 207 | this._renderNode(child, level + 1, groupDiv); 208 | }); 209 | } 210 | 211 | parent.appendChild(nodeDiv); 212 | 213 | contentDiv.tabIndex = 0; 214 | if (node.path === this._focusedPath) { 215 | contentDiv.focus(); 216 | } 217 | } 218 | 219 | // Event delegation handler 220 | _handleClick(event) { 221 | const content = event.target.closest('.node-content'); 222 | if (!content) return; 223 | 224 | const path = content.dataset.path; 225 | const node = this._findNodeByPath(path); 226 | if (!node) return; 227 | 228 | if (event.target.type === 'checkbox') { 229 | this._handleCheckboxClick(event, node); 230 | } else if (node.isDir) { 231 | this._toggleExpanded(path); 232 | } 233 | } 234 | 235 | // Helper to find node by path 236 | _findNodeByPath(path) { 237 | let result = null; 238 | this._walk(this._data, (node) => { 239 | if (node.path === path) { 240 | result = node; 241 | } 242 | }); 243 | return result; 244 | } 245 | 246 | // Checkbox click handler 247 | _handleCheckboxClick(event, node) { 248 | event.stopPropagation(); 249 | 250 | if (node.isDir) { 251 | const { total, selected } = this._countTextFiles(node); 252 | const fullySelected = total > 0 && total === selected; 253 | if (fullySelected) { 254 | this._unselectSubtree(node); 255 | } else { 256 | this._selectSubtree(node); 257 | } 258 | } else if (node.isTextFile) { 259 | if (this._selectedPaths.has(node.path)) { 260 | this._selectedPaths.delete(node.path); 261 | } else { 262 | this._selectedPaths.add(node.path); 263 | } 264 | } 265 | 266 | this.dispatchEvent( 267 | new CustomEvent('selection-changed', { 268 | detail: { selectedPaths: this._selectedPaths }, 269 | bubbles: true, 270 | composed: true, 271 | }) 272 | ); 273 | 274 | this._render(); 275 | } 276 | 277 | // Toggle directory expansion 278 | _toggleExpanded(path) { 279 | if (this._expandedPaths.has(path)) { 280 | this._expandedPaths.delete(path); 281 | } else { 282 | this._expandedPaths.add(path); 283 | } 284 | 285 | this.dispatchEvent( 286 | new CustomEvent('expansion-changed', { 287 | detail: { expandedPaths: this._expandedPaths }, 288 | bubbles: true, 289 | composed: true, 290 | }) 291 | ); 292 | 293 | this._render(); 294 | } 295 | 296 | // Helper methods (kept from original) 297 | _computeCheckboxState(node) { 298 | if (!node.isDir && !node.isTextFile) { 299 | return { checked: false, indeterminate: false }; 300 | } 301 | 302 | if (!node.isDir) { 303 | return { 304 | checked: this._selectedPaths.has(node.path), 305 | indeterminate: false, 306 | }; 307 | } 308 | 309 | let total = 0; 310 | let selected = 0; 311 | this._walk(node, (child) => { 312 | if (!child.isDir && child.isTextFile) { 313 | total++; 314 | if (this._selectedPaths.has(child.path)) { 315 | selected++; 316 | } 317 | } 318 | }); 319 | 320 | if (total === 0) return { checked: false, indeterminate: false }; 321 | if (selected === 0) return { checked: false, indeterminate: false }; 322 | if (selected === total) return { checked: true, indeterminate: false }; 323 | return { checked: false, indeterminate: true }; 324 | } 325 | 326 | _countTextFiles(node) { 327 | let total = 0; 328 | let selected = 0; 329 | this._walk(node, (child) => { 330 | if (!child.isDir && child.isTextFile) { 331 | total++; 332 | if (this._selectedPaths.has(child.path)) { 333 | selected++; 334 | } 335 | } 336 | }); 337 | return { total, selected }; 338 | } 339 | 340 | _selectSubtree(node) { 341 | this._walk(node, (child) => { 342 | if (!child.isDir && child.isTextFile) { 343 | this._selectedPaths.add(child.path); 344 | } 345 | }); 346 | } 347 | 348 | _unselectSubtree(node) { 349 | this._walk(node, (child) => { 350 | if (!child.isDir && child.isTextFile) { 351 | this._selectedPaths.delete(child.path); 352 | } 353 | }); 354 | } 355 | 356 | _walk(node, fn) { 357 | fn(node); 358 | if (node.children) { 359 | node.children.forEach((child) => this._walk(child, fn)); 360 | } 361 | } 362 | 363 | _formatSize(bytes) { 364 | const units = ['B', 'KB', 'MB', 'GB']; 365 | let size = bytes; 366 | let idx = 0; 367 | while (size >= 1024 && idx < units.length - 1) { 368 | size /= 1024; 369 | idx++; 370 | } 371 | return `${size.toFixed(1)} ${units[idx]}`; 372 | } 373 | 374 | // Add new keyboard handler 375 | _handleKeyDown(event) { 376 | const focused = this._findNodeByPath(this._focusedPath); 377 | if (!focused) return; 378 | 379 | switch (event.key) { 380 | case 'ArrowRight': 381 | if (focused.isDir && !this._expandedPaths.has(focused.path)) { 382 | this._toggleExpanded(focused.path); 383 | } else if (focused.isDir && focused.children?.length) { 384 | this._focusNode(focused.children[0].path); 385 | } 386 | break; 387 | 388 | case 'ArrowLeft': 389 | if (focused.isDir && this._expandedPaths.has(focused.path)) { 390 | this._toggleExpanded(focused.path); 391 | } else { 392 | const parent = this._findParentNode(focused.path); 393 | if (parent) this._focusNode(parent.path); 394 | } 395 | break; 396 | 397 | case 'ArrowDown': 398 | const next = this._findNextNode(focused.path); 399 | if (next) this._focusNode(next.path); 400 | break; 401 | 402 | case 'ArrowUp': 403 | const prev = this._findPrevNode(focused.path); 404 | if (prev) this._focusNode(prev.path); 405 | break; 406 | 407 | case 'Home': 408 | const first = this._findFirstNode(); 409 | if (first) this._focusNode(first.path); 410 | break; 411 | 412 | case 'End': 413 | const last = this._findLastNode(); 414 | if (last) this._focusNode(last.path); 415 | break; 416 | 417 | case ' ': 418 | if (focused.isTextFile || focused.isDir) { 419 | this._handleCheckboxClick({ stopPropagation: () => {} }, focused); 420 | } 421 | break; 422 | 423 | case 'Enter': 424 | if (focused.isDir) { 425 | this._toggleExpanded(focused.path); 426 | } 427 | break; 428 | 429 | default: 430 | if (event.key.length === 1) { 431 | this._handleTypeAhead(event.key); 432 | } 433 | } 434 | } 435 | 436 | // Add new helper methods 437 | _focusNode(path) { 438 | this._focusedPath = path; 439 | this._render(); 440 | } 441 | 442 | _handleTypeAhead(char) { 443 | clearTimeout(this._searchTimeout); 444 | this._searchString += char.toLowerCase(); 445 | 446 | const match = this._findNodeByPrefix(this._searchString); 447 | if (match) { 448 | this._focusNode(match.path); 449 | } 450 | 451 | this._searchTimeout = setTimeout(() => { 452 | this._searchString = ''; 453 | }, 500); 454 | } 455 | } 456 | 457 | // Register the component 458 | customElements.define('tree-view', TreeView); 459 | -------------------------------------------------------------------------------- /lib/localforage.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | localForage -- Offline Storage, Improved 3 | Version 1.10.0 4 | https://localforage.github.io/localForage 5 | (c) 2013-2017 Mozilla, Apache License 2.0 6 | */ 7 | !function(a){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=a();else if("function"==typeof define&&define.amd)define([],a);else{var b;b="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this,b.localforage=a()}}(function(){return function a(b,c,d){function e(g,h){if(!c[g]){if(!b[g]){var i="function"==typeof require&&require;if(!h&&i)return i(g,!0);if(f)return f(g,!0);var j=new Error("Cannot find module '"+g+"'");throw j.code="MODULE_NOT_FOUND",j}var k=c[g]={exports:{}};b[g][0].call(k.exports,function(a){var c=b[g][1][a];return e(c||a)},k,k.exports,a,b,c,d)}return c[g].exports}for(var f="function"==typeof require&&require,g=0;g=43)}}).catch(function(){return!1})}function n(a){return"boolean"==typeof xa?va.resolve(xa):m(a).then(function(a){return xa=a})}function o(a){var b=ya[a.name],c={};c.promise=new va(function(a,b){c.resolve=a,c.reject=b}),b.deferredOperations.push(c),b.dbReady?b.dbReady=b.dbReady.then(function(){return c.promise}):b.dbReady=c.promise}function p(a){var b=ya[a.name],c=b.deferredOperations.pop();if(c)return c.resolve(),c.promise}function q(a,b){var c=ya[a.name],d=c.deferredOperations.pop();if(d)return d.reject(b),d.promise}function r(a,b){return new va(function(c,d){if(ya[a.name]=ya[a.name]||B(),a.db){if(!b)return c(a.db);o(a),a.db.close()}var e=[a.name];b&&e.push(a.version);var f=ua.open.apply(ua,e);b&&(f.onupgradeneeded=function(b){var c=f.result;try{c.createObjectStore(a.storeName),b.oldVersion<=1&&c.createObjectStore(wa)}catch(c){if("ConstraintError"!==c.name)throw c;console.warn('The database "'+a.name+'" has been upgraded from version '+b.oldVersion+" to version "+b.newVersion+', but the storage "'+a.storeName+'" already exists.')}}),f.onerror=function(a){a.preventDefault(),d(f.error)},f.onsuccess=function(){var b=f.result;b.onversionchange=function(a){a.target.close()},c(b),p(a)}})}function s(a){return r(a,!1)}function t(a){return r(a,!0)}function u(a,b){if(!a.db)return!0;var c=!a.db.objectStoreNames.contains(a.storeName),d=a.versiona.db.version;if(d&&(a.version!==b&&console.warn('The database "'+a.name+"\" can't be downgraded from version "+a.db.version+" to version "+a.version+"."),a.version=a.db.version),e||c){if(c){var f=a.db.version+1;f>a.version&&(a.version=f)}return!0}return!1}function v(a){return new va(function(b,c){var d=new FileReader;d.onerror=c,d.onloadend=function(c){var d=btoa(c.target.result||"");b({__local_forage_encoded_blob:!0,data:d,type:a.type})},d.readAsBinaryString(a)})}function w(a){return g([l(atob(a.data))],{type:a.type})}function x(a){return a&&a.__local_forage_encoded_blob}function y(a){var b=this,c=b._initReady().then(function(){var a=ya[b._dbInfo.name];if(a&&a.dbReady)return a.dbReady});return i(c,a,a),c}function z(a){o(a);for(var b=ya[a.name],c=b.forages,d=0;d0&&(!a.db||"InvalidStateError"===e.name||"NotFoundError"===e.name))return va.resolve().then(function(){if(!a.db||"NotFoundError"===e.name&&!a.db.objectStoreNames.contains(a.storeName)&&a.version<=a.db.version)return a.db&&(a.version=a.db.version+1),t(a)}).then(function(){return z(a).then(function(){A(a,b,c,d-1)})}).catch(c);c(e)}}function B(){return{forages:[],db:null,dbReady:null,deferredOperations:[]}}function C(a){function b(){return va.resolve()}var c=this,d={db:null};if(a)for(var e in a)d[e]=a[e];var f=ya[d.name];f||(f=B(),ya[d.name]=f),f.forages.push(c),c._initReady||(c._initReady=c.ready,c.ready=y);for(var g=[],h=0;h>4,k[i++]=(15&d)<<4|e>>2,k[i++]=(3&e)<<6|63&f;return j}function O(a){var b,c=new Uint8Array(a),d="";for(b=0;b>2],d+=Da[(3&c[b])<<4|c[b+1]>>4],d+=Da[(15&c[b+1])<<2|c[b+2]>>6],d+=Da[63&c[b+2]];return c.length%3==2?d=d.substring(0,d.length-1)+"=":c.length%3==1&&(d=d.substring(0,d.length-2)+"=="),d}function P(a,b){var c="";if(a&&(c=Ua.call(a)),a&&("[object ArrayBuffer]"===c||a.buffer&&"[object ArrayBuffer]"===Ua.call(a.buffer))){var d,e=Ga;a instanceof ArrayBuffer?(d=a,e+=Ia):(d=a.buffer,"[object Int8Array]"===c?e+=Ka:"[object Uint8Array]"===c?e+=La:"[object Uint8ClampedArray]"===c?e+=Ma:"[object Int16Array]"===c?e+=Na:"[object Uint16Array]"===c?e+=Pa:"[object Int32Array]"===c?e+=Oa:"[object Uint32Array]"===c?e+=Qa:"[object Float32Array]"===c?e+=Ra:"[object Float64Array]"===c?e+=Sa:b(new Error("Failed to get type for BinaryArray"))),b(e+O(d))}else if("[object Blob]"===c){var f=new FileReader;f.onload=function(){var c=Ea+a.type+"~"+O(this.result);b(Ga+Ja+c)},f.readAsArrayBuffer(a)}else try{b(JSON.stringify(a))}catch(c){console.error("Couldn't convert value into a JSON string: ",a),b(null,c)}}function Q(a){if(a.substring(0,Ha)!==Ga)return JSON.parse(a);var b,c=a.substring(Ta),d=a.substring(Ha,Ta);if(d===Ja&&Fa.test(c)){var e=c.match(Fa);b=e[1],c=c.substring(e[0].length)}var f=N(c);switch(d){case Ia:return f;case Ja:return g([f],{type:b});case Ka:return new Int8Array(f);case La:return new Uint8Array(f);case Ma:return new Uint8ClampedArray(f);case Na:return new Int16Array(f);case Pa:return new Uint16Array(f);case Oa:return new Int32Array(f);case Qa:return new Uint32Array(f);case Ra:return new Float32Array(f);case Sa:return new Float64Array(f);default:throw new Error("Unkown type: "+d)}}function R(a,b,c,d){a.executeSql("CREATE TABLE IF NOT EXISTS "+b.storeName+" (id INTEGER PRIMARY KEY, key unique, value)",[],c,d)}function S(a){var b=this,c={db:null};if(a)for(var d in a)c[d]="string"!=typeof a[d]?a[d].toString():a[d];var e=new va(function(a,d){try{c.db=openDatabase(c.name,String(c.version),c.description,c.size)}catch(a){return d(a)}c.db.transaction(function(e){R(e,c,function(){b._dbInfo=c,a()},function(a,b){d(b)})},d)});return c.serializer=Va,e}function T(a,b,c,d,e,f){a.executeSql(c,d,e,function(a,g){g.code===g.SYNTAX_ERR?a.executeSql("SELECT name FROM sqlite_master WHERE type='table' AND name = ?",[b.storeName],function(a,h){h.rows.length?f(a,g):R(a,b,function(){a.executeSql(c,d,e,f)},f)},f):f(a,g)},f)}function U(a,b){var c=this;a=j(a);var d=new va(function(b,d){c.ready().then(function(){var e=c._dbInfo;e.db.transaction(function(c){T(c,e,"SELECT * FROM "+e.storeName+" WHERE key = ? LIMIT 1",[a],function(a,c){var d=c.rows.length?c.rows.item(0).value:null;d&&(d=e.serializer.deserialize(d)),b(d)},function(a,b){d(b)})})}).catch(d)});return h(d,b),d}function V(a,b){var c=this,d=new va(function(b,d){c.ready().then(function(){var e=c._dbInfo;e.db.transaction(function(c){T(c,e,"SELECT * FROM "+e.storeName,[],function(c,d){for(var f=d.rows,g=f.length,h=0;h0)return void f(W.apply(e,[a,h,c,d-1]));g(b)}})})}).catch(g)});return h(f,c),f}function X(a,b,c){return W.apply(this,[a,b,c,1])}function Y(a,b){var c=this;a=j(a);var d=new va(function(b,d){c.ready().then(function(){var e=c._dbInfo;e.db.transaction(function(c){T(c,e,"DELETE FROM "+e.storeName+" WHERE key = ?",[a],function(){b()},function(a,b){d(b)})})}).catch(d)});return h(d,b),d}function Z(a){var b=this,c=new va(function(a,c){b.ready().then(function(){var d=b._dbInfo;d.db.transaction(function(b){T(b,d,"DELETE FROM "+d.storeName,[],function(){a()},function(a,b){c(b)})})}).catch(c)});return h(c,a),c}function $(a){var b=this,c=new va(function(a,c){b.ready().then(function(){var d=b._dbInfo;d.db.transaction(function(b){T(b,d,"SELECT COUNT(key) as c FROM "+d.storeName,[],function(b,c){var d=c.rows.item(0).c;a(d)},function(a,b){c(b)})})}).catch(c)});return h(c,a),c}function _(a,b){var c=this,d=new va(function(b,d){c.ready().then(function(){var e=c._dbInfo;e.db.transaction(function(c){T(c,e,"SELECT key FROM "+e.storeName+" WHERE id = ? LIMIT 1",[a+1],function(a,c){var d=c.rows.length?c.rows.item(0).key:null;b(d)},function(a,b){d(b)})})}).catch(d)});return h(d,b),d}function aa(a){var b=this,c=new va(function(a,c){b.ready().then(function(){var d=b._dbInfo;d.db.transaction(function(b){T(b,d,"SELECT key FROM "+d.storeName,[],function(b,c){for(var d=[],e=0;e '__WebKitDatabaseInfoTable__'",[],function(c,d){for(var e=[],f=0;f0}function ha(a){var b=this,c={};if(a)for(var d in a)c[d]=a[d];return c.keyPrefix=ea(a,b._defaultConfig),ga()?(b._dbInfo=c,c.serializer=Va,va.resolve()):va.reject()}function ia(a){var b=this,c=b.ready().then(function(){for(var a=b._dbInfo.keyPrefix,c=localStorage.length-1;c>=0;c--){var d=localStorage.key(c);0===d.indexOf(a)&&localStorage.removeItem(d)}});return h(c,a),c}function ja(a,b){var c=this;a=j(a);var d=c.ready().then(function(){var b=c._dbInfo,d=localStorage.getItem(b.keyPrefix+a);return d&&(d=b.serializer.deserialize(d)),d});return h(d,b),d}function ka(a,b){var c=this,d=c.ready().then(function(){for(var b=c._dbInfo,d=b.keyPrefix,e=d.length,f=localStorage.length,g=1,h=0;h=0;b--){var c=localStorage.key(b);0===c.indexOf(a)&&localStorage.removeItem(c)}}):va.reject("Invalid arguments"),h(d,b),d}function ra(a,b){a[b]=function(){var c=arguments;return a.ready().then(function(){return a[b].apply(a,c)})}}function sa(){for(var a=1;a 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 | '
Select a directory to view its contents
'; 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 |
502 |
503 | ${ 504 | node.isTextFile !== false 505 | ? ` 506 | 513 | ` 514 | : '' 515 | } 516 | ${icon} 517 | ${node.name}${ 518 | node.size ? ` (${this.formatSize(node.size)})` : '' 519 | } 520 |
521 |
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(`\n${this.generateAsciiTree()}\n`); 735 | 736 | for (const path of state.selectedPaths) { 737 | const text = state.fileContents[path]; 738 | if (text) { 739 | content.push(`\n${text}\n`); 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 | }); 848 | --------------------------------------------------------------------------------