feat(FN-943): add file browser context menu with copy, move, delete, rename, and download operations

- Add backend file operations (copy, move, delete, rename, download) in file-service.ts with workspace root protection
- Add API routes for file operations including directory download as zip via archiver
- Add client API functions in api.ts for all file operation endpoints
- Add context menu UI in FileBrowser component with operations dialog for confirming destructive actions
- Add CSS styles for context menu including danger items and dividers
- Add 33 FileBrowser context menu tests and 485+ file-service tests
- Fix pre-existing test failures in routes-diff, mission-e2e, and theme sync tests
This commit is contained in:
gsxdsm
2026-04-05 20:54:11 -07:00
parent ec36033005
commit 81e4eb002b
13 changed files with 2580 additions and 29 deletions

View File

@@ -1096,6 +1096,61 @@ export function saveWorkspaceFileContent(workspace: string, filePath: string, co
});
}
// --- Workspace File Operations API (Copy, Move, Delete, Rename, Download) ---
/** File operation response for copy/move/delete/rename operations */
export interface FileOperationResponse {
success: true;
message?: string;
}
/** Copy a file or directory to a new location within a workspace. */
export function copyFile(workspace: string, filePath: string, destination: string): Promise<FileOperationResponse> {
const query = new URLSearchParams({ workspace });
return api<FileOperationResponse>(`/files/${encodeURIComponent(filePath)}/copy?${query.toString()}`, {
method: "POST",
body: JSON.stringify({ destination }),
});
}
/** Move a file or directory to a new location within a workspace. */
export function moveFile(workspace: string, filePath: string, destination: string): Promise<FileOperationResponse> {
const query = new URLSearchParams({ workspace });
return api<FileOperationResponse>(`/files/${encodeURIComponent(filePath)}/move?${query.toString()}`, {
method: "POST",
body: JSON.stringify({ destination }),
});
}
/** Delete a file or directory within a workspace. */
export function deleteFile(workspace: string, filePath: string): Promise<FileOperationResponse> {
const query = new URLSearchParams({ workspace });
return api<FileOperationResponse>(`/files/${encodeURIComponent(filePath)}/delete?${query.toString()}`, {
method: "POST",
});
}
/** Rename a file or directory within a workspace. */
export function renameFile(workspace: string, filePath: string, newName: string): Promise<FileOperationResponse> {
const query = new URLSearchParams({ workspace });
return api<FileOperationResponse>(`/files/${encodeURIComponent(filePath)}/rename?${query.toString()}`, {
method: "POST",
body: JSON.stringify({ newName }),
});
}
/** Get the download URL for a single file in a workspace. */
export function downloadFileUrl(workspace: string, filePath: string): string {
const query = new URLSearchParams({ workspace });
return `/api/files/${encodeURIComponent(filePath)}/download?${query.toString()}`;
}
/** Get the download URL for a folder as ZIP in a workspace. */
export function downloadZipUrl(workspace: string, filePath: string): string {
const query = new URLSearchParams({ workspace });
return `/api/files/${encodeURIComponent(filePath)}/download-zip?${query.toString()}`;
}
// --- Planning Mode API ---
/** Planning session state returned from API */

View File

@@ -1,5 +1,7 @@
import { Folder, File, ChevronRight, Loader2 } from "lucide-react";
import { useState, useCallback, useEffect, useRef } from "react";
import { Folder, File, ChevronRight, Loader2, Copy, Move, Trash2, Pencil, Download, Archive } from "lucide-react";
import type { FileNode } from "../api";
import { copyFile, moveFile, deleteFile, renameFile, downloadFileUrl, downloadZipUrl } from "../api";
interface FileBrowserProps {
entries: FileNode[];
@@ -9,6 +11,10 @@ interface FileBrowserProps {
loading?: boolean;
error?: string | null;
onRetry?: () => void;
/** Workspace identifier for file operations ("project" or task ID) */
workspace?: string;
/** Callback to refresh the file list after an operation */
onRefresh?: () => void;
}
function formatBytes(bytes?: number): string {
@@ -24,6 +30,252 @@ function formatTime(mtime?: string): string {
return date.toLocaleDateString();
}
/** Build the full relative path for a file/directory entry */
function entryPath(currentPath: string, name: string): string {
return currentPath === "." ? name : `${currentPath}/${name}`;
}
// ── Context Menu State ──────────────────────────────────────────────────
interface ContextMenuState {
visible: boolean;
x: number;
y: number;
entry: FileNode | null;
entryFullPath: string;
}
const INITIAL_CONTEXT_MENU: ContextMenuState = {
visible: false,
x: 0,
y: 0,
entry: null,
entryFullPath: "",
};
// ── Operation Dialog Types ──────────────────────────────────────────────
type DialogType = "copy" | "move" | "rename" | "delete" | null;
interface DialogState {
type: DialogType;
entry: FileNode | null;
entryFullPath: string;
}
const INITIAL_DIALOG: DialogState = { type: null, entry: null, entryFullPath: "" };
// ── Context Menu Component ──────────────────────────────────────────────
interface ContextMenuItem {
id: string;
label: string;
icon: typeof Copy;
disabled: boolean;
}
interface FileContextMenuProps {
x: number;
y: number;
entry: FileNode;
onAction: (action: string) => void;
onClose: () => void;
}
function FileContextMenu({ x, y, entry, onAction, onClose }: FileContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [adjustedPos, setAdjustedPos] = useState({ x, y });
// Adjust position to prevent viewport overflow
useEffect(() => {
const menu = menuRef.current;
if (!menu) return;
const rect = menu.getBoundingClientRect();
const pad = 8;
let ax = x;
let ay = y;
if (ax + rect.width > window.innerWidth - pad) {
ax = window.innerWidth - pad - rect.width;
}
if (ay + rect.height > window.innerHeight - pad) {
ay = window.innerHeight - pad - rect.height;
}
if (ax < pad) ax = pad;
if (ay < pad) ay = pad;
setAdjustedPos({ x: ax, y: ay });
}, [x, y]);
// Close on Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const isDir = entry.type === "directory";
const items: ContextMenuItem[] = [
{ id: "copy", label: "Copy", icon: Copy, disabled: false },
{ id: "move", label: "Move", icon: Move, disabled: false },
{ id: "rename", label: "Rename", icon: Pencil, disabled: false },
...(isDir
? [{ id: "download-zip" as string, label: "Download as ZIP", icon: Archive, disabled: false }]
: [{ id: "download" as string, label: "Download", icon: Download, disabled: false }]
),
{ id: "divider", label: "", icon: Copy, disabled: true },
{ id: "delete", label: "Delete", icon: Trash2, disabled: false },
];
return (
<div className="context-menu-overlay" onClick={onClose}>
<div
ref={menuRef}
className="file-browser-context-menu"
role="menu"
aria-label="File operations"
style={{ left: adjustedPos.x, top: adjustedPos.y }}
onClick={(e) => e.stopPropagation()}
>
{items.map((item) =>
item.id === "divider" ? (
<div key="divider" className="file-browser-context-menu__divider" role="separator" />
) : (
<button
key={item.id}
role="menuitem"
className={`file-browser-context-menu__item ${
item.disabled ? "file-browser-context-menu__disabled" : ""
} ${item.id === "delete" ? "file-browser-context-menu__item--danger" : ""}`}
disabled={item.disabled}
onClick={() => onAction(item.id)}
>
<item.icon size={14} className="file-browser-context-menu__item-icon" />
<span>{item.label}</span>
</button>
)
)}
</div>
</div>
);
}
// ── Operation Dialog Component ──────────────────────────────────────────
interface OperationDialogProps {
type: DialogType;
entry: FileNode;
entryFullPath: string;
onConfirm: (value: string) => void;
onCancel: () => void;
loading: boolean;
error: string | null;
}
function OperationDialog({ type, entry, entryFullPath, onConfirm, onCancel, loading, error }: OperationDialogProps) {
const inputRef = useRef<HTMLInputElement>(null);
const defaultValue = type === "rename" ? entry.name : "";
const [value, setValue] = useState(defaultValue);
// Focus input on mount
useEffect(() => {
setTimeout(() => inputRef.current?.focus(), 50);
}, []);
// Select filename without extension for rename
useEffect(() => {
if (type === "rename" && inputRef.current) {
const dotIndex = entry.name.lastIndexOf(".");
if (dotIndex > 0) {
inputRef.current.setSelectionRange(0, dotIndex);
} else {
inputRef.current.select();
}
}
}, [type, entry.name]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && value.trim()) {
onConfirm(value.trim());
}
if (e.key === "Escape") {
onCancel();
}
};
if (type === "delete") {
return (
<div className="context-menu-overlay" onClick={onCancel}>
<div className="file-browser-dialog" onClick={(e) => e.stopPropagation()}>
<div className="file-browser-dialog-title">Delete {entry.type === "directory" ? "Folder" : "File"}</div>
<div className="file-browser-dialog-message">
Are you sure you want to delete <strong>{entry.name}</strong>?
{entry.type === "directory" && " This will delete all contents recursively."}
</div>
{error && <div className="file-browser-dialog-error">{error}</div>}
<div className="file-browser-dialog-actions">
<button className="btn btn-sm" onClick={onCancel} disabled={loading}>
Cancel
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => onConfirm("")}
disabled={loading}
>
{loading ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
);
}
const labels: Record<string, { title: string; placeholder: string; confirm: string }> = {
copy: { title: "Copy", placeholder: "Destination path", confirm: "Copy" },
move: { title: "Move", placeholder: "Destination path", confirm: "Move" },
rename: { title: "Rename", placeholder: "New name", confirm: "Rename" },
};
const config = labels[type!];
return (
<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>
<input
ref={inputRef}
className="file-browser-dialog-input"
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={config.placeholder}
onKeyDown={handleKeyDown}
disabled={loading}
/>
{error && <div className="file-browser-dialog-error">{error}</div>}
<div className="file-browser-dialog-actions">
<button className="btn btn-sm" onClick={onCancel} disabled={loading}>
Cancel
</button>
<button
className="btn btn-primary btn-sm"
onClick={() => onConfirm(value.trim())}
disabled={loading || !value.trim()}
>
{loading ? `${config.confirm}ing...` : config.confirm}
</button>
</div>
</div>
</div>
);
}
// ── Main FileBrowser Component ──────────────────────────────────────────
export function FileBrowser({
entries,
currentPath,
@@ -32,7 +284,113 @@ export function FileBrowser({
loading,
error,
onRetry,
workspace,
onRefresh,
}: FileBrowserProps) {
const [contextMenu, setContextMenu] = useState<ContextMenuState>(INITIAL_CONTEXT_MENU);
const [dialog, setDialog] = useState<DialogState>(INITIAL_DIALOG);
const [operationLoading, setOperationLoading] = useState(false);
const [operationError, setOperationError] = useState<string | null>(null);
// Close context menu on scroll within the file browser
useEffect(() => {
if (!contextMenu.visible) return;
const browserList = document.querySelector(".file-browser-list");
const handleClose = () => setContextMenu(INITIAL_CONTEXT_MENU);
browserList?.addEventListener("scroll", handleClose);
return () => browserList?.removeEventListener("scroll", handleClose);
}, [contextMenu.visible]);
// Close context menu on click outside or Escape
useEffect(() => {
if (!contextMenu.visible) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setContextMenu(INITIAL_CONTEXT_MENU);
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [contextMenu.visible]);
const handleContextMenu = useCallback((e: React.MouseEvent, entry: FileNode) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
entry,
entryFullPath: entryPath(currentPath, entry.name),
});
}, [currentPath]);
const handleContextAction = useCallback((action: string) => {
if (!contextMenu.entry) return;
const entry = contextMenu.entry;
const fullPath = contextMenu.entryFullPath;
setContextMenu(INITIAL_CONTEXT_MENU);
// Download actions trigger directly (no dialog)
if (action === "download") {
if (!workspace) return;
const url = downloadFileUrl(workspace, fullPath);
window.open(url, "_blank");
return;
}
if (action === "download-zip") {
if (!workspace) return;
const url = downloadZipUrl(workspace, fullPath);
window.open(url, "_blank");
return;
}
// Other actions open a dialog
setDialog({
type: action as DialogType,
entry,
entryFullPath: fullPath,
});
setOperationError(null);
}, [contextMenu, workspace]);
const handleDialogConfirm = useCallback(async (value: string) => {
if (!dialog.type || !dialog.entry || !workspace) return;
setOperationLoading(true);
setOperationError(null);
try {
switch (dialog.type) {
case "copy":
await copyFile(workspace, dialog.entryFullPath, value);
break;
case "move":
await moveFile(workspace, dialog.entryFullPath, value);
break;
case "rename":
await renameFile(workspace, dialog.entryFullPath, value);
break;
case "delete":
await deleteFile(workspace, dialog.entryFullPath);
break;
}
setDialog(INITIAL_DIALOG);
onRefresh?.();
} catch (err: any) {
setOperationError(err.message || "Operation failed");
} finally {
setOperationLoading(false);
}
}, [dialog, workspace, onRefresh]);
const handleDialogCancel = useCallback(() => {
setDialog(INITIAL_DIALOG);
setOperationError(null);
}, []);
if (loading) {
return (
<div className="file-browser-loading">
@@ -83,12 +441,14 @@ export function FileBrowser({
key={entry.name}
className={`file-node file-node--${entry.type}`}
onClick={() => {
if (contextMenu.visible) return;
if (entry.type === "directory") {
onNavigate(currentPath === "." ? entry.name : `${currentPath}/${entry.name}`);
} else {
onSelectFile(currentPath === "." ? entry.name : `${currentPath}/${entry.name}`);
}
}}
onContextMenu={(e) => handleContextMenu(e, entry)}
>
<div className="file-node-icon">
{entry.type === "directory" ? (
@@ -108,6 +468,30 @@ export function FileBrowser({
))
)}
</div>
{/* Context Menu */}
{contextMenu.visible && contextMenu.entry && (
<FileContextMenu
x={contextMenu.x}
y={contextMenu.y}
entry={contextMenu.entry}
onAction={handleContextAction}
onClose={() => setContextMenu(INITIAL_CONTEXT_MENU)}
/>
)}
{/* Operation Dialog */}
{dialog.type && dialog.entry && (
<OperationDialog
type={dialog.type}
entry={dialog.entry}
entryFullPath={dialog.entryFullPath}
onConfirm={handleDialogConfirm}
onCancel={handleDialogCancel}
loading={operationLoading}
error={operationError}
/>
)}
</div>
);
}

View File

@@ -181,6 +181,8 @@ export function FileBrowserModal({
loading={browserLoading}
error={browserError}
onRetry={refresh}
workspace={currentWorkspace}
onRefresh={refresh}
/>
</div>

View File

@@ -0,0 +1,459 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { FileBrowser } from "../FileBrowser";
import type { FileNode } from "../../api";
// ── Mocks ───────────────────────────────────────────────────────────────
vi.mock("lucide-react", async () => {
const actual = await vi.importActual("lucide-react");
return {
...actual,
Folder: (props: any) => <span data-testid="folder-icon" {...props} />,
File: (props: any) => <span data-testid="file-icon" {...props} />,
ChevronRight: (props: any) => <span data-testid="chevron" {...props} />,
Loader2: (props: any) => <span data-testid="loader" {...props} />,
Copy: (props: any) => <span data-testid="icon-copy" {...props} />,
Move: (props: any) => <span data-testid="icon-move" {...props} />,
Trash2: (props: any) => <span data-testid="icon-trash" {...props} />,
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} />,
};
});
const mockCopyFile = vi.fn();
const mockMoveFile = vi.fn();
const mockDeleteFile = vi.fn();
const mockRenameFile = vi.fn();
vi.mock("../../api", () => ({
copyFile: (...args: any[]) => mockCopyFile(...args),
moveFile: (...args: any[]) => mockMoveFile(...args),
deleteFile: (...args: any[]) => mockDeleteFile(...args),
renameFile: (...args: any[]) => mockRenameFile(...args),
downloadFileUrl: (_workspace: string, filePath: string) =>
`/api/files/${encodeURIComponent(filePath)}/download?workspace=test-ws`,
downloadZipUrl: (_workspace: string, filePath: string) =>
`/api/files/${encodeURIComponent(filePath)}/download-zip?workspace=test-ws`,
}));
// ── Test Data ───────────────────────────────────────────────────────────
const fileEntry: FileNode = {
name: "readme.md",
type: "file",
size: 1234,
mtime: "2026-01-15T10:30:00Z",
};
const dirEntry: FileNode = {
name: "src",
type: "directory",
mtime: "2026-01-14T08:00:00Z",
};
const sampleEntries: FileNode[] = [dirEntry, fileEntry];
// ── Helpers ─────────────────────────────────────────────────────────────
const defaultProps = {
entries: sampleEntries,
currentPath: ".",
onSelectFile: vi.fn(),
onNavigate: vi.fn(),
workspace: "test-ws",
onRefresh: vi.fn(),
};
function renderFileBrowser(overrides: Partial<typeof defaultProps> = {}) {
const props = { ...defaultProps, ...overrides };
return render(<FileBrowser {...props} />);
}
function contextMenuClick(entryName: string) {
const entry = screen.getByText(entryName).closest(".file-node");
if (!entry) throw new Error(`Entry not found: ${entryName}`);
fireEvent.contextMenu(entry, { clientX: 200, clientY: 300 });
}
// ── Tests ───────────────────────────────────────────────────────────────
describe("FileBrowser", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
// ── Basic Rendering ─────────────────────────────────────────────────
it("renders file entries with names and sizes", () => {
renderFileBrowser();
expect(screen.getByText("readme.md")).toBeDefined();
expect(screen.getByText("src")).toBeDefined();
expect(screen.getByText("1.2 KB")).toBeDefined();
});
it("shows root path label", () => {
renderFileBrowser({ currentPath: "." });
expect(screen.getByText("Root")).toBeDefined();
});
it("shows current path when not root", () => {
renderFileBrowser({ currentPath: "packages/core" });
expect(screen.getByText("packages/core")).toBeDefined();
});
it("shows empty directory message when no entries", () => {
renderFileBrowser({ entries: [] });
expect(screen.getByText("(empty directory)")).toBeDefined();
});
it("shows loading state", () => {
renderFileBrowser({ entries: [], loading: true });
expect(screen.getByText("Loading files...")).toBeDefined();
});
it("shows error state with retry button", () => {
const onRetry = vi.fn();
renderFileBrowser({ entries: [], error: "Something broke", onRetry });
expect(screen.getByText(/Something broke/)).toBeDefined();
fireEvent.click(screen.getByText("Retry"));
expect(onRetry).toHaveBeenCalled();
});
// ── Navigation ──────────────────────────────────────────────────────
it("navigates into directory on click", () => {
const onNavigate = vi.fn();
renderFileBrowser({ onNavigate });
fireEvent.click(screen.getByText("src"));
expect(onNavigate).toHaveBeenCalledWith("src");
});
it("selects file on click", () => {
const onSelectFile = vi.fn();
renderFileBrowser({ onSelectFile });
fireEvent.click(screen.getByText("readme.md"));
expect(onSelectFile).toHaveBeenCalledWith("readme.md");
});
it("navigates into nested directory", () => {
const onNavigate = vi.fn();
renderFileBrowser({ currentPath: "packages", onNavigate });
fireEvent.click(screen.getByText("src"));
expect(onNavigate).toHaveBeenCalledWith("packages/src");
});
it("navigates up one level", () => {
const onNavigate = vi.fn();
renderFileBrowser({ currentPath: "packages/core/src", onNavigate });
const upButton = screen.getByText("Up one level");
fireEvent.click(upButton);
expect(onNavigate).toHaveBeenCalledWith("packages/core");
});
// ── Context Menu Appearance ─────────────────────────────────────────
it("shows context menu on right-click on a file", () => {
renderFileBrowser();
contextMenuClick("readme.md");
// Menu should be visible with the role
expect(screen.getByRole("menu")).toBeDefined();
});
it("shows context menu on right-click on a directory", () => {
renderFileBrowser();
contextMenuClick("src");
expect(screen.getByRole("menu")).toBeDefined();
});
// ── Context Menu Items for Files ────────────────────────────────────
it("shows file context menu with Download option (not Download as ZIP)", () => {
renderFileBrowser();
contextMenuClick("readme.md");
expect(screen.getByText("Download")).toBeDefined();
expect(screen.queryByText("Download as ZIP")).toBeNull();
});
it("shows Copy, Move, Rename, Delete for files", () => {
renderFileBrowser();
contextMenuClick("readme.md");
expect(screen.getByText("Copy")).toBeDefined();
expect(screen.getByText("Move")).toBeDefined();
expect(screen.getByText("Rename")).toBeDefined();
expect(screen.getByText("Delete")).toBeDefined();
});
// ── Context Menu Items for Directories ──────────────────────────────
it("shows directory context menu with Download as ZIP (not Download)", () => {
renderFileBrowser();
contextMenuClick("src");
expect(screen.getByText("Download as ZIP")).toBeDefined();
expect(screen.queryByText("Download")).toBeNull();
});
// ── Context Menu Closing ────────────────────────────────────────────
it("closes context menu on Escape key", () => {
renderFileBrowser();
contextMenuClick("readme.md");
expect(screen.getByRole("menu")).toBeDefined();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("menu")).toBeNull();
});
it("closes context menu on overlay click", () => {
renderFileBrowser();
contextMenuClick("readme.md");
const overlay = document.querySelector(".context-menu-overlay");
expect(overlay).not.toBeNull();
fireEvent.click(overlay!);
expect(screen.queryByRole("menu")).toBeNull();
});
// ── Download Actions ────────────────────────────────────────────────
it("opens download URL for file when Download is clicked", () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
renderFileBrowser();
contextMenuClick("readme.md");
fireEvent.click(screen.getByText("Download"));
expect(openSpy).toHaveBeenCalledWith(
"/api/files/readme.md/download?workspace=test-ws",
"_blank"
);
openSpy.mockRestore();
});
it("opens download-zip URL for directory when Download as ZIP is clicked", () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
renderFileBrowser();
contextMenuClick("src");
fireEvent.click(screen.getByText("Download as ZIP"));
expect(openSpy).toHaveBeenCalledWith(
"/api/files/src/download-zip?workspace=test-ws",
"_blank"
);
openSpy.mockRestore();
});
// ── Delete Dialog ───────────────────────────────────────────────────
it("shows delete confirmation dialog when Delete is clicked", () => {
renderFileBrowser();
contextMenuClick("readme.md");
fireEvent.click(screen.getByText("Delete"));
expect(screen.getByText("Delete File")).toBeDefined();
expect(screen.getByText(/Are you sure you want to delete/)).toBeDefined();
});
it("shows directory delete warning for directories", () => {
renderFileBrowser();
contextMenuClick("src");
fireEvent.click(screen.getByText("Delete"));
expect(screen.getByText("Delete Folder")).toBeDefined();
expect(screen.getByText(/recursively/)).toBeDefined();
});
it("calls deleteFile API and refreshes on delete confirm", async () => {
mockDeleteFile.mockResolvedValue({ success: true });
const onRefresh = vi.fn();
renderFileBrowser({ onRefresh });
contextMenuClick("readme.md");
// Click Delete in the context menu (role=menuitem)
const menuDelete = screen.getAllByText("Delete").find(
(el) => el.closest('[role="menuitem"]')
);
fireEvent.click(menuDelete!);
// Click the danger Delete button in the confirmation dialog
const dangerBtn = document.querySelector(".btn-danger");
expect(dangerBtn).not.toBeNull();
fireEvent.click(dangerBtn!);
await waitFor(() => {
expect(mockDeleteFile).toHaveBeenCalledWith("test-ws", "readme.md");
expect(onRefresh).toHaveBeenCalled();
});
});
it("shows error when delete fails", async () => {
mockDeleteFile.mockRejectedValue(new Error("Delete failed"));
renderFileBrowser();
contextMenuClick("readme.md");
// Click Delete in the menu
const menuItems = screen.getAllByText("Delete");
fireEvent.click(menuItems[0]);
// Click Delete in the dialog
const dialogBtn = screen.getAllByText("Delete");
const deleteButton = dialogBtn.find(
(el) => el.closest("button")?.classList.contains("btn-danger")
);
if (deleteButton) {
fireEvent.click(deleteButton.closest("button")!);
}
await waitFor(() => {
expect(screen.getByText("Delete failed")).toBeDefined();
});
});
it("closes delete dialog on Cancel", () => {
renderFileBrowser();
contextMenuClick("readme.md");
fireEvent.click(screen.getByText("Delete"));
// Click Cancel in the dialog
const cancelButtons = screen.getAllByText("Cancel");
fireEvent.click(cancelButtons[cancelButtons.length - 1]);
expect(screen.queryByText("Delete File")).toBeNull();
});
// ── Rename Dialog ───────────────────────────────────────────────────
it("shows rename dialog with pre-filled name", () => {
renderFileBrowser();
contextMenuClick("readme.md");
// Click Rename in the context menu (role=menuitem)
const menuRename = screen.getAllByText("Rename").find(
(el) => el.closest('[role="menuitem"]')
);
fireEvent.click(menuRename!);
const input = screen.getByPlaceholderText("New name") as HTMLInputElement;
expect(input.value).toBe("readme.md");
});
it("calls renameFile API and refreshes on rename confirm", async () => {
mockRenameFile.mockResolvedValue({ success: true });
const onRefresh = vi.fn();
renderFileBrowser({ onRefresh });
contextMenuClick("readme.md");
// Click Rename in the menu
const menuRename = screen.getAllByText("Rename").find(
(el) => el.closest('[role="menuitem"]')
);
fireEvent.click(menuRename!);
// Type new name in the dialog input
const input = screen.getByPlaceholderText("New name");
fireEvent.change(input, { target: { value: "new-readme.md" } });
// Click Rename in the dialog
const dialogRename = screen.getAllByText("Rename").find(
(el) => el.closest("button")?.classList.contains("btn-primary")
);
fireEvent.click(dialogRename!.closest("button")!);
await waitFor(() => {
expect(mockRenameFile).toHaveBeenCalledWith("test-ws", "readme.md", "new-readme.md");
expect(onRefresh).toHaveBeenCalled();
});
});
it("closes rename dialog on Cancel", () => {
renderFileBrowser();
contextMenuClick("readme.md");
fireEvent.click(screen.getByText("Rename"));
const cancelButtons = screen.getAllByText("Cancel");
fireEvent.click(cancelButtons[cancelButtons.length - 1]);
expect(screen.queryByPlaceholderText("New name")).toBeNull();
});
// ── Copy Dialog ─────────────────────────────────────────────────────
it("shows copy dialog with destination input", () => {
renderFileBrowser();
contextMenuClick("readme.md");
const menuCopy = screen.getAllByText("Copy").find(
(el) => el.closest('[role="menuitem"]')
);
fireEvent.click(menuCopy!);
expect(screen.getByPlaceholderText("Destination path")).toBeDefined();
});
it("calls copyFile API and refreshes on copy confirm", async () => {
mockCopyFile.mockResolvedValue({ success: true });
const onRefresh = vi.fn();
renderFileBrowser({ onRefresh });
contextMenuClick("readme.md");
const menuCopy = screen.getAllByText("Copy").find(
(el) => el.closest('[role="menuitem"]')
);
fireEvent.click(menuCopy!);
const input = screen.getByPlaceholderText("Destination path");
fireEvent.change(input, { target: { value: "backup/readme.md" } });
const dialogCopy = screen.getAllByText("Copy").find(
(el) => el.closest("button")?.classList.contains("btn-primary")
);
fireEvent.click(dialogCopy!.closest("button")!);
await waitFor(() => {
expect(mockCopyFile).toHaveBeenCalledWith("test-ws", "readme.md", "backup/readme.md");
expect(onRefresh).toHaveBeenCalled();
});
});
// ── Move Dialog ─────────────────────────────────────────────────────
it("shows move dialog with destination input", () => {
renderFileBrowser();
contextMenuClick("readme.md");
const menuMove = screen.getAllByText("Move").find(
(el) => el.closest('[role="menuitem"]')
);
fireEvent.click(menuMove!);
expect(screen.getByPlaceholderText("Destination path")).toBeDefined();
});
it("calls moveFile API and refreshes on move confirm", async () => {
mockMoveFile.mockResolvedValue({ success: true });
const onRefresh = vi.fn();
renderFileBrowser({ onRefresh });
contextMenuClick("readme.md");
const menuMove = screen.getAllByText("Move").find(
(el) => el.closest('[role="menuitem"]')
);
fireEvent.click(menuMove!);
const input = screen.getByPlaceholderText("Destination path");
fireEvent.change(input, { target: { value: "docs/readme.md" } });
const dialogMove = screen.getAllByText("Move").find(
(el) => el.closest("button")?.classList.contains("btn-primary")
);
fireEvent.click(dialogMove!.closest("button")!);
await waitFor(() => {
expect(mockMoveFile).toHaveBeenCalledWith("test-ws", "readme.md", "docs/readme.md");
expect(onRefresh).toHaveBeenCalled();
});
});
// ── Error Handling ──────────────────────────────────────────────────
it("shows error in dialog when API call fails", async () => {
mockRenameFile.mockRejectedValue(new Error("Something went wrong"));
renderFileBrowser();
contextMenuClick("readme.md");
const menuRename = screen.getAllByText("Rename").find(
(el) => el.closest('[role="menuitem"]')
);
fireEvent.click(menuRename!);
const input = screen.getByPlaceholderText("New name");
fireEvent.change(input, { target: { value: "new-name.md" } });
const dialogRename = screen.getAllByText("Rename").find(
(el) => el.closest("button")?.classList.contains("btn-primary")
);
fireEvent.click(dialogRename!.closest("button")!);
await waitFor(() => {
expect(screen.getByText("Something went wrong")).toBeDefined();
});
});
it("closes dialog on Escape from dialog", () => {
renderFileBrowser();
contextMenuClick("readme.md");
const menuRename = screen.getAllByText("Rename").find(
(el) => el.closest('[role="menuitem"]')
);
fireEvent.click(menuRename!);
expect(screen.getByPlaceholderText("New name")).toBeDefined();
fireEvent.keyDown(screen.getByPlaceholderText("New name"), { key: "Escape" });
expect(screen.queryByPlaceholderText("New name")).toBeNull();
});
});

View File

@@ -11,7 +11,7 @@
try {
var mode = localStorage.getItem('kb-dashboard-theme-mode') || 'dark';
var colorTheme = localStorage.getItem('kb-dashboard-color-theme') || 'default';
var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'solarized', 'factory', 'ayu', 'one-dark', 'nord', 'dracula', 'gruvbox', 'tokyo-night', 'catppuccin-mocha', 'github-dark', 'everforest', 'rose-pine', 'kanagawa', 'night-owl', 'palenight', 'monokai-pro', 'slime'];
var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'graphite', 'silver', 'solarized', 'factory', 'ayu', 'one-dark', 'nord', 'dracula', 'gruvbox', 'tokyo-night', 'catppuccin-mocha', 'github-dark', 'everforest', 'rose-pine', 'kanagawa', 'night-owl', 'palenight', 'monokai-pro', 'slime'];
if (!validThemes.includes(colorTheme)) {
colorTheme = 'default';
}

View File

@@ -10846,6 +10846,156 @@ html .column.drag-over * {
margin-left: 8px;
}
/* File Browser Context Menu */
.context-menu-overlay {
position: fixed;
inset: 0;
z-index: 1000;
}
.file-browser-context-menu {
position: fixed;
min-width: 180px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: var(--space-xs);
display: flex;
flex-direction: column;
gap: 2px;
z-index: 1001;
}
.file-browser-context-menu__item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px;
text-align: left;
cursor: pointer;
width: 100%;
transition: background var(--transition-fast);
}
.file-browser-context-menu__item:hover {
background: var(--card-hover);
}
.file-browser-context-menu__item-icon {
color: var(--text-muted);
flex-shrink: 0;
display: flex;
align-items: center;
}
.file-browser-context-menu__disabled {
opacity: 0.5;
cursor: not-allowed;
}
.file-browser-context-menu__disabled:hover {
background: none;
}
/* Delete action gets danger styling */
.file-browser-context-menu__item--danger:hover {
background: rgba(248, 81, 73, 0.1);
color: var(--color-error);
}
.file-browser-context-menu__item--danger .file-browser-context-menu__item-icon {
color: var(--color-error);
}
.file-browser-context-menu__divider {
height: 1px;
background: var(--border);
margin: var(--space-xs) var(--space-sm);
}
/* File Browser Operation Dialog */
.file-browser-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
max-width: 420px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: var(--space-lg);
z-index: 1001;
}
.file-browser-dialog-title {
font-size: 15px;
font-weight: 600;
color: var(--text);
margin-bottom: var(--space-sm);
}
.file-browser-dialog-message {
font-size: 13px;
color: var(--text-muted);
margin-bottom: var(--space-md);
line-height: 1.5;
}
.file-browser-dialog-info {
font-size: 12px;
color: var(--text-dim);
margin-bottom: var(--space-md);
font-family: var(--font-mono);
word-break: break-all;
padding: var(--space-sm) var(--space-md);
background: var(--card);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.file-browser-dialog-input {
width: 100%;
padding: var(--space-sm) var(--space-md);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px;
font-family: var(--font-mono);
outline: none;
box-sizing: border-box;
margin-bottom: var(--space-md);
}
.file-browser-dialog-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
}
.file-browser-dialog-error {
font-size: 12px;
color: var(--color-error);
margin-bottom: var(--space-md);
padding: var(--space-sm) var(--space-md);
background: rgba(248, 81, 73, 0.1);
border-radius: var(--radius-sm);
line-height: 1.4;
}
.file-browser-dialog-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
}
/* File Editor Toolbar */
.file-browser-toolbar {
display: flex;

View File

@@ -40,11 +40,12 @@
"@fusion/core": "workspace:*",
"@fusion/engine": "workspace:*",
"@types/multer": "^2.1.0",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"archiver": "^7.0.1",
"express": "^5.1.0",
"ioredis": "^5.6.0",
"lucide-react": "^1.7.0",
@@ -60,6 +61,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.5.0",
"@types/archiver": "^7.0.0",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",

View File

@@ -10,16 +10,26 @@ import {
listWorkspaceFiles,
readWorkspaceFile,
writeWorkspaceFile,
copyWorkspaceFile,
moveWorkspaceFile,
deleteWorkspaceFile,
renameWorkspaceFile,
getWorkspaceFileForDownload,
getWorkspaceFolderForZip,
MAX_FILE_SIZE,
} from "../file-service.js";
import type { TaskStore } from "@fusion/core";
// Mock node:fs/promises - use vi.hoisted for proper hoisting with ES modules
const { mockReaddir, mockReadFile, mockWriteFile, mockStat } = vi.hoisted(() => ({
const { mockReaddir, mockReadFile, mockWriteFile, mockStat, mockCopyFile, mockRename, mockRm, mockMkdir } = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
mockCopyFile: vi.fn(),
mockRename: vi.fn(),
mockRm: vi.fn(),
mockMkdir: vi.fn(),
}));
// Mock node:fs
@@ -36,11 +46,19 @@ vi.mock("node:fs/promises", async (importOriginal) => {
readFile: mockReadFile,
writeFile: mockWriteFile,
stat: mockStat,
copyFile: mockCopyFile,
rename: mockRename,
rm: mockRm,
mkdir: mockMkdir,
},
readdir: mockReaddir,
readFile: mockReadFile,
writeFile: mockWriteFile,
stat: mockStat,
copyFile: mockCopyFile,
rename: mockRename,
rm: mockRm,
mkdir: mockMkdir,
};
});
@@ -826,3 +844,468 @@ describe("URL-encoded characters handling", () => {
);
});
});
// ── File Operation Tests (Copy, Move, Delete, Rename) ──────────────
describe("copyWorkspaceFile", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
mockCopyFile.mockReset();
mockReaddir.mockReset();
mockMkdir.mockReset();
});
it("copies a file within the workspace", async () => {
mockGetRootDir.mockReturnValue("/project");
// stat for source (exists), stat for destination (ENOENT), stat for dest parent (exists)
mockStat
.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false, size: 100 }) // source
.mockRejectedValueOnce({ code: "ENOENT" }) // dest doesn't exist
.mockResolvedValueOnce({ isDirectory: () => true }); // dest parent
mockCopyFile.mockResolvedValue(undefined);
const result = await copyWorkspaceFile(mockStore, "project", "src/file.ts", "src/file-copy.ts");
expect(result.success).toBe(true);
expect(result.message).toContain("Copied");
expect(mockCopyFile).toHaveBeenCalledWith("/project/src/file.ts", "/project/src/file-copy.ts");
});
it("copies a directory recursively", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isFile: () => false, isDirectory: () => true }) // source is dir
.mockRejectedValueOnce({ code: "ENOENT" }) // dest doesn't exist
.mockResolvedValueOnce({ isDirectory: () => true }); // dest parent
// readdir for the source directory
mockReaddir.mockResolvedValue([
{ name: "sub.ts", isDirectory: () => false },
]);
// copyFile for the file inside
mockCopyFile.mockResolvedValue(undefined);
mockMkdir.mockResolvedValue(undefined);
const result = await copyWorkspaceFile(mockStore, "project", "src", "src-copy");
expect(result.success).toBe(true);
expect(mockMkdir).toHaveBeenCalledWith("/project/src-copy", { recursive: true });
expect(mockCopyFile).toHaveBeenCalledWith("/project/src/sub.ts", "/project/src-copy/sub.ts");
});
it("rejects path traversal in source", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(copyWorkspaceFile(mockStore, "project", "../secret", "dest")).rejects.toThrow("Path traversal");
});
it("rejects path traversal in destination", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(copyWorkspaceFile(mockStore, "project", "file.ts", "../outside")).rejects.toThrow("Path traversal");
});
it("rejects missing source path", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(copyWorkspaceFile(mockStore, "project", "", "dest")).rejects.toThrow("Source path is required");
});
it("rejects missing destination path", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(copyWorkspaceFile(mockStore, "project", "file.ts", "")).rejects.toThrow("Destination path is required");
});
it("rejects when source does not exist", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockRejectedValueOnce({ code: "ENOENT" });
await expect(copyWorkspaceFile(mockStore, "project", "missing.ts", "dest.ts")).rejects.toThrow("Source not found");
});
it("rejects when destination already exists", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isFile: () => true }) // source exists
.mockResolvedValueOnce({ isFile: () => true }); // dest exists
await expect(copyWorkspaceFile(mockStore, "project", "file.ts", "existing.ts")).rejects.toThrow("Destination already exists");
});
it("rejects when destination parent does not exist", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isFile: () => true }) // source
.mockRejectedValueOnce({ code: "ENOENT" }) // dest doesn't exist
.mockRejectedValueOnce({ code: "ENOENT" }); // dest parent doesn't exist
await expect(copyWorkspaceFile(mockStore, "project", "file.ts", "nonexistent/file.ts")).rejects.toThrow("Destination parent directory does not exist");
});
it("rejects operating on workspace root", async () => {
mockGetRootDir.mockReturnValue("/project");
// Even though validatePath would resolve "." to the root, the function checks for it
await expect(copyWorkspaceFile(mockStore, "project", ".", "dest")).rejects.toThrow("Cannot operate on workspace root");
});
});
describe("moveWorkspaceFile", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
mockRename.mockReset();
mockCopyFile.mockReset();
mockRm.mockReset();
});
it("moves a file within the workspace", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isFile: () => true }) // source exists
.mockRejectedValueOnce({ code: "ENOENT" }) // dest doesn't exist
.mockResolvedValueOnce({ isDirectory: () => true }); // dest parent
mockRename.mockResolvedValue(undefined);
const result = await moveWorkspaceFile(mockStore, "project", "old.ts", "new.ts");
expect(result.success).toBe(true);
expect(result.message).toContain("Moved");
expect(mockRename).toHaveBeenCalledWith("/project/old.ts", "/project/new.ts");
});
it("rejects path traversal in source", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(moveWorkspaceFile(mockStore, "project", "../secret", "dest")).rejects.toThrow("Path traversal");
});
it("rejects path traversal in destination", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(moveWorkspaceFile(mockStore, "project", "file.ts", "../outside")).rejects.toThrow("Path traversal");
});
it("rejects when source does not exist", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockRejectedValueOnce({ code: "ENOENT" });
await expect(moveWorkspaceFile(mockStore, "project", "missing.ts", "dest.ts")).rejects.toThrow("Source not found");
});
it("rejects when destination already exists", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isFile: () => true }) // source
.mockResolvedValueOnce({ isFile: () => true }); // dest exists
await expect(moveWorkspaceFile(mockStore, "project", "file.ts", "existing.ts")).rejects.toThrow("Destination already exists");
});
it("rejects missing source path", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(moveWorkspaceFile(mockStore, "project", "", "dest")).rejects.toThrow("Source path is required");
});
});
describe("deleteWorkspaceFile", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
mockRm.mockReset();
});
it("deletes a file", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false });
mockRm.mockResolvedValue(undefined);
const result = await deleteWorkspaceFile(mockStore, "project", "src/old.ts");
expect(result.success).toBe(true);
expect(result.message).toContain("Deleted");
expect(mockRm).toHaveBeenCalledWith("/project/src/old.ts");
});
it("deletes a directory recursively", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValueOnce({ isFile: () => false, isDirectory: () => true });
mockRm.mockResolvedValue(undefined);
const result = await deleteWorkspaceFile(mockStore, "project", "src/olddir");
expect(result.success).toBe(true);
expect(mockRm).toHaveBeenCalledWith("/project/src/olddir", { recursive: true });
});
it("rejects path traversal", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(deleteWorkspaceFile(mockStore, "project", "../secret")).rejects.toThrow("Path traversal");
});
it("rejects deleting workspace root", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(deleteWorkspaceFile(mockStore, "project", ".")).rejects.toThrow("Cannot delete workspace root");
});
it("rejects missing file path", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(deleteWorkspaceFile(mockStore, "project", "")).rejects.toThrow("File path is required");
});
it("rejects when file does not exist", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockRejectedValueOnce({ code: "ENOENT" });
await expect(deleteWorkspaceFile(mockStore, "project", "missing.ts")).rejects.toThrow("Not found");
});
});
describe("renameWorkspaceFile", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
mockRename.mockReset();
});
it("renames a file", async () => {
mockGetRootDir.mockReturnValue("/project");
// stat: source exists, dest doesn't exist
mockStat
.mockResolvedValueOnce({ isFile: () => true }) // source
.mockRejectedValueOnce({ code: "ENOENT" }); // dest doesn't exist
mockRename.mockResolvedValue(undefined);
const result = await renameWorkspaceFile(mockStore, "project", "old.ts", "new.ts");
expect(result.success).toBe(true);
expect(result.message).toContain("Renamed");
expect(mockRename).toHaveBeenCalledWith("/project/old.ts", "/project/new.ts");
});
it("renames a directory", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isDirectory: () => true })
.mockRejectedValueOnce({ code: "ENOENT" });
mockRename.mockResolvedValue(undefined);
const result = await renameWorkspaceFile(mockStore, "project", "src/olddir", "newdir");
expect(result.success).toBe(true);
expect(mockRename).toHaveBeenCalledWith("/project/src/olddir", "/project/src/newdir");
});
it("rejects path traversal", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(renameWorkspaceFile(mockStore, "project", "../secret", "newname")).rejects.toThrow("Path traversal");
});
it("rejects new name with path separator", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(renameWorkspaceFile(mockStore, "project", "file.ts", "sub/name.ts")).rejects.toThrow("path separators");
});
it("rejects new name with backslash", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(renameWorkspaceFile(mockStore, "project", "file.ts", "sub\\name.ts")).rejects.toThrow("path separators");
});
it("rejects empty new name", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(renameWorkspaceFile(mockStore, "project", "file.ts", "")).rejects.toThrow("New name is required");
});
it("rejects when file does not exist", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockRejectedValueOnce({ code: "ENOENT" });
await expect(renameWorkspaceFile(mockStore, "project", "missing.ts", "new.ts")).rejects.toThrow("Not found");
});
it("rejects when a file with the new name already exists", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat
.mockResolvedValueOnce({ isFile: () => true }) // source exists
.mockResolvedValueOnce({ isFile: () => true }); // dest exists
await expect(renameWorkspaceFile(mockStore, "project", "file.ts", "existing.ts")).rejects.toThrow("already exists");
});
it("rejects renaming workspace root", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(renameWorkspaceFile(mockStore, "project", ".", "newname")).rejects.toThrow("Cannot rename workspace root");
});
it("rejects null bytes in new name", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(renameWorkspaceFile(mockStore, "project", "file.ts", "bad\0name")).rejects.toThrow("path separators");
});
});
describe("getWorkspaceFileForDownload", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
});
it("returns file info for download", async () => {
const mtime = new Date("2024-01-15");
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 2048,
mtime,
});
const result = await getWorkspaceFileForDownload(mockStore, "project", "src/file.ts");
expect(result.absolutePath).toBe("/project/src/file.ts");
expect(result.fileName).toBe("file.ts");
expect(result.stats.size).toBe(2048);
expect(result.stats.isFile).toBe(true);
});
it("rejects directories", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isFile: () => false,
isDirectory: () => true,
});
await expect(getWorkspaceFileForDownload(mockStore, "project", "src")).rejects.toThrow("Not a file");
});
it("rejects path traversal", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(getWorkspaceFileForDownload(mockStore, "project", "../secret")).rejects.toThrow("Path traversal");
});
it("rejects empty path", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(getWorkspaceFileForDownload(mockStore, "project", "")).rejects.toThrow("File path is required");
});
it("rejects non-existent file", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockRejectedValue({ code: "ENOENT" });
await expect(getWorkspaceFileForDownload(mockStore, "project", "missing.ts")).rejects.toThrow("File not found");
});
it("rejects downloading workspace root", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(getWorkspaceFileForDownload(mockStore, "project", ".")).rejects.toThrow("Cannot download workspace root");
});
});
describe("getWorkspaceFolderForZip", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
});
it("returns directory info for zip download", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isFile: () => false,
isDirectory: () => true,
});
const result = await getWorkspaceFolderForZip(mockStore, "project", "src");
expect(result.absolutePath).toBe("/project/src");
expect(result.dirName).toBe("src");
});
it("rejects files (not directories)", async () => {
mockGetRootDir.mockReturnValue("/project");
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
});
await expect(getWorkspaceFolderForZip(mockStore, "project", "file.ts")).rejects.toThrow("Not a directory");
});
it("rejects path traversal", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(getWorkspaceFolderForZip(mockStore, "project", "../secret")).rejects.toThrow("Path traversal");
});
it("rejects empty path", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(getWorkspaceFolderForZip(mockStore, "project", "")).rejects.toThrow("Directory path is required");
});
it("rejects downloading workspace root as ZIP", async () => {
mockGetRootDir.mockReturnValue("/project");
await expect(getWorkspaceFolderForZip(mockStore, "project", ".")).rejects.toThrow("Cannot download workspace root as ZIP");
});
});
describe("moveWorkspaceFile EXDEV fallback", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
beforeEach(() => {
mockGetRootDir.mockReset();
mockStat.mockReset();
mockRename.mockReset();
mockCopyFile.mockReset();
mockRm.mockReset();
mockReaddir.mockReset();
mockMkdir.mockReset();
});
it("falls back to copy+delete on cross-device move (EXDEV)", async () => {
mockGetRootDir.mockReturnValue("/project");
// Source exists, destination doesn't exist, dest parent exists
mockStat
.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false }) // source exists (move check)
.mockRejectedValueOnce({ code: "ENOENT" }) // dest doesn't exist (move check)
.mockResolvedValueOnce({ isDirectory: () => true }) // dest parent (move check)
// copyWorkspaceFile will be called with same paths
.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false }) // source (copy)
.mockRejectedValueOnce({ code: "ENOENT" }) // dest doesn't exist (copy)
.mockResolvedValueOnce({ isDirectory: () => true }) // dest parent (copy)
// deleteWorkspaceFile will validate
.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false }); // source exists (delete)
// rename throws EXDEV
mockRename.mockRejectedValue({ code: "EXDEV" });
mockCopyFile.mockResolvedValue(undefined);
mockRm.mockResolvedValue(undefined);
const result = await moveWorkspaceFile(mockStore, "project", "file.ts", "moved.ts");
expect(result.success).toBe(true);
expect(result.message).toContain("Moved");
expect(mockCopyFile).toHaveBeenCalledWith("/project/file.ts", "/project/moved.ts");
expect(mockRm).toHaveBeenCalledWith("/project/file.ts");
});
});

View File

@@ -386,15 +386,15 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
mockExecSync.mockImplementation((command) => {
const cmd = String(command);
// No baseCommitSha set, so Priority 2: merge-base with base branch
if (cmd.includes("git merge-base merge789 origin/main") || cmd.includes("git merge-base merge789 main")) {
// No baseCommitSha set, so Priority 2: rev-parse first parent
if (cmd === "git rev-parse merge789^") {
return "base456\n" as any;
}
// git diff --name-status base456..merge789 → only this task's files
if (cmd === "git diff --name-status base456..merge789") {
return "A\tfile-b.txt\n" as any;
}
// Per-file diff using merge base
// Per-file diff using first parent
if (cmd === 'git diff base456..merge789 -- "file-b.txt"') {
return "diff --git a/file-b.txt b/file-b.txt\n--- /dev/null\n+++ b/file-b.txt\n+hello\n" as any;
}
@@ -422,8 +422,8 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
mockExecSync.mockImplementation((command) => {
const cmd = String(command);
// No baseCommitSha, so Priority 2: merge-base with base branch
if (cmd.includes("git merge-base sha_with_modifications origin/main") || cmd.includes("git merge-base sha_with_modifications main")) {
// No baseCommitSha, so Priority 2: rev-parse first parent
if (cmd === "git rev-parse sha_with_modifications^") {
return "base_xyz\n" as any;
}
if (cmd === "git diff --name-status base_xyz..sha_with_modifications") {
@@ -468,11 +468,7 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
mockExecSync.mockImplementation((command) => {
const cmd = String(command);
// No baseCommitSha — Priority 1 skipped
// Priority 2: merge-base with base branch fails
if (cmd.includes("git merge-base merge_ff origin/main") || cmd.includes("git merge-base merge_ff main")) {
throw new Error("fatal: not a git repository");
}
// Priority 3: fall back to first parent
// Priority 2: rev-parse first parent (used to be merge-base)
if (cmd === "git rev-parse merge_ff^") {
return "parent_ff\n" as any;
}
@@ -528,8 +524,8 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
mockExecSync.mockImplementation((command) => {
const cmd = String(command);
// No baseCommitSha — Priority 2: merge-base with base branch
if (cmd.includes("git merge-base multi_merge origin/main") || cmd.includes("git merge-base multi_merge main")) {
// No baseCommitSha — Priority 2: rev-parse first parent
if (cmd === "git rev-parse multi_merge^") {
return "multi_base\n" as any;
}
if (cmd === "git diff --name-status multi_base..multi_merge") {
@@ -615,11 +611,11 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
if (cmd === "git merge-base --is-ancestor stale_base merged_commit") {
throw new Error("not an ancestor");
}
// Priority 2: merge-base with base branch succeeds
if (cmd.includes("git merge-base merged_commit origin/develop") || cmd.includes("git merge-base merged_commit develop")) {
// Priority 2: rev-parse first parent of merge commit
if (cmd === "git rev-parse merged_commit^") {
return "branch_base\n" as any;
}
// Diff from branch merge-base
// Diff from first parent
if (cmd === "git diff --name-status branch_base..merged_commit") {
return "M\tsrc/app.ts\n" as any;
}
@@ -640,9 +636,9 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
expect.stringContaining("git merge-base --is-ancestor stale_base merged_commit"),
expect.any(Object),
);
// Verify branch merge-base was called as fallback
// Verify rev-parse first parent was called as fallback
expect(mockExecSync).toHaveBeenCalledWith(
expect.stringContaining("git merge-base merged_commit"),
expect.stringContaining("git rev-parse merged_commit^"),
expect.any(Object),
);
});
@@ -658,8 +654,8 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
mockExecSync.mockImplementation((command) => {
const cmd = String(command);
// Priority 2: merge-base with custom base branch
if (cmd.includes("git merge-base custom_merge origin/release/v2") || cmd.includes("git merge-base custom_merge release/v2")) {
// No baseCommitSha — Priority 2: rev-parse first parent
if (cmd === "git rev-parse custom_merge^") {
return "release_base\n" as any;
}
if (cmd === "git diff --name-status release_base..custom_merge") {
@@ -677,9 +673,9 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
expect(response.status).toBe(200);
expect(response.body.files).toHaveLength(1);
expect(response.body.files[0].path).toBe("release-file.ts");
// Verify the custom base branch was used
// Verify the first parent was resolved via rev-parse
expect(mockExecSync).toHaveBeenCalledWith(
expect.stringContaining("release/v2"),
expect.stringContaining("git rev-parse custom_merge^"),
expect.any(Object),
);
});

View File

@@ -1,6 +1,8 @@
import { join, resolve, relative, dirname } from "node:path";
import { readdir, readFile as fsReadFile, writeFile as fsWriteFile, stat } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, resolve, relative, dirname, basename } from "node:path";
import { readdir, readFile as fsReadFile, writeFile as fsWriteFile, stat, copyFile as fsCopyFile, rename as fsRename, rm as fsRm, mkdir } from "node:fs/promises";
import { existsSync, createReadStream, statSync } from "node:fs";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
import type { TaskStore } from "@fusion/core";
/**
@@ -39,6 +41,14 @@ export interface SaveFileResponse {
size: number;
}
/**
* File operation response for copy/move/delete/rename operations.
*/
export interface FileOperationResponse {
success: true;
message?: string;
}
/**
* Maximum file size for reading/writing (1MB).
*/
@@ -467,3 +477,431 @@ export async function writeWorkspaceFile(
const workspaceBase = await getWorkspaceBasePath(store, workspace);
return writeFileForBasePath(workspaceBase, filePath, content);
}
// ── Workspace File Operations (Copy, Move, Delete, Rename) ─────────
/**
* Validate that both source and destination paths are within the allowed workspace.
* Prevents copying/moving files outside the workspace boundary.
*/
function validateSourceAndDestination(basePath: string, sourcePath: string, destinationPath: string): { resolvedSource: string; resolvedDest: string } {
const resolvedSource = validatePath(basePath, sourcePath);
const resolvedDest = validatePath(basePath, destinationPath);
// Prevent operating on the workspace root itself
const sourceRelative = relative(resolve(basePath), resolvedSource);
if (!sourceRelative || sourceRelative === "." || sourceRelative === "") {
throw new FileServiceError("Cannot operate on workspace root directory", "EINVAL");
}
return { resolvedSource, resolvedDest };
}
/**
* Copy a file or directory within a workspace.
*
* @param store - The TaskStore instance
* @param workspace - Workspace identifier ("project" or task ID)
* @param sourcePath - Relative source path within the workspace
* @param destinationPath - Relative destination path within the workspace
* @returns FileOperationResponse indicating success
* @throws FileServiceError on validation or filesystem errors
*/
export async function copyWorkspaceFile(
store: TaskStore,
workspace: WorkspaceId,
sourcePath: string,
destinationPath: string,
): Promise<FileOperationResponse> {
if (!sourcePath) {
throw new FileServiceError("Source path is required", "EINVAL");
}
if (!destinationPath) {
throw new FileServiceError("Destination path is required", "EINVAL");
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
const { resolvedSource, resolvedDest } = validateSourceAndDestination(workspaceBase, sourcePath, destinationPath);
// Verify source exists
let sourceStats;
try {
sourceStats = await stat(resolvedSource);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError(`Source not found: ${sourcePath}`, "ENOENT");
}
throw err;
}
// Check destination doesn't already exist
try {
await stat(resolvedDest);
throw new FileServiceError(`Destination already exists: ${destinationPath}`, "EEXIST");
} catch (err: any) {
if (err.code !== "ENOENT") {
if (err instanceof FileServiceError) throw err;
}
// ENOENT is expected - destination should not exist
}
// Ensure destination parent directory exists
const destParent = dirname(resolvedDest);
try {
const parentStats = await stat(destParent);
if (!parentStats.isDirectory()) {
throw new FileServiceError("Destination parent is not a directory", "ENOTDIR");
}
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError("Destination parent directory does not exist", "ENOENT");
}
throw err;
}
try {
if (sourceStats.isFile()) {
await fsCopyFile(resolvedSource, resolvedDest);
} else if (sourceStats.isDirectory()) {
await copyDirectoryRecursive(resolvedSource, resolvedDest);
}
return { success: true, message: `Copied "${sourcePath}" to "${destinationPath}"` };
} catch (err: any) {
if (err.code === "EACCES" || err.code === "EPERM") {
throw new FileServiceError(`Permission denied: ${err.message}`, "EACCES");
}
throw err;
}
}
/**
* Move a file or directory within a workspace.
*
* @param store - The TaskStore instance
* @param workspace - Workspace identifier ("project" or task ID)
* @param sourcePath - Relative source path within the workspace
* @param destinationPath - Relative destination path within the workspace
* @returns FileOperationResponse indicating success
* @throws FileServiceError on validation or filesystem errors
*/
export async function moveWorkspaceFile(
store: TaskStore,
workspace: WorkspaceId,
sourcePath: string,
destinationPath: string,
): Promise<FileOperationResponse> {
if (!sourcePath) {
throw new FileServiceError("Source path is required", "EINVAL");
}
if (!destinationPath) {
throw new FileServiceError("Destination path is required", "EINVAL");
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
const { resolvedSource, resolvedDest } = validateSourceAndDestination(workspaceBase, sourcePath, destinationPath);
// Verify source exists
try {
await stat(resolvedSource);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError(`Source not found: ${sourcePath}`, "ENOENT");
}
throw err;
}
// Check destination doesn't already exist
try {
await stat(resolvedDest);
throw new FileServiceError(`Destination already exists: ${destinationPath}`, "EEXIST");
} catch (err: any) {
if (err.code !== "ENOENT") {
if (err instanceof FileServiceError) throw err;
}
}
// Ensure destination parent directory exists
const destParent = dirname(resolvedDest);
try {
const parentStats = await stat(destParent);
if (!parentStats.isDirectory()) {
throw new FileServiceError("Destination parent is not a directory", "ENOTDIR");
}
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError("Destination parent directory does not exist", "ENOENT");
}
throw err;
}
try {
await fsRename(resolvedSource, resolvedDest);
return { success: true, message: `Moved "${sourcePath}" to "${destinationPath}"` };
} catch (err: any) {
if (err.code === "EACCES" || err.code === "EPERM") {
throw new FileServiceError(`Permission denied: ${err.message}`, "EACCES");
}
if (err.code === "EXDEV") {
// Cross-device move: copy then delete
await copyWorkspaceFile(store, workspace, sourcePath, destinationPath);
await deleteWorkspaceFile(store, workspace, sourcePath);
return { success: true, message: `Moved "${sourcePath}" to "${destinationPath}"` };
}
throw err;
}
}
/**
* Delete a file or directory within a workspace.
* Directories are deleted recursively.
*
* @param store - The TaskStore instance
* @param workspace - Workspace identifier ("project" or task ID)
* @param filePath - Relative file/directory path within the workspace
* @returns FileOperationResponse indicating success
* @throws FileServiceError on validation or filesystem errors
*/
export async function deleteWorkspaceFile(
store: TaskStore,
workspace: WorkspaceId,
filePath: string,
): Promise<FileOperationResponse> {
if (!filePath) {
throw new FileServiceError("File path is required", "EINVAL");
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
const resolvedPath = validatePath(workspaceBase, filePath);
// Prevent operating on the workspace root itself
const relativePath = relative(resolve(workspaceBase), resolvedPath);
if (!relativePath || relativePath === "." || relativePath === "") {
throw new FileServiceError("Cannot delete workspace root directory", "EINVAL");
}
// Verify the file/directory exists
let stats;
try {
stats = await stat(resolvedPath);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError(`Not found: ${filePath}`, "ENOENT");
}
throw err;
}
try {
if (stats.isDirectory()) {
await fsRm(resolvedPath, { recursive: true });
} else {
await fsRm(resolvedPath);
}
return { success: true, message: `Deleted "${filePath}"` };
} catch (err: any) {
if (err.code === "EACCES" || err.code === "EPERM") {
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
}
throw err;
}
}
/**
* Rename a file or directory within a workspace.
* The new name must not contain path separators.
*
* @param store - The TaskStore instance
* @param workspace - Workspace identifier ("project" or task ID)
* @param filePath - Relative file/directory path within the workspace
* @param newName - New name for the file/directory (no path separators)
* @returns FileOperationResponse indicating success
* @throws FileServiceError on validation or filesystem errors
*/
export async function renameWorkspaceFile(
store: TaskStore,
workspace: WorkspaceId,
filePath: string,
newName: string,
): Promise<FileOperationResponse> {
if (!filePath) {
throw new FileServiceError("File path is required", "EINVAL");
}
if (!newName || !newName.trim()) {
throw new FileServiceError("New name is required", "EINVAL");
}
// Reject new names with path separators
if (newName.includes("/") || newName.includes("\\") || newName.includes("\0")) {
throw new FileServiceError("New name must not contain path separators", "EINVAL");
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
const resolvedPath = validatePath(workspaceBase, filePath);
// Prevent operating on the workspace root itself
const relativePath = relative(resolve(workspaceBase), resolvedPath);
if (!relativePath || relativePath === "." || relativePath === "") {
throw new FileServiceError("Cannot rename workspace root directory", "EINVAL");
}
// Verify source exists
try {
await stat(resolvedPath);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError(`Not found: ${filePath}`, "ENOENT");
}
throw err;
}
// Build destination path by replacing the basename
const destPath = join(dirname(resolvedPath), newName);
// Validate destination stays within workspace
const destRelative = relative(resolve(workspaceBase), destPath);
if (destRelative.startsWith("..") || destRelative.startsWith("../") || destRelative === "..") {
throw new FileServiceError("Destination would be outside workspace", "EINVAL");
}
if (!destPath.startsWith(resolve(workspaceBase))) {
throw new FileServiceError("Destination would be outside workspace", "EINVAL");
}
// Check destination doesn't already exist
try {
await stat(destPath);
throw new FileServiceError(`A file or directory named "${newName}" already exists`, "EEXIST");
} catch (err: any) {
if (err.code !== "ENOENT") {
if (err instanceof FileServiceError) throw err;
}
}
try {
await fsRename(resolvedPath, destPath);
return { success: true, message: `Renamed to "${newName}"` };
} catch (err: any) {
if (err.code === "EACCES" || err.code === "EPERM") {
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
}
throw err;
}
}
/**
* Get the absolute path for a file to download, along with file stats.
* Used by the download endpoint to create a stream response.
*
* @param store - The TaskStore instance
* @param workspace - Workspace identifier ("project" or task ID)
* @param filePath - Relative file path within the workspace
* @returns Object with resolved absolute path, stats, and basename
* @throws FileServiceError on validation or filesystem errors
*/
export async function getWorkspaceFileForDownload(
store: TaskStore,
workspace: WorkspaceId,
filePath: string,
): Promise<{ absolutePath: string; stats: { size: number; mtime: Date; isFile: boolean }; fileName: string }> {
if (!filePath) {
throw new FileServiceError("File path is required", "EINVAL");
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
const resolvedPath = validatePath(workspaceBase, filePath);
// Prevent downloading the workspace root itself (it's not a file)
const relativePath = relative(resolve(workspaceBase), resolvedPath);
if (!relativePath || relativePath === "." || relativePath === "") {
throw new FileServiceError("Cannot download workspace root", "EINVAL");
}
let stats;
try {
stats = await stat(resolvedPath);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError(`File not found: ${filePath}`, "ENOENT");
}
throw err;
}
if (!stats.isFile()) {
throw new FileServiceError(`Not a file: ${filePath}`, "EISDIR");
}
return {
absolutePath: resolvedPath,
stats: {
size: stats.size,
mtime: stats.mtime,
isFile: true,
},
fileName: basename(resolvedPath),
};
}
/**
* Get the absolute path for a folder to download as ZIP.
*
* @param store - The TaskStore instance
* @param workspace - Workspace identifier ("project" or task ID)
* @param dirPath - Relative directory path within the workspace
* @returns Object with resolved absolute path and directory name
* @throws FileServiceError on validation or filesystem errors
*/
export async function getWorkspaceFolderForZip(
store: TaskStore,
workspace: WorkspaceId,
dirPath: string,
): Promise<{ absolutePath: string; dirName: string }> {
if (!dirPath) {
throw new FileServiceError("Directory path is required", "EINVAL");
}
const workspaceBase = await getWorkspaceBasePath(store, workspace);
const resolvedPath = validatePath(workspaceBase, dirPath);
// Prevent downloading the workspace root as ZIP (too broad)
const relativePath = relative(resolve(workspaceBase), resolvedPath);
if (!relativePath || relativePath === "." || relativePath === "") {
throw new FileServiceError("Cannot download workspace root as ZIP", "EINVAL");
}
let stats;
try {
stats = await stat(resolvedPath);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new FileServiceError(`Directory not found: ${dirPath}`, "ENOENT");
}
throw err;
}
if (!stats.isDirectory()) {
throw new FileServiceError(`Not a directory: ${dirPath}`, "ENOTDIR");
}
return {
absolutePath: resolvedPath,
dirName: basename(resolvedPath),
};
}
/**
* Recursively copy a directory and all its contents.
*/
async function copyDirectoryRecursive(source: string, destination: string): Promise<void> {
await mkdir(destination, { recursive: true });
const entries = await readdir(source, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = join(source, entry.name);
const destPath = join(destination, entry.name);
if (entry.isDirectory()) {
await copyDirectoryRecursive(sourcePath, destPath);
} else {
await fsCopyFile(sourcePath, destPath);
}
}
}

View File

@@ -89,6 +89,15 @@ function createMockMissionStore() {
)
),
getMissionSummary: vi.fn((_missionId: string) => ({
totalMilestones: 0,
completedMilestones: 0,
totalSlices: 0,
completedSlices: 0,
totalFeatures: 0,
completedFeatures: 0,
})),
updateMission: vi.fn((id: string, updates: Partial<Mission>) => {
const mission = missions.get(id);
if (!mission) throw new Error("Mission " + id + " not found");

View File

@@ -12,7 +12,7 @@ import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
import { terminalSessionManager } from "./terminal.js";
import { getTerminalService } from "./terminal-service.js";
import { listFiles, readFile, writeFile, listWorkspaceFiles, readWorkspaceFile, writeWorkspaceFile, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse } from "./file-service.js";
import { listFiles, readFile, writeFile, listWorkspaceFiles, readWorkspaceFile, writeWorkspaceFile, copyWorkspaceFile, moveWorkspaceFile, deleteWorkspaceFile, renameWorkspaceFile, getWorkspaceFileForDownload, getWorkspaceFolderForZip, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse, type FileOperationResponse } from "./file-service.js";
import { fetchAllProviderUsage } from "./usage.js";
import {
getGitHubAppConfig,
@@ -4898,6 +4898,210 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// ── File Operation Routes ─────────────────────────────────────────────
/**
* Helper to extract filepath and workspace from request.
*/
function extractFileParams(req: Request): { filePath: string; workspace: string } {
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
? req.query.workspace
: "project";
return { filePath, workspace };
}
/**
* POST /api/files/{*filepath}/copy
* Copy a file or directory to a new location within the workspace.
* Query param: ?workspace=project|TASK-ID
* Body: { destination: string }
* Returns: FileOperationResponse
*/
router.post("/files/{*filepath}/copy", async (req, res) => {
try {
const { filePath, workspace } = extractFileParams(req);
const { destination } = req.body;
if (!destination || typeof destination !== "string") {
res.status(400).json({ error: "destination is required and must be a string" });
return;
}
const result = await copyWorkspaceFile(store, workspace, filePath, destination);
res.json(result);
} catch (err: any) {
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EEXIST" ? 409
: err.code === "EACCES" ? 403
: 400;
res.status(status).json({ error: err.message, code: err.code });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
/**
* POST /api/files/{*filepath}/move
* Move a file or directory to a new location within the workspace.
* Query param: ?workspace=project|TASK-ID
* Body: { destination: string }
* Returns: FileOperationResponse
*/
router.post("/files/{*filepath}/move", async (req, res) => {
try {
const { filePath, workspace } = extractFileParams(req);
const { destination } = req.body;
if (!destination || typeof destination !== "string") {
res.status(400).json({ error: "destination is required and must be a string" });
return;
}
const result = await moveWorkspaceFile(store, workspace, filePath, destination);
res.json(result);
} catch (err: any) {
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EEXIST" ? 409
: err.code === "EACCES" ? 403
: 400;
res.status(status).json({ error: err.message, code: err.code });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
/**
* DELETE /api/files/{*filepath}
* Note: This conflicts with the existing GET endpoint for files.
* Instead, use POST /api/files/{*filepath}/delete to avoid route collision.
* Delete a file or directory within the workspace.
* Query param: ?workspace=project|TASK-ID
* Returns: FileOperationResponse
*/
router.post("/files/{*filepath}/delete", async (req, res) => {
try {
const { filePath, workspace } = extractFileParams(req);
const result = await deleteWorkspaceFile(store, workspace, filePath);
res.json(result);
} catch (err: any) {
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EACCES" ? 403
: 400;
res.status(status).json({ error: err.message, code: err.code });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
/**
* POST /api/files/{*filepath}/rename
* Rename a file or directory within the workspace.
* Query param: ?workspace=project|TASK-ID
* Body: { newName: string }
* Returns: FileOperationResponse
*/
router.post("/files/{*filepath}/rename", async (req, res) => {
try {
const { filePath, workspace } = extractFileParams(req);
const { newName } = req.body;
if (!newName || typeof newName !== "string") {
res.status(400).json({ error: "newName is required and must be a string" });
return;
}
const result = await renameWorkspaceFile(store, workspace, filePath, newName);
res.json(result);
} catch (err: any) {
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EEXIST" ? 409
: err.code === "EACCES" ? 403
: 400;
res.status(status).json({ error: err.message, code: err.code });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
/**
* GET /api/files/{*filepath}/download
* Download a single file from the workspace.
* Query param: ?workspace=project|TASK-ID
* Streams the file with Content-Disposition header.
*/
router.get("/files/{*filepath}/download", async (req, res) => {
try {
const { filePath, workspace } = extractFileParams(req);
const { absolutePath, stats, fileName } = await getWorkspaceFileForDownload(store, workspace, filePath);
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
res.setHeader("Content-Length", stats.size);
res.setHeader("Last-Modified", stats.mtime.toUTCString());
const stream = createReadStream(absolutePath);
stream.pipe(res);
} catch (err: any) {
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EISDIR" ? 400
: err.code === "EACCES" ? 403
: 400;
res.status(status).json({ error: err.message, code: err.code });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
/**
* GET /api/files/{*filepath}/download-zip
* Download a folder as a ZIP archive from the workspace.
* Query param: ?workspace=project|TASK-ID
* Streams the ZIP archive with Content-Disposition header.
*/
router.get("/files/{*filepath}/download-zip", async (req, res) => {
try {
const { filePath, workspace } = extractFileParams(req);
const { absolutePath, dirName } = await getWorkspaceFolderForZip(store, workspace, filePath);
const archiver = await import("archiver");
const archive = archiver.default("zip", { zlib: { level: 6 } });
res.setHeader("Content-Type", "application/zip");
res.setHeader("Content-Disposition", `attachment; filename="${dirName}.zip"`);
archive.pipe(res);
archive.directory(absolutePath, dirName);
await archive.finalize();
} catch (err: any) {
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "ENOTDIR" ? 400
: err.code === "EACCES" ? 403
: 400;
res.status(status).json({ error: err.message, code: err.code });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
// ── Planning Mode Routes ──────────────────────────────────────────────────
router.post("/subtasks/start-streaming", async (req, res) => {