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:
@@ -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
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user