Browse Source

Let browser users choose local workflow folders

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 server
handoff-20260429-1057
jiajia 3 months ago
parent
commit
162521dc23
  1. 7
      src/components/Header.tsx
  2. 29
      src/components/ProjectSetupModal.tsx
  3. 35
      src/store/workflowStore.ts
  4. 164
      src/utils/browserFileSystem.ts

7
src/components/Header.tsx

@ -8,6 +8,7 @@ import { CostIndicator } from "./CostIndicator";
import { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog";
import { WorkflowBrowserModal } from "./WorkflowBrowserModal";
import { LANGUAGE_OPTIONS, useI18n } from "@/i18n";
import { isBrowserFileSystemPath } from "@/utils/browserFileSystem";
function CommentsNavigationIcon() {
const { t } = useI18n();
@ -137,6 +138,10 @@ export function Header() {
const handleOpenDirectory = async () => {
if (!saveDirectoryPath) return;
if (isBrowserFileSystemPath(saveDirectoryPath)) {
alert(t("header.openFolderFailed", { error: "Browser-selected folders cannot be opened from the server." }));
return;
}
try {
const response = await fetch("/api/open-directory", {
@ -261,7 +266,7 @@ export function Header() {
<span className="absolute top-0.5 right-0.5 w-2 h-2 rounded-full bg-red-500 ring-2 ring-neutral-900" />
)}
</button>
{saveDirectoryPath && (
{saveDirectoryPath && !isBrowserFileSystemPath(saveDirectoryPath) && (
<button
onClick={handleOpenDirectory}
className="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 rounded transition-colors"

29
src/components/ProjectSetupModal.tsx

@ -10,6 +10,12 @@ import { ProviderModel } from "@/lib/providers/types";
import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { useInlineParameters } from "@/hooks/useInlineParameters";
import { useI18n } from "@/i18n";
import {
isBrowserDirectoryPickerSupported,
isBrowserFileSystemPath,
joinBrowserFileSystemPath,
pickBrowserWorkflowDirectory,
} from "@/utils/browserFileSystem";
// LLM provider and model options (mirrored from LLMGenerateNode)
const LLM_PROVIDERS: { value: LLMProvider; label: string }[] = [
@ -119,6 +125,9 @@ export function ProjectSetupModal({
const joinPathForPlatform = (basePath: string, folderName: string): string => {
const trimmedBase = basePath.trim();
if (isBrowserFileSystemPath(trimmedBase)) {
return joinBrowserFileSystemPath(trimmedBase, folderName);
}
const separator = /^[A-Za-z]:[\\\/]/.test(trimmedBase) || trimmedBase.startsWith("\\\\") ? "\\" : "/";
const endsWithSeparator = trimmedBase.endsWith("/") || trimmedBase.endsWith("\\");
return `${trimmedBase}${endsWithSeparator ? "" : separator}${folderName}`;
@ -259,6 +268,14 @@ export function ProjectSetupModal({
setError(null);
try {
if (isBrowserDirectoryPickerSupported()) {
const browserPath = await pickBrowserWorkflowDirectory();
if (browserPath) {
setDirectoryPath(browserPath);
}
return;
}
const response = await fetch("/api/browse-directory");
const result = await response.json();
@ -295,8 +312,9 @@ export function ProjectSetupModal({
}
const fullProjectPath = ensureProjectSubfolderPath(directoryPath, name);
const isBrowserPath = isBrowserFileSystemPath(fullProjectPath);
if (!(fullProjectPath.startsWith("/") || /^[A-Za-z]:[\\\/]/.test(fullProjectPath) || fullProjectPath.startsWith("\\\\"))) {
if (!isBrowserPath && !(fullProjectPath.startsWith("/") || /^[A-Za-z]:[\\\/]/.test(fullProjectPath) || fullProjectPath.startsWith("\\\\"))) {
setError(t("project.errorAbsolutePath"));
return;
}
@ -305,6 +323,15 @@ export function ProjectSetupModal({
setError(null);
try {
if (isBrowserPath) {
const id = mode === "new" ? generateWorkflowId() : useWorkflowStore.getState().workflowId || generateWorkflowId();
setUseExternalImageStorage(false);
setLastProjectBaseDir(directoryPath);
onSave(id, name.trim(), fullProjectPath);
setIsValidating(false);
return;
}
// Validate path shape when it already exists
const response = await fetch(
`/api/workflow?path=${encodeURIComponent(fullProjectPath)}`

35
src/store/workflowStore.ts

@ -29,6 +29,7 @@ import { UndoManager, UndoSnapshot, clonePreservingStrings } from "./undoHistory
import { useToast } from "@/components/Toast";
import { logger } from "@/utils/logger";
import { externalizeWorkflowMedia, hydrateWorkflowMedia } from "@/utils/mediaStorage";
import { isBrowserFileSystemPath, writeBrowserWorkflowFile } from "@/utils/browserFileSystem";
import { EditOperation, applyEditOperations as executeEditOps } from "@/lib/chat/editOperations";
import { findNearestFreePosition } from "@/utils/spatialLayout";
import {
@ -2317,7 +2318,9 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
setWorkflowMetadata: (id: string, name: string, path: string, generationsPath?: string | null) => {
// Auto-derive generationsPath: use provided value, fall back to existing, then auto-derive
const currentGenPath = get().generationsPath;
const derivedGenerationsPath = generationsPath ?? currentGenPath ?? `${path}/generations`;
const derivedGenerationsPath = isBrowserFileSystemPath(path)
? null
: generationsPath ?? currentGenPath ?? `${path}/generations`;
set({
workflowId: id,
@ -2421,29 +2424,31 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
groups: groups && Object.keys(groups).length > 0 ? groups : undefined,
};
const isBrowserPath = isBrowserFileSystemPath(saveDirectoryPath);
// If external media storage is enabled, externalize media before saving
if (useExternalImageStorage) {
if (useExternalImageStorage && !isBrowserPath) {
workflow = await externalizeWorkflowMedia(workflow, saveDirectoryPath);
}
const response = await fetch("/api/workflow", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
directoryPath: saveDirectoryPath,
filename: workflowName,
workflow,
}),
});
const result = await response.json();
const result = isBrowserPath
? { success: true, ...(await writeBrowserWorkflowFile(saveDirectoryPath, workflowName, workflow)) }
: await fetch("/api/workflow", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
directoryPath: saveDirectoryPath,
filename: workflowName,
workflow,
}),
}).then((response) => response.json());
if (result.success) {
const timestamp = Date.now();
// If we externalized media, update store nodes with the refs
// This prevents duplicate media on subsequent saves
if (useExternalImageStorage && workflow.nodes !== currentNodes) {
if (useExternalImageStorage && !isBrowserPath && workflow.nodes !== currentNodes) {
// Merge refs from externalized nodes into current nodes (keeping media data)
const nodesWithRefs = currentNodes.map((node, index) => {
const externalizedNode = workflow.nodes[index];
@ -2509,7 +2514,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
lastSavedAt: timestamp,
hasUnsavedChanges: false,
// Update imageRefBasePath to reflect save location
imageRefBasePath: useExternalImageStorage ? saveDirectoryPath : null,
imageRefBasePath: useExternalImageStorage && !isBrowserPath ? saveDirectoryPath : null,
});
}

164
src/utils/browserFileSystem.ts

@ -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…
Cancel
Save