diff --git a/dev-setup.bat b/dev-setup.bat new file mode 100644 index 0000000..5189f30 --- /dev/null +++ b/dev-setup.bat @@ -0,0 +1,25 @@ +@echo off + +if not "%OS%"=="Windows_NT" ( + echo This script must be run on Windows only. + exit /b 1 +) + +set APP_NAME=ai-code-fusion +set APP_DATA=%APPDATA%\%APP_NAME% + +echo === Cleaning app settings === +if exist "%APP_DATA%" ( + echo Removing %APP_DATA%... + rmdir /s /q "%APP_DATA%" +) else ( + echo No settings found at %APP_DATA%, skipping. +) + +echo. +echo === Installing dependencies === +call npm install + +echo. +echo === Starting in dev mode === +call npm run dev diff --git a/dev-setup.sh b/dev-setup.sh new file mode 100755 index 0000000..03a3bc1 --- /dev/null +++ b/dev-setup.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# Detect OS and set app data path +case "$(uname -s)" in + Linux*) + APP_DATA="$HOME/.config/ai-code-fusion" + ;; + Darwin*) + APP_DATA="$HOME/Library/Application Support/ai-code-fusion" + ;; + *) + echo "Unsupported OS. Use dev-setup.bat on Windows." + exit 1 + ;; +esac + +echo "=== Cleaning app settings ===" +if [ -d "$APP_DATA" ]; then + echo "Removing $APP_DATA..." + rm -rf "$APP_DATA" +else + echo "No settings found at $APP_DATA, skipping." +fi + +echo "" +echo "=== Installing dependencies ===" +npm install + +echo "" +echo "=== Starting in dev mode ===" +npm run dev diff --git a/scripts/capture-ui-screenshot.js b/scripts/capture-ui-screenshot.js index 82595c8..875e302 100644 --- a/scripts/capture-ui-screenshot.js +++ b/scripts/capture-ui-screenshot.js @@ -330,15 +330,19 @@ const UI_SELECTORS = { secretFileEntry: `[title="${MOCK_SECRET_FILE_PATH}"]`, refreshFileListButton: 'button[title="Refresh the file list"]', fileTreeScrollContainer: '.file-tree .overflow-auto', + processSelectedFilesButton: '[data-testid="process-selected-files-button"]', }; async function setupMockElectronApi(page) { await page.addInitScript( ({ mockRootPath, mockConfig, mockDirectoryTree, mockFilteredDirectoryTree, fixedMtime }) => { + localStorage.clear(); + sessionStorage.clear(); localStorage.setItem('rootPath', mockRootPath); localStorage.setItem('configContent', mockConfig); const cloneTree = (treeItems) => JSON.parse(JSON.stringify(treeItems)); + const delay = (durationMs) => new Promise((resolve) => window.setTimeout(resolve, durationMs)); window.electronAPI = { getDefaultConfig: async () => mockConfig, @@ -368,11 +372,14 @@ async function setupMockElectronApi(page) { processedFiles: 0, }, }), - countFilesTokens: async (files) => { + countFilesTokens: async (options) => { + const filePaths = Array.isArray(options?.filePaths) ? options.filePaths : []; const results = {}; const stats = {}; - for (const filePath of files) { + await delay(450); + + for (const filePath of filePaths) { results[filePath] = 120; stats[filePath] = { mtime: fixedMtime, size: 1024 }; } @@ -495,6 +502,34 @@ async function captureAppStateScreenshots(page) { }); }); + await runStep('Verify process button shows selecting state during token counting', async () => { + await page.waitForFunction((selector) => { + const button = document.querySelector(selector); + if (!(button instanceof HTMLButtonElement)) { + return false; + } + + return ( + button.disabled && + /selecting files\.\.\./i.test(button.textContent || '') && + Boolean(button.querySelector('svg.animate-spin')) + ); + }, UI_SELECTORS.processSelectedFilesButton); + }); + + await runStep('Verify process button re-enables after token counting completes', async () => { + await page.waitForFunction((selector) => { + const button = document.querySelector(selector); + if (!(button instanceof HTMLButtonElement)) { + return false; + } + + const buttonLabel = button.textContent || ''; + const hasSpinner = Boolean(button.querySelector('svg.animate-spin')); + return !button.disabled && /process selected files/i.test(buttonLabel) && !hasSpinner; + }, UI_SELECTORS.processSelectedFilesButton); + }); + await runStep('Capture selected file screenshot', async () => { await page.screenshot({ path: SCREENSHOTS.sourceSelected, fullPage: true }); }); diff --git a/src/main/index.ts b/src/main/index.ts index 9225cb6..c62fb15 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,13 +5,20 @@ import yaml from 'yaml'; import { loadDefaultConfig } from '../utils/config-manager'; import { ContentProcessor } from '../utils/content-processor'; import { FileAnalyzer, isBinaryFile } from '../utils/file-analyzer'; -import { normalizePath, getRelativePath, shouldExclude } from '../utils/filter-utils'; +import { getRelativePath, shouldExclude } from '../utils/filter-utils'; import { GitignoreParser } from '../utils/gitignore-parser'; import { TokenCounter } from '../utils/token-counter'; +import { + normalizeExportFormat, + normalizeTokenCount, + toXmlNumericAttribute, + wrapXmlCdata, +} from '../utils/export-format'; import type { AnalyzeRepositoryOptions, AnalyzeRepositoryResult, ConfigObject, + CountFilesTokensOptions, CountFilesTokensResult, DirectoryTreeItem, FileInfo, @@ -28,6 +35,7 @@ const tokenCounter = new TokenCounter(); // Keep a global reference of the window object to avoid garbage collection let mainWindow: BrowserWindow | null = null; +let authorizedRootPath: string | null = null; const APP_ROOT = path.resolve(__dirname, '../../..'); const RENDERER_INDEX_PATH = path.join(APP_ROOT, 'src', 'renderer', 'index.html'); @@ -80,6 +88,10 @@ app.whenReady().then(() => { protocol.registerFileProtocol('assets', (request, callback) => { const url = request.url.replace('assets://', ''); const assetPath = path.normalize(path.join(PUBLIC_ASSETS_DIR, url)); + if (!isPathWithinRoot(PUBLIC_ASSETS_DIR, assetPath)) { + callback({ error: -6 }); + return; + } callback({ path: assetPath }); }); @@ -103,6 +115,19 @@ app.on('activate', () => { type FilterPatternBundle = string[] & { includePatterns?: string[]; includeExtensions?: string[] }; +const resolveAuthorizedPath = (candidatePath: string): string | null => { + if (!authorizedRootPath || !candidatePath) { + return null; + } + + const resolvedCandidatePath = path.resolve(candidatePath); + if (!isPathWithinRoot(authorizedRootPath, resolvedCandidatePath)) { + return null; + } + + return resolvedCandidatePath; +}; + // Select directory dialog ipcMain.handle('dialog:selectDirectory', async () => { const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow ?? undefined, { @@ -113,143 +138,171 @@ ipcMain.handle('dialog:selectDirectory', async () => { return null; } - return filePaths[0]; + const selectedPath = filePaths[0]; + authorizedRootPath = path.resolve(selectedPath); + return selectedPath; }); // Get directory tree ipcMain.handle( 'fs:getDirectoryTree', async (_event, dirPath: string, configContent?: string | null) => { - // IMPORTANT: This function applies exclude patterns to the directory tree, - // preventing node_modules, .git, and other large directories from being included - // in the UI tree view. This is critical for performance with large repositories. - - // Parse config to get settings and exclude patterns - let excludePatterns: FilterPatternBundle = []; - try { - const config = (configContent - ? (yaml.parse(configContent) as ConfigObject) - : ({ exclude_patterns: [] } as ConfigObject)) || { exclude_patterns: [] }; + const authorizedDirPath = resolveAuthorizedPath(dirPath); + if (!authorizedDirPath) { + console.warn(`Rejected unauthorized directory tree request: ${dirPath}`); + return []; + } - // Check if we should use custom excludes (default to true if not specified) - const useCustomExcludes = config.use_custom_excludes !== false; + // IMPORTANT: This function applies exclude patterns to the directory tree, + // preventing node_modules, .git, and other large directories from being included + // in the UI tree view. This is critical for performance with large repositories. - // Check if we should use custom includes (default to true if not specified) - const useCustomIncludes = config.use_custom_includes !== false; + // Parse config to get settings and exclude patterns + let excludePatterns: FilterPatternBundle = []; + let config: ConfigObject = { exclude_patterns: [] }; + try { + config = (configContent + ? (yaml.parse(configContent) as ConfigObject) + : ({ exclude_patterns: [] } as ConfigObject)) || { exclude_patterns: [] }; - // Check if we should use gitignore (default to true if not specified) - const useGitignore = config.use_gitignore !== false; + // Check if we should use custom excludes (default to true if not specified) + const useCustomExcludes = config.use_custom_excludes !== false; - // Start with empty excludePatterns array (no hardcoded patterns) - // Add custom exclude patterns if enabled - if (useCustomExcludes && config.exclude_patterns && Array.isArray(config.exclude_patterns)) { - excludePatterns = [...excludePatterns, ...config.exclude_patterns]; - } + // Check if we should use custom includes (default to true if not specified) + const useCustomIncludes = config.use_custom_includes !== false; - // Store include extensions for filtering later (if enabled) - if ( - useCustomIncludes && - config.include_extensions && - Array.isArray(config.include_extensions) - ) { - excludePatterns.includeExtensions = config.include_extensions; - } + // Check if we should use gitignore (default to true if not specified) + const useGitignore = config.use_gitignore !== false; - // Add gitignore patterns if enabled - if (useGitignore) { - const gitignoreResult = gitignoreParser.parseGitignore(dirPath); - if (gitignoreResult.excludePatterns && gitignoreResult.excludePatterns.length > 0) { - excludePatterns = [...excludePatterns, ...gitignoreResult.excludePatterns]; + // Start with empty excludePatterns array (no hardcoded patterns) + // Add custom exclude patterns if enabled + if (useCustomExcludes && config.exclude_patterns && Array.isArray(config.exclude_patterns)) { + excludePatterns = [...excludePatterns, ...config.exclude_patterns]; } - // Handle negated patterns (these will be processed later to override excludes) - if (gitignoreResult.includePatterns && gitignoreResult.includePatterns.length > 0) { - // We'll store includePatterns separately to process later - excludePatterns.includePatterns = gitignoreResult.includePatterns; + // Store include extensions for filtering later (if enabled) + if ( + useCustomIncludes && + config.include_extensions && + Array.isArray(config.include_extensions) + ) { + excludePatterns.includeExtensions = config.include_extensions; } - } - } catch (error) { - console.error('Error parsing config:', error); - // Fall back to only hiding .git folder - excludePatterns = ['**/.git/**']; - } - // Import the fnmatch module - - // Get the config for filtering - const config = (configContent - ? (yaml.parse(configContent) as ConfigObject) - : ({ exclude_patterns: [] } as ConfigObject)) || { exclude_patterns: [] }; + // Add gitignore patterns if enabled + if (useGitignore) { + const gitignoreResult = gitignoreParser.parseGitignore(authorizedDirPath); + if (gitignoreResult.excludePatterns && gitignoreResult.excludePatterns.length > 0) { + excludePatterns = [...excludePatterns, ...gitignoreResult.excludePatterns]; + } - // Use the shared shouldExclude function from filter-utils - const localShouldExclude = (itemPath: string) => { - return shouldExclude(itemPath, dirPath, excludePatterns, config); - }; + // Handle negated patterns (these will be processed later to override excludes) + if (gitignoreResult.includePatterns && gitignoreResult.includePatterns.length > 0) { + // We'll store includePatterns separately to process later + excludePatterns.includePatterns = gitignoreResult.includePatterns; + } + } + } catch (error) { + console.error('Error parsing config:', error); + // Fall back to only hiding .git folder + excludePatterns = ['**/.git/**']; + config = { exclude_patterns: [] }; + } - const walkDirectory = (dir: string): DirectoryTreeItem[] => { - const items = fs.readdirSync(dir); - const result: DirectoryTreeItem[] = []; + // Use the shared shouldExclude function from filter-utils + const localShouldExclude = (itemPath: string) => { + return shouldExclude(itemPath, authorizedDirPath, excludePatterns, config); + }; - for (const item of items) { - try { - const itemPath = path.join(dir, item); + const walkDirectory = (dir: string): DirectoryTreeItem[] => { + const items = fs.readdirSync(dir); + const result: DirectoryTreeItem[] = []; - // Skip excluded items based on patterns, but don't exclude binary files from the tree - if (localShouldExclude(itemPath)) { - continue; - } + for (const item of items) { + try { + const itemPath = path.join(dir, item); - const stats = fs.statSync(itemPath); - const ext = path.extname(item).toLowerCase(); + // Skip excluded items based on patterns, but don't exclude binary files from the tree + if (localShouldExclude(itemPath)) { + continue; + } - if (stats.isDirectory()) { - const children = walkDirectory(itemPath); - // Only include directory if it has children or if we want to show empty dirs - if (children.length > 0) { + const stats = fs.statSync(itemPath); + const ext = path.extname(item).toLowerCase(); + + if (stats.isDirectory()) { + const children = walkDirectory(itemPath); + // Only include directory if it has children or if we want to show empty dirs + if (children.length > 0) { + result.push({ + name: item, + path: itemPath, + type: 'directory', + size: stats.size, + lastModified: stats.mtime, + children: children, + itemCount: children.length, + }); + } + } else { result.push({ name: item, path: itemPath, - type: 'directory', + type: 'file', size: stats.size, lastModified: stats.mtime, - children: children, - itemCount: children.length, + extension: ext, }); } - } else { - result.push({ - name: item, - path: itemPath, - type: 'file', - size: stats.size, - lastModified: stats.mtime, - extension: ext, - }); + } catch (err) { + console.error(`Error processing ${path.join(dir, item)}:`, err); + // Continue with next file instead of breaking } - } catch (err) { - console.error(`Error processing ${path.join(dir, item)}:`, err); - // Continue with next file instead of breaking } - } - // Sort directories first, then files alphabetically - return result.sort((a, b) => { - if (a.type === 'directory' && b.type === 'file') return -1; - if (a.type === 'file' && b.type === 'directory') return 1; - return a.name.localeCompare(b.name); - }); - }; + // Sort directories first, then files alphabetically + return result.sort((a, b) => { + if (a.type === 'directory' && b.type === 'file') return -1; + if (a.type === 'file' && b.type === 'directory') return 1; + return a.name.localeCompare(b.name); + }); + }; - try { - return walkDirectory(dirPath); - } catch (error) { - console.error('Error getting directory tree:', error); - return []; - } + try { + return walkDirectory(authorizedDirPath); + } catch (error) { + console.error('Error getting directory tree:', error); + return []; + } } ); -// Use the imported normalizePath from filter-utils +const isPathWithinRoot = (rootPath: string, candidatePath: string): boolean => { + if (!rootPath || !candidatePath) { + return false; + } + + const resolveForBoundaryCheck = (inputPath: string): string => { + const resolvedPath = path.resolve(inputPath); + const realpathFn = fs.realpathSync?.native ?? fs.realpathSync; + + if (typeof realpathFn === 'function') { + try { + return realpathFn(resolvedPath); + } catch { + return resolvedPath; + } + } + + return resolvedPath; + }; + + const resolvedRootPath = resolveForBoundaryCheck(rootPath); + const resolvedCandidatePath = resolveForBoundaryCheck(candidatePath); + const relativePath = path.relative(resolvedRootPath, resolvedCandidatePath); + + return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); +}; // Analyze repository ipcMain.handle( @@ -258,79 +311,86 @@ ipcMain.handle( _event, { rootPath, configContent, selectedFiles }: AnalyzeRepositoryOptions ): Promise => { - try { - const config = (yaml.parse(configContent) || {}) as ConfigObject; - const localTokenCounter = new TokenCounter(); + try { + const authorizedAnalyzeRoot = resolveAuthorizedPath(rootPath); + if (!authorizedAnalyzeRoot) { + throw new Error('Unauthorized root path. Please select the directory again.'); + } - // Process gitignore if enabled - let gitignorePatterns = { excludePatterns: [], includePatterns: [] }; - if (config.use_gitignore === true) { - gitignorePatterns = gitignoreParser.parseGitignore(rootPath); - } + const config = (yaml.parse(configContent) || {}) as ConfigObject; + const localTokenCounter = new TokenCounter(); - // Create a file analyzer instance with the appropriate settings - const fileAnalyzer = new FileAnalyzer(config, localTokenCounter, { - useGitignore: config.use_gitignore === true, - gitignorePatterns: gitignorePatterns, - }); + // Process gitignore if enabled + let gitignorePatterns = { excludePatterns: [], includePatterns: [] }; + if (config.use_gitignore !== false) { + gitignorePatterns = gitignoreParser.parseGitignore(authorizedAnalyzeRoot); + } - // If selectedFiles is provided, only analyze those files - const filesInfo: FileInfo[] = []; - let totalTokens = 0; - let skippedBinaryFiles = 0; + // Create a file analyzer instance with the appropriate settings + const fileAnalyzer = new FileAnalyzer(config, localTokenCounter, { + useGitignore: config.use_gitignore !== false, + gitignorePatterns: gitignorePatterns, + }); - for (const filePath of selectedFiles) { - // Verify the file is within the current root path - if (!filePath.startsWith(rootPath)) { - console.warn(`Skipping file outside current root directory: ${filePath}`); - continue; - } + // If selectedFiles is provided, only analyze those files + const filesInfo: FileInfo[] = []; + let totalTokens = 0; + let skippedBinaryFiles = 0; - // Use consistent path normalization - const relativePath = getRelativePath(filePath, rootPath); + for (const filePath of selectedFiles) { + const resolvedFilePath = path.resolve(authorizedAnalyzeRoot, filePath); - // For binary files, record them as skipped but don't prevent selection - const binaryFile = isBinaryFile(filePath); - if (binaryFile) { - console.log(`Binary file detected (will skip processing): ${relativePath}`); - skippedBinaryFiles++; - } + // Verify the file is within the current root path + if (!isPathWithinRoot(authorizedAnalyzeRoot, resolvedFilePath)) { + console.warn(`Skipping file outside current root directory: ${filePath}`); + continue; + } - if (binaryFile) { - // For binary files, add to filesInfo but with zero tokens and a flag - filesInfo.push({ - path: relativePath, - tokens: 0, - isBinary: true, - }); - } else if (fileAnalyzer.shouldProcessFile(relativePath)) { - const tokenCount = fileAnalyzer.analyzeFile(filePath); - - if (tokenCount !== null) { + // Use consistent path normalization + const relativePath = getRelativePath(resolvedFilePath, authorizedAnalyzeRoot); + + // For binary files, record them as skipped but don't prevent selection + const binaryFile = isBinaryFile(resolvedFilePath); + if (binaryFile) { + console.log(`Binary file detected (will skip processing): ${relativePath}`); + skippedBinaryFiles++; + } + + if (binaryFile) { + // For binary files, add to filesInfo but with zero tokens and a flag filesInfo.push({ path: relativePath, - tokens: tokenCount, + tokens: 0, + isBinary: true, }); + } else if (fileAnalyzer.shouldProcessFile(relativePath)) { + const tokenCount = fileAnalyzer.analyzeFile(resolvedFilePath); - totalTokens += tokenCount; + if (tokenCount !== null) { + filesInfo.push({ + path: relativePath, + tokens: tokenCount, + }); + + totalTokens += tokenCount; + } } } - } - // Sort by token count - filesInfo.sort((a, b) => b.tokens - a.tokens); + // Sort by token count + filesInfo.sort((a, b) => b.tokens - a.tokens); - console.log(`Skipped ${skippedBinaryFiles} binary files during analysis`); + console.log(`Skipped ${skippedBinaryFiles} binary files during analysis`); - return { - filesInfo, - totalTokens, - skippedBinaryFiles, - }; - } catch (error) { - console.error('Error analyzing repository:', error); - throw error; - } + return { + filesInfo, + totalTokens, + skippedBinaryFiles, + }; + } catch (error) { + console.error('Error analyzing repository:', error); + throw error; + } } ); @@ -369,7 +429,7 @@ function generateTreeView(filesInfo: FileInfo[]): string { }); // Recursive function to print the tree - const printTree = (tree: PathTree, prefix = '', isLast = true): string => { + const printTree = (tree: PathTree, prefix = '', _isLast = true): string => { const entries = Object.entries(tree); let result = ''; @@ -377,11 +437,11 @@ function generateTreeView(filesInfo: FileInfo[]): string { const isLastItem = index === entries.length - 1; // Print current level - result += `${prefix}${isLast ? '└── ' : '├── '}${key}\n`; + result += `${prefix}${isLastItem ? '└── ' : '├── '}${key}\n`; // Print children if (value !== null) { - const newPrefix = `${prefix}${isLast ? ' ' : '│ '}`; + const newPrefix = `${prefix}${isLastItem ? ' ' : '│ '}`; result += printTree(value, newPrefix, isLastItem); } }); @@ -392,6 +452,9 @@ function generateTreeView(filesInfo: FileInfo[]): string { return printTree(pathTree); } +const getErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + // Process repository ipcMain.handle( 'repo:process', @@ -399,104 +462,156 @@ ipcMain.handle( _event, { rootPath, filesInfo, treeView, options = {} }: ProcessRepositoryOptions ): Promise => { - try { - const tokenCounter = new TokenCounter(); - const contentProcessor = new ContentProcessor(tokenCounter); + try { + const authorizedProcessRoot = resolveAuthorizedPath(rootPath); + if (!authorizedProcessRoot) { + throw new Error('Unauthorized root path. Please select the directory again.'); + } - // Ensure options is an object with default values if missing - const processingOptions = { - showTokenCount: options.showTokenCount !== false, // Default to true if not explicitly false - }; + const tokenCounter = new TokenCounter(); + const contentProcessor = new ContentProcessor(tokenCounter); - console.log('Processing with options:', processingOptions); + // Ensure options is an object with default values if missing + const processingOptions = { + showTokenCount: options.showTokenCount !== false, // Default to true if not explicitly false + includeTreeView: options.includeTreeView === true, + exportFormat: normalizeExportFormat(options.exportFormat), + }; - let processedContent = '# Repository Content\n\n'; + console.log('Processing with options:', processingOptions); - // Add tree view if requested in options, whether provided or not - if (options.includeTreeView) { - processedContent += '## File Structure\n\n'; - processedContent += '```\n'; + let processedContent = ''; - // If treeView was provided, use it, otherwise generate a more complete one - processedContent += treeView || generateTreeView(filesInfo); + if (processingOptions.exportFormat === 'xml') { + processedContent += '\n'; + processedContent += '\n'; + } else { + processedContent += '# Repository Content\n\n'; + } - processedContent += '```\n\n'; - processedContent += '## File Contents\n\n'; - } + // Add tree view if requested in options, whether provided or not + if (processingOptions.includeTreeView) { + const resolvedTreeView = treeView || generateTreeView(filesInfo); + if (processingOptions.exportFormat === 'xml') { + processedContent += `${wrapXmlCdata(resolvedTreeView)}\n`; + } else { + processedContent += '## File Structure\n\n'; + processedContent += '```\n'; + processedContent += resolvedTreeView; + processedContent += '```\n\n'; + } + } - let totalTokens = 0; - let processedFiles = 0; - let skippedFiles = 0; + if (processingOptions.exportFormat === 'markdown' && processingOptions.includeTreeView) { + processedContent += '## File Contents\n\n'; + } - for (const fileInfo of filesInfo ?? []) { - try { - if (!fileInfo || !fileInfo.path) { - console.warn('Skipping invalid file info entry'); - skippedFiles++; - continue; - } + if (processingOptions.exportFormat === 'xml') { + processedContent += '\n'; + } - const { path: filePath, tokens = 0 } = fileInfo; + let totalTokens = 0; + let processedFiles = 0; + let skippedFiles = 0; - // Use consistent path joining - const fullPath = path.join(rootPath, filePath); + for (const fileInfo of filesInfo ?? []) { + try { + if (!fileInfo || !fileInfo.path) { + console.warn('Skipping invalid file info entry'); + skippedFiles++; + continue; + } - // Validate the full path is within the root path - const normalizedFullPath = normalizePath(fullPath); - const normalizedRootPath = normalizePath(rootPath); + const filePath = fileInfo.path; + const tokenCount = normalizeTokenCount(fileInfo.tokens); - if (!normalizedFullPath.startsWith(normalizedRootPath)) { - console.warn(`Skipping file outside root directory: ${filePath}`); - skippedFiles++; - continue; - } + // Resolve and validate against root path to prevent traversal and prefix bypasses. + const fullPath = path.resolve(authorizedProcessRoot, filePath); - if (fs.existsSync(fullPath)) { - const content = contentProcessor.processFile(fullPath, filePath); + if (!isPathWithinRoot(authorizedProcessRoot, fullPath)) { + console.warn(`Skipping file outside root directory: ${filePath}`); + skippedFiles++; + continue; + } + + if (fs.existsSync(fullPath)) { + const content = contentProcessor.processFile(fullPath, filePath, { + exportFormat: processingOptions.exportFormat, + showTokenCount: processingOptions.showTokenCount, + tokenCount, + }); - if (content) { - processedContent += content; - totalTokens += tokens; - processedFiles++; + if (content) { + processedContent += content; + totalTokens += tokenCount; + processedFiles++; + } + } else { + console.warn(`File not found: ${filePath}`); + skippedFiles++; } - } else { - console.warn(`File not found: ${filePath}`); + } catch (error) { + console.warn(`Failed to process file: ${getErrorMessage(error)}`); skippedFiles++; } - } catch (error) { - console.warn(`Failed to process file: ${error.message}`); - skippedFiles++; } - } - processedContent += '\n--END--\n'; + if (processingOptions.exportFormat === 'xml') { + processedContent += '\n'; + processedContent += + `\n`; + processedContent += '\n'; + } else { + processedContent += '\n--END--\n'; + } - return { - content: processedContent, - totalTokens, - processedFiles, - skippedFiles, - filesInfo: filesInfo, // Add filesInfo to the response - }; - } catch (error) { - console.error('Error processing repository:', error); - throw error; - } + return { + content: processedContent, + exportFormat: processingOptions.exportFormat, + totalTokens, + processedFiles, + skippedFiles, + filesInfo: filesInfo, // Add filesInfo to the response + }; + } catch (error) { + console.error('Error processing repository:', error); + throw error; + } } ); // Save output to file ipcMain.handle('fs:saveFile', async (_event, { content, defaultPath }: SaveFileOptions) => { - const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, { - defaultPath, - filters: [ - { name: 'Markdown Files', extensions: ['md'] }, - { name: 'Text Files', extensions: ['txt'] }, - { name: 'All Files', extensions: ['*'] }, - ], - }); - - if (canceled) { + const safeDefaultPath = typeof defaultPath === 'string' ? defaultPath : ''; + const defaultExtension = safeDefaultPath ? path.extname(safeDefaultPath).toLowerCase() : ''; + const filters = + defaultExtension === '.xml' + ? [ + { name: 'XML Files', extensions: ['xml'] }, + { name: 'Markdown Files', extensions: ['md'] }, + { name: 'Text Files', extensions: ['txt'] }, + { name: 'All Files', extensions: ['*'] }, + ] + : [ + { name: 'Markdown Files', extensions: ['md'] }, + { name: 'XML Files', extensions: ['xml'] }, + { name: 'Text Files', extensions: ['txt'] }, + { name: 'All Files', extensions: ['*'] }, + ]; + + const { canceled, filePath } = mainWindow + ? await dialog.showSaveDialog(mainWindow, { + defaultPath: safeDefaultPath, + filters, + }) + : await dialog.showSaveDialog({ + defaultPath: safeDefaultPath, + filters, + }); + + if (canceled || !filePath) { return null; } @@ -524,6 +639,11 @@ ipcMain.handle('config:getDefault', async () => { ipcMain.handle('assets:getPath', (_event, assetName: string) => { try { const assetPath = path.join(ASSETS_DIR, assetName); + if (!isPathWithinRoot(ASSETS_DIR, assetPath)) { + console.warn(`Rejected asset path outside assets directory: ${assetName}`); + return null; + } + if (fs.existsSync(assetPath)) { return assetPath; } @@ -538,51 +658,70 @@ ipcMain.handle('assets:getPath', (_event, assetName: string) => { // Count tokens for multiple files in a single call ipcMain.handle( 'tokens:countFiles', - async (_event, filePaths: string[]): Promise => { - try { - const results: Record = {}; - const stats: Record = {}; + async (_event, options: CountFilesTokensOptions): Promise => { + try { + const { rootPath, filePaths } = options ?? {}; + if (!rootPath || !Array.isArray(filePaths) || filePaths.length === 0) { + return { results: {}, stats: {} }; + } - // Process each file - for (const filePath of filePaths) { - try { - // Check if file exists - if (!fs.existsSync(filePath)) { - console.warn(`File not found for token counting: ${filePath}`); - results[filePath] = 0; - continue; - } + const authorizedTokensRoot = resolveAuthorizedPath(rootPath); + if (!authorizedTokensRoot) { + console.warn(`Rejected unauthorized token count request for root: ${rootPath}`); + return { results: {}, stats: {} }; + } - // Get file stats - const fileStats = fs.statSync(filePath); - stats[filePath] = { - size: fileStats.size, - mtime: fileStats.mtime.getTime(), // Modification time for cache validation - }; + const results: Record = {}; + const stats: Record = {}; - // Skip binary files - if (isBinaryFile(filePath)) { - console.log(`Skipping binary file for token counting: ${filePath}`); - results[filePath] = 0; - continue; - } + // Process each file + for (const filePath of filePaths) { + try { + const resolvedFilePath = path.resolve(authorizedTokensRoot, filePath); + + if (!isPathWithinRoot(authorizedTokensRoot, resolvedFilePath)) { + console.warn(`Skipping file outside current root directory: ${filePath}`); + results[filePath] = 0; + continue; + } + + // Check if file exists + if (!fs.existsSync(resolvedFilePath)) { + console.warn(`File not found for token counting: ${filePath}`); + results[filePath] = 0; + continue; + } - // Read file content - const content = fs.readFileSync(filePath, { encoding: 'utf-8', flag: 'r' }); + // Get file stats + const fileStats = fs.statSync(resolvedFilePath); + stats[filePath] = { + size: fileStats.size, + mtime: fileStats.mtime.getTime(), // Modification time for cache validation + }; + + // Skip binary files + if (isBinaryFile(resolvedFilePath)) { + console.log(`Skipping binary file for token counting: ${filePath}`); + results[filePath] = 0; + continue; + } - // Count tokens using the singleton token counter - const tokenCount = tokenCounter.countTokens(content); - results[filePath] = tokenCount; - } catch (error) { - console.error(`Error counting tokens for file ${filePath}:`, error); - results[filePath] = 0; + // Read file content + const content = fs.readFileSync(resolvedFilePath, { encoding: 'utf-8', flag: 'r' }); + + // Count tokens using the singleton token counter + const tokenCount = tokenCounter.countTokens(content); + results[filePath] = tokenCount; + } catch (error) { + console.error(`Error counting tokens for file ${filePath}:`, error); + results[filePath] = 0; + } } - } - return { results, stats }; - } catch (error) { - console.error(`Error counting tokens for files:`, error); - return { results: {}, stats: {} }; - } + return { results, stats }; + } catch (error) { + console.error(`Error counting tokens for files:`, error); + return { results: {}, stats: {} }; + } } ); diff --git a/src/main/preload.ts b/src/main/preload.ts index 049ef38..49a3d49 100755 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer, shell } from 'electron'; import type { AnalyzeRepositoryOptions, AnalyzeRepositoryResult, + CountFilesTokensOptions, CountFilesTokensResult, DirectoryTreeItem, ElectronApi, @@ -37,7 +38,9 @@ const electronShellApi: ElectronShellApi = { const electronAPI: ElectronApi = { selectDirectory: () => ipcRenderer.invoke('dialog:selectDirectory') as Promise, getDirectoryTree: (dirPath: string, configContent?: string | null) => - ipcRenderer.invoke('fs:getDirectoryTree', dirPath, configContent) as Promise, + ipcRenderer.invoke('fs:getDirectoryTree', dirPath, configContent) as Promise< + DirectoryTreeItem[] + >, saveFile: (options: SaveFileOptions) => ipcRenderer.invoke('fs:saveFile', options) as Promise, resetGitignoreCache: () => ipcRenderer.invoke('gitignore:resetCache') as Promise, @@ -48,8 +51,8 @@ const electronAPI: ElectronApi = { getDefaultConfig: () => ipcRenderer.invoke('config:getDefault') as Promise, getAssetPath: (assetName: string) => ipcRenderer.invoke('assets:getPath', assetName) as Promise, - countFilesTokens: (filePaths: string[]) => - ipcRenderer.invoke('tokens:countFiles', filePaths) as Promise, + countFilesTokens: (options: CountFilesTokensOptions) => + ipcRenderer.invoke('tokens:countFiles', options) as Promise, }; contextBridge.exposeInMainWorld('devUtils', devUtils); diff --git a/src/renderer/components/App.tsx b/src/renderer/components/App.tsx index 0defa89..31712da 100755 --- a/src/renderer/components/App.tsx +++ b/src/renderer/components/App.tsx @@ -6,11 +6,12 @@ import ProcessedTab from './ProcessedTab'; import DarkModeToggle from './DarkModeToggle'; import { DarkModeProvider } from '../context/DarkModeContext'; import yaml from 'yaml'; +import { normalizeExportFormat } from '../../utils/export-format'; import type { AnalyzeRepositoryResult, ConfigObject, DirectoryTreeItem, - ProcessRepositoryOptions, + ExportFormat, ProcessRepositoryResult, TabId, } from '../../types/ipc'; @@ -24,6 +25,7 @@ const ensureError = (error: unknown): Error => { type ProcessingOptions = { showTokenCount: boolean; includeTreeView: boolean; + exportFormat: ExportFormat; }; const App = () => { @@ -35,8 +37,9 @@ const App = () => { const [, setAnalysisResult] = useState(null); const [processedResult, setProcessedResult] = useState(null); const [processingOptions, setProcessingOptions] = useState({ - showTokenCount: false, + showTokenCount: true, includeTreeView: false, + exportFormat: 'markdown', }); // Load config from localStorage or via API, no fallbacks const [configContent, setConfigContent] = useState('# Loading configuration...'); @@ -129,8 +132,9 @@ const App = () => { // Update processing options from config to maintain consistency setProcessingOptions({ - showTokenCount: config.show_token_count === true, + showTokenCount: config.show_token_count !== false, includeTreeView: config.include_tree_view === true, + exportFormat: normalizeExportFormat(config.export_format), }); // Ensure we've saved any config changes before switching tabs @@ -222,7 +226,7 @@ const App = () => { try { // Validate selected files before analysis const validFiles = selectedFiles.filter((file) => { - const withinRoot = file.startsWith(rootPath); + const withinRoot = isPathWithinRootBoundary(file); if (!withinRoot) { console.warn(`Skipping file outside current root directory: ${file}`); @@ -254,17 +258,20 @@ const App = () => { setAnalysisResult(currentAnalysisResult); // Read options from config - const options: ProcessRepositoryOptions['options'] = { - showTokenCount: false, + const options: ProcessingOptions = { + showTokenCount: true, includeTreeView: false, + exportFormat: 'markdown', }; try { const config = (yaml.parse(configContent) || {}) as ConfigObject; - options.showTokenCount = config.show_token_count === true; + options.showTokenCount = config.show_token_count !== false; options.includeTreeView = config.include_tree_view === true; + options.exportFormat = normalizeExportFormat(config.export_format); } catch (error) { console.error('Error parsing config for processing:', ensureError(error)); } + setProcessingOptions(options); // Process directly without going to analyze tab const result = await window.electronAPI.processRepository({ @@ -293,8 +300,46 @@ const App = () => { } }; - // Helper function for consistent path normalization (used by handleFolderSelect indirectly) - // We'll just use inline path normalization where needed + const normalizePathForBoundaryCheck = (inputPath: string): string => { + const normalizedSlashes = inputPath.replace(/\\/g, '/'); + const driveMatch = normalizedSlashes.match(/^[A-Za-z]:/); + const drivePrefix = driveMatch ? driveMatch[0].toLowerCase() : ''; + const pathWithoutDrive = drivePrefix ? normalizedSlashes.slice(2) : normalizedSlashes; + const hasLeadingSlash = pathWithoutDrive.startsWith('/'); + + const segments = pathWithoutDrive.split('/').filter((segment) => segment && segment !== '.'); + const resolvedSegments: string[] = []; + + for (const segment of segments) { + if (segment === '..') { + if (resolvedSegments.length > 0 && resolvedSegments[resolvedSegments.length - 1] !== '..') { + resolvedSegments.pop(); + } else if (!hasLeadingSlash) { + // Preserve relative parent traversals so boundary checks can reject them. + resolvedSegments.push('..'); + } + continue; + } + + resolvedSegments.push(segment); + } + + return `${drivePrefix}${hasLeadingSlash ? '/' : ''}${resolvedSegments.join('/')}`; + }; + + const isPathWithinRootBoundary = (candidatePath: string): boolean => { + if (!candidatePath || !rootPath) { + return false; + } + + const normalizedRootPath = normalizePathForBoundaryCheck(rootPath); + const normalizedCandidatePath = normalizePathForBoundaryCheck(candidatePath); + + return ( + normalizedCandidatePath === normalizedRootPath || + normalizedCandidatePath.startsWith(`${normalizedRootPath}/`) + ); + }; // Method to reload and reprocess files with the latest content const handleRefreshProcessed = async () => { @@ -324,17 +369,19 @@ const App = () => { setAnalysisResult(currentReanalysisResult); // Get the latest config options - const options: ProcessRepositoryOptions['options'] = { ...processingOptions }; + const options: ProcessingOptions = { ...processingOptions }; try { const configStr = localStorage.getItem('configContent'); if (configStr) { const config = (yaml.parse(configStr) || {}) as ConfigObject; - options.showTokenCount = config.show_token_count === true; + options.showTokenCount = config.show_token_count !== false; options.includeTreeView = config.include_tree_view === true; + options.exportFormat = normalizeExportFormat(config.export_format); } } catch (error) { console.error('Error parsing config for refresh:', ensureError(error)); } + setProcessingOptions(options); console.log('Processing with fresh analysis and options:', options); @@ -370,9 +417,10 @@ const App = () => { } try { + const outputExtension = processedResult.exportFormat === 'xml' ? 'xml' : 'md'; await window.electronAPI?.saveFile?.({ content: processedResult.content, - defaultPath: `${rootPath}/output.md`, + defaultPath: `${rootPath}/output.${outputExtension}`, }); } catch (error) { const processedError = ensureError(error); @@ -387,7 +435,7 @@ const App = () => { if (!filePath || !rootPath) return false; // Ensure the file is within the current root path - return filePath.startsWith(rootPath); + return isPathWithinRootBoundary(filePath); }; const handleFileSelect = (filePath: string, isSelected: boolean) => { @@ -409,7 +457,7 @@ const App = () => { const handleFolderSelect = (folderPath: string, isSelected: boolean) => { // Validate folder path before selection - if (isSelected && (!folderPath || !rootPath || !folderPath.startsWith(rootPath))) { + if (isSelected && !isPathWithinRootBoundary(folderPath)) { console.warn(`Attempted to select an invalid folder: ${folderPath}`); return; } @@ -444,7 +492,7 @@ const App = () => { for (const item of folder.children ?? []) { if (item.type === 'directory') { // Validate each folder is within current root - if (item.path.startsWith(rootPath)) { + if (isPathWithinRootBoundary(item.path)) { folders.push(item.path); folders = [...folders, ...getAllSubFolders(item)]; } @@ -463,7 +511,7 @@ const App = () => { for (const item of folder.children ?? []) { if (item.type === 'file') { // Validate each file is within current root - if (item.path.startsWith(rootPath)) { + if (isPathWithinRootBoundary(item.path)) { files.push(item.path); } } else if (item.type === 'directory') { diff --git a/src/renderer/components/ConfigTab.tsx b/src/renderer/components/ConfigTab.tsx index bee9603..f27758d 100755 --- a/src/renderer/components/ConfigTab.tsx +++ b/src/renderer/components/ConfigTab.tsx @@ -1,7 +1,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import yaml from 'yaml'; import { yamlArrayToPlainText } from '../../utils/formatters/list-formatter'; -import type { ConfigObject } from '../../types/ipc'; +import { normalizeExportFormat } from '../../utils/export-format'; +import type { ConfigObject, ExportFormat } from '../../types/ipc'; type ConfigTabProps = { configContent: string; @@ -18,6 +19,7 @@ type ConfigStateSetters = { setExcludeSuspiciousFiles: React.Dispatch>; setIncludeTreeView: React.Dispatch>; setShowTokenCount: React.Dispatch>; + setExportFormat: React.Dispatch>; }; // Helper functions for extension and pattern handling to reduce complexity @@ -55,6 +57,7 @@ const updateConfigStates = (config: ConfigObject, stateSetters: ConfigStateSette setExcludeSuspiciousFiles, setIncludeTreeView, setShowTokenCount, + setExportFormat, } = stateSetters; // Process extensions and patterns @@ -89,6 +92,10 @@ const updateConfigStates = (config: ConfigObject, stateSetters: ConfigStateSette if (config?.show_token_count !== undefined) { setShowTokenCount(config.show_token_count === true); } + + if (config?.export_format !== undefined) { + setExportFormat(normalizeExportFormat(config.export_format)); + } }; const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { @@ -100,6 +107,7 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { const [excludeSuspiciousFiles, setExcludeSuspiciousFiles] = useState(true); const [includeTreeView, setIncludeTreeView] = useState(true); const [showTokenCount, setShowTokenCount] = useState(true); + const [exportFormat, setExportFormat] = useState('markdown'); const [fileExtensions, setFileExtensions] = useState(''); const [excludePatterns, setExcludePatterns] = useState(''); @@ -120,6 +128,7 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { setExcludeSuspiciousFiles, setIncludeTreeView, setShowTokenCount, + setExportFormat, }); } catch (error) { console.error('Error parsing config:', error); @@ -151,6 +160,7 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { config.exclude_suspicious_files = excludeSuspiciousFiles; config.include_tree_view = includeTreeView; config.show_token_count = showTokenCount; + config.export_format = exportFormat; // Process file extensions from the textarea config.include_extensions = fileExtensions @@ -189,6 +199,7 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { excludeSuspiciousFiles, includeTreeView, showTokenCount, + exportFormat, fileExtensions, excludePatterns, onConfigChange, @@ -207,6 +218,7 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { excludeSuspiciousFiles, includeTreeView, showTokenCount, + exportFormat, saveConfig, ]); @@ -440,6 +452,24 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { Display token counts + +
+ + +
diff --git a/src/renderer/components/SourceTab.tsx b/src/renderer/components/SourceTab.tsx index 9f5cab6..58ee907 100755 --- a/src/renderer/components/SourceTab.tsx +++ b/src/renderer/components/SourceTab.tsx @@ -42,8 +42,8 @@ const updateTokenCache = ( }); }; -const getProcessButtonClass = (rootPath: string, hasSelection: boolean, isAnalyzing: boolean) => { - const isDisabled = !rootPath || !hasSelection || isAnalyzing; +const getProcessButtonClass = (rootPath: string, hasSelection: boolean, isBusy: boolean) => { + const isDisabled = !rootPath || !hasSelection || isBusy; const baseClass = 'inline-flex items-center border border-transparent px-5 py-2 text-sm font-medium text-white shadow-sm'; @@ -130,12 +130,22 @@ const SourceTab = ({ const batchSize = Math.min(20, filesToProcess.length); const fileBatch = filesToProcess.slice(0, batchSize); - const { results, stats } = await window.electronAPI!.countFilesTokens(fileBatch); + const { results, stats } = await window.electronAPI!.countFilesTokens({ + rootPath, + filePaths: fileBatch, + }); - updateTokenCache(results, stats, setTokenCache); + const normalizedResults: CountFilesTokensResult['results'] = { ...results }; + for (const filePath of fileBatch) { + if (!Object.prototype.hasOwnProperty.call(normalizedResults, filePath)) { + normalizedResults[filePath] = 0; + } + } + + updateTokenCache(normalizedResults, stats, setTokenCache); const newTotal = selectedFiles.reduce((sum, filePath) => { - return sum + (results[filePath] || tokenCache[filePath]?.tokenCount || 0); + return sum + (normalizedResults[filePath] || tokenCache[filePath]?.tokenCount || 0); }, 0); setTotalTokens(newTotal); @@ -173,6 +183,10 @@ const SourceTab = ({ }; }, []); + const hasSelection = selectedFiles.length > 0 || selectedFolders.length > 0; + const isProcessBusy = isAnalyzing || isCalculating; + const isProcessDisabled = !rootPath || !hasSelection || isProcessBusy; + return (
@@ -280,7 +294,9 @@ const SourceTab = ({
Files - {selectedFiles.length} + + {selectedFiles.length} +
{showTokenCount && ( @@ -290,28 +306,6 @@ const SourceTab = ({ Tokens {totalTokens.toLocaleString()} - {isCalculating && ( - - - - - )}
@@ -319,6 +313,7 @@ const SourceTab = ({