Browse Source
Server-side native folder pickers cannot select a Windows user's local directory when the app is deployed remotely. This adds a browser File System Access path for supported browsers so the user can choose a local folder, create the project subfolder, and save the workflow JSON directly from the client. Constraint: File System Access handles are browser-only and cannot be passed to server filesystem APIs Constraint: External media folder storage still depends on server filesystem endpoints, so browser-local projects keep media embedded or remote in the workflow JSON Rejected: Make /api/browse-directory choose the user's Windows folder | that endpoint runs on the server, not in the browser Confidence: medium Scope-risk: moderate Directive: Extend the browser filesystem utility for local media inputs/generations before enabling external media storage for browserfs paths Tested: npx vitest run src/components/__tests__/ProjectSetupModal.test.tsx src/components/__tests__/Header.test.tsx Tested: npm run build Not-tested: Manual Chrome/Edge File System Access flow on Windows test serverhandoff-20260429-1057
4 changed files with 218 additions and 17 deletions
@ -0,0 +1,164 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import type { WorkflowFile } from "@/store/workflowStore"; |
||||
|
|
||||
|
const PREFIX = "browserfs://"; |
||||
|
const DB_NAME = "popiart-browser-file-system"; |
||||
|
const DB_VERSION = 1; |
||||
|
const STORE_NAME = "directory-handles"; |
||||
|
|
||||
|
type BrowserDirectoryRecord = { |
||||
|
id: string; |
||||
|
handle: FileSystemDirectoryHandle; |
||||
|
}; |
||||
|
|
||||
|
type PermissionMode = "read" | "readwrite"; |
||||
|
|
||||
|
type FileSystemPermissionDescriptor = { |
||||
|
mode?: PermissionMode; |
||||
|
}; |
||||
|
|
||||
|
type PermissionedDirectoryHandle = FileSystemDirectoryHandle & { |
||||
|
queryPermission?: (descriptor?: FileSystemPermissionDescriptor) => Promise<PermissionState>; |
||||
|
requestPermission?: (descriptor?: FileSystemPermissionDescriptor) => Promise<PermissionState>; |
||||
|
}; |
||||
|
|
||||
|
declare global { |
||||
|
interface Window { |
||||
|
showDirectoryPicker?: () => Promise<FileSystemDirectoryHandle>; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const memoryHandles = new Map<string, FileSystemDirectoryHandle>(); |
||||
|
|
||||
|
export function isBrowserFileSystemPath(path: string | null | undefined): boolean { |
||||
|
return typeof path === "string" && path.startsWith(PREFIX); |
||||
|
} |
||||
|
|
||||
|
export function isBrowserDirectoryPickerSupported(): boolean { |
||||
|
return typeof window !== "undefined" && typeof window.showDirectoryPicker === "function"; |
||||
|
} |
||||
|
|
||||
|
export function joinBrowserFileSystemPath(basePath: string, folderName: string): string { |
||||
|
return `${basePath.replace(/\/+$/, "")}/${encodeURIComponent(folderName)}`; |
||||
|
} |
||||
|
|
||||
|
export async function pickBrowserWorkflowDirectory(): Promise<string | null> { |
||||
|
if (!isBrowserDirectoryPickerSupported() || !window.showDirectoryPicker) return null; |
||||
|
|
||||
|
const handle = await window.showDirectoryPicker(); |
||||
|
await ensurePermission(handle, "readwrite"); |
||||
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; |
||||
|
memoryHandles.set(id, handle); |
||||
|
await putDirectoryRecord({ id, handle }); |
||||
|
return `${PREFIX}${id}`; |
||||
|
} |
||||
|
|
||||
|
export async function writeBrowserWorkflowFile( |
||||
|
directoryPath: string, |
||||
|
filename: string, |
||||
|
workflow: WorkflowFile |
||||
|
): Promise<{ filePath: string }> { |
||||
|
const directoryHandle = await resolveDirectoryHandle(directoryPath, true); |
||||
|
await ensurePermission(directoryHandle, "readwrite"); |
||||
|
|
||||
|
await directoryHandle.getDirectoryHandle("inputs", { create: true }); |
||||
|
await directoryHandle.getDirectoryHandle("generations", { create: true }); |
||||
|
|
||||
|
const safeName = filename.replace(/[^a-zA-Z0-9-_]/g, "_"); |
||||
|
const fileName = `${safeName}.json`; |
||||
|
const fileHandle = await directoryHandle.getFileHandle(fileName, { create: true }); |
||||
|
const writable = await fileHandle.createWritable(); |
||||
|
await writable.write(JSON.stringify(workflow, null, 2)); |
||||
|
await writable.close(); |
||||
|
|
||||
|
return { filePath: `${directoryPath}/${fileName}` }; |
||||
|
} |
||||
|
|
||||
|
async function resolveDirectoryHandle(path: string, create: boolean): Promise<FileSystemDirectoryHandle> { |
||||
|
const parsed = parseBrowserFileSystemPath(path); |
||||
|
let handle = await getDirectoryHandleById(parsed.id); |
||||
|
await ensurePermission(handle, "readwrite"); |
||||
|
|
||||
|
for (const segment of parsed.segments) { |
||||
|
handle = await handle.getDirectoryHandle(segment, { create }); |
||||
|
} |
||||
|
|
||||
|
return handle; |
||||
|
} |
||||
|
|
||||
|
function parseBrowserFileSystemPath(path: string): { id: string; segments: string[] } { |
||||
|
if (!isBrowserFileSystemPath(path)) throw new Error("Not a browser file-system path"); |
||||
|
|
||||
|
const parts = path |
||||
|
.slice(PREFIX.length) |
||||
|
.split("/") |
||||
|
.filter(Boolean) |
||||
|
.map((part) => decodeURIComponent(part)); |
||||
|
|
||||
|
const id = parts[0]; |
||||
|
if (!id) throw new Error("Browser directory handle is missing"); |
||||
|
|
||||
|
return { id, segments: parts.slice(1) }; |
||||
|
} |
||||
|
|
||||
|
async function getDirectoryHandleById(id: string): Promise<FileSystemDirectoryHandle> { |
||||
|
const cached = memoryHandles.get(id); |
||||
|
if (cached) return cached; |
||||
|
|
||||
|
const record = await getDirectoryRecord(id); |
||||
|
if (!record) { |
||||
|
throw new Error("Browser folder permission was lost. Choose the folder again."); |
||||
|
} |
||||
|
|
||||
|
memoryHandles.set(id, record.handle); |
||||
|
return record.handle; |
||||
|
} |
||||
|
|
||||
|
async function ensurePermission(handle: FileSystemDirectoryHandle, mode: PermissionMode): Promise<void> { |
||||
|
const permissioned = handle as PermissionedDirectoryHandle; |
||||
|
if (!permissioned.queryPermission || !permissioned.requestPermission) return; |
||||
|
|
||||
|
const descriptor = { mode }; |
||||
|
const current = await permissioned.queryPermission(descriptor); |
||||
|
if (current === "granted") return; |
||||
|
|
||||
|
const requested = await permissioned.requestPermission(descriptor); |
||||
|
if (requested !== "granted") { |
||||
|
throw new Error("Folder permission was not granted."); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function openDb(): Promise<IDBDatabase> { |
||||
|
return new Promise((resolve, reject) => { |
||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION); |
||||
|
request.onupgradeneeded = () => { |
||||
|
request.result.createObjectStore(STORE_NAME, { keyPath: "id" }); |
||||
|
}; |
||||
|
request.onsuccess = () => resolve(request.result); |
||||
|
request.onerror = () => reject(request.error); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async function putDirectoryRecord(record: BrowserDirectoryRecord): Promise<void> { |
||||
|
const db = await openDb(); |
||||
|
await new Promise<void>((resolve, reject) => { |
||||
|
const tx = db.transaction(STORE_NAME, "readwrite"); |
||||
|
tx.objectStore(STORE_NAME).put(record); |
||||
|
tx.oncomplete = () => resolve(); |
||||
|
tx.onerror = () => reject(tx.error); |
||||
|
}); |
||||
|
db.close(); |
||||
|
} |
||||
|
|
||||
|
async function getDirectoryRecord(id: string): Promise<BrowserDirectoryRecord | null> { |
||||
|
const db = await openDb(); |
||||
|
const record = await new Promise<BrowserDirectoryRecord | null>((resolve, reject) => { |
||||
|
const tx = db.transaction(STORE_NAME, "readonly"); |
||||
|
const request = tx.objectStore(STORE_NAME).get(id); |
||||
|
request.onsuccess = () => resolve((request.result as BrowserDirectoryRecord | undefined) ?? null); |
||||
|
request.onerror = () => reject(request.error); |
||||
|
}); |
||||
|
db.close(); |
||||
|
return record; |
||||
|
} |
||||
Loading…
Reference in new issue