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.
This commit is contained in:
Kacper
2025-12-12 19:15:31 +01:00
parent c079b3ef88
commit 4924cf1453
3 changed files with 469 additions and 32 deletions

View File

@@ -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<DirectoryPickerResult | null> {
// Use webkitdirectory (works on Windows and all modern browsers)
return new Promise<DirectoryPickerResult | null>((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<typeof setTimeout> | 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<string | string[] | null> {
// Use standard file input (works on all browsers including Windows)
return new Promise<string | string[] | null>((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 });
});
}

View File

@@ -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<DialogResult> {
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<DialogResult> {
// 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

View File

@@ -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 {