From 4924cf1453b4a7e03aed4c1f2967d453a7e67362 Mon Sep 17 00:00:00 2001 From: Kacper Date: Fri, 12 Dec 2025 19:15:31 +0100 Subject: [PATCH] feat: implement web-based file picker for improved directory and file selection - Replaced prompt-based directory input with a web-based directory picker in HttpApiClient. - Added server endpoint for resolving directory paths based on directory name and file structure. - Enhanced error handling and logging for directory and file selection processes. - Updated file picker to validate file existence with the server for better user feedback. --- apps/app/src/lib/file-picker.ts | 279 ++++++++++++++++++++++++++++ apps/app/src/lib/http-api-client.ts | 115 ++++++++---- apps/server/src/routes/fs.ts | 107 +++++++++++ 3 files changed, 469 insertions(+), 32 deletions(-) create mode 100644 apps/app/src/lib/file-picker.ts diff --git a/apps/app/src/lib/file-picker.ts b/apps/app/src/lib/file-picker.ts new file mode 100644 index 00000000..baf28d33 --- /dev/null +++ b/apps/app/src/lib/file-picker.ts @@ -0,0 +1,279 @@ +/** + * 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 }); + }); +} diff --git a/apps/app/src/lib/http-api-client.ts b/apps/app/src/lib/http-api-client.ts index 1437d152..d11d6044 100644 --- a/apps/app/src/lib/http-api-client.ts +++ b/apps/app/src/lib/http-api-client.ts @@ -31,6 +31,7 @@ import type { ModelDefinition, ProviderStatus, } from "@/types/electron"; +import { openDirectoryPicker, openFilePicker, type DirectoryPickerResult } from "./file-picker"; // Server URL - configurable via environment variable @@ -201,46 +202,96 @@ export class HttpApiClient implements ElectronAPI { return { success: true }; } - // File picker - uses prompt for path input + // File picker - uses web-based file picker (works on Windows) async openDirectory(): Promise { - const path = prompt("Enter project directory path:"); - if (!path) { + try { + console.log("[HttpApiClient] Opening directory picker..."); + const directoryInfo = await openDirectoryPicker(); + console.log("[HttpApiClient] Directory info:", directoryInfo); + + if (!directoryInfo) { + console.log("[HttpApiClient] No directory selected (user canceled)"); + return { canceled: true, filePaths: [] }; + } + + // Try to resolve directory path using server endpoint + // First, try if we have an absolute path (from file.path property) + if (directoryInfo.directoryName && (directoryInfo.directoryName.includes("\\") || directoryInfo.directoryName.includes("/") || directoryInfo.directoryName.startsWith("/"))) { + // Looks like an absolute path, try validating it directly + console.log("[HttpApiClient] Attempting direct path validation:", directoryInfo.directoryName); + const directResult = await this.post<{ + success: boolean; + path?: string; + error?: string; + }>("/api/fs/validate-path", { filePath: directoryInfo.directoryName }); + + if (directResult.success && directResult.path) { + console.log("[HttpApiClient] Direct path validation succeeded:", directResult.path); + return { canceled: false, filePaths: [directResult.path] }; + } + } + + // If direct validation failed or we only have a directory name, + // use the resolve endpoint with directory structure + console.log("[HttpApiClient] Resolving directory using structure info..."); + const result = await this.post<{ + success: boolean; + path?: string; + error?: string; + }>("/api/fs/resolve-directory", { + directoryName: directoryInfo.directoryName, + sampleFiles: directoryInfo.sampleFiles, + fileCount: directoryInfo.fileCount, + }); + + console.log("[HttpApiClient] Directory resolution result:", result); + + if (result.success && result.path) { + console.log("[HttpApiClient] Directory resolved successfully:", result.path); + return { canceled: false, filePaths: [result.path] }; + } + + // If resolution failed, show error + console.warn("[HttpApiClient] Directory resolution failed:", result.error); + const errorMsg = result.error || "Could not locate directory. Please ensure the directory exists and try selecting it again."; + alert(errorMsg); + return { canceled: true, filePaths: [] }; + } catch (error) { + console.error("[HttpApiClient] Failed to open directory picker:", error); + alert("Failed to open directory picker. Please try again."); return { canceled: true, filePaths: [] }; } - - // Validate with server - const result = await this.post<{ - success: boolean; - path?: string; - error?: string; - }>("/api/fs/validate-path", { filePath: path }); - - if (result.success && result.path) { - return { canceled: false, filePaths: [result.path] }; - } - - alert(result.error || "Invalid path"); - return { canceled: true, filePaths: [] }; } async openFile(options?: object): Promise { - // Prompt for file path - const path = prompt("Enter file path:"); - if (!path) { + try { + const selectedPath = await openFilePicker(options); + if (!selectedPath) { + return { canceled: true, filePaths: [] }; + } + + // Handle both single file and multiple files + const filePaths = Array.isArray(selectedPath) ? selectedPath : [selectedPath]; + + // Validate files exist with server + // For multiple files, check the first one as a validation step + const firstPath = filePaths[0]; + const result = await this.post<{ success: boolean; exists: boolean }>( + "/api/fs/exists", + { filePath: firstPath } + ); + + if (result.success && result.exists) { + return { canceled: false, filePaths }; + } + + alert("File does not exist or cannot be accessed."); + return { canceled: true, filePaths: [] }; + } catch (error) { + console.error("[HttpApiClient] Failed to open file picker:", error); + alert("Failed to open file picker. Please try again."); return { canceled: true, filePaths: [] }; } - - const result = await this.post<{ success: boolean; exists: boolean }>( - "/api/fs/exists", - { filePath: path } - ); - - if (result.success && result.exists) { - return { canceled: false, filePaths: [path] }; - } - - alert("File does not exist"); - return { canceled: true, filePaths: [] }; } // File system operations diff --git a/apps/server/src/routes/fs.ts b/apps/server/src/routes/fs.ts index 6258fce7..400da5f6 100644 --- a/apps/server/src/routes/fs.ts +++ b/apps/server/src/routes/fs.ts @@ -217,6 +217,113 @@ export function createFsRoutes(_events: EventEmitter): Router { } }); + // Resolve directory path from directory name and file structure + // Used when browser file picker only provides directory name (not full path) + router.post("/resolve-directory", async (req: Request, res: Response) => { + try { + const { directoryName, sampleFiles, fileCount } = req.body as { + directoryName: string; + sampleFiles?: string[]; + fileCount?: number; + }; + + if (!directoryName) { + res.status(400).json({ success: false, error: "directoryName is required" }); + return; + } + + // If directoryName looks like an absolute path, try validating it directly + if (path.isAbsolute(directoryName) || directoryName.includes(path.sep)) { + try { + const resolvedPath = path.resolve(directoryName); + const stats = await fs.stat(resolvedPath); + if (stats.isDirectory()) { + addAllowedPath(resolvedPath); + return res.json({ + success: true, + path: resolvedPath, + }); + } + } catch { + // Not a valid absolute path, continue to search + } + } + + // Search for directory in common locations + const searchPaths: string[] = [ + process.cwd(), // Current working directory + process.env.HOME || process.env.USERPROFILE || "", // User home + path.join(process.env.HOME || process.env.USERPROFILE || "", "Documents"), + path.join(process.env.HOME || process.env.USERPROFILE || "", "Desktop"), + // Common project locations + path.join(process.env.HOME || process.env.USERPROFILE || "", "Projects"), + ].filter(Boolean); + + // Also check parent of current working directory + try { + const parentDir = path.dirname(process.cwd()); + if (!searchPaths.includes(parentDir)) { + searchPaths.push(parentDir); + } + } catch { + // Ignore + } + + // Search for directory matching the name and file structure + for (const searchPath of searchPaths) { + try { + const candidatePath = path.join(searchPath, directoryName); + const stats = await fs.stat(candidatePath); + + if (stats.isDirectory()) { + // Verify it matches by checking for sample files + if (sampleFiles && sampleFiles.length > 0) { + let matches = 0; + for (const sampleFile of sampleFiles.slice(0, 5)) { + // Remove directory name prefix from sample file path + const relativeFile = sampleFile.startsWith(directoryName + "/") + ? sampleFile.substring(directoryName.length + 1) + : sampleFile.split("/").slice(1).join("/") || sampleFile.split("/").pop() || sampleFile; + + try { + const filePath = path.join(candidatePath, relativeFile); + await fs.access(filePath); + matches++; + } catch { + // File doesn't exist, continue checking + } + } + + // If at least one file matches, consider it a match + if (matches === 0 && sampleFiles.length > 0) { + continue; // Try next candidate + } + } + + // Found matching directory + addAllowedPath(candidatePath); + return res.json({ + success: true, + path: candidatePath, + }); + } + } catch { + // Directory doesn't exist at this location, continue searching + continue; + } + } + + // Directory not found + res.status(404).json({ + success: false, + error: `Directory "${directoryName}" not found in common locations. Please ensure the directory exists.`, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + // Save image to .automaker/images directory router.post("/save-image", async (req: Request, res: Response) => { try {