mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
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:
279
apps/app/src/lib/file-picker.ts
Normal file
279
apps/app/src/lib/file-picker.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user