/** * Folder Picker Utility * ===================== * * Handles directory selection with hybrid approach: * - File System Access API for Chromium browsers (best UX) * - webkitdirectory fallback for all other browsers * * Based on the ChatGPT recommendation for "it just works" UX. */ export interface FileWithPath extends File { relativePath: string; } /** * Pick a directory using the best available method * @returns Promise - Array of files with relative paths * @throws 'fallback-input' - Indicates fallback input should be used */ export async function pickDirectory(): Promise { // Check if we have the modern File System Access API (Chromium) if ('showDirectoryPicker' in window) { try { const handle = await (window as any).showDirectoryPicker({ mode: 'read' }); const files: FileWithPath[] = []; await walkDirectoryHandle(handle, '', files); return files; } catch (error: any) { if (error.name === 'AbortError') { throw new Error('user-cancelled'); } // Fall back to input method throw new Error('fallback-input'); } } // No native directory picker support, use fallback throw new Error('fallback-input'); } /** * Recursively walk directory handle and collect files * @param dirHandle - Directory handle from File System Access API * @param prefix - Current path prefix * @param files - Array to collect files */ async function walkDirectoryHandle(dirHandle: any, prefix: string, files: FileWithPath[]) { for await (const entry of dirHandle.values()) { if (entry.kind === 'file') { try { const file = await entry.getFile(); const relativePath = `${prefix}${entry.name}`; // Add relative path property const fileWithPath = file as FileWithPath; fileWithPath.relativePath = relativePath; files.push(fileWithPath); } catch (error) { console.warn(`Failed to read file ${entry.name}:`, error); } } else if (entry.kind === 'directory') { // Recursively process subdirectory await walkDirectoryHandle(entry, `${prefix}${entry.name}/`, files); } } } /** * Process files from webkitdirectory input * @param fileList - FileList from input element * @returns FileWithPath[] - Array of files with relative paths */ export function processDirectoryFiles(fileList: FileList): FileWithPath[] { const files: FileWithPath[] = []; for (let i = 0; i < fileList.length; i++) { const file = fileList[i]; // webkitRelativePath preserves the directory structure const relativePath = (file as any).webkitRelativePath || file.name; // Skip empty files (usually directories) if (file.size === 0 && relativePath.endsWith('/')) { continue; } const fileWithPath = file as FileWithPath; fileWithPath.relativePath = relativePath; files.push(fileWithPath); } return files; } /** * Calculate total size and file count for a file array * @param files - Array of files * @returns Object with total size and count */ export function calculateDirectoryStats(files: FileWithPath[]) { const totalSize = files.reduce((sum, file) => sum + file.size, 0); const fileCount = files.length; // Get unique directories const directories = new Set(); files.forEach(file => { const pathParts = file.relativePath.split('/'); for (let i = 1; i < pathParts.length; i++) { directories.add(pathParts.slice(0, i).join('/')); } }); return { fileCount, totalSize, directoryCount: directories.size, formattedSize: formatFileSize(totalSize) }; } /** * Format file size in human-readable format * @param bytes - Size in bytes * @returns Formatted string */ export function formatFileSize(bytes: number): string { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } /** * Create a directory tree structure from files * @param files - Array of files with relative paths * @returns Tree structure object */ export function createDirectoryTree(files: FileWithPath[]) { const tree: any = {}; files.forEach(file => { const pathParts = file.relativePath.split('/'); let currentLevel = tree; // Navigate through directory structure for (let i = 0; i < pathParts.length - 1; i++) { const dirName = pathParts[i]; if (!currentLevel[dirName]) { currentLevel[dirName] = { type: 'directory', children: {}, fileCount: 0, totalSize: 0 }; } currentLevel = currentLevel[dirName].children; } // Add file to the tree const fileName = pathParts[pathParts.length - 1]; currentLevel[fileName] = { type: 'file', file: file, size: file.size, mimeType: file.type }; // Update parent directory stats let statsLevel = tree; for (let i = 0; i < pathParts.length - 1; i++) { const dirName = pathParts[i]; if (statsLevel[dirName]) { statsLevel[dirName].fileCount++; statsLevel[dirName].totalSize += file.size; statsLevel = statsLevel[dirName].children; } } }); return tree; } /** * Check if directory picker is supported * @returns boolean */ export function isDirectoryPickerSupported(): boolean { return 'showDirectoryPicker' in window; } /** * Get browser compatibility info * @returns Object with compatibility details */ export function getBrowserCompatibility() { const isChromium = 'showDirectoryPicker' in window; const supportsWebkitDirectory = HTMLInputElement.prototype.hasOwnProperty('webkitdirectory'); return { hasNativeDirectoryPicker: isChromium, hasWebkitDirectory: supportsWebkitDirectory, recommendedMethod: isChromium ? 'native' : 'fallback', browserType: isChromium ? 'chromium' : 'other' }; }