From 2e48e8627a33b3b295968bb1917a84b554b86f07 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 9 Jun 2026 11:28:50 -0700 Subject: [PATCH] FN-6098: add workspace file creation actions Add workspace file and folder creation to the dashboard file browser. - add dashboard API helpers and backend route support for creating workspace directories and empty files - add New File and New Folder header actions in FileBrowser with dialog handling and responsive header styling - cover create flows with FileBrowser and route tests and document the new browser actions Files changed: docs/dashboard-guide.md | 1 + packages/dashboard/app/api/legacy.ts | 29 ++++++- packages/dashboard/app/components/FileBrowser.css | 32 ++++++++ packages/dashboard/app/components/FileBrowser.tsx | 75 ++++++++++++++---- packages/dashboard/app/components/__tests__/FileBrowser.test.tsx | 92 ++++++++++++++++++++++ packages/dashboard/src/__tests__/routes-git.test.ts | 55 +++++++++++++ packages/dashboard/src/file-service.ts | 69 +++++++++++++++- packages/dashboard/src/routes/README.md | 8 +- packages/dashboard/src/routes/register-file-workspace-routes.ts | 34 +++++++- 9 files changed, 372 insertions(+), 23 deletions(-) Fusion-Task-Id: FN-6098 Fusion-Task-Lineage: 9a2a1ca1-8b5d-4e95-ba76-e7166621c898 --- docs/dashboard-guide.md | 1 + packages/dashboard/app/api/legacy.ts | 29 +++++- .../dashboard/app/components/FileBrowser.css | 32 +++++++ .../dashboard/app/components/FileBrowser.tsx | 75 ++++++++++++--- .../components/__tests__/FileBrowser.test.tsx | 92 +++++++++++++++++++ .../src/__tests__/routes-git.test.ts | 55 +++++++++++ packages/dashboard/src/file-service.ts | 69 +++++++++++++- packages/dashboard/src/routes/README.md | 8 +- .../routes/register-file-workspace-routes.ts | 34 ++++++- 9 files changed, 372 insertions(+), 23 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 33ff139b8b..23115dedaf 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -421,6 +421,7 @@ For the full research workflow, provider setup, CLI commands, API reference, and The Files modal provides a workspace-aware file browser and editor. +- Use **New File** or **New Folder** in the browser header to create entries in the current folder; new files open in the editor after creation - Source/text editing supports a **Line #** header toggle to show or hide line numbers in the editor gutter - The line-number preference is saved per project and restored automatically when you switch projects diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 797739a352..b8a579d067 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -3242,12 +3242,37 @@ export function searchFiles(query: string, workspace?: string, projectId?: strin return api(`/files/search?${params.toString()}`); } -// --- Workspace File Operations API (Copy, Move, Delete, Rename, Download) --- +// --- Workspace File Operations API (Create, Copy, Move, Delete, Rename, Download) --- -/** File operation response for copy/move/delete/rename operations */ +/** File operation response for create/copy/move/delete/rename operations */ export interface FileOperationResponse { success: true; message?: string; + path?: string; +} + +/** Create a directory within a workspace. */ +export function createWorkspaceDirectory(workspace: string, dirPath: string, projectId?: string): Promise { + const query = new URLSearchParams({ workspace }); + if (projectId) { + query.set("projectId", projectId); + } + return api(`/files/mkdir?${query.toString()}`, { + method: "POST", + body: JSON.stringify({ path: dirPath }), + }); +} + +/** Create an empty file within a workspace. */ +export function createWorkspaceFile(workspace: string, filePath: string, projectId?: string): Promise { + const query = new URLSearchParams({ workspace }); + if (projectId) { + query.set("projectId", projectId); + } + return api(`/files/${encodeURIComponent(filePath)}?${query.toString()}`, { + method: "POST", + body: JSON.stringify({ content: "" }), + }); } /** Copy a file or directory to a new location within a workspace. */ diff --git a/packages/dashboard/app/components/FileBrowser.css b/packages/dashboard/app/components/FileBrowser.css index 6f62b16cb3..d04ea92728 100644 --- a/packages/dashboard/app/components/FileBrowser.css +++ b/packages/dashboard/app/components/FileBrowser.css @@ -150,6 +150,22 @@ font-size: var(--space-md); color: var(--text-muted); margin-left: auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-browser .file-browser-header-actions { + margin-left: var(--space-sm); + gap: var(--space-sm); + flex-shrink: 0; +} + +.file-browser .file-browser-header-actions .btn-sm { + display: inline-flex; + align-items: center; + gap: var(--space-xs); } .file-browser-list { @@ -388,6 +404,22 @@ flex-wrap: wrap; } + .file-browser .file-browser-header { + align-items: flex-start; + flex-wrap: wrap; + } + + .file-browser .file-browser-path { + flex: 1 1 auto; + max-width: none; + } + + .file-browser .file-browser-header-actions { + width: 100%; + margin-left: 0; + justify-content: flex-start; + } + .file-editor-toolbar { align-items: center; flex-wrap: nowrap; diff --git a/packages/dashboard/app/components/FileBrowser.tsx b/packages/dashboard/app/components/FileBrowser.tsx index 139abb9377..c2e9f89885 100644 --- a/packages/dashboard/app/components/FileBrowser.tsx +++ b/packages/dashboard/app/components/FileBrowser.tsx @@ -1,9 +1,9 @@ import "./FileBrowser.css"; import { useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Folder, File, ChevronRight, Loader2, Copy, Move, Trash2, Pencil, Download, Archive } from "lucide-react"; +import { Folder, File, ChevronRight, Loader2, Copy, Move, Trash2, Pencil, Download, Archive, FilePlus2, FolderPlus } from "lucide-react"; import type { FileNode } from "../api"; -import { copyFile, moveFile, deleteFile, renameFile, downloadFileUrl, downloadZipUrl } from "../api"; +import { copyFile, createWorkspaceDirectory, createWorkspaceFile, moveFile, deleteFile, renameFile, downloadFileUrl, downloadZipUrl } from "../api"; import { appendTokenQuery } from "../auth"; import { getErrorMessage } from "@fusion/core"; import { getParentDisplayPath, joinDisplayPath, normalizeDisplayPath } from "../utils/pathDisplay"; @@ -62,7 +62,7 @@ const INITIAL_CONTEXT_MENU: ContextMenuState = { // ── Operation Dialog Types ────────────────────────────────────────────── -type DialogType = "copy" | "move" | "rename" | "delete" | null; +type DialogType = "copy" | "move" | "rename" | "delete" | "create-file" | "create-folder" | null; interface DialogState { type: DialogType; @@ -192,7 +192,7 @@ function FileContextMenu({ x, y, entry, onAction, onClose }: FileContextMenuProp interface OperationDialogProps { type: DialogType; - entry: FileNode; + entry: FileNode | null; entryFullPath: string; onConfirm: (value: string) => void; onCancel: () => void; @@ -203,7 +203,7 @@ interface OperationDialogProps { function OperationDialog({ type, entry, entryFullPath, onConfirm, onCancel, loading, error }: OperationDialogProps) { const { t } = useTranslation("app"); const inputRef = useRef(null); - const defaultValue = type === "rename" ? entry.name : ""; + const defaultValue = type === "rename" && entry ? entry.name : ""; const [value, setValue] = useState(defaultValue); // Focus input on mount @@ -213,7 +213,7 @@ function OperationDialog({ type, entry, entryFullPath, onConfirm, onCancel, load // Select filename without extension for rename useEffect(() => { - if (type === "rename" && inputRef.current) { + if (type === "rename" && entry && inputRef.current) { const dotIndex = entry.name.lastIndexOf("."); if (dotIndex > 0) { inputRef.current.setSelectionRange(0, dotIndex); @@ -221,7 +221,7 @@ function OperationDialog({ type, entry, entryFullPath, onConfirm, onCancel, load inputRef.current.select(); } } - }, [type, entry.name]); + }, [type, entry]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && value.trim()) { @@ -232,7 +232,7 @@ function OperationDialog({ type, entry, entryFullPath, onConfirm, onCancel, load } }; - if (type === "delete") { + if (type === "delete" && entry) { return (
e.stopPropagation()}> @@ -263,6 +263,8 @@ function OperationDialog({ type, entry, entryFullPath, onConfirm, onCancel, load copy: { title: t("fileBrowser.copyTitle", "Copy"), placeholder: t("fileBrowser.copyPlaceholder", "Destination path"), confirm: t("fileBrowser.copy", "Copy") }, move: { title: t("fileBrowser.moveTitle", "Move"), placeholder: t("fileBrowser.movePlaceholder", "Destination path"), confirm: t("fileBrowser.move", "Move") }, rename: { title: t("fileBrowser.renameTitle", "Rename"), placeholder: t("fileBrowser.renamePlaceholder", "New name"), confirm: t("fileBrowser.rename", "Rename") }, + "create-file": { title: t("fileBrowser.newFile", "New File"), placeholder: t("fileBrowser.fileNamePlaceholder", "File name"), confirm: t("fileBrowser.create", "Create") }, + "create-folder": { title: t("fileBrowser.newFolder", "New Folder"), placeholder: t("fileBrowser.folderNamePlaceholder", "Folder name"), confirm: t("fileBrowser.create", "Create") }, }; const config = labels[type!]; @@ -271,9 +273,11 @@ function OperationDialog({ type, entry, entryFullPath, onConfirm, onCancel, load
e.stopPropagation()}>
{config.title}
-
- {type === "rename" ? entry.name : entryFullPath} -
+ {entry && ( +
+ {type === "rename" ? entry.name : entryFullPath} +
+ )} { + if (!workspace) return; + setDialog({ + type, + entry: null, + entryFullPath: currentPath, + }); + setOperationError(null); + }, [currentPath, workspace]); + const handleContextAction = useCallback((action: string) => { if (!contextMenu.entry) return; @@ -476,7 +490,7 @@ export function FileBrowser({ }, [contextMenu, workspace, projectId]); const handleDialogConfirm = useCallback(async (value: string) => { - if (!dialog.type || !dialog.entry || !workspace) return; + if (!dialog.type || !workspace) return; setOperationLoading(true); setOperationError(null); @@ -484,17 +498,30 @@ export function FileBrowser({ try { switch (dialog.type) { case "copy": + if (!dialog.entry) return; await copyFile(workspace, dialog.entryFullPath, value, projectId); break; case "move": + if (!dialog.entry) return; await moveFile(workspace, dialog.entryFullPath, value, projectId); break; case "rename": + if (!dialog.entry) return; await renameFile(workspace, dialog.entryFullPath, value, projectId); break; case "delete": + if (!dialog.entry) return; await deleteFile(workspace, dialog.entryFullPath, projectId); break; + case "create-file": { + const newFilePath = joinDisplayPath(dialog.entryFullPath, value); + await createWorkspaceFile(workspace, newFilePath, projectId); + onSelectFile(newFilePath); + break; + } + case "create-folder": + await createWorkspaceDirectory(workspace, joinDisplayPath(dialog.entryFullPath, value), projectId); + break; } setDialog(INITIAL_DIALOG); @@ -504,7 +531,7 @@ export function FileBrowser({ } finally { setOperationLoading(false); } - }, [dialog, workspace, onRefresh, projectId]); + }, [dialog, workspace, onRefresh, onSelectFile, projectId, t]); const handleDialogCancel = useCallback(() => { setDialog(INITIAL_DIALOG); @@ -563,6 +590,26 @@ export function FileBrowser({ )} {currentPath === "." ? t("fileBrowser.root", "Root") : normalizeDisplayPath(currentPath)} +
+ + +
@@ -619,7 +666,7 @@ export function FileBrowser({ )} {/* Operation Dialog */} - {dialog.type && dialog.entry && ( + {dialog.type && ( { Pencil: (props: any) => , Download: (props: any) => , Archive: (props: any) => , + FilePlus2: (props: any) => , + FolderPlus: (props: any) => , }; }); const mockCopyFile = vi.fn(); +const mockCreateWorkspaceDirectory = vi.fn(); +const mockCreateWorkspaceFile = vi.fn(); const mockMoveFile = vi.fn(); const mockDeleteFile = vi.fn(); const mockRenameFile = vi.fn(); @@ -37,6 +41,8 @@ const mockDownloadZipUrl = vi.fn((_workspace: string, filePath: string) => vi.mock("../../api", () => ({ copyFile: (...args: any[]) => mockCopyFile(...args), + createWorkspaceDirectory: (...args: any[]) => mockCreateWorkspaceDirectory(...args), + createWorkspaceFile: (...args: any[]) => mockCreateWorkspaceFile(...args), moveFile: (...args: any[]) => mockMoveFile(...args), deleteFile: (...args: any[]) => mockDeleteFile(...args), renameFile: (...args: any[]) => mockRenameFile(...args), @@ -161,6 +167,92 @@ describe("FileBrowser", () => { expect(screen.getByText("(empty directory)")).toBeDefined(); }); + it("renders New File button in header when workspace is provided", () => { + renderFileBrowser(); + expect(screen.getByRole("button", { name: /New File/i })).toBeDefined(); + }); + + it("renders New Folder button in header when workspace is provided", () => { + renderFileBrowser(); + expect(screen.getByRole("button", { name: /New Folder/i })).toBeDefined(); + }); + + it("disables create buttons when no workspace is provided", () => { + renderFileBrowser({ workspace: undefined }); + expect(screen.getByRole("button", { name: /New File/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /New Folder/i })).toBeDisabled(); + }); + + it("clicking New File opens a dialog with name input", () => { + renderFileBrowser(); + fireEvent.click(screen.getByRole("button", { name: /New File/i })); + expect(document.querySelector(".file-browser-dialog-title")?.textContent).toBe("New File"); + expect(screen.getByPlaceholderText("File name")).toBeDefined(); + }); + + it("clicking New Folder opens a dialog with name input", () => { + renderFileBrowser(); + fireEvent.click(screen.getByRole("button", { name: /New Folder/i })); + expect(document.querySelector(".file-browser-dialog-title")?.textContent).toBe("New Folder"); + expect(screen.getByPlaceholderText("Folder name")).toBeDefined(); + }); + + it("creating a file calls createWorkspaceFile, refreshes, and selects the file", async () => { + mockCreateWorkspaceFile.mockResolvedValue({ success: true }); + const onRefresh = vi.fn(); + const onSelectFile = vi.fn(); + renderFileBrowser({ currentPath: "docs", onRefresh, onSelectFile }); + fireEvent.click(screen.getByRole("button", { name: /New File/i })); + fireEvent.change(screen.getByPlaceholderText("File name"), { target: { value: "notes.md" } }); + fireEvent.click(screen.getByRole("button", { name: "Create" })); + + await waitFor(() => { + expect(mockCreateWorkspaceFile).toHaveBeenCalledWith("test-ws", "docs/notes.md", "project-1"); + expect(onRefresh).toHaveBeenCalled(); + expect(onSelectFile).toHaveBeenCalledWith("docs/notes.md"); + }); + }); + + it("creating a folder calls createWorkspaceDirectory and refreshes", async () => { + mockCreateWorkspaceDirectory.mockResolvedValue({ success: true }); + const onRefresh = vi.fn(); + renderFileBrowser({ currentPath: "docs", onRefresh }); + fireEvent.click(screen.getByRole("button", { name: /New Folder/i })); + fireEvent.change(screen.getByPlaceholderText("Folder name"), { target: { value: "drafts" } }); + fireEvent.click(screen.getByRole("button", { name: "Create" })); + + await waitFor(() => { + expect(mockCreateWorkspaceDirectory).toHaveBeenCalledWith("test-ws", "docs/drafts", "project-1"); + expect(onRefresh).toHaveBeenCalled(); + }); + }); + + it("shows create error state in dialog", async () => { + mockCreateWorkspaceDirectory.mockRejectedValue(new Error("Already exists")); + renderFileBrowser(); + fireEvent.click(screen.getByRole("button", { name: /New Folder/i })); + fireEvent.change(screen.getByPlaceholderText("Folder name"), { target: { value: "src" } }); + fireEvent.click(screen.getByRole("button", { name: "Create" })); + + await waitFor(() => { + expect(screen.getByText("Already exists")).toBeDefined(); + }); + }); + + it("cancels create dialog on Cancel", () => { + renderFileBrowser(); + fireEvent.click(screen.getByRole("button", { name: /New File/i })); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByPlaceholderText("File name")).toBeNull(); + }); + + it("closes create dialog on Escape", () => { + renderFileBrowser(); + fireEvent.click(screen.getByRole("button", { name: /New Folder/i })); + fireEvent.keyDown(screen.getByPlaceholderText("Folder name"), { key: "Escape" }); + expect(screen.queryByPlaceholderText("Folder name")).toBeNull(); + }); + it("shows loading state", () => { renderFileBrowser({ entries: [], loading: true }); expect(screen.getByText("Loading files...")).toBeDefined(); diff --git a/packages/dashboard/src/__tests__/routes-git.test.ts b/packages/dashboard/src/__tests__/routes-git.test.ts index cc13bc868d..d643a856f9 100644 --- a/packages/dashboard/src/__tests__/routes-git.test.ts +++ b/packages/dashboard/src/__tests__/routes-git.test.ts @@ -1647,6 +1647,61 @@ describe("Workspace File Routes", () => { }); }); + describe("POST /files/mkdir", () => { + let rootDir: string; + + beforeEach(() => { + rootDir = mkdtempSync(join(tmpdir(), "kb-mkdir-route-")); + store = createMockStore({ getRootDir: vi.fn().mockReturnValue(rootDir) }); + }); + + afterEach(() => { + rmSync(rootDir, { recursive: true, force: true }); + }); + + it("creates a directory in the workspace", async () => { + const res = await REQUEST( + buildApp(), + "POST", + "/api/files/mkdir?workspace=project", + JSON.stringify({ path: "docs" }), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, path: "docs" }); + expect(existsSync(join(rootDir, "docs"))).toBe(true); + }); + + it("rejects duplicate directories with 409", async () => { + mkdirSync(join(rootDir, "docs")); + + const res = await REQUEST( + buildApp(), + "POST", + "/api/files/mkdir?workspace=project", + JSON.stringify({ path: "docs" }), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("already exists"); + }); + + it("rejects missing parent directories with 404", async () => { + const res = await REQUEST( + buildApp(), + "POST", + "/api/files/mkdir?workspace=project", + JSON.stringify({ path: "missing/child" }), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("Parent directory does not exist"); + }); + }); + describe("Generic write route still enforces content validation", () => { it("POST /files/{*filepath} still requires content for actual writes", async () => { // Make sure the generic write route still validates content correctly diff --git a/packages/dashboard/src/file-service.ts b/packages/dashboard/src/file-service.ts index 10480c973f..4130a4154b 100644 --- a/packages/dashboard/src/file-service.ts +++ b/packages/dashboard/src/file-service.ts @@ -40,11 +40,12 @@ export interface SaveFileResponse { } /** - * File operation response for copy/move/delete/rename operations. + * File operation response for copy/move/delete/rename/create operations. */ export interface FileOperationResponse { success: true; message?: string; + path?: string; } /** @@ -465,7 +466,71 @@ export async function writeWorkspaceFile( return writeFileForBasePath(workspaceBase, filePath, content); } -// ── Workspace File Operations (Copy, Move, Delete, Rename) ───────── +// ── Workspace File Operations (Create, Copy, Move, Delete, Rename) ───────── + +/** + * Create a directory within a workspace. + * + * @param store - The TaskStore instance + * @param workspace - Workspace identifier ("project" or task ID) + * @param dirPath - Relative directory path within the workspace + * @returns FileOperationResponse indicating success with the created path + * @throws FileServiceError on validation or filesystem errors + */ +export async function createWorkspaceDirectory( + store: TaskStore, + workspace: WorkspaceId, + dirPath: string, +): Promise { + if (!dirPath || !dirPath.trim()) { + throw new FileServiceError("Directory path is required", "EINVAL"); + } + + const workspaceBase = await getWorkspaceBasePath(store, workspace); + const resolvedPath = validatePath(workspaceBase, dirPath); + + try { + await stat(resolvedPath); + throw new FileServiceError(`Path already exists: ${dirPath}`, "EEXIST"); + } catch (err: unknown) { + const error = err as Error & { code?: string }; + if (error.code !== "ENOENT") { + if (err instanceof FileServiceError) throw err; + throw err; + } + } + + const parentDir = dirname(resolvedPath); + try { + const parentStats = await stat(parentDir); + if (!parentStats.isDirectory()) { + throw new FileServiceError(`Parent is not a directory: ${dirPath}`, "ENOENT"); + } + } catch (err: unknown) { + const error = err as Error & { code?: string }; + if (error.code === "ENOENT") { + throw new FileServiceError(`Parent directory does not exist: ${dirPath}`, "ENOENT"); + } + throw err; + } + + try { + await mkdir(resolvedPath); + return { success: true, path: dirPath }; + } catch (err: unknown) { + const error = err as Error & { code?: string }; + if (error.code === "EEXIST") { + throw new FileServiceError(`Path already exists: ${dirPath}`, "EEXIST"); + } + if (error.code === "ENOENT") { + throw new FileServiceError(`Parent directory does not exist: ${dirPath}`, "ENOENT"); + } + if (error.code === "EACCES" || error.code === "EPERM") { + throw new FileServiceError(`Permission denied: ${dirPath}`, "EACCES"); + } + throw err; + } +} /** * Validate that both source and destination paths are within the allowed workspace. diff --git a/packages/dashboard/src/routes/README.md b/packages/dashboard/src/routes/README.md index 4fdee38f78..e2cfc6b16b 100644 --- a/packages/dashboard/src/routes/README.md +++ b/packages/dashboard/src/routes/README.md @@ -52,8 +52,8 @@ The context provides core cross-cutting plumbing: - `register-file-workspace-routes.ts` — task/workspace file domain: - Task files: `/tasks/:id/files`, `/tasks/:id/files/{*filepath}` (read/write) - Workspace discovery/files: `/workspaces`, `/files`, `/files/markdown-list`, `/files/search`, `/files/{*filepath}` - - File operations: `/files/{*filepath}/copy|move|delete|rename`, `/files/{*filepath}/download`, `/files/{*filepath}/download-zip` - - Generic wildcard write: `/files/{*filepath}` (must remain after operation routes) + - File operations: `/files/{*filepath}/copy|move|delete|rename`, `/files/mkdir`, `/files/{*filepath}/download`, `/files/{*filepath}/download-zip` + - Generic wildcard write: `/files/{*filepath}` (must remain after operation routes, including `/files/mkdir`) - Project markdown search: `/project-files/md` - `register-session-diff-routes.ts` — task session/diff domain: - Session changed-file list: `/tasks/:id/session-files` @@ -110,8 +110,8 @@ Compatibility re-exports that must remain on `routes.ts` for tests and existing Express matches in registration order. Keep registrar and in-registrar route ordering stable: 1. **Specific operation routes before generic parameterized routes** (`/runs`, `/runs/:id`, `/copy`, `/delete` before `/:id` style handlers) -2. **Specific operation routes before wildcard paths** (`/files/{*filepath}/copy|move|delete|rename|download|download-zip` before `POST /files/{*filepath}`) - - Why: Express route matching is first-win. If the wildcard write route is registered first, paths like `/files/somefolder/delete` will be treated as file writes instead of delete operations. +2. **Specific operation routes before wildcard paths** (`/files/{*filepath}/copy|move|delete|rename|download|download-zip` and `/files/mkdir` before `POST /files/{*filepath}`) + - Why: Express route matching is first-win. If the wildcard write route is registered first, paths like `/files/somefolder/delete` or `/files/mkdir` will be treated as file writes instead of file operations. 3. **Do not move proxy/script/message/file wildcards ahead of specific routes** - For proxy routes specifically, keep all explicit `GET /proxy/:nodeId/*` handlers ahead of `ALL /proxy/:nodeId/{*splat}` and keep proxy registration last in `createApiRoutes()`. 4. **Project/node/sync/discovery ordering constraints must stay intact**: diff --git a/packages/dashboard/src/routes/register-file-workspace-routes.ts b/packages/dashboard/src/routes/register-file-workspace-routes.ts index 6245caaa32..2b93fd58e2 100644 --- a/packages/dashboard/src/routes/register-file-workspace-routes.ts +++ b/packages/dashboard/src/routes/register-file-workspace-routes.ts @@ -4,6 +4,7 @@ import type { Request } from "express"; import { ApiError, badRequest } from "../api-error.js"; import { copyWorkspaceFile, + createWorkspaceDirectory, deleteWorkspaceFile, FileServiceError, getWorkspaceFileForDownload, @@ -423,7 +424,38 @@ export function registerFileWorkspaceRoutes(ctx: ApiRoutesContext): void { } }); - // Must remain after copy/move/delete/rename/download routes. + // MUST be before generic wildcard write route. + router.post("/files/mkdir", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0 + ? req.query.workspace + : "project"; + const { path } = req.body; + + if (!path || typeof path !== "string") { + throw badRequest("path is required and must be a string"); + } + + const result = await createWorkspaceDirectory(scopedStore, workspace, path); + res.json(result); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if (err instanceof FileServiceError) { + const status = err.code === "ENOTASK" ? 404 + : err.code === "ENOENT" ? 404 + : err.code === "EEXIST" ? 409 + : err.code === "EACCES" ? 403 + : 400; + throw new ApiError(status, err.message, { code: err.code }); + } + rethrowAsApiError(err, "Internal server error"); + } + }); + + // Must remain after copy/move/delete/rename/download/mkdir routes. router.post("/files/{*filepath}", async (req, res) => { try { const { store: scopedStore } = await getProjectContext(req);