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) { export async function GET(request: NextRequest) {
const directoryPath = request.nextUrl.searchParams.get("path"); 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, directoryPath,
shouldLoad,
}); });
if (!directoryPath) { if (!directoryPath) {
@ -173,6 +175,54 @@ export async function GET(request: NextRequest) {
try { try {
const stats = await fs.stat(directoryPath); const stats = await fs.stat(directoryPath);
const isDirectory = stats.isDirectory(); 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', { logger.info('file.load', 'Directory validation successful', {
directoryPath, directoryPath,
exists: true, exists: true,

57
src/components/Header.tsx

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useRef, useMemo, useCallback } from "react"; import { useState, useMemo, useCallback } from "react";
import { useWorkflowStore, WorkflowFile } from "@/store/workflowStore"; import { useWorkflowStore, WorkflowFile } from "@/store/workflowStore";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { ProjectSetupModal } from "./ProjectSetupModal"; import { ProjectSetupModal } from "./ProjectSetupModal";
@ -93,7 +93,6 @@ export function Header() {
const [showProjectModal, setShowProjectModal] = useState(false); const [showProjectModal, setShowProjectModal] = useState(false);
const [projectModalMode, setProjectModalMode] = useState<"new" | "settings">("new"); const [projectModalMode, setProjectModalMode] = useState<"new" | "settings">("new");
const fileInputRef = useRef<HTMLInputElement>(null);
const isProjectConfigured = !!workflowName; const isProjectConfigured = !!workflowName;
const canSave = !!(workflowId && workflowName && saveDirectoryPath); const canSave = !!(workflowId && workflowName && saveDirectoryPath);
@ -115,31 +114,36 @@ export function Header() {
setShowProjectModal(true); setShowProjectModal(true);
}; };
const handleOpenFile = () => { const handleOpenFile = async () => {
fileInputRef.current?.click(); try {
}; // Open native OS directory picker
const browseRes = await fetch("/api/browse-directory");
const browseResult = await browseRes.json();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { if (!browseResult.success || browseResult.cancelled || !browseResult.path) {
const file = e.target.files?.[0]; if (!browseResult.success && !browseResult.cancelled) {
if (!file) return; alert(browseResult.error || "Failed to open directory picker");
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");
} }
} catch { return;
alert("Failed to parse workflow file");
} }
};
reader.readAsText(file);
// Reset input so same file can be loaded again const dirPath = browseResult.path;
e.target.value = "";
// 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) => { const handleProjectSave = async (id: string, name: string, path: string) => {
@ -226,13 +230,6 @@ export function Header() {
onSave={handleProjectSave} onSave={handleProjectSave}
mode={projectModalMode} 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"> <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"> <div className="flex items-center gap-2">
<button <button

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

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

Loading…
Cancel
Save