UNPKG

filetree-pro

Version:

A powerful file tree generator for VS Code and Cursor. Generate beautiful file trees in multiple formats with smart exclusions and custom configurations.

379 lines (336 loc) 11.7 kB
import * as path from 'path'; import * as vscode from 'vscode'; import { ExclusionPattern, ExtensionConfig, FileSystemStats, FileTreeItem } from '../types'; import { CacheManager, createCache } from '../utils/cacheManager'; import { ErrorCategory, ErrorSeverity, getErrorHandler } from '../utils/errorHandler'; import { getFileTypeInfo } from '../utils/fileUtils'; import { validateExclusionPatterns, validateFileSize, validatePath, validatePattern, } from '../utils/securityUtils'; /** * File system service with caching, security validation, and performance optimization. * Uses LRU cache to prevent memory leaks and validates all inputs for security. * * @since 0.2.0 */ export class FileSystemService { private cache: CacheManager<string, FileTreeItem>; private stats: FileSystemStats = { readTime: 0, writeTime: 0, errorCount: 0, cacheHitRate: 0, totalFiles: 0, totalFolders: 0, totalSize: 0, averageFileSize: 0, }; private config: ExtensionConfig; private exclusionPatterns: ExclusionPattern[] = []; private errorHandler = getErrorHandler(); private cleanupInterval: NodeJS.Timeout | null = null; constructor() { this.config = this.loadConfig(); this.initializeExclusionPatterns(); // Initialize LRU cache with 100 entries and 5-minute TTL this.cache = createCache<string, FileTreeItem>(100, 5); // Start periodic cache cleanup (every 5 minutes) this.cleanupInterval = setInterval( () => { const expired = this.cache.cleanup(); if (expired > 0) { console.log(`[FileSystemService] Cleaned up ${expired} expired cache entries`); } }, 5 * 60 * 1000 ); } private loadConfig(): ExtensionConfig { const config = vscode.workspace.getConfiguration('filetree-pro'); return { exclude: config.get('exclude', []), useCopilot: config.get('useCopilot', true), maxDepth: config.get('maxDepth', 10), showFileSize: config.get('showFileSize', true), showFileDate: config.get('showFileDate', false), enableSearch: config.get('enableSearch', true), enableAnalytics: config.get('enableAnalytics', true), }; } private initializeExclusionPatterns(): void { this.exclusionPatterns = [ // Default exclusions { pattern: 'node_modules', type: 'exact' }, { pattern: 'dist', type: 'exact' }, { pattern: 'build', type: 'exact' }, { pattern: 'out', type: 'exact' }, { pattern: '.git', type: 'exact' }, { pattern: '.venv', type: 'exact' }, { pattern: 'venv', type: 'exact' }, { pattern: 'env', type: 'exact' }, { pattern: '.env', type: 'exact' }, { pattern: 'target', type: 'exact' }, { pattern: 'bin', type: 'exact' }, { pattern: 'obj', type: 'exact' }, { pattern: '.vs', type: 'exact' }, { pattern: '.idea', type: 'exact' }, { pattern: '*.pyc', type: 'glob' }, { pattern: '*.log', type: 'glob' }, { pattern: '*.tmp', type: 'glob' }, { pattern: '*.cache', type: 'glob' }, ]; // Add user-defined exclusions with validation const userExclusions = this.config.exclude || []; const validationResult = validateExclusionPatterns(userExclusions); if (!validationResult.valid) { this.errorHandler.handleError({ message: `Invalid exclusion patterns: ${validationResult.error}`, severity: ErrorSeverity.WARNING, category: ErrorCategory.SECURITY, context: { patterns: userExclusions }, timestamp: new Date(), }); // Use only default patterns if user patterns are invalid return; } // Add validated user patterns userExclusions.forEach(pattern => { const patternValidation = validatePattern(pattern); if (patternValidation.valid) { this.exclusionPatterns.push({ pattern, type: pattern.includes('*') ? 'glob' : 'exact', }); } }); } async getFileTree(uri: vscode.Uri, depth: number = 0): Promise<FileTreeItem[]> { const startTime = Date.now(); const cacheKey = `${uri.fsPath}-${depth}`; // Validate path for security const pathValidation = validatePath(uri.fsPath); if (!pathValidation.valid) { await this.errorHandler.handleError( this.errorHandler.createSecurityError(`Invalid path: ${pathValidation.error}`, { path: uri.fsPath, }) ); if (this.stats.errorCount !== undefined) { this.stats.errorCount += 1; } return []; } // Check cache first const cachedResult = this.cache.get(cacheKey); if (cachedResult) { // Update cache statistics const cacheStats = this.cache.getStatistics(); this.stats.cacheHitRate = cacheStats.hitRate; return cachedResult.children || []; } try { const items: FileTreeItem[] = []; const entries = await vscode.workspace.fs.readDirectory(uri); for (const [name, type] of entries) { const itemUri = vscode.Uri.joinPath(uri, name); // Check if item should be excluded if (this.isExcluded(name, itemUri)) { continue; } const item: FileTreeItem = { uri: itemUri, name, type: type === vscode.FileType.Directory ? 'folder' : 'file', }; // Get additional file information if needed if (item.type === 'file') { await this.addFileDetails(item); this.stats.totalFiles += 1; } else { this.stats.totalFolders += 1; if (depth < this.config.maxDepth) { // Recursively get children for folders const children = await this.getFileTree(itemUri, depth + 1); if (children.length > 0) { item.children = children; } } } items.push(item); } // Sort items: folders first, then files items.sort((a, b) => { if (a.type !== b.type) { return a.type === 'folder' ? -1 : 1; } return a.name.localeCompare(b.name); }); // Cache the result with LRU eviction const cacheItem: FileTreeItem = { uri, name: path.basename(uri.fsPath), type: 'folder', children: items, }; this.cache.set(cacheKey, cacheItem); if (this.stats.readTime !== undefined) { this.stats.readTime += Date.now() - startTime; } // Update cache statistics const cacheStats = this.cache.getStatistics(); this.stats.cacheHitRate = cacheStats.hitRate; return items; } catch (error) { if (this.stats.errorCount !== undefined) { this.stats.errorCount += 1; } await this.errorHandler.handleError( this.errorHandler.createFileSystemError('Error reading directory', error as Error, { path: uri.fsPath, depth, }) ); return []; } } private isExcluded(name: string, _uri: vscode.Uri): boolean { return this.exclusionPatterns.some(pattern => { switch (pattern.type) { case 'exact': return name === pattern.pattern; case 'glob': return this.matchesGlob(name, pattern.pattern); default: return false; } }); } private matchesGlob(name: string, pattern: string): boolean { // Simple glob matching - can be enhanced with a proper glob library const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.'); const regex = new RegExp(`^${regexPattern}$`); return regex.test(name); } private async addFileDetails(item: FileTreeItem): Promise<void> { try { if (this.config.showFileSize || this.config.showFileDate) { if (item.uri) { const stat = await vscode.workspace.fs.stat(item.uri); // Validate file size for security const sizeValidation = validateFileSize(stat.size); if (!sizeValidation.valid) { await this.errorHandler.handleError({ message: `File too large: ${sizeValidation.error}`, severity: ErrorSeverity.WARNING, category: ErrorCategory.SECURITY, context: { path: item.uri.fsPath, size: stat.size }, timestamp: new Date(), }); return; } if (this.config.showFileSize) { item.size = stat.size; this.stats.totalSize += stat.size; } if (this.config.showFileDate) { item.modifiedDate = new Date(stat.mtime); } } } // Add file type context const fileTypeInfo = getFileTypeInfo(item.name); item.contextValue = `file-${fileTypeInfo.type}`; } catch (error) { await this.errorHandler.handleError( this.errorHandler.createFileSystemError('Error getting file details', error as Error, { path: item.uri?.fsPath, }) ); } } async searchFiles(query: string, _options: any = {}): Promise<FileTreeItem[]> { if (!this.config.enableSearch) { return []; } const workspaceFolders = vscode.workspace.workspaceFolders; if (!workspaceFolders) { return []; } try { const pattern = `**/*${query}*`; const files = await vscode.workspace.findFiles(pattern, '**/node_modules/**'); return files.map(uri => ({ uri, name: path.basename(uri.fsPath), type: 'file' as const, contextValue: 'search-result', })); } catch (error) { console.error('Error searching files:', error); return []; } } async getFileContent(uri: vscode.Uri): Promise<string> { try { // Validate path const pathValidation = validatePath(uri.fsPath); if (!pathValidation.valid) { await this.errorHandler.handleError( this.errorHandler.createSecurityError(`Invalid path: ${pathValidation.error}`, { path: uri.fsPath, }) ); return ''; } // Check file size before reading const stat = await vscode.workspace.fs.stat(uri); const sizeValidation = validateFileSize(stat.size); if (!sizeValidation.valid) { await this.errorHandler.handleError({ message: `File too large to read: ${sizeValidation.error}`, severity: ErrorSeverity.WARNING, category: ErrorCategory.SECURITY, context: { path: uri.fsPath, size: stat.size }, timestamp: new Date(), }); return ''; } const content = await vscode.workspace.fs.readFile(uri); return Buffer.from(content).toString('utf8'); } catch (error) { await this.errorHandler.handleError( this.errorHandler.createFileSystemError('Error reading file content', error as Error, { path: uri.fsPath, }) ); return ''; } } getStats(): FileSystemStats { // Calculate average file size if (this.stats.totalFiles > 0) { this.stats.averageFileSize = this.stats.totalSize / this.stats.totalFiles; } return { ...this.stats }; } refreshConfig(): void { this.config = this.loadConfig(); this.initializeExclusionPatterns(); this.clearCache(); } clearCache(): void { const cleared = this.cache.clear(); console.log(`[FileSystemService] Cleared ${cleared} cache entries`); } dispose(): void { // Stop cleanup interval if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } this.clearCache(); } }