/** * File Picker Utility for Web Browsers * * Provides cross-platform file and directory selection using: * 1. HTML5 webkitdirectory input - primary method (works on Windows) * 2. File System Access API (showDirectoryPicker) - fallback for modern browsers * * Note: Browsers don't expose absolute file paths for security reasons. * This implementation extracts directory information and may require * user confirmation or server-side path resolution. */ /** * Directory picker result with structure information for server-side resolution */ export interface DirectoryPickerResult { directoryName: string; sampleFiles: string[]; // Relative paths of sample files for identification fileCount: number; } /** * Opens a directory picker dialog * @returns Promise resolving to directory information, or null if canceled * * Note: Browsers don't expose absolute file paths for security reasons. * This function returns directory structure information that the server * can use to locate the actual directory path. */ export async function openDirectoryPicker(): Promise { // Use webkitdirectory (works on Windows and all modern browsers) return new Promise((resolve) => { let resolved = false; const input = document.createElement("input"); input.type = "file"; input.webkitdirectory = true; input.style.display = "none"; const cleanup = () => { if (input.parentNode) { document.body.removeChild(input); } }; let changeEventFired = false; let focusTimeout: ReturnType | null = null; const safeResolve = (value: DirectoryPickerResult | null) => { if (!resolved) { resolved = true; changeEventFired = true; if (focusTimeout) { clearTimeout(focusTimeout); focusTimeout = null; } cleanup(); resolve(value); } }; input.addEventListener("change", (e) => { changeEventFired = true; if (focusTimeout) { clearTimeout(focusTimeout); focusTimeout = null; } console.log("[FilePicker] Change event fired"); const files = input.files; console.log("[FilePicker] Files selected:", files?.length || 0); if (!files || files.length === 0) { console.log("[FilePicker] No files selected"); safeResolve(null); return; } const firstFile = files[0]; console.log("[FilePicker] First file:", { name: firstFile.name, webkitRelativePath: firstFile.webkitRelativePath, // @ts-expect-error path: firstFile.path, }); // Extract directory name from webkitRelativePath // webkitRelativePath format: "directoryName/subfolder/file.txt" or "directoryName/file.txt" let directoryName = "Selected Directory"; // Method 1: Try to get absolute path from File object (non-standard, works in Electron/Chromium) // @ts-expect-error - path property is non-standard but available in some browsers if (firstFile.path) { // @ts-expect-error const filePath = firstFile.path as string; console.log("[FilePicker] Found file.path:", filePath); // Extract directory path (remove filename) const lastSeparator = Math.max( filePath.lastIndexOf("\\"), filePath.lastIndexOf("/") ); if (lastSeparator > 0) { const absolutePath = filePath.substring(0, lastSeparator); console.log("[FilePicker] Found absolute path:", absolutePath); // Return as directory name for now - server can validate it directly directoryName = absolutePath; } } // Method 2: Extract directory name from webkitRelativePath if (directoryName === "Selected Directory" && firstFile.webkitRelativePath) { const relativePath = firstFile.webkitRelativePath; console.log("[FilePicker] Using webkitRelativePath:", relativePath); const pathParts = relativePath.split("/"); if (pathParts.length > 0) { directoryName = pathParts[0]; // Top-level directory name console.log("[FilePicker] Extracted directory name:", directoryName); } } // Collect sample file paths for server-side directory matching // Take first 10 files to identify the directory const sampleFiles: string[] = []; const maxSamples = 10; for (let i = 0; i < Math.min(files.length, maxSamples); i++) { const file = files[i]; if (file.webkitRelativePath) { sampleFiles.push(file.webkitRelativePath); } else if (file.name) { sampleFiles.push(file.name); } } console.log("[FilePicker] Directory info:", { directoryName, fileCount: files.length, sampleFiles: sampleFiles.slice(0, 5), // Log first 5 }); safeResolve({ directoryName, sampleFiles, fileCount: files.length, }); }); // Handle cancellation - but be very careful not to interfere with change event // On Windows, the dialog might take time to process, so we wait longer const handleFocus = () => { // Wait longer on Windows - the dialog might take time to process // Only resolve as canceled if change event hasn't fired after a delay focusTimeout = setTimeout(() => { if (!resolved && !changeEventFired && (!input.files || input.files.length === 0)) { console.log("[FilePicker] Dialog canceled (no files after focus and no change event)"); safeResolve(null); } }, 2000); // Increased timeout for Windows - give it time }; // Add to DOM temporarily document.body.appendChild(input); console.log("[FilePicker] Opening directory picker..."); // Try to show picker programmatically if ("showPicker" in HTMLInputElement.prototype) { try { (input as any).showPicker(); console.log("[FilePicker] Using showPicker()"); } catch (error) { console.log("[FilePicker] showPicker() failed, using click()", error); input.click(); } } else { console.log("[FilePicker] Using click()"); input.click(); } // Set up cancellation detection with longer delay // Only add focus listener if we're not already resolved window.addEventListener("focus", handleFocus, { once: true }); // Also handle blur as a cancellation signal (but with delay) window.addEventListener("blur", () => { // Dialog opened, wait for it to close setTimeout(() => { window.addEventListener("focus", handleFocus, { once: true }); }, 100); }, { once: true }); }); } /** * Opens a file picker dialog * @param options Optional configuration (multiple files, file types, etc.) * @returns Promise resolving to selected file path(s), or null if canceled */ export async function openFilePicker( options?: { multiple?: boolean; accept?: string; } ): Promise { // Use standard file input (works on all browsers including Windows) return new Promise((resolve) => { const input = document.createElement("input"); input.type = "file"; input.multiple = options?.multiple ?? false; if (options?.accept) { input.accept = options.accept; } input.style.display = "none"; const cleanup = () => { if (input.parentNode) { document.body.removeChild(input); } }; input.addEventListener("change", () => { const files = input.files; if (!files || files.length === 0) { cleanup(); resolve(null); return; } // Try to extract paths from File objects const extractPath = (file: File): string => { // Try to get path from File object (non-standard, but available in some browsers) // @ts-expect-error - path property is non-standard if (file.path) { // @ts-expect-error return file.path as string; } // Fallback to filename (server will need to resolve) return file.name; }; if (options?.multiple) { const paths = Array.from(files).map(extractPath); cleanup(); resolve(paths); } else { const path = extractPath(files[0]); cleanup(); resolve(path); } }); // Handle window focus (user may have canceled) const handleFocus = () => { setTimeout(() => { if (!input.files || input.files.length === 0) { cleanup(); resolve(null); } }, 200); }; // Add to DOM temporarily document.body.appendChild(input); // Try to show picker programmatically // Note: showPicker() is available in modern browsers but TypeScript types it as void // In practice, it may return a Promise in some implementations, but we'll handle errors via try/catch if ("showPicker" in HTMLInputElement.prototype) { try { (input as any).showPicker(); } catch { // Fallback to click if showPicker fails input.click(); } } else { input.click(); } // Set up cancellation detection window.addEventListener("focus", handleFocus, { once: true }); }); }