app/src/utils/folderPicker.ts
2025-11-14 14:47:26 +00:00

217 lines
6.0 KiB
TypeScript

/**
* 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<FileWithPath[]> - Array of files with relative paths
* @throws 'fallback-input' - Indicates fallback input should be used
*/
export async function pickDirectory(): Promise<FileWithPath[]> {
// 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<string>();
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'
};
}