Browse Source

feat: open workflow via native OS directory picker for media hydration

Replace browser file picker with the existing /api/browse-directory native
OS folder picker so loadWorkflow receives the actual filesystem path,
enabling media hydration from sibling inputs/ and generations/ folders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
42432f721f
  1. 54
      src/app/api/workflow/route.ts
  2. 57
      src/components/Header.tsx
  3. 66
      src/components/__tests__/Header.test.tsx

54
src/app/api/workflow/route.ts

@ -141,12 +141,14 @@ export async function POST(request: NextRequest) {
}
}
// GET: Validate directory path
// GET: Validate directory path, or load workflow from directory
export async function GET(request: NextRequest) {
const directoryPath = request.nextUrl.searchParams.get("path");
const shouldLoad = request.nextUrl.searchParams.get("load") === "true";
logger.info('file.load', 'Directory validation request received', {
logger.info('file.load', 'Directory request received', {
directoryPath,
shouldLoad,
});
if (!directoryPath) {
@ -173,6 +175,54 @@ export async function GET(request: NextRequest) {
try {
const stats = await fs.stat(directoryPath);
const isDirectory = stats.isDirectory();
if (!isDirectory) {
return NextResponse.json({
success: true,
exists: true,
isDirectory: false,
});
}
// If load=true, find and return a workflow JSON from the directory
if (shouldLoad) {
const entries = await fs.readdir(directoryPath);
const jsonFiles = entries.filter(f => f.endsWith(".json"));
for (const jsonFile of jsonFiles) {
try {
const filePath = path.join(directoryPath, jsonFile);
const content = await fs.readFile(filePath, "utf-8");
const parsed = JSON.parse(content);
if (parsed.version && parsed.nodes && parsed.edges) {
const filename = path.basename(jsonFile, ".json");
logger.info('file.load', 'Workflow loaded from directory', {
directoryPath,
filename: jsonFile,
});
return NextResponse.json({
success: true,
workflow: parsed,
filename,
});
}
} catch {
// Skip files that can't be read or parsed
continue;
}
}
logger.warn('file.load', 'No valid workflow file found in directory', {
directoryPath,
jsonFilesChecked: jsonFiles.length,
});
return NextResponse.json(
{ success: false, error: "No workflow file found in directory" },
{ status: 404 }
);
}
logger.info('file.load', 'Directory validation successful', {
directoryPath,
exists: true,

57
src/components/Header.tsx

@ -1,6 +1,6 @@
"use client";
import { useState, useRef, useMemo, useCallback } from "react";
import { useState, useMemo, useCallback } from "react";
import { useWorkflowStore, WorkflowFile } from "@/store/workflowStore";
import { useShallow } from "zustand/shallow";
import { ProjectSetupModal } from "./ProjectSetupModal";
@ -93,7 +93,6 @@ export function Header() {
const [showProjectModal, setShowProjectModal] = useState(false);
const [projectModalMode, setProjectModalMode] = useState<"new" | "settings">("new");
const fileInputRef = useRef<HTMLInputElement>(null);
const isProjectConfigured = !!workflowName;
const canSave = !!(workflowId && workflowName && saveDirectoryPath);
@ -115,31 +114,36 @@ export function Header() {
setShowProjectModal(true);
};
const handleOpenFile = () => {
fileInputRef.current?.click();
};
const handleOpenFile = async () => {
try {
// Open native OS directory picker
const browseRes = await fetch("/api/browse-directory");
const browseResult = await browseRes.json();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
try {
const workflow = JSON.parse(event.target?.result as string) as WorkflowFile;
if (workflow.version && workflow.nodes && workflow.edges) {
await loadWorkflow(workflow);
} else {
alert("Invalid workflow file format");
if (!browseResult.success || browseResult.cancelled || !browseResult.path) {
if (!browseResult.success && !browseResult.cancelled) {
alert(browseResult.error || "Failed to open directory picker");
}
} catch {
alert("Failed to parse workflow file");
return;
}
};
reader.readAsText(file);
// Reset input so same file can be loaded again
e.target.value = "";
const dirPath = browseResult.path;
// Load workflow JSON from that directory
const loadRes = await fetch(`/api/workflow?path=${encodeURIComponent(dirPath)}&load=true`);
const loadResult = await loadRes.json();
if (!loadResult.success) {
alert(loadResult.error || "No workflow file found in directory");
return;
}
const workflow = loadResult.workflow as WorkflowFile;
await loadWorkflow(workflow, dirPath);
} catch (error) {
console.error("Failed to open workflow:", error);
alert("Failed to open workflow. Please try again.");
}
};
const handleProjectSave = async (id: string, name: string, path: string) => {
@ -226,13 +230,6 @@ export function Header() {
onSave={handleProjectSave}
mode={projectModalMode}
/>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
className="hidden"
/>
<header className="h-11 bg-neutral-900 border-b border-neutral-800 flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-2">
<button

66
src/components/__tests__/Header.test.tsx

@ -250,24 +250,18 @@ describe("Header", () => {
expect(openButton).toBeInTheDocument();
});
it("should have hidden file input for loading workflows", () => {
const { container } = render(<Header />);
const fileInput = container.querySelector('input[type="file"]');
expect(fileInput).toBeInTheDocument();
expect(fileInput).toHaveAttribute("accept", ".json");
expect(fileInput).toHaveClass("hidden");
});
it("should call browse-directory API when open button is clicked", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, cancelled: true }),
});
global.fetch = mockFetch;
it("should trigger file input click when open button is clicked", () => {
const { container } = render(<Header />);
render(<Header />);
const openButton = screen.getByTitle("Open project");
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
// Mock click on file input
const clickSpy = vi.spyOn(fileInput, "click");
fireEvent.click(openButton);
expect(clickSpy).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith("/api/browse-directory");
});
});
@ -405,23 +399,45 @@ describe("Header", () => {
});
});
describe("File Loading", () => {
it("should not call loadWorkflow when no file is selected", () => {
const { container } = render(<Header />);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
describe("File Loading via Directory Picker", () => {
it("should not call loadWorkflow when directory picker is cancelled", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, cancelled: true }),
});
global.fetch = mockFetch;
// Trigger file change with empty files
fireEvent.change(fileInput, { target: { files: [] } });
render(<Header />);
const openButton = screen.getByTitle("Open project");
fireEvent.click(openButton);
// Wait for the async handler to settle
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("/api/browse-directory");
});
expect(mockLoadWorkflow).not.toHaveBeenCalled();
});
it("should reset file input value after file selection to allow re-selecting same file", () => {
const { container } = render(<Header />);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
it("should load workflow from selected directory", async () => {
const mockWorkflow = { version: "1.0", nodes: [], edges: [], name: "Test" };
const mockFetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, path: "/path/to/project" }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, workflow: mockWorkflow, filename: "Test" }),
});
global.fetch = mockFetch;
render(<Header />);
const openButton = screen.getByTitle("Open project");
fireEvent.click(openButton);
// File input should accept .json files
expect(fileInput).toHaveAttribute("accept", ".json");
await vi.waitFor(() => {
expect(mockLoadWorkflow).toHaveBeenCalledWith(mockWorkflow, "/path/to/project");
});
});
});

Loading…
Cancel
Save