mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
- 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.
280 lines
9.2 KiB
TypeScript
280 lines
9.2 KiB
TypeScript
/**
|
|
* 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 });
|
|
});
|
|
}
|