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