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
This commit is contained in:
gsxdsm
2026-06-09 11:28:50 -07:00
parent c98f27f525
commit 2e48e8627a
9 changed files with 372 additions and 23 deletions

View File

@@ -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

View File

@@ -3242,12 +3242,37 @@ export function searchFiles(query: string, workspace?: string, projectId?: strin
return api<FileSearchResult>(`/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<FileOperationResponse> {
const query = new URLSearchParams({ workspace });
if (projectId) {
query.set("projectId", projectId);
}
return api<FileOperationResponse>(`/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<FileOperationResponse> {
const query = new URLSearchParams({ workspace });
if (projectId) {
query.set("projectId", projectId);
}
return api<FileOperationResponse>(`/files/${encodeURIComponent(filePath)}?${query.toString()}`, {
method: "POST",
body: JSON.stringify({ content: "" }),
});
}
/** Copy a file or directory to a new location within a workspace. */

View File

@@ -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;

View File

@@ -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<HTMLInputElement>(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 (
<div className="context-menu-overlay" onClick={onCancel}>
<div className="file-browser-dialog" onClick={(e) => 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
<div className="context-menu-overlay" onClick={onCancel}>
<div className="file-browser-dialog" onClick={(e) => e.stopPropagation()}>
<div className="file-browser-dialog-title">{config.title}</div>
<div className="file-browser-dialog-info">
{type === "rename" ? entry.name : entryFullPath}
</div>
{entry && (
<div className="file-browser-dialog-info">
{type === "rename" ? entry.name : entryFullPath}
</div>
)}
<input
ref={inputRef}
className="file-browser-dialog-input"
@@ -441,6 +445,16 @@ export function FileBrowser({
openContextMenuAt(e.clientX, e.clientY, entry, entryPath(currentPath, entry.name));
}, [cancelLongPress, currentPath, openContextMenuAt]);
const openCreateDialog = useCallback((type: "create-file" | "create-folder") => {
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({
</button>
)}
<span className="file-browser-path">{currentPath === "." ? t("fileBrowser.root", "Root") : normalizeDisplayPath(currentPath)}</span>
<div className="file-browser-header-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => openCreateDialog("create-file")}
disabled={!workspace}
>
<FilePlus2 size={14} />
{t("fileBrowser.newFile", "New File")}
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => openCreateDialog("create-folder")}
disabled={!workspace}
>
<FolderPlus size={14} />
{t("fileBrowser.newFolder", "New Folder")}
</button>
</div>
</div>
<div className="file-browser-list">
@@ -619,7 +666,7 @@ export function FileBrowser({
)}
{/* Operation Dialog */}
{dialog.type && dialog.entry && (
{dialog.type && (
<OperationDialog
type={dialog.type}
entry={dialog.entry}

View File

@@ -21,10 +21,14 @@ vi.mock("lucide-react", async () => {
Pencil: (props: any) => <span data-testid="icon-pencil" {...props} />,
Download: (props: any) => <span data-testid="icon-download" {...props} />,
Archive: (props: any) => <span data-testid="icon-archive" {...props} />,
FilePlus2: (props: any) => <span data-testid="icon-file-plus" {...props} />,
FolderPlus: (props: any) => <span data-testid="icon-folder-plus" {...props} />,
};
});
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();

View File

@@ -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

View File

@@ -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<FileOperationResponse> {
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.

View File

@@ -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**:

View File

@@ -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);