feat(KB-175): move file browser to header with workspace support
- Add workspace file APIs and hooks for session-scoped file browsing - Create WorkspaceSelector component for switching between workspaces - Update FileBrowserModal with workspace mode and improved UX - Add files button to header with workspace-aware file browsing - Add session files indicator to task cards for quick file access - Include comprehensive tests for all new components and hooks
This commit is contained in:
@@ -33,6 +33,7 @@ function AppInner() {
|
||||
const [usageOpen, setUsageOpen] = useState(false);
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project");
|
||||
const [activityLogOpen, setActivityLogOpen] = useState(false);
|
||||
const [gitManagerOpen, setGitManagerOpen] = useState(false);
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
|
||||
@@ -208,8 +209,17 @@ function AppInner() {
|
||||
setTerminalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleToggleFiles = useCallback(() => {
|
||||
setFilesOpen((prev) => !prev);
|
||||
const handleOpenFiles = useCallback(() => {
|
||||
setFilesOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleOpenFilesForTask = useCallback((taskId: string) => {
|
||||
setFileBrowserWorkspace(taskId);
|
||||
setFilesOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleWorkspaceChange = useCallback((workspace: string) => {
|
||||
setFileBrowserWorkspace(workspace);
|
||||
}, []);
|
||||
|
||||
// Activity log handlers
|
||||
@@ -231,7 +241,7 @@ function AppInner() {
|
||||
onOpenSchedules={handleOpenSchedules}
|
||||
onOpenGitManager={handleOpenGitManager}
|
||||
onToggleTerminal={handleToggleTerminal}
|
||||
onToggleFiles={handleToggleFiles}
|
||||
onOpenFiles={handleOpenFiles}
|
||||
filesOpen={filesOpen}
|
||||
globalPaused={globalPaused}
|
||||
enginePaused={enginePaused}
|
||||
@@ -262,6 +272,7 @@ function AppInner() {
|
||||
onArchiveAllDone={archiveAllDone}
|
||||
searchQuery={searchQuery}
|
||||
availableModels={availableModels}
|
||||
onOpenFilesForTask={handleOpenFilesForTask}
|
||||
/>
|
||||
) : (
|
||||
// List view now uses the same modal-based create flow as board view.
|
||||
@@ -326,9 +337,10 @@ function AppInner() {
|
||||
/>
|
||||
{filesOpen && (
|
||||
<FileBrowserModal
|
||||
projectRoot={rootDir}
|
||||
initialWorkspace={fileBrowserWorkspace}
|
||||
isOpen={true}
|
||||
onClose={() => setFilesOpen(false)}
|
||||
onWorkspaceChange={handleWorkspaceChange}
|
||||
/>
|
||||
)}
|
||||
<UsageIndicator
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
fetchGitRemotes,
|
||||
refineTask,
|
||||
fetchBatchStatus,
|
||||
fetchWorkspaces,
|
||||
fetchWorkspaceFileList,
|
||||
fetchWorkspaceFileContent,
|
||||
saveWorkspaceFileContent,
|
||||
} from "./api";
|
||||
import type { Task, TaskDetail, BatchStatusResponse } from "@kb/core";
|
||||
|
||||
@@ -799,6 +803,67 @@ describe("Git Management API", () => {
|
||||
await expect(unarchiveTask("KB-001")).rejects.toThrow("Task not in archived");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace file APIs", () => {
|
||||
it("fetchWorkspaces requests the workspace list", async () => {
|
||||
const payload = {
|
||||
project: "/repo",
|
||||
tasks: [{ id: "KB-001", title: "Task", worktree: "/repo/.worktrees/kb-001" }],
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, payload));
|
||||
|
||||
const response = await fetchWorkspaces();
|
||||
|
||||
expect(response).toEqual(payload);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/workspaces", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("fetchWorkspaceFileList sends workspace and path query params", async () => {
|
||||
const payload = { path: "src", entries: [] };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, payload));
|
||||
|
||||
const response = await fetchWorkspaceFileList("KB-001", "src");
|
||||
|
||||
expect(response).toEqual(payload);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/files?workspace=KB-001&path=src", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("fetchWorkspaceFileContent sends the workspace query param", async () => {
|
||||
const payload = { content: "hello", mtime: "2026-01-01T00:00:00.000Z", size: 5 };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, payload));
|
||||
|
||||
const response = await fetchWorkspaceFileContent("project", "src/index.ts");
|
||||
|
||||
expect(response).toEqual(payload);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/files/src%2Findex.ts?workspace=project", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("saveWorkspaceFileContent posts content to the workspace route", async () => {
|
||||
const payload = { success: true, mtime: "2026-01-01T00:00:00.000Z", size: 5 };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, payload));
|
||||
|
||||
const response = await saveWorkspaceFileContent("KB-001", "src/index.ts", "hello");
|
||||
|
||||
expect(response).toEqual(payload);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/files/src%2Findex.ts?workspace=KB-001", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content: "hello" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates workspace API errors", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Task not found" }, 404));
|
||||
|
||||
await expect(fetchWorkspaceFileList("KB-404")).rejects.toThrow("Task not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Planning Mode API Tests ---
|
||||
|
||||
@@ -227,6 +227,10 @@ export function fetchAgentLogs(taskId: string): Promise<AgentLogEntry[]> {
|
||||
return api<AgentLogEntry[]>(`/tasks/${taskId}/logs`);
|
||||
}
|
||||
|
||||
export function fetchSessionFiles(taskId: string): Promise<string[]> {
|
||||
return api<string[]>(`/tasks/${taskId}/session-files`);
|
||||
}
|
||||
|
||||
export function addSteeringComment(id: string, text: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/steer`, {
|
||||
method: "POST",
|
||||
@@ -759,22 +763,43 @@ export function saveFileContent(taskId: string, filePath: string, content: strin
|
||||
});
|
||||
}
|
||||
|
||||
// --- Project File Browser API ---
|
||||
// --- Workspace File Browser API ---
|
||||
|
||||
/** List files in project root directory */
|
||||
export function fetchProjectFileList(path?: string): Promise<FileListResponse> {
|
||||
const query = path ? `?path=${encodeURIComponent(path)}` : "";
|
||||
return api<FileListResponse>(`/files${query}`);
|
||||
export interface WorkspaceTaskInfo {
|
||||
id: string;
|
||||
title?: string;
|
||||
worktree: string;
|
||||
}
|
||||
|
||||
/** Fetch file content from project directory */
|
||||
export function fetchProjectFileContent(filePath: string): Promise<FileContentResponse> {
|
||||
return api<FileContentResponse>(`/files/${encodeURIComponent(filePath)}`);
|
||||
export interface WorkspaceListResponse {
|
||||
project: string;
|
||||
tasks: WorkspaceTaskInfo[];
|
||||
}
|
||||
|
||||
/** Save file content to project directory */
|
||||
export function saveProjectFileContent(filePath: string, content: string): Promise<SaveFileResponse> {
|
||||
return api<SaveFileResponse>(`/files/${encodeURIComponent(filePath)}`, {
|
||||
/** Fetch available file browser workspaces. */
|
||||
export function fetchWorkspaces(): Promise<WorkspaceListResponse> {
|
||||
return api<WorkspaceListResponse>("/workspaces");
|
||||
}
|
||||
|
||||
/** List files in a workspace (project root or task worktree). */
|
||||
export function fetchWorkspaceFileList(workspace: string, path?: string): Promise<FileListResponse> {
|
||||
const query = new URLSearchParams({ workspace });
|
||||
if (path) {
|
||||
query.set("path", path);
|
||||
}
|
||||
return api<FileListResponse>(`/files?${query.toString()}`);
|
||||
}
|
||||
|
||||
/** Fetch file content from a workspace. */
|
||||
export function fetchWorkspaceFileContent(workspace: string, filePath: string): Promise<FileContentResponse> {
|
||||
const query = new URLSearchParams({ workspace });
|
||||
return api<FileContentResponse>(`/files/${encodeURIComponent(filePath)}?${query.toString()}`);
|
||||
}
|
||||
|
||||
/** Save file content to a workspace. */
|
||||
export function saveWorkspaceFileContent(workspace: string, filePath: string, content: string): Promise<SaveFileResponse> {
|
||||
const query = new URLSearchParams({ workspace });
|
||||
return api<SaveFileResponse>(`/files/${encodeURIComponent(filePath)}?${query.toString()}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ interface BoardProps {
|
||||
* Called when the user clicks the "Subtask" button in the inline create card.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
function sortTasksForColumn(tasks: Task[]): Task[] {
|
||||
@@ -52,7 +53,7 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
|
||||
return previous.every((task, index) => task === next[index]);
|
||||
}
|
||||
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown }: BoardProps) {
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask }: BoardProps) {
|
||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||
const { fetchBatch } = useBatchBadgeFetch();
|
||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -158,6 +159,7 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
allTasks={filteredTasks}
|
||||
availableModels={availableModels}
|
||||
onOpenFilesForTask={onOpenFilesForTask}
|
||||
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
{...(col === "done" ? { onArchiveAllDone } : {})}
|
||||
|
||||
@@ -45,9 +45,10 @@ interface ColumnProps {
|
||||
* Called when the user clicks the "Subtask" button in the inline create card.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown }: ColumnProps) {
|
||||
function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask }: ColumnProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
||||
const countFlashing = useFlashOnIncrease(tasks.length);
|
||||
@@ -209,6 +210,7 @@ function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetai
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onOpenFilesForTask={onOpenFilesForTask}
|
||||
/>
|
||||
))
|
||||
)
|
||||
@@ -226,6 +228,7 @@ function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetai
|
||||
onUpdateTask={onUpdateTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
onOpenFilesForTask={onOpenFilesForTask}
|
||||
/>
|
||||
))}
|
||||
{shouldPaginate && hiddenTaskCount > 0 && (
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FileBrowserModal } from "./FileBrowserModal";
|
||||
import * as useFileBrowserHook from "../hooks/useFileBrowser";
|
||||
import * as useFileEditorHook from "../hooks/useFileEditor";
|
||||
import * as useProjectFileBrowserHook from "../hooks/useProjectFileBrowser";
|
||||
import * as useProjectFileEditorHook from "../hooks/useProjectFileEditor";
|
||||
import * as workspaceBrowserHook from "../hooks/useWorkspaceFileBrowser";
|
||||
import * as workspaceEditorHook from "../hooks/useWorkspaceFileEditor";
|
||||
import * as workspacesHook from "../hooks/useWorkspaces";
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock("../hooks/useFileBrowser");
|
||||
vi.mock("../hooks/useFileEditor");
|
||||
vi.mock("../hooks/useProjectFileBrowser");
|
||||
vi.mock("../hooks/useProjectFileEditor");
|
||||
vi.mock("../hooks/useWorkspaceFileBrowser");
|
||||
vi.mock("../hooks/useWorkspaceFileEditor");
|
||||
vi.mock("../hooks/useWorkspaces");
|
||||
|
||||
const mockUseWorkspaceFileBrowser = vi.mocked(workspaceBrowserHook.useWorkspaceFileBrowser);
|
||||
const mockUseWorkspaceFileEditor = vi.mocked(workspaceEditorHook.useWorkspaceFileEditor);
|
||||
const mockUseWorkspaces = vi.mocked(workspacesHook.useWorkspaces);
|
||||
|
||||
describe("FileBrowserModal", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockSave = vi.fn();
|
||||
const mockOnWorkspaceChange = vi.fn();
|
||||
const mockSave = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSetContent = vi.fn();
|
||||
const mockSetPath = vi.fn();
|
||||
const mockRefresh = vi.fn();
|
||||
|
||||
const defaultBrowserState = {
|
||||
entries: [
|
||||
{ name: "file1.ts", type: "file", size: 1024, mtime: "2024-01-01" },
|
||||
{ name: "folder1", type: "directory" },
|
||||
{ name: "file1.ts", type: "file" as const, size: 1024, mtime: "2024-01-01" },
|
||||
{ name: "folder1", type: "directory" as const, mtime: "2024-01-01" },
|
||||
],
|
||||
currentPath: ".",
|
||||
setPath: mockSetPath,
|
||||
@@ -45,219 +48,121 @@ describe("FileBrowserModal", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Default mock implementations
|
||||
vi.mocked(useFileBrowserHook.useFileBrowser).mockReturnValue(defaultBrowserState);
|
||||
vi.mocked(useFileEditorHook.useFileEditor).mockReturnValue(defaultEditorState);
|
||||
vi.mocked(useProjectFileBrowserHook.useProjectFileBrowser).mockReturnValue(defaultBrowserState);
|
||||
vi.mocked(useProjectFileEditorHook.useProjectFileEditor).mockReturnValue(defaultEditorState);
|
||||
|
||||
mockUseWorkspaceFileBrowser.mockReturnValue(defaultBrowserState);
|
||||
mockUseWorkspaceFileEditor.mockReturnValue(defaultEditorState);
|
||||
mockUseWorkspaces.mockReturnValue({
|
||||
projectName: "kb",
|
||||
workspaces: [
|
||||
{ id: "KB-001", label: "KB-001", title: "Task One", worktree: "/repo/.worktrees/kb-001", kind: "task" },
|
||||
{ id: "KB-002", label: "KB-002", title: "Task Two", worktree: "/repo/.worktrees/kb-002", kind: "task" },
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1024,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Desktop view", () => {
|
||||
beforeEach(() => {
|
||||
// Mock desktop viewport
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1024,
|
||||
});
|
||||
it("renders project-root modal title and workspace selector", () => {
|
||||
render(
|
||||
<FileBrowserModal
|
||||
initialWorkspace="project"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onWorkspaceChange={mockOnWorkspaceChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Files — Project")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /kb/i })).toBeInTheDocument();
|
||||
expect(mockUseWorkspaceFileBrowser).toHaveBeenCalledWith("project", true);
|
||||
});
|
||||
|
||||
it("opens a file in the editor when selected", async () => {
|
||||
render(
|
||||
<FileBrowserModal
|
||||
initialWorkspace="project"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("file1.ts"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Editor for file1.ts")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders file browser sidebar and empty state on desktop", () => {
|
||||
render(
|
||||
<FileBrowserModal
|
||||
taskId="KB-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "file1.ts", true);
|
||||
});
|
||||
|
||||
// Sidebar should be visible
|
||||
expect(screen.getByText("file1.ts")).toBeInTheDocument();
|
||||
expect(screen.getByText("folder1")).toBeInTheDocument();
|
||||
it("switches workspace and notifies parent", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FileBrowserModal
|
||||
initialWorkspace="project"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onWorkspaceChange={mockOnWorkspaceChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Empty state placeholder should be visible
|
||||
expect(screen.getByText("Select a file to edit")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /kb/i }));
|
||||
await user.click(screen.getByRole("button", { name: /KB-002 Task Two/i }));
|
||||
|
||||
expect(mockOnWorkspaceChange).toHaveBeenCalledWith("KB-002");
|
||||
});
|
||||
|
||||
it("shows back button in mobile editor view", async () => {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 375,
|
||||
});
|
||||
|
||||
it("selecting a file shows editor on desktop (both views visible)", async () => {
|
||||
const editorStateWithChanges = {
|
||||
...defaultEditorState,
|
||||
hasChanges: true,
|
||||
};
|
||||
vi.mocked(useFileEditorHook.useFileEditor).mockReturnValue(editorStateWithChanges);
|
||||
render(
|
||||
<FileBrowserModal
|
||||
initialWorkspace="project"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
render(
|
||||
<FileBrowserModal
|
||||
taskId="KB-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
fireEvent(window, new Event("resize"));
|
||||
fireEvent.click(screen.getByText("file1.ts"));
|
||||
|
||||
// Click on a file
|
||||
fireEvent.click(screen.getByText("file1.ts"));
|
||||
|
||||
// Editor content should be visible - check for the editor textarea
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Editor for file1.ts")).toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Back to file list")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mobile view", () => {
|
||||
beforeEach(() => {
|
||||
// Mock mobile viewport
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 375,
|
||||
});
|
||||
it("closes on Escape and saves on Cmd+S", () => {
|
||||
mockUseWorkspaceFileEditor.mockReturnValue({
|
||||
...defaultEditorState,
|
||||
hasChanges: true,
|
||||
});
|
||||
|
||||
it("initially shows only file list (sidebar visible, content hidden)", () => {
|
||||
render(
|
||||
<FileBrowserModal
|
||||
taskId="KB-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
render(
|
||||
<FileBrowserModal
|
||||
initialWorkspace="project"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Fire resize event to trigger mobile detection
|
||||
fireEvent(window, new Event("resize"));
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
fireEvent.keyDown(document, { key: "s", metaKey: true });
|
||||
|
||||
// File list should be visible
|
||||
expect(screen.getByText("file1.ts")).toBeInTheDocument();
|
||||
|
||||
// Empty state should be in the DOM but hidden via CSS
|
||||
const placeholder = screen.getByText("Select a file to edit");
|
||||
expect(placeholder).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting a file switches to editor view with back button", async () => {
|
||||
const editorStateWithChanges = {
|
||||
...defaultEditorState,
|
||||
hasChanges: true,
|
||||
};
|
||||
vi.mocked(useFileEditorHook.useFileEditor).mockReturnValue(editorStateWithChanges);
|
||||
|
||||
render(
|
||||
<FileBrowserModal
|
||||
taskId="KB-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
|
||||
// Trigger mobile detection
|
||||
fireEvent(window, new Event("resize"));
|
||||
|
||||
// Click on a file
|
||||
fireEvent.click(screen.getByText("file1.ts"));
|
||||
|
||||
// Back button should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Back to file list")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("back button returns to list view when clicked", async () => {
|
||||
const editorStateWithChanges = {
|
||||
...defaultEditorState,
|
||||
hasChanges: true,
|
||||
};
|
||||
vi.mocked(useFileEditorHook.useFileEditor).mockReturnValue(editorStateWithChanges);
|
||||
|
||||
render(
|
||||
<FileBrowserModal
|
||||
taskId="KB-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
|
||||
// Trigger mobile detection
|
||||
fireEvent(window, new Event("resize"));
|
||||
|
||||
// Click on a file to enter editor view
|
||||
fireEvent.click(screen.getByText("file1.ts"));
|
||||
|
||||
// Wait for back button to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Back to file list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click back button
|
||||
fireEvent.click(screen.getByLabelText("Back to file list"));
|
||||
|
||||
// After clicking back, we should still see the file list (file node should be visible)
|
||||
expect(screen.getAllByText("file1.ts").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("back button only renders on mobile when file is selected", () => {
|
||||
render(
|
||||
<FileBrowserModal
|
||||
taskId="KB-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
|
||||
// Trigger mobile detection
|
||||
fireEvent(window, new Event("resize"));
|
||||
|
||||
// Back button should NOT be visible when no file is selected
|
||||
expect(screen.queryByLabelText("Back to file list")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("modal resets to list view when reopened", () => {
|
||||
const { unmount } = render(
|
||||
<FileBrowserModal
|
||||
taskId="KB-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
|
||||
// Trigger mobile detection
|
||||
fireEvent(window, new Event("resize"));
|
||||
|
||||
// Unmount and remount to simulate reopening
|
||||
unmount();
|
||||
|
||||
render(
|
||||
<FileBrowserModal
|
||||
taskId="KB-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
|
||||
// Trigger mobile detection again
|
||||
fireEvent(window, new Event("resize"));
|
||||
|
||||
// Should start in list view - file list visible
|
||||
expect(screen.getByText("file1.ts")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Keyboard shortcuts", () => {
|
||||
it("calls onClose when Escape key is pressed", () => {
|
||||
render(
|
||||
<FileBrowserModal
|
||||
taskId="KB-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
expect(mockSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { X, Save, RotateCcw, Folder, FileType, ArrowLeft } from "lucide-react";
|
||||
import { useFileBrowser } from "../hooks/useFileBrowser";
|
||||
import { useFileEditor } from "../hooks/useFileEditor";
|
||||
import { useProjectFileBrowser } from "../hooks/useProjectFileBrowser";
|
||||
import { useProjectFileEditor } from "../hooks/useProjectFileEditor";
|
||||
import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
|
||||
import { useWorkspaceFileEditor } from "../hooks/useWorkspaceFileEditor";
|
||||
import { useWorkspaces } from "../hooks/useWorkspaces";
|
||||
import { FileBrowser } from "./FileBrowser";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
import { WorkspaceSelector } from "./WorkspaceSelector";
|
||||
|
||||
// Mobile breakpoint - must match existing codebase
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
/**
|
||||
@@ -23,72 +22,33 @@ const BINARY_EXTENSIONS = new Set([
|
||||
".wasm", ".bin",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if a file is a binary file based on extension.
|
||||
*/
|
||||
function isBinaryFile(filename: string): boolean {
|
||||
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
|
||||
return BINARY_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
type TaskModeProps = {
|
||||
taskId: string;
|
||||
worktreePath?: string;
|
||||
projectRoot?: never;
|
||||
};
|
||||
|
||||
type ProjectModeProps = {
|
||||
projectRoot: string;
|
||||
taskId?: never;
|
||||
worktreePath?: never;
|
||||
};
|
||||
|
||||
type FileBrowserModalProps = {
|
||||
interface FileBrowserModalProps {
|
||||
isOpen?: boolean;
|
||||
initialWorkspace?: string;
|
||||
onClose: () => void;
|
||||
} & (TaskModeProps | ProjectModeProps);
|
||||
onWorkspaceChange?: (workspace: string) => void;
|
||||
}
|
||||
|
||||
export function FileBrowserModal(props: FileBrowserModalProps) {
|
||||
const { taskId, projectRoot, onClose } = props;
|
||||
/**
|
||||
* Workspace-aware file browser modal used by the top-level dashboard Files button.
|
||||
* Supports browsing the project root or any active task worktree from one shared UI.
|
||||
*/
|
||||
export function FileBrowserModal({
|
||||
initialWorkspace = "project",
|
||||
onClose,
|
||||
onWorkspaceChange,
|
||||
}: FileBrowserModalProps) {
|
||||
const { projectName, workspaces } = useWorkspaces();
|
||||
const [currentWorkspace, setCurrentWorkspace] = useState(initialWorkspace);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
|
||||
// Mobile detection and view state (following GitHubImportModal pattern)
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [mobileView, setMobileView] = useState<'list' | 'editor'>('list');
|
||||
|
||||
// Detect mobile viewport
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
|
||||
};
|
||||
|
||||
// Check initially
|
||||
checkMobile();
|
||||
|
||||
// Listen for resize
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
|
||||
// Reset to list view when modal opens (selectedFile becomes null)
|
||||
useEffect(() => {
|
||||
if (!selectedFile) {
|
||||
setMobileView('list');
|
||||
}
|
||||
}, [selectedFile]);
|
||||
const [mobileView, setMobileView] = useState<"list" | "editor">("list");
|
||||
|
||||
// Determine mode based on which prop is provided
|
||||
const isTaskMode = taskId !== undefined;
|
||||
const isProjectMode = projectRoot !== undefined;
|
||||
|
||||
// Use appropriate hooks based on mode
|
||||
const taskBrowser = useFileBrowser(taskId ?? "", isTaskMode);
|
||||
const projectBrowser = useProjectFileBrowser(projectRoot ?? "", isProjectMode);
|
||||
|
||||
const taskEditor = useFileEditor(taskId ?? "", selectedFile, isTaskMode);
|
||||
const projectEditor = useProjectFileEditor(projectRoot ?? "", selectedFile, isProjectMode);
|
||||
|
||||
// Select the active hooks based on mode
|
||||
const {
|
||||
entries,
|
||||
currentPath,
|
||||
@@ -96,7 +56,7 @@ export function FileBrowserModal(props: FileBrowserModalProps) {
|
||||
loading: browserLoading,
|
||||
error: browserError,
|
||||
refresh,
|
||||
} = isTaskMode ? taskBrowser : projectBrowser;
|
||||
} = useWorkspaceFileBrowser(currentWorkspace, true);
|
||||
|
||||
const {
|
||||
content,
|
||||
@@ -108,9 +68,28 @@ export function FileBrowserModal(props: FileBrowserModalProps) {
|
||||
save,
|
||||
hasChanges,
|
||||
mtime,
|
||||
} = isTaskMode ? taskEditor : projectEditor;
|
||||
} = useWorkspaceFileEditor(currentWorkspace, selectedFile, true);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentWorkspace(initialWorkspace);
|
||||
}, [initialWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFile) {
|
||||
setMobileView("list");
|
||||
}
|
||||
}, [selectedFile]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
@@ -119,7 +98,7 @@ export function FileBrowserModal(props: FileBrowserModalProps) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
if (hasChanges && !saving) {
|
||||
save();
|
||||
void save();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -130,51 +109,70 @@ export function FileBrowserModal(props: FileBrowserModalProps) {
|
||||
|
||||
const handleSelectFile = useCallback((path: string) => {
|
||||
setSelectedFile(path);
|
||||
// On mobile, switch to editor view when a file is selected
|
||||
if (isMobile) {
|
||||
setMobileView('editor');
|
||||
setMobileView("editor");
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
// Handle back button - return to list view on mobile
|
||||
|
||||
const handleBackToList = useCallback(() => {
|
||||
setMobileView('list');
|
||||
setMobileView("list");
|
||||
}, []);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
setContent(originalContent);
|
||||
}, [originalContent, setContent]);
|
||||
|
||||
const formatFileSize = (content: string): string => {
|
||||
const bytes = new Blob([content]).size;
|
||||
const handleWorkspaceSelect = useCallback((workspace: string) => {
|
||||
setCurrentWorkspace(workspace);
|
||||
setSelectedFile(null);
|
||||
setMobileView("list");
|
||||
onWorkspaceChange?.(workspace);
|
||||
}, [onWorkspaceChange]);
|
||||
|
||||
const workspaceLabel = useMemo(() => {
|
||||
if (currentWorkspace === "project") {
|
||||
return "Project";
|
||||
}
|
||||
|
||||
return workspaces.find((workspace) => workspace.id === currentWorkspace)?.id ?? currentWorkspace;
|
||||
}, [currentWorkspace, workspaces]);
|
||||
|
||||
const modalTitle = `Files — ${workspaceLabel}`;
|
||||
|
||||
const formatFileSize = (value: string): string => {
|
||||
const bytes = new Blob([value]).size;
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
};
|
||||
|
||||
// Determine modal title based on mode
|
||||
const modalTitle = isTaskMode ? `Files — ${taskId}` : "Files — Project";
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={onClose}>
|
||||
<div className="modal file-browser-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-header file-browser-modal-header">
|
||||
<div className="file-browser-header-title">
|
||||
<Folder size={18} />
|
||||
<span>{modalTitle}</span>
|
||||
{selectedFile && (
|
||||
<span className="file-browser-header-path">
|
||||
{currentPath === "." ? "" : currentPath + "/"}
|
||||
{selectedFile}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<div className="file-browser-header-actions">
|
||||
<WorkspaceSelector
|
||||
currentWorkspace={currentWorkspace}
|
||||
projectName={projectName}
|
||||
workspaces={workspaces}
|
||||
onSelect={handleWorkspaceSelect}
|
||||
/>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="file-browser-body">
|
||||
<div className={`file-browser-sidebar ${isMobile ? 'mobile' : ''} ${mobileView === 'list' ? 'active' : ''}`}>
|
||||
<div className={`file-browser-sidebar ${isMobile ? "mobile" : ""} ${mobileView === "list" ? "active" : ""}`}>
|
||||
<FileBrowser
|
||||
entries={entries}
|
||||
currentPath={currentPath}
|
||||
@@ -186,12 +184,12 @@ export function FileBrowserModal(props: FileBrowserModalProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`file-browser-content ${isMobile ? 'mobile' : ''} ${mobileView === 'editor' ? 'active' : ''}`}>
|
||||
<div className={`file-browser-content ${isMobile ? "mobile" : ""} ${mobileView === "editor" ? "active" : ""}`}>
|
||||
{selectedFile ? (
|
||||
<>
|
||||
<div className="file-browser-toolbar">
|
||||
<div className="file-browser-file-info">
|
||||
{isMobile && mobileView === 'editor' && (
|
||||
{isMobile && mobileView === "editor" && (
|
||||
<button
|
||||
className="file-browser-back-button"
|
||||
onClick={handleBackToList}
|
||||
@@ -202,7 +200,7 @@ export function FileBrowserModal(props: FileBrowserModalProps) {
|
||||
</button>
|
||||
)}
|
||||
{selectedFile}
|
||||
{selectedFile && isBinaryFile(selectedFile) && (
|
||||
{isBinaryFile(selectedFile) && (
|
||||
<span className="file-browser-binary-indicator">
|
||||
<FileType size={12} />
|
||||
Binary file — read only
|
||||
@@ -230,7 +228,7 @@ export function FileBrowserModal(props: FileBrowserModalProps) {
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={save}
|
||||
onClick={() => void save()}
|
||||
disabled={saving}
|
||||
>
|
||||
<Save size={14} />
|
||||
@@ -250,7 +248,7 @@ export function FileBrowserModal(props: FileBrowserModalProps) {
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
filePath={selectedFile}
|
||||
readOnly={selectedFile ? isBinaryFile(selectedFile) : false}
|
||||
readOnly={isBinaryFile(selectedFile)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -138,6 +138,44 @@ describe("Header", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("files button", () => {
|
||||
it("renders files button on desktop when handler is provided", () => {
|
||||
renderHeader({ onOpenFiles: vi.fn() }, false);
|
||||
expect(screen.getByTitle("Browse files")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render files button on desktop when handler is omitted", () => {
|
||||
renderHeader({}, false);
|
||||
expect(screen.queryByTitle("Browse files")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onOpenFiles when desktop files button is clicked", () => {
|
||||
const onOpenFiles = vi.fn();
|
||||
renderHeader({ onOpenFiles }, false);
|
||||
fireEvent.click(screen.getByTitle("Browse files"));
|
||||
expect(onOpenFiles).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies active class when files modal is open", () => {
|
||||
renderHeader({ onOpenFiles: vi.fn(), filesOpen: true }, false);
|
||||
expect(screen.getByTitle("Browse files").className).toContain("btn-icon--active");
|
||||
});
|
||||
|
||||
it("shows files action in mobile overflow menu", () => {
|
||||
renderHeader({ onOpenFiles: vi.fn() }, true);
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
expect(screen.getByTestId("overflow-files-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onOpenFiles from mobile overflow menu", () => {
|
||||
const onOpenFiles = vi.fn();
|
||||
renderHeader({ onOpenFiles }, true);
|
||||
fireEvent.click(screen.getByTitle("More header actions"));
|
||||
fireEvent.click(screen.getByTestId("overflow-files-btn"));
|
||||
expect(onOpenFiles).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pause controls", () => {
|
||||
it("renders pause button for engine pause", () => {
|
||||
renderHeader();
|
||||
|
||||
@@ -25,7 +25,8 @@ interface HeaderProps {
|
||||
onOpenSchedules?: () => void;
|
||||
onOpenGitManager?: () => void;
|
||||
onToggleTerminal?: () => void;
|
||||
onToggleFiles?: () => void;
|
||||
/** Opens the top-level workspace-aware file browser modal. */
|
||||
onOpenFiles?: () => void;
|
||||
filesOpen?: boolean;
|
||||
globalPaused?: boolean;
|
||||
enginePaused?: boolean;
|
||||
@@ -63,7 +64,7 @@ export function Header({
|
||||
onOpenSchedules,
|
||||
onOpenGitManager,
|
||||
onToggleTerminal,
|
||||
onToggleFiles,
|
||||
onOpenFiles,
|
||||
filesOpen,
|
||||
globalPaused,
|
||||
enginePaused,
|
||||
@@ -293,11 +294,11 @@ export function Header({
|
||||
)}
|
||||
|
||||
{/* Files button - desktop only */}
|
||||
{!isMobile && onToggleFiles && (
|
||||
{!isMobile && onOpenFiles && (
|
||||
<button
|
||||
className={`btn-icon${filesOpen ? " btn-icon--active" : ""}`}
|
||||
onClick={onToggleFiles}
|
||||
title="Browse Files"
|
||||
onClick={onOpenFiles}
|
||||
title="Browse files"
|
||||
data-testid="files-toggle-btn"
|
||||
>
|
||||
<Folder size={16} />
|
||||
@@ -376,10 +377,10 @@ export function Header({
|
||||
<span>Open Terminal</span>
|
||||
</button>
|
||||
{/* Files - in overflow on mobile */}
|
||||
{onToggleFiles && (
|
||||
{onOpenFiles && (
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onToggleFiles)}
|
||||
onClick={() => handleOverflowAction(onOpenFiles)}
|
||||
role="menuitem"
|
||||
data-testid="overflow-files-btn"
|
||||
>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown } from "lucide-react";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@kb/core";
|
||||
import { fetchTaskDetail, uploadAttachment } from "../api";
|
||||
import { GitHubBadge } from "./GitHubBadge";
|
||||
import { pickPreferredBadge } from "./TaskCardBadge";
|
||||
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
||||
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
|
||||
import { useSessionFiles } from "../hooks/useSessionFiles";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
@@ -42,6 +43,7 @@ interface TaskCardProps {
|
||||
) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
onOpenFilesForTask?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
function areTaskBadgeInfosEqual(
|
||||
@@ -83,6 +85,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previous.onUpdateTask === next.onUpdateTask &&
|
||||
previous.onArchiveTask === next.onArchiveTask &&
|
||||
previous.onUnarchiveTask === next.onUnarchiveTask &&
|
||||
previous.onOpenFilesForTask === next.onOpenFilesForTask &&
|
||||
previousTask.id === nextTask.id &&
|
||||
previousTask.title === nextTask.title &&
|
||||
previousTask.description === nextTask.description &&
|
||||
@@ -123,6 +126,7 @@ function TaskCardComponent({
|
||||
onUpdateTask,
|
||||
onArchiveTask,
|
||||
onUnarchiveTask,
|
||||
onOpenFilesForTask,
|
||||
}: TaskCardProps) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [fileDragOver, setFileDragOver] = useState(false);
|
||||
@@ -324,6 +328,7 @@ function TaskCardComponent({
|
||||
}, [hasGitHubBadge, isInViewport, subscribeToBadge, task.id, unsubscribeFromBadge]);
|
||||
|
||||
const liveBadgeData = badgeUpdates.get(task.id);
|
||||
const { files: sessionFiles, loading: sessionFilesLoading } = useSessionFiles(task.id, task.worktree, task.column);
|
||||
|
||||
// Get fresh batch data if available
|
||||
const batchData = useMemo(() => getFreshBatchData(task.id), [task.id]);
|
||||
@@ -683,6 +688,22 @@ function TaskCardComponent({
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{task.worktree && (task.column === "in-progress" || task.column === "in-review") && (
|
||||
<button
|
||||
type="button"
|
||||
className="card-session-files"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenFilesForTask?.(task.id);
|
||||
}}
|
||||
disabled={!onOpenFilesForTask}
|
||||
>
|
||||
<Folder size={12} />
|
||||
<span>
|
||||
{sessionFilesLoading ? "Checking files…" : `${sessionFiles.length} files changed`}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (
|
||||
<div className="card-meta">
|
||||
{task.dependencies && task.dependencies.length > 0 && (
|
||||
|
||||
111
packages/dashboard/app/components/WorkspaceSelector.css
Normal file
111
packages/dashboard/app/components/WorkspaceSelector.css
Normal file
@@ -0,0 +1,111 @@
|
||||
.workspace-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.workspace-selector-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workspace-selector-trigger:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.workspace-selector-trigger-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.workspace-selector-trigger-icon {
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.workspace-selector-trigger-icon.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.workspace-selector-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
min-width: 300px;
|
||||
max-width: 360px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-elevated, var(--surface));
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.workspace-selector-group {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.workspace-selector-group-label {
|
||||
padding: 4px 8px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.workspace-selector-option {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.workspace-selector-option:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.workspace-selector-option.active {
|
||||
background: rgba(88, 166, 255, 0.12);
|
||||
}
|
||||
|
||||
.workspace-selector-option-main {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.workspace-selector-option-meta {
|
||||
min-width: 0;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
97
packages/dashboard/app/components/WorkspaceSelector.tsx
Normal file
97
packages/dashboard/app/components/WorkspaceSelector.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ChevronDown, FolderGit2, FolderRoot } from "lucide-react";
|
||||
import type { WorkspaceInfo } from "../hooks/useWorkspaces";
|
||||
import "./WorkspaceSelector.css";
|
||||
|
||||
interface WorkspaceSelectorProps {
|
||||
currentWorkspace: string;
|
||||
projectName: string;
|
||||
workspaces: WorkspaceInfo[];
|
||||
onSelect: (workspace: string) => void;
|
||||
}
|
||||
|
||||
function truncateTitle(title?: string, maxLength = 44): string | undefined {
|
||||
if (!title) return undefined;
|
||||
if (title.length <= maxLength) return title;
|
||||
return `${title.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
export function WorkspaceSelector({
|
||||
currentWorkspace,
|
||||
projectName,
|
||||
workspaces,
|
||||
onSelect,
|
||||
}: WorkspaceSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const currentLabel = useMemo(() => {
|
||||
if (currentWorkspace === "project") {
|
||||
return projectName || "Project Root";
|
||||
}
|
||||
|
||||
const match = workspaces.find((workspace) => workspace.id === currentWorkspace);
|
||||
return match?.label ?? currentWorkspace;
|
||||
}, [currentWorkspace, projectName, workspaces]);
|
||||
|
||||
return (
|
||||
<div className="workspace-selector">
|
||||
<button
|
||||
type="button"
|
||||
className="workspace-selector-trigger"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
{currentWorkspace === "project" ? <FolderRoot size={14} /> : <FolderGit2 size={14} />}
|
||||
<span className="workspace-selector-trigger-label">{currentLabel}</span>
|
||||
<ChevronDown size={14} className={`workspace-selector-trigger-icon${open ? " open" : ""}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="workspace-selector-menu" role="listbox" aria-label="Select workspace">
|
||||
<button
|
||||
type="button"
|
||||
className={`workspace-selector-option${currentWorkspace === "project" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
onSelect("project");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="workspace-selector-option-main">
|
||||
<FolderRoot size={14} />
|
||||
<span>Project Root</span>
|
||||
</div>
|
||||
<span className="workspace-selector-option-meta">{projectName}</span>
|
||||
</button>
|
||||
|
||||
{workspaces.length > 0 && (
|
||||
<div className="workspace-selector-group">
|
||||
<div className="workspace-selector-group-label">Task Worktrees</div>
|
||||
{workspaces.map((workspace) => (
|
||||
<button
|
||||
key={workspace.id}
|
||||
type="button"
|
||||
className={`workspace-selector-option${currentWorkspace === workspace.id ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
onSelect(workspace.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="workspace-selector-option-main">
|
||||
<FolderGit2 size={14} />
|
||||
<span>{workspace.id}</span>
|
||||
</div>
|
||||
{workspace.title && (
|
||||
<span className="workspace-selector-option-meta" title={workspace.title}>
|
||||
{truncateTitle(workspace.title)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ interface WorktreeGroupProps {
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onOpenFilesForTask?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
function WorktreeGroupComponent({
|
||||
@@ -25,6 +26,7 @@ function WorktreeGroupComponent({
|
||||
addToast,
|
||||
globalPaused,
|
||||
onUpdateTask,
|
||||
onOpenFilesForTask,
|
||||
}: WorktreeGroupProps) {
|
||||
return (
|
||||
<div className="worktree-group">
|
||||
@@ -35,7 +37,7 @@ function WorktreeGroupComponent({
|
||||
<span className="worktree-label">{label}</span>
|
||||
</div>
|
||||
{activeTasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} />
|
||||
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onOpenFilesForTask={onOpenFilesForTask} />
|
||||
))}
|
||||
{queuedTasks.map((task) => (
|
||||
<TaskCard
|
||||
@@ -46,6 +48,7 @@ function WorktreeGroupComponent({
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onOpenFilesForTask={onOpenFilesForTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { WorkspaceSelector } from "../WorkspaceSelector";
|
||||
import type { WorkspaceInfo } from "../../hooks/useWorkspaces";
|
||||
|
||||
const workspaces: WorkspaceInfo[] = [
|
||||
{
|
||||
id: "KB-123",
|
||||
label: "KB-123",
|
||||
title: "Implement a very long task title that should be truncated in the dropdown list",
|
||||
worktree: "/repo/.worktrees/kb-123",
|
||||
kind: "task",
|
||||
},
|
||||
{
|
||||
id: "KB-200",
|
||||
label: "KB-200",
|
||||
title: "Short title",
|
||||
worktree: "/repo/.worktrees/kb-200",
|
||||
kind: "task",
|
||||
},
|
||||
];
|
||||
|
||||
describe("WorkspaceSelector", () => {
|
||||
it("shows the project name when project root is selected", () => {
|
||||
render(
|
||||
<WorkspaceSelector
|
||||
currentWorkspace="project"
|
||||
projectName="kb"
|
||||
workspaces={workspaces}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /kb/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the menu and highlights the current workspace", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<WorkspaceSelector
|
||||
currentWorkspace="KB-200"
|
||||
projectName="kb"
|
||||
workspaces={workspaces}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /KB-200/i }));
|
||||
|
||||
expect(screen.getByText("Project Root")).toBeInTheDocument();
|
||||
const menu = screen.getByRole("listbox", { name: /select workspace/i });
|
||||
const activeOption = within(menu).getByRole("button", { name: /KB-200 Short title/i });
|
||||
expect(activeOption.className).toContain("active");
|
||||
});
|
||||
|
||||
it("calls onSelect and closes when choosing a workspace", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<WorkspaceSelector
|
||||
currentWorkspace="project"
|
||||
projectName="kb"
|
||||
workspaces={workspaces}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /kb/i }));
|
||||
await user.click(screen.getByRole("button", { name: /KB-123/i }));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("KB-123");
|
||||
expect(screen.queryByText("Task Worktrees")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("truncates long task titles in the dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<WorkspaceSelector
|
||||
currentWorkspace="project"
|
||||
projectName="kb"
|
||||
workspaces={workspaces}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /kb/i }));
|
||||
|
||||
expect(screen.getByText(/Implement a very long task title that shoul/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/…$/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,14 +6,14 @@ import type { FileListResponse } from "../../api";
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchProjectFileList: vi.fn(),
|
||||
fetchWorkspaceFileList: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchProjectFileList = vi.mocked(api.fetchProjectFileList);
|
||||
const mockFetchWorkspaceFileList = vi.mocked(api.fetchWorkspaceFileList);
|
||||
|
||||
describe("useProjectFileBrowser", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchProjectFileList.mockReset();
|
||||
mockFetchWorkspaceFileList.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -37,20 +37,17 @@ describe("useProjectFileBrowser", () => {
|
||||
{ name: "package.json", type: "file", size: 100, mtime: "2024-01-01T00:00:00Z" },
|
||||
],
|
||||
};
|
||||
mockFetchProjectFileList.mockResolvedValueOnce(mockResponse);
|
||||
mockFetchWorkspaceFileList.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useProjectFileBrowser("/project", true));
|
||||
|
||||
// Should start loading
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
// Wait for fetch to complete
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.entries).toHaveLength(2);
|
||||
expect(result.current.entries[0].name).toBe("src");
|
||||
expect(result.current.entries[1].name).toBe("package.json");
|
||||
expect(mockFetchProjectFileList).toHaveBeenCalledWith(undefined);
|
||||
expect(mockFetchWorkspaceFileList).toHaveBeenCalledWith("project", undefined);
|
||||
});
|
||||
|
||||
it("fetches subdirectory when path changes", async () => {
|
||||
@@ -63,7 +60,7 @@ describe("useProjectFileBrowser", () => {
|
||||
entries: [{ name: "index.ts", type: "file", size: 200, mtime: "2024-01-01T00:00:00Z" }],
|
||||
};
|
||||
|
||||
mockFetchProjectFileList
|
||||
mockFetchWorkspaceFileList
|
||||
.mockResolvedValueOnce(rootResponse)
|
||||
.mockResolvedValueOnce(subdirResponse);
|
||||
|
||||
@@ -72,7 +69,6 @@ describe("useProjectFileBrowser", () => {
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
|
||||
// Navigate to subdirectory
|
||||
act(() => {
|
||||
result.current.setPath("src");
|
||||
});
|
||||
@@ -80,11 +76,11 @@ describe("useProjectFileBrowser", () => {
|
||||
await waitFor(() => expect(result.current.currentPath).toBe("src"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(mockFetchProjectFileList).toHaveBeenLastCalledWith("src");
|
||||
expect(mockFetchWorkspaceFileList).toHaveBeenLastCalledWith("project", "src");
|
||||
});
|
||||
|
||||
it("handles fetch errors", async () => {
|
||||
mockFetchProjectFileList.mockRejectedValueOnce(new Error("Failed to load files"));
|
||||
mockFetchWorkspaceFileList.mockRejectedValueOnce(new Error("Failed to load files"));
|
||||
|
||||
const { result } = renderHook(() => useProjectFileBrowser("/project", true));
|
||||
|
||||
@@ -107,7 +103,7 @@ describe("useProjectFileBrowser", () => {
|
||||
],
|
||||
};
|
||||
|
||||
mockFetchProjectFileList
|
||||
mockFetchWorkspaceFileList
|
||||
.mockResolvedValueOnce(initialResponse)
|
||||
.mockResolvedValueOnce(refreshedResponse);
|
||||
|
||||
@@ -116,17 +112,16 @@ describe("useProjectFileBrowser", () => {
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
|
||||
// Refresh
|
||||
act(() => {
|
||||
result.current.refresh();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.entries).toHaveLength(2));
|
||||
expect(mockFetchProjectFileList).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetchWorkspaceFileList).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("clears error when path changes", async () => {
|
||||
mockFetchProjectFileList
|
||||
mockFetchWorkspaceFileList
|
||||
.mockRejectedValueOnce(new Error("Failed to load files"))
|
||||
.mockResolvedValueOnce({
|
||||
path: ".",
|
||||
@@ -137,7 +132,6 @@ describe("useProjectFileBrowser", () => {
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBe("Failed to load files"));
|
||||
|
||||
// Change path should clear error
|
||||
act(() => {
|
||||
result.current.setPath("subdir");
|
||||
});
|
||||
@@ -148,10 +142,9 @@ describe("useProjectFileBrowser", () => {
|
||||
it("does not fetch when disabled", async () => {
|
||||
renderHook(() => useProjectFileBrowser("/project", false));
|
||||
|
||||
// Wait a bit to ensure no fetch happens
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockFetchProjectFileList).not.toHaveBeenCalled();
|
||||
expect(mockFetchWorkspaceFileList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels in-flight requests on unmount", async () => {
|
||||
@@ -159,23 +152,19 @@ describe("useProjectFileBrowser", () => {
|
||||
const fetchPromise = new Promise<FileListResponse>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
mockFetchProjectFileList.mockReturnValueOnce(fetchPromise);
|
||||
mockFetchWorkspaceFileList.mockReturnValueOnce(fetchPromise);
|
||||
|
||||
const { unmount } = renderHook(() => useProjectFileBrowser("/project", true));
|
||||
|
||||
// Unmount before fetch completes
|
||||
unmount();
|
||||
|
||||
// Complete the fetch after unmount
|
||||
resolveFetch!({
|
||||
path: ".",
|
||||
entries: [{ name: "file.txt", type: "file", size: 100, mtime: "2024-01-01T00:00:00Z" }],
|
||||
});
|
||||
|
||||
// Wait a bit to ensure state update doesn't happen
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Should not throw or have any issues
|
||||
expect(mockFetchProjectFileList).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchWorkspaceFileList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,319 +6,221 @@ import type { FileContentResponse, SaveFileResponse } from "../../api";
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchProjectFileContent: vi.fn(),
|
||||
saveProjectFileContent: vi.fn(),
|
||||
fetchWorkspaceFileContent: vi.fn(),
|
||||
saveWorkspaceFileContent: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchProjectFileContent = vi.mocked(api.fetchProjectFileContent);
|
||||
const mockSaveProjectFileContent = vi.mocked(api.saveProjectFileContent);
|
||||
const mockFetchWorkspaceFileContent = vi.mocked(api.fetchWorkspaceFileContent);
|
||||
const mockSaveWorkspaceFileContent = vi.mocked(api.saveWorkspaceFileContent);
|
||||
|
||||
describe("useProjectFileEditor", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchProjectFileContent.mockReset();
|
||||
mockSaveProjectFileContent.mockReset();
|
||||
mockFetchWorkspaceFileContent.mockReset();
|
||||
mockSaveWorkspaceFileContent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("initializes with empty content when no file selected", () => {
|
||||
it("initializes with empty content when no file is selected", () => {
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", null, true));
|
||||
|
||||
expect(result.current.content).toBe("");
|
||||
expect(result.current.originalContent).toBe("");
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.saving).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
expect(result.current.mtime).toBeNull();
|
||||
});
|
||||
|
||||
it("initializes with empty content when disabled", () => {
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "src/index.ts", false));
|
||||
|
||||
expect(result.current.content).toBe("");
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(mockFetchProjectFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches file content when filePath is provided and enabled", async () => {
|
||||
it("loads file content when file is selected", async () => {
|
||||
const mockResponse: FileContentResponse = {
|
||||
content: "console.log('hello');",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 21,
|
||||
};
|
||||
mockFetchProjectFileContent.mockResolvedValueOnce(mockResponse);
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "src/index.ts", true));
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.content).toBe("console.log('hello');");
|
||||
expect(result.current.originalContent).toBe("console.log('hello');");
|
||||
expect(result.current.mtime).toBe("2024-01-01T00:00:00Z");
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
expect(mockFetchProjectFileContent).toHaveBeenCalledWith("src/index.ts");
|
||||
expect(mockFetchWorkspaceFileContent).toHaveBeenCalledWith("project", "src/index.ts");
|
||||
});
|
||||
|
||||
it("tracks content changes", async () => {
|
||||
const mockResponse: FileContentResponse = {
|
||||
content: "console.log('hello');",
|
||||
content: "original content",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 21,
|
||||
size: 16,
|
||||
};
|
||||
mockFetchProjectFileContent.mockResolvedValueOnce(mockResponse);
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "src/index.ts", true));
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "file.txt", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
|
||||
// Edit content
|
||||
act(() => {
|
||||
result.current.setContent("console.log('world');");
|
||||
result.current.setContent("modified content");
|
||||
});
|
||||
|
||||
expect(result.current.content).toBe("console.log('world');");
|
||||
expect(result.current.content).toBe("modified content");
|
||||
expect(result.current.hasChanges).toBe(true);
|
||||
});
|
||||
|
||||
it("saves file content", async () => {
|
||||
it("saves changes successfully", async () => {
|
||||
const loadResponse: FileContentResponse = {
|
||||
content: "console.log('hello');",
|
||||
content: "original",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 21,
|
||||
size: 8,
|
||||
};
|
||||
const saveResponse: SaveFileResponse = {
|
||||
success: true,
|
||||
mtime: "2024-01-02T00:00:00Z",
|
||||
size: 22,
|
||||
size: 16,
|
||||
};
|
||||
|
||||
mockFetchProjectFileContent.mockResolvedValueOnce(loadResponse);
|
||||
mockSaveProjectFileContent.mockResolvedValueOnce(saveResponse);
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(loadResponse);
|
||||
mockSaveWorkspaceFileContent.mockResolvedValueOnce(saveResponse);
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "src/index.ts", true));
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "file.txt", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
// Edit and save
|
||||
act(() => {
|
||||
result.current.setContent("console.log('world');");
|
||||
result.current.setContent("modified content");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
|
||||
expect(mockSaveProjectFileContent).toHaveBeenCalledWith("src/index.ts", "console.log('world');");
|
||||
expect(result.current.originalContent).toBe("console.log('world');");
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
expect(result.current.mtime).toBe("2024-01-02T00:00:00Z");
|
||||
});
|
||||
|
||||
it("does not save when there are no changes", async () => {
|
||||
const mockResponse: FileContentResponse = {
|
||||
content: "console.log('hello');",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 21,
|
||||
};
|
||||
mockFetchProjectFileContent.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "src/index.ts", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
// Try to save without changes
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
|
||||
expect(mockSaveProjectFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles fetch errors", async () => {
|
||||
mockFetchProjectFileContent.mockRejectedValueOnce(new Error("File not found"));
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "src/missing.ts", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBe("File not found");
|
||||
expect(result.current.content).toBe("");
|
||||
});
|
||||
|
||||
it("handles save errors", async () => {
|
||||
const loadResponse: FileContentResponse = {
|
||||
content: "console.log('hello');",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 21,
|
||||
};
|
||||
mockFetchProjectFileContent.mockResolvedValueOnce(loadResponse);
|
||||
mockSaveProjectFileContent.mockRejectedValueOnce(new Error("Permission denied"));
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "src/index.ts", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
// Edit and try to save
|
||||
act(() => {
|
||||
result.current.setContent("console.log('world');");
|
||||
});
|
||||
|
||||
let saveError: Error | undefined;
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.save();
|
||||
} catch (err) {
|
||||
saveError = err as Error;
|
||||
}
|
||||
});
|
||||
|
||||
expect(saveError?.message).toBe("Permission denied");
|
||||
expect(result.current.error).toBe("Permission denied");
|
||||
// Original content should not be updated on error
|
||||
expect(result.current.originalContent).toBe("console.log('hello');");
|
||||
expect(result.current.hasChanges).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
|
||||
expect(result.current.originalContent).toBe("modified content");
|
||||
expect(result.current.mtime).toBe("2024-01-02T00:00:00Z");
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
expect(mockSaveWorkspaceFileContent).toHaveBeenCalledWith("project", "file.txt", "modified content");
|
||||
});
|
||||
|
||||
it("clears content when filePath changes", async () => {
|
||||
const firstResponse: FileContentResponse = {
|
||||
content: "// file 1",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 10,
|
||||
};
|
||||
const secondResponse: FileContentResponse = {
|
||||
content: "// file 2",
|
||||
mtime: "2024-01-02T00:00:00Z",
|
||||
size: 10,
|
||||
};
|
||||
it("handles load errors", async () => {
|
||||
mockFetchWorkspaceFileContent.mockRejectedValueOnce(new Error("Failed to load file"));
|
||||
|
||||
mockFetchProjectFileContent
|
||||
.mockResolvedValueOnce(firstResponse)
|
||||
.mockResolvedValueOnce(secondResponse);
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "missing.txt", true));
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ filePath }) => useProjectFileEditor("/project", filePath, true),
|
||||
{ initialProps: { filePath: "file1.ts" } }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.content).toBe("// file 1"));
|
||||
|
||||
// Change file
|
||||
rerender({ filePath: "file2.ts" });
|
||||
|
||||
await waitFor(() => expect(result.current.content).toBe("// file 2"));
|
||||
});
|
||||
|
||||
it("clears content when disabled", async () => {
|
||||
const mockResponse: FileContentResponse = {
|
||||
content: "console.log('hello');",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 21,
|
||||
};
|
||||
mockFetchProjectFileContent.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled }) => useProjectFileEditor("/project", "src/index.ts", enabled),
|
||||
{ initialProps: { enabled: true } }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.content).toBe("console.log('hello');"));
|
||||
|
||||
// Disable
|
||||
rerender({ enabled: false });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBe("Failed to load file");
|
||||
expect(result.current.content).toBe("");
|
||||
expect(result.current.originalContent).toBe("");
|
||||
expect(result.current.mtime).toBeNull();
|
||||
});
|
||||
|
||||
it("clears error when content is edited", async () => {
|
||||
mockFetchProjectFileContent.mockRejectedValueOnce(new Error("File not found"));
|
||||
it("handles save errors", async () => {
|
||||
const loadResponse: FileContentResponse = {
|
||||
content: "original",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 8,
|
||||
};
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(loadResponse);
|
||||
mockSaveWorkspaceFileContent.mockRejectedValueOnce(new Error("Failed to save file"));
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "src/missing.ts", true));
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "file.txt", true));
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBe("File not found"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
// Edit content should clear error
|
||||
act(() => {
|
||||
result.current.setContent("new content");
|
||||
result.current.setContent("modified");
|
||||
});
|
||||
|
||||
await expect(result.current.save()).rejects.toThrow("Failed to save file");
|
||||
expect(result.current.error).toBe("Failed to save file");
|
||||
expect(result.current.hasChanges).toBe(true);
|
||||
});
|
||||
|
||||
it("does not load when disabled", async () => {
|
||||
renderHook(() => useProjectFileEditor("/project", "file.txt", false));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockFetchWorkspaceFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not save when there are no changes", async () => {
|
||||
const loadResponse: FileContentResponse = {
|
||||
content: "original",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 8,
|
||||
};
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(loadResponse);
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "file.txt", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
|
||||
expect(mockSaveWorkspaceFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears error when content changes", async () => {
|
||||
const loadResponse: FileContentResponse = {
|
||||
content: "original",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 8,
|
||||
};
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(loadResponse);
|
||||
mockSaveWorkspaceFileContent.mockRejectedValueOnce(new Error("Save failed"));
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "file.txt", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
act(() => {
|
||||
result.current.setContent("modified");
|
||||
});
|
||||
|
||||
await expect(result.current.save()).rejects.toThrow("Save failed");
|
||||
expect(result.current.error).toBe("Save failed");
|
||||
|
||||
act(() => {
|
||||
result.current.setContent("modified again");
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("shows saving state during save operation", async () => {
|
||||
it("resets state when filePath becomes null", async () => {
|
||||
const loadResponse: FileContentResponse = {
|
||||
content: "console.log('hello');",
|
||||
content: "content",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 21,
|
||||
};
|
||||
const saveResponse: SaveFileResponse = {
|
||||
success: true,
|
||||
mtime: "2024-01-02T00:00:00Z",
|
||||
size: 22,
|
||||
size: 7,
|
||||
};
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(loadResponse);
|
||||
|
||||
let resolveSave: (value: SaveFileResponse) => void;
|
||||
const savePromise = new Promise<SaveFileResponse>((resolve) => {
|
||||
resolveSave = resolve;
|
||||
});
|
||||
|
||||
mockFetchProjectFileContent.mockResolvedValueOnce(loadResponse);
|
||||
mockSaveProjectFileContent.mockReturnValueOnce(savePromise);
|
||||
|
||||
const { result } = renderHook(() => useProjectFileEditor("/project", "src/index.ts", true));
|
||||
const { result, rerender } = renderHook(
|
||||
({ filePath }) => useProjectFileEditor("/project", filePath, true),
|
||||
{ initialProps: { filePath: "file.txt" } }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.content).toBe("content");
|
||||
|
||||
// Edit and save
|
||||
act(() => {
|
||||
result.current.setContent("console.log('world');");
|
||||
});
|
||||
rerender({ filePath: null });
|
||||
|
||||
// Start save
|
||||
let savePromiseResolved = false;
|
||||
act(() => {
|
||||
result.current.save().then(() => {
|
||||
savePromiseResolved = true;
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.saving).toBe(true);
|
||||
|
||||
// Complete save
|
||||
resolveSave!(saveResponse);
|
||||
await waitFor(() => expect(savePromiseResolved).toBe(true));
|
||||
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it("cancels in-flight fetch on unmount", async () => {
|
||||
let resolveFetch: (value: FileContentResponse) => void;
|
||||
const fetchPromise = new Promise<FileContentResponse>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
mockFetchProjectFileContent.mockReturnValueOnce(fetchPromise);
|
||||
|
||||
const { unmount } = renderHook(() => useProjectFileEditor("/project", "src/index.ts", true));
|
||||
|
||||
// Unmount before fetch completes
|
||||
unmount();
|
||||
|
||||
// Complete the fetch after unmount
|
||||
resolveFetch!({
|
||||
content: "console.log('hello');",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 21,
|
||||
});
|
||||
|
||||
// Wait a bit to ensure state update doesn't happen
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(mockFetchProjectFileContent).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.content).toBe("");
|
||||
expect(result.current.originalContent).toBe("");
|
||||
expect(result.current.mtime).toBeNull();
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { useSessionFiles } from "../useSessionFiles";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchSessionFiles: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchSessionFiles = vi.mocked(api.fetchSessionFiles);
|
||||
|
||||
describe("useSessionFiles", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchSessionFiles.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("fetches session files for active tasks with a worktree", async () => {
|
||||
mockFetchSessionFiles.mockResolvedValueOnce(["src/a.ts", "src/b.ts"]);
|
||||
|
||||
const { result } = renderHook(() => useSessionFiles("KB-123", "/repo/.worktrees/kb-123", "in-progress"));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.files).toEqual(["src/a.ts", "src/b.ts"]);
|
||||
expect(mockFetchSessionFiles).toHaveBeenCalledWith("KB-123");
|
||||
});
|
||||
|
||||
it("does not fetch for tasks without worktrees or inactive columns", async () => {
|
||||
const { result: noWorktree } = renderHook(() => useSessionFiles("KB-123", undefined, "in-progress"));
|
||||
const { result: inactive } = renderHook(() => useSessionFiles("KB-123", "/repo/.worktrees/kb-123", "todo"));
|
||||
|
||||
await waitFor(() => expect(noWorktree.current.loading).toBe(false));
|
||||
await waitFor(() => expect(inactive.current.loading).toBe(false));
|
||||
|
||||
expect(noWorktree.current.files).toEqual([]);
|
||||
expect(inactive.current.files).toEqual([]);
|
||||
expect(mockFetchSessionFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns empty files on fetch failure", async () => {
|
||||
mockFetchSessionFiles.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
const { result } = renderHook(() => useSessionFiles("KB-123", "/repo/.worktrees/kb-123", "in-review"));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.files).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useWorkspaceFileBrowser } from "../useWorkspaceFileBrowser";
|
||||
import * as api from "../../api";
|
||||
import type { FileListResponse } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchWorkspaceFileList: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchWorkspaceFileList = vi.mocked(api.fetchWorkspaceFileList);
|
||||
|
||||
describe("useWorkspaceFileBrowser", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchWorkspaceFileList.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("fetches workspace files when enabled", async () => {
|
||||
const mockResponse: FileListResponse = {
|
||||
path: ".",
|
||||
entries: [{ name: "src", type: "directory", mtime: "2024-01-01T00:00:00Z" }],
|
||||
};
|
||||
mockFetchWorkspaceFileList.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceFileBrowser("KB-123", true));
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.entries).toEqual(mockResponse.entries);
|
||||
expect(mockFetchWorkspaceFileList).toHaveBeenCalledWith("KB-123", undefined);
|
||||
});
|
||||
|
||||
it("resets path when workspace changes", async () => {
|
||||
mockFetchWorkspaceFileList
|
||||
.mockResolvedValueOnce({ path: ".", entries: [{ name: "src", type: "directory", mtime: "2024-01-01T00:00:00Z" }] })
|
||||
.mockResolvedValueOnce({ path: "src", entries: [{ name: "index.ts", type: "file", size: 1, mtime: "2024-01-01T00:00:00Z" }] })
|
||||
.mockResolvedValueOnce({ path: ".", entries: [{ name: "README.md", type: "file", size: 5, mtime: "2024-01-01T00:00:00Z" }] });
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ workspace }) => useWorkspaceFileBrowser(workspace, true),
|
||||
{ initialProps: { workspace: "KB-123" } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
act(() => {
|
||||
result.current.setPath("src");
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.currentPath).toBe("src"));
|
||||
await waitFor(() => expect(mockFetchWorkspaceFileList).toHaveBeenLastCalledWith("KB-123", "src"));
|
||||
|
||||
rerender({ workspace: "project" });
|
||||
|
||||
await waitFor(() => expect(result.current.currentPath).toBe("."));
|
||||
await waitFor(() => expect(mockFetchWorkspaceFileList).toHaveBeenLastCalledWith("project", undefined));
|
||||
});
|
||||
|
||||
it("handles fetch errors", async () => {
|
||||
mockFetchWorkspaceFileList.mockRejectedValueOnce(new Error("Failed to load files"));
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceFileBrowser("KB-404", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBe("Failed to load files");
|
||||
expect(result.current.entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useWorkspaceFileEditor } from "../useWorkspaceFileEditor";
|
||||
import * as api from "../../api";
|
||||
import type { FileContentResponse, SaveFileResponse } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchWorkspaceFileContent: vi.fn(),
|
||||
saveWorkspaceFileContent: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchWorkspaceFileContent = vi.mocked(api.fetchWorkspaceFileContent);
|
||||
const mockSaveWorkspaceFileContent = vi.mocked(api.saveWorkspaceFileContent);
|
||||
|
||||
describe("useWorkspaceFileEditor", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchWorkspaceFileContent.mockReset();
|
||||
mockSaveWorkspaceFileContent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads content for the selected workspace file", async () => {
|
||||
const response: FileContentResponse = {
|
||||
content: "hello",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 5,
|
||||
};
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(response);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceFileEditor("KB-123", "src/index.ts", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.content).toBe("hello");
|
||||
expect(result.current.originalContent).toBe("hello");
|
||||
expect(mockFetchWorkspaceFileContent).toHaveBeenCalledWith("KB-123", "src/index.ts");
|
||||
});
|
||||
|
||||
it("saves workspace file changes", async () => {
|
||||
const loadResponse: FileContentResponse = {
|
||||
content: "original",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 8,
|
||||
};
|
||||
const saveResponse: SaveFileResponse = {
|
||||
success: true,
|
||||
mtime: "2024-01-02T00:00:00Z",
|
||||
size: 9,
|
||||
};
|
||||
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(loadResponse);
|
||||
mockSaveWorkspaceFileContent.mockResolvedValueOnce(saveResponse);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceFileEditor("project", "README.md", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
act(() => {
|
||||
result.current.setContent("changed");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
|
||||
expect(mockSaveWorkspaceFileContent).toHaveBeenCalledWith("project", "README.md", "changed");
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
expect(result.current.mtime).toBe("2024-01-02T00:00:00Z");
|
||||
});
|
||||
|
||||
it("resets state when disabled or file is cleared", async () => {
|
||||
const response: FileContentResponse = {
|
||||
content: "hello",
|
||||
mtime: "2024-01-01T00:00:00Z",
|
||||
size: 5,
|
||||
};
|
||||
mockFetchWorkspaceFileContent.mockResolvedValueOnce(response);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ workspace, filePath, enabled }) => useWorkspaceFileEditor(workspace, filePath, enabled),
|
||||
{ initialProps: { workspace: "KB-123", filePath: "src/index.ts", enabled: true } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.content).toBe("hello");
|
||||
|
||||
rerender({ workspace: "KB-123", filePath: null, enabled: true });
|
||||
|
||||
expect(result.current.content).toBe("");
|
||||
expect(result.current.originalContent).toBe("");
|
||||
expect(result.current.mtime).toBeNull();
|
||||
});
|
||||
});
|
||||
74
packages/dashboard/app/hooks/__tests__/useWorkspaces.test.ts
Normal file
74
packages/dashboard/app/hooks/__tests__/useWorkspaces.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { useWorkspaces } from "../useWorkspaces";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchWorkspaces: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchWorkspaces = vi.mocked(api.fetchWorkspaces);
|
||||
|
||||
describe("useWorkspaces", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockFetchWorkspaces.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads project and task workspaces", async () => {
|
||||
mockFetchWorkspaces.mockResolvedValueOnce({
|
||||
project: "/Users/test/repo",
|
||||
tasks: [{ id: "KB-123", title: "Feature", worktree: "/Users/test/.worktrees/kb-123" }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useWorkspaces());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.projectName).toBe("repo");
|
||||
expect(result.current.workspaces).toEqual([
|
||||
{
|
||||
id: "KB-123",
|
||||
label: "KB-123",
|
||||
title: "Feature",
|
||||
worktree: "/Users/test/.worktrees/kb-123",
|
||||
kind: "task",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("polls for workspace updates", async () => {
|
||||
mockFetchWorkspaces
|
||||
.mockResolvedValueOnce({ project: "/repo", tasks: [] })
|
||||
.mockResolvedValueOnce({
|
||||
project: "/repo",
|
||||
tasks: [{ id: "KB-200", title: "Later", worktree: "/repo/.worktrees/kb-200" }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useWorkspaces());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.workspaces).toEqual([]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
|
||||
await waitFor(() => expect(result.current.workspaces).toHaveLength(1));
|
||||
expect(mockFetchWorkspaces).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("surfaces fetch errors", async () => {
|
||||
mockFetchWorkspaces.mockRejectedValueOnce(new Error("Failed to load workspaces"));
|
||||
|
||||
const { result } = renderHook(() => useWorkspaces());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBe("Failed to load workspaces");
|
||||
expect(result.current.workspaces).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { FileNode, FileListResponse } from "../api";
|
||||
import { fetchProjectFileList } from "../api";
|
||||
import { fetchWorkspaceFileList } from "../api";
|
||||
|
||||
interface UseProjectFileBrowserReturn {
|
||||
entries: FileNode[];
|
||||
@@ -46,7 +46,8 @@ export function useProjectFileBrowser(rootPath: string, enabled: boolean): UsePr
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response: FileListResponse = await fetchProjectFileList(
|
||||
const response: FileListResponse = await fetchWorkspaceFileList(
|
||||
"project",
|
||||
currentPath === "." ? undefined : currentPath
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { FileContentResponse, SaveFileResponse } from "../api";
|
||||
import { fetchProjectFileContent, saveProjectFileContent } from "../api";
|
||||
import { fetchWorkspaceFileContent, saveWorkspaceFileContent } from "../api";
|
||||
|
||||
interface UseProjectFileEditorReturn {
|
||||
content: string;
|
||||
@@ -56,7 +56,7 @@ export function useProjectFileEditor(
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response: FileContentResponse = await fetchProjectFileContent(filePath!);
|
||||
const response: FileContentResponse = await fetchWorkspaceFileContent("project", filePath!);
|
||||
|
||||
if (!cancelled) {
|
||||
setContentState(response.content);
|
||||
@@ -95,7 +95,7 @@ export function useProjectFileEditor(
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response: SaveFileResponse = await saveProjectFileContent(filePath, content);
|
||||
const response: SaveFileResponse = await saveWorkspaceFileContent("project", filePath, content);
|
||||
setOriginalContent(content);
|
||||
setMtime(response.mtime);
|
||||
} catch (err: any) {
|
||||
|
||||
46
packages/dashboard/app/hooks/useSessionFiles.ts
Normal file
46
packages/dashboard/app/hooks/useSessionFiles.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchSessionFiles } from "../api";
|
||||
|
||||
const ACTIVE_COLUMNS = new Set(["in-progress", "in-review"]);
|
||||
|
||||
interface UseSessionFilesResult {
|
||||
files: string[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useSessionFiles(taskId: string, worktree: string | undefined, column: string): UseSessionFilesResult {
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId || !worktree || !ACTIVE_COLUMNS.has(column)) {
|
||||
setFiles([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchSessionFiles(taskId);
|
||||
if (!cancelled) {
|
||||
setFiles(result);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setFiles([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
}, [taskId, worktree, column]);
|
||||
|
||||
return { files, loading };
|
||||
}
|
||||
92
packages/dashboard/app/hooks/useWorkspaceFileBrowser.ts
Normal file
92
packages/dashboard/app/hooks/useWorkspaceFileBrowser.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { FileNode, FileListResponse } from "../api";
|
||||
import { fetchWorkspaceFileList } from "../api";
|
||||
|
||||
interface UseWorkspaceFileBrowserReturn {
|
||||
entries: FileNode[];
|
||||
currentPath: string;
|
||||
setPath: (path: string) => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for browsing files in a selected workspace.
|
||||
*
|
||||
* @param workspace - The workspace identifier ("project" or task ID)
|
||||
* @param enabled - Whether fetching is enabled
|
||||
*/
|
||||
export function useWorkspaceFileBrowser(
|
||||
workspace: string,
|
||||
enabled: boolean,
|
||||
): UseWorkspaceFileBrowserReturn {
|
||||
const [entries, setEntries] = useState<FileNode[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState<string>(".");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshKey((key) => key + 1);
|
||||
}, []);
|
||||
|
||||
const setPath = useCallback((path: string) => {
|
||||
setCurrentPath(path);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPath(".");
|
||||
setError(null);
|
||||
setEntries([]);
|
||||
}, [workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function loadFiles() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response: FileListResponse = await fetchWorkspaceFileList(
|
||||
workspace,
|
||||
currentPath === "." ? undefined : currentPath,
|
||||
);
|
||||
|
||||
if (!cancelled) {
|
||||
setEntries(response.entries);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!cancelled) {
|
||||
setError(err.message || "Failed to load files");
|
||||
setEntries([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadFiles();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspace, currentPath, enabled, refreshKey]);
|
||||
|
||||
return {
|
||||
entries,
|
||||
currentPath,
|
||||
setPath,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
118
packages/dashboard/app/hooks/useWorkspaceFileEditor.ts
Normal file
118
packages/dashboard/app/hooks/useWorkspaceFileEditor.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { FileContentResponse, SaveFileResponse } from "../api";
|
||||
import { fetchWorkspaceFileContent, saveWorkspaceFileContent } from "../api";
|
||||
|
||||
interface UseWorkspaceFileEditorReturn {
|
||||
content: string;
|
||||
setContent: (content: string) => void;
|
||||
originalContent: string;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
save: () => Promise<void>;
|
||||
hasChanges: boolean;
|
||||
mtime: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for editing a file in a selected workspace.
|
||||
*
|
||||
* @param workspace - The workspace identifier ("project" or task ID)
|
||||
* @param filePath - The selected file path
|
||||
* @param enabled - Whether loading is enabled
|
||||
*/
|
||||
export function useWorkspaceFileEditor(
|
||||
workspace: string,
|
||||
filePath: string | null,
|
||||
enabled: boolean,
|
||||
): UseWorkspaceFileEditorReturn {
|
||||
const [content, setContentState] = useState<string>("");
|
||||
const [originalContent, setOriginalContent] = useState<string>("");
|
||||
const [mtime, setMtime] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const setContent = useCallback((newContent: string) => {
|
||||
setContentState(newContent);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !workspace || !filePath) {
|
||||
setContentState("");
|
||||
setOriginalContent("");
|
||||
setMtime(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function loadFile() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response: FileContentResponse = await fetchWorkspaceFileContent(workspace, filePath!);
|
||||
|
||||
if (!cancelled) {
|
||||
setContentState(response.content);
|
||||
setOriginalContent(response.content);
|
||||
setMtime(response.mtime);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!cancelled) {
|
||||
setError(err.message || "Failed to load file");
|
||||
setContentState("");
|
||||
setOriginalContent("");
|
||||
setMtime(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadFile();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspace, filePath, enabled]);
|
||||
|
||||
const hasChanges = content !== originalContent;
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!workspace || !filePath || !hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response: SaveFileResponse = await saveWorkspaceFileContent(workspace, filePath, content);
|
||||
setOriginalContent(content);
|
||||
setMtime(response.mtime);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to save file");
|
||||
throw err;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [workspace, filePath, content, hasChanges]);
|
||||
|
||||
return {
|
||||
content,
|
||||
setContent,
|
||||
originalContent,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
save,
|
||||
hasChanges,
|
||||
mtime,
|
||||
};
|
||||
}
|
||||
87
packages/dashboard/app/hooks/useWorkspaces.ts
Normal file
87
packages/dashboard/app/hooks/useWorkspaces.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchWorkspaces, type WorkspaceTaskInfo } from "../api";
|
||||
|
||||
export interface WorkspaceInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
title?: string;
|
||||
worktree?: string;
|
||||
kind: "project" | "task";
|
||||
}
|
||||
|
||||
interface UseWorkspacesReturn {
|
||||
projectName: string;
|
||||
workspaces: WorkspaceInfo[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 10000;
|
||||
|
||||
function getProjectName(projectPath: string): string {
|
||||
const normalized = projectPath.replace(/[\\/]+$/, "");
|
||||
const segments = normalized.split(/[\\/]/).filter(Boolean);
|
||||
return segments[segments.length - 1] || projectPath || "Project Root";
|
||||
}
|
||||
|
||||
function mapTaskWorkspace(task: WorkspaceTaskInfo): WorkspaceInfo {
|
||||
return {
|
||||
id: task.id,
|
||||
label: task.id,
|
||||
title: task.title,
|
||||
worktree: task.worktree,
|
||||
kind: "task",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and poll the list of available file browser workspaces.
|
||||
*/
|
||||
export function useWorkspaces(): UseWorkspacesReturn {
|
||||
const [projectName, setProjectName] = useState("Project Root");
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadWorkspaces() {
|
||||
try {
|
||||
const response = await fetchWorkspaces();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProjectName(getProjectName(response.project));
|
||||
setWorkspaces(response.tasks.map(mapTaskWorkspace));
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
if (!cancelled) {
|
||||
setError(err.message || "Failed to load workspaces");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadWorkspaces();
|
||||
const intervalId = window.setInterval(() => {
|
||||
void loadWorkspaces();
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
projectName,
|
||||
workspaces,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -166,6 +166,12 @@ body {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.btn-icon--active {
|
||||
color: var(--text);
|
||||
background: var(--border);
|
||||
border-color: var(--todo);
|
||||
}
|
||||
|
||||
.btn-badge {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
@@ -769,6 +775,28 @@ body {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.card-session-files {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-session-files:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card-session-files:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.card-progress-bar {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
@@ -6454,6 +6482,13 @@ html .column.drag-over * {
|
||||
max-height: 800px;
|
||||
}
|
||||
|
||||
.file-browser-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.file-browser-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -6462,6 +6497,16 @@ html .column.drag-over * {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.file-browser-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.file-browser-modal-header .modal-close {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.file-browser-header-path {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
|
||||
@@ -81,6 +81,8 @@ const TEXT_EXTENSIONS = new Set([
|
||||
".lock", ".log",
|
||||
]);
|
||||
|
||||
export type WorkspaceId = "project" | string;
|
||||
|
||||
/**
|
||||
* Get the base path for a task's files.
|
||||
* Returns the worktree path if it exists, otherwise the task directory.
|
||||
@@ -111,6 +113,20 @@ function getProjectBasePath(store: TaskStore): string {
|
||||
return resolve(store.getRootDir());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a workspace identifier to a filesystem base path.
|
||||
*
|
||||
* - "project" maps to the dashboard/project root
|
||||
* - any other value is treated as a task ID and resolved to that task's worktree/task directory
|
||||
*/
|
||||
async function getWorkspaceBasePath(store: TaskStore, workspace: WorkspaceId): Promise<string> {
|
||||
if (workspace === "project") {
|
||||
return getProjectBasePath(store);
|
||||
}
|
||||
|
||||
return getTaskBasePath(store, workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and resolve a file path to ensure it stays within the allowed directory.
|
||||
* Prevents directory traversal attacks.
|
||||
@@ -149,22 +165,8 @@ function validatePath(basePath: string, filePath: string): string {
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a task directory or subdirectory.
|
||||
*
|
||||
* @param store - The TaskStore instance
|
||||
* @param taskId - The task ID
|
||||
* @param subPath - Optional relative path within the task directory
|
||||
* @returns File listing response with entries sorted (dirs first, then files alphabetically)
|
||||
* @throws FileServiceError on validation or filesystem errors
|
||||
*/
|
||||
export async function listFiles(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
subPath?: string,
|
||||
): Promise<FileListResponse> {
|
||||
const taskBase = await getTaskBasePath(store, taskId);
|
||||
const targetPath = subPath ? validatePath(taskBase, subPath) : taskBase;
|
||||
async function listFilesForBasePath(basePath: string, subPath?: string): Promise<FileListResponse> {
|
||||
const targetPath = subPath ? validatePath(basePath, subPath) : basePath;
|
||||
|
||||
let stats;
|
||||
try {
|
||||
@@ -209,8 +211,7 @@ export async function listFiles(
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Calculate relative path from task base
|
||||
const relativeBase = relative(taskBase, targetPath);
|
||||
const relativeBase = relative(basePath, targetPath);
|
||||
|
||||
return {
|
||||
path: relativeBase || ".",
|
||||
@@ -227,26 +228,12 @@ export async function listFiles(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file contents from a task directory.
|
||||
*
|
||||
* @param store - The TaskStore instance
|
||||
* @param taskId - The task ID
|
||||
* @param filePath - The relative file path
|
||||
* @returns File content response with content, mtime, and size
|
||||
* @throws FileServiceError on validation or filesystem errors
|
||||
*/
|
||||
export async function readFile(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
filePath: string,
|
||||
): Promise<FileContentResponse> {
|
||||
async function readFileForBasePath(basePath: string, filePath: string): Promise<FileContentResponse> {
|
||||
if (!filePath) {
|
||||
throw new FileServiceError("File path is required", "EINVAL");
|
||||
}
|
||||
|
||||
const taskBase = await getTaskBasePath(store, taskId);
|
||||
const resolvedPath = validatePath(taskBase, filePath);
|
||||
const resolvedPath = validatePath(basePath, filePath);
|
||||
|
||||
let stats;
|
||||
try {
|
||||
@@ -285,6 +272,98 @@ export async function readFile(
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFileForBasePath(basePath: string, filePath: string, content: string): Promise<SaveFileResponse> {
|
||||
if (!filePath) {
|
||||
throw new FileServiceError("File path is required", "EINVAL");
|
||||
}
|
||||
|
||||
const contentBytes = Buffer.byteLength(content, "utf-8");
|
||||
if (contentBytes > MAX_FILE_SIZE) {
|
||||
throw new FileServiceError(`Content too large: ${contentBytes} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE");
|
||||
}
|
||||
|
||||
const resolvedPath = validatePath(basePath, filePath);
|
||||
|
||||
try {
|
||||
const stats = await stat(resolvedPath);
|
||||
if (stats.isDirectory()) {
|
||||
throw new FileServiceError(`Cannot write to directory: ${filePath}`, "EISDIR");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const parentDir = dirname(resolvedPath);
|
||||
try {
|
||||
const parentStats = await stat(parentDir);
|
||||
if (!parentStats.isDirectory()) {
|
||||
throw new FileServiceError(`Parent is not a directory: ${filePath}`, "ENOENT");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await fsWriteFile(resolvedPath, content, "utf-8");
|
||||
|
||||
const stats = await stat(resolvedPath);
|
||||
return {
|
||||
success: true,
|
||||
mtime: stats.mtime.toISOString(),
|
||||
size: stats.size,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
|
||||
}
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a task directory or subdirectory.
|
||||
*
|
||||
* @param store - The TaskStore instance
|
||||
* @param taskId - The task ID
|
||||
* @param subPath - Optional relative path within the task directory
|
||||
* @returns File listing response with entries sorted (dirs first, then files alphabetically)
|
||||
* @throws FileServiceError on validation or filesystem errors
|
||||
*/
|
||||
export async function listFiles(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
subPath?: string,
|
||||
): Promise<FileListResponse> {
|
||||
const taskBase = await getTaskBasePath(store, taskId);
|
||||
return listFilesForBasePath(taskBase, subPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file contents from a task directory.
|
||||
*
|
||||
* @param store - The TaskStore instance
|
||||
* @param taskId - The task ID
|
||||
* @param filePath - The relative file path
|
||||
* @returns File content response with content, mtime, and size
|
||||
* @throws FileServiceError on validation or filesystem errors
|
||||
*/
|
||||
export async function readFile(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
filePath: string,
|
||||
): Promise<FileContentResponse> {
|
||||
const taskBase = await getTaskBasePath(store, taskId);
|
||||
return readFileForBasePath(taskBase, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write file contents to a task directory.
|
||||
*
|
||||
@@ -301,64 +380,8 @@ export async function writeFile(
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<SaveFileResponse> {
|
||||
if (!filePath) {
|
||||
throw new FileServiceError("File path is required", "EINVAL");
|
||||
}
|
||||
|
||||
// Check content size
|
||||
const contentBytes = Buffer.byteLength(content, "utf-8");
|
||||
if (contentBytes > MAX_FILE_SIZE) {
|
||||
throw new FileServiceError(`Content too large: ${contentBytes} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE");
|
||||
}
|
||||
|
||||
const taskBase = await getTaskBasePath(store, taskId);
|
||||
const resolvedPath = validatePath(taskBase, filePath);
|
||||
|
||||
// Check if target is a directory
|
||||
try {
|
||||
const stats = await stat(resolvedPath);
|
||||
if (stats.isDirectory()) {
|
||||
throw new FileServiceError(`Cannot write to directory: ${filePath}`, "EISDIR");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
// File doesn't exist, that's fine for writing
|
||||
}
|
||||
|
||||
// Check if parent directory exists
|
||||
const parentDir = dirname(resolvedPath);
|
||||
try {
|
||||
const parentStats = await stat(parentDir);
|
||||
if (!parentStats.isDirectory()) {
|
||||
throw new FileServiceError(`Parent is not a directory: ${filePath}`, "ENOENT");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await fsWriteFile(resolvedPath, content, "utf-8");
|
||||
|
||||
const stats = await stat(resolvedPath);
|
||||
return {
|
||||
success: true,
|
||||
mtime: stats.mtime.toISOString(),
|
||||
size: stats.size,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
|
||||
}
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return writeFileForBasePath(taskBase, filePath, content);
|
||||
}
|
||||
|
||||
// ── Project File Functions ────────────────────────────────────────
|
||||
@@ -376,67 +399,7 @@ export async function listProjectFiles(
|
||||
subPath?: string,
|
||||
): Promise<FileListResponse> {
|
||||
const projectBase = getProjectBasePath(store);
|
||||
const targetPath = subPath ? validatePath(projectBase, subPath) : projectBase;
|
||||
|
||||
let stats;
|
||||
try {
|
||||
stats = await stat(targetPath);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Directory not found: ${subPath || "."}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
throw new FileServiceError(`Not a directory: ${subPath || "."}`, "ENOTDIR");
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(targetPath, { withFileTypes: true });
|
||||
const fileNodes: FileNode[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip hidden files and directories
|
||||
if (entry.name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryPath = join(targetPath, entry.name);
|
||||
const entryStats = await stat(entryPath);
|
||||
|
||||
fileNodes.push({
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? "directory" : "file",
|
||||
size: entry.isFile() ? entryStats.size : undefined,
|
||||
mtime: entryStats.mtime.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: directories first, then files, both alphabetically
|
||||
fileNodes.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Calculate relative path from project base
|
||||
const relativeBase = relative(projectBase, targetPath);
|
||||
|
||||
return {
|
||||
path: relativeBase || ".",
|
||||
entries: fileNodes,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Directory not found: ${subPath || "."}`, "ENOENT");
|
||||
}
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${subPath || "."}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return listFilesForBasePath(projectBase, subPath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -451,48 +414,8 @@ export async function readProjectFile(
|
||||
store: TaskStore,
|
||||
filePath: string,
|
||||
): Promise<FileContentResponse> {
|
||||
if (!filePath) {
|
||||
throw new FileServiceError("File path is required", "EINVAL");
|
||||
}
|
||||
|
||||
const projectBase = getProjectBasePath(store);
|
||||
const resolvedPath = validatePath(projectBase, filePath);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
if (stats.size > MAX_FILE_SIZE) {
|
||||
throw new FileServiceError(`File too large: ${stats.size} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE");
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fsReadFile(resolvedPath, "utf-8");
|
||||
|
||||
return {
|
||||
content,
|
||||
mtime: stats.mtime.toISOString(),
|
||||
size: stats.size,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`File not found: ${filePath}`, "ENOENT");
|
||||
}
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return readFileForBasePath(projectBase, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -509,62 +432,43 @@ export async function writeProjectFile(
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<SaveFileResponse> {
|
||||
if (!filePath) {
|
||||
throw new FileServiceError("File path is required", "EINVAL");
|
||||
}
|
||||
|
||||
// Check content size
|
||||
const contentBytes = Buffer.byteLength(content, "utf-8");
|
||||
if (contentBytes > MAX_FILE_SIZE) {
|
||||
throw new FileServiceError(`Content too large: ${contentBytes} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE");
|
||||
}
|
||||
|
||||
const projectBase = getProjectBasePath(store);
|
||||
const resolvedPath = validatePath(projectBase, filePath);
|
||||
|
||||
// Check if target is a directory
|
||||
try {
|
||||
const stats = await stat(resolvedPath);
|
||||
if (stats.isDirectory()) {
|
||||
throw new FileServiceError(`Cannot write to directory: ${filePath}`, "EISDIR");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
// File doesn't exist, that's fine for writing
|
||||
}
|
||||
|
||||
// Check if parent directory exists
|
||||
const parentDir = dirname(resolvedPath);
|
||||
try {
|
||||
const parentStats = await stat(parentDir);
|
||||
if (!parentStats.isDirectory()) {
|
||||
throw new FileServiceError(`Parent is not a directory: ${filePath}`, "ENOENT");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await fsWriteFile(resolvedPath, content, "utf-8");
|
||||
|
||||
const stats = await stat(resolvedPath);
|
||||
return {
|
||||
success: true,
|
||||
mtime: stats.mtime.toISOString(),
|
||||
size: stats.size,
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
|
||||
}
|
||||
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return writeFileForBasePath(projectBase, filePath, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-aware file listing used by the top-level dashboard file browser.
|
||||
*/
|
||||
export async function listWorkspaceFiles(
|
||||
store: TaskStore,
|
||||
workspace: WorkspaceId,
|
||||
subPath?: string,
|
||||
): Promise<FileListResponse> {
|
||||
const workspaceBase = await getWorkspaceBasePath(store, workspace);
|
||||
return listFilesForBasePath(workspaceBase, subPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-aware file reading used by the top-level dashboard file browser.
|
||||
*/
|
||||
export async function readWorkspaceFile(
|
||||
store: TaskStore,
|
||||
workspace: WorkspaceId,
|
||||
filePath: string,
|
||||
): Promise<FileContentResponse> {
|
||||
const workspaceBase = await getWorkspaceBasePath(store, workspace);
|
||||
return readFileForBasePath(workspaceBase, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-aware file writing used by the top-level dashboard file browser.
|
||||
*/
|
||||
export async function writeWorkspaceFile(
|
||||
store: TaskStore,
|
||||
workspace: WorkspaceId,
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<SaveFileResponse> {
|
||||
const workspaceBase = await getWorkspaceBasePath(store, workspace);
|
||||
return writeFileForBasePath(workspaceBase, filePath, content);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router, type Request, type Response, type NextFunction } from "express";
|
||||
import multer from "multer";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { createReadStream, existsSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType } from "@kb/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore } from "@kb/core";
|
||||
@@ -9,7 +9,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, listProjectFiles, readProjectFile, writeProjectFile, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse } from "./file-service.js";
|
||||
import { listFiles, readFile, writeFile, listWorkspaceFiles, readWorkspaceFile, writeWorkspaceFile, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse } from "./file-service.js";
|
||||
import { fetchAllProviderUsage } from "./usage.js";
|
||||
import {
|
||||
getGitHubAppConfig,
|
||||
@@ -565,6 +565,7 @@ function pushGitBranch(): GitPushResult {
|
||||
|
||||
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
|
||||
const router = Router();
|
||||
const sessionFilesCache = new Map<string, { files: string[]; expiresAt: number }>();
|
||||
|
||||
// Get GitHub token from options or env
|
||||
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
|
||||
@@ -915,6 +916,55 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/tasks/:id/session-files", async (req, res) => {
|
||||
try {
|
||||
const task = await store.getTask(req.params.id);
|
||||
if (!task.worktree || !existsSync(task.worktree)) {
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = sessionFilesCache.get(task.id);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
res.json(cached.files);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseBranch = task.baseBranch ?? "main";
|
||||
let files: string[] = [];
|
||||
|
||||
try {
|
||||
const output = execSync(`git diff --name-only ${baseBranch}...HEAD`, {
|
||||
cwd: task.worktree,
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
|
||||
files = output ? output.split("\n").filter(Boolean) : [];
|
||||
} catch {
|
||||
const fallback = execSync("git diff --name-only HEAD", {
|
||||
cwd: task.worktree,
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
files = fallback ? fallback.split("\n").filter(Boolean) : [];
|
||||
}
|
||||
|
||||
sessionFilesCache.set(task.id, {
|
||||
files,
|
||||
expiresAt: Date.now() + 10000,
|
||||
});
|
||||
|
||||
res.json(files);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
res.status(404).json({ error: `Task ${req.params.id} not found` });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message || "Internal server error" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get single task with prompt content
|
||||
router.get("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
@@ -2751,22 +2801,47 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// ── Project File API Routes ───────────────────────────────────────
|
||||
// ── Workspace File API Routes ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/workspaces
|
||||
* List available file browser workspaces.
|
||||
* Returns: { project: string; tasks: Array<{ id: string; title?: string; worktree: string }> }
|
||||
*/
|
||||
router.get("/workspaces", async (_req, res) => {
|
||||
try {
|
||||
const tasks = await store.listTasks();
|
||||
res.json({
|
||||
project: store.getRootDir(),
|
||||
tasks: tasks
|
||||
.filter((task) => typeof task.worktree === "string" && task.worktree.length > 0 && existsSync(task.worktree))
|
||||
.map((task) => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
worktree: task.worktree!,
|
||||
})),
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/files
|
||||
* List files in project root directory.
|
||||
* Query param: ?path=relative/path for subdirectory navigation.
|
||||
* List files in the requested workspace. Defaults to the project root when omitted.
|
||||
* Query params: ?workspace=project|TASK-ID and ?path=relative/path for subdirectory navigation.
|
||||
* Returns: { path: string; entries: FileNode[] }
|
||||
*/
|
||||
router.get("/files", async (req, res) => {
|
||||
try {
|
||||
const { path: subPath } = req.query;
|
||||
const result = await listProjectFiles(store, typeof subPath === "string" ? subPath : undefined);
|
||||
const { path: subPath, workspace } = req.query;
|
||||
const workspaceId = typeof workspace === "string" && workspace.length > 0 ? workspace : "project";
|
||||
const result = await listWorkspaceFiles(store, workspaceId, typeof subPath === "string" ? subPath : undefined);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err instanceof FileServiceError) {
|
||||
const status = err.code === "ENOENT" ? 404
|
||||
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 });
|
||||
@@ -2778,17 +2853,22 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
|
||||
/**
|
||||
* GET /api/files/{*filepath}
|
||||
* Read file contents from project directory.
|
||||
* Read file contents from the requested workspace. Defaults to the project root when omitted.
|
||||
* Query param: ?workspace=project|TASK-ID
|
||||
* Returns: { content: string; mtime: string; size: number }
|
||||
*/
|
||||
router.get("/files/{*filepath}", async (req, res) => {
|
||||
try {
|
||||
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
|
||||
const result = await readProjectFile(store, filePath);
|
||||
const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
|
||||
? req.query.workspace
|
||||
: "project";
|
||||
const result = await readWorkspaceFile(store, workspace, filePath);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err instanceof FileServiceError) {
|
||||
const status = err.code === "ENOENT" ? 404
|
||||
const status = err.code === "ENOTASK" ? 404
|
||||
: err.code === "ENOENT" ? 404
|
||||
: err.code === "EACCES" ? 403
|
||||
: err.code === "ETOOLARGE" ? 413
|
||||
: err.code === "EINVAL" && err.message.includes("Binary file") ? 415
|
||||
@@ -2802,7 +2882,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
|
||||
/**
|
||||
* POST /api/files/{*filepath}
|
||||
* Write file contents to project directory.
|
||||
* Write file contents to the requested workspace. Defaults to the project root when omitted.
|
||||
* Query param: ?workspace=project|TASK-ID
|
||||
* Body: { content: string }
|
||||
* Returns: { success: true; mtime: string; size: number }
|
||||
*/
|
||||
@@ -2810,17 +2891,21 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
try {
|
||||
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
|
||||
const { content } = req.body;
|
||||
const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
|
||||
? req.query.workspace
|
||||
: "project";
|
||||
|
||||
if (typeof content !== "string") {
|
||||
res.status(400).json({ error: "content is required and must be a string" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await writeProjectFile(store, filePath, content);
|
||||
const result = await writeWorkspaceFile(store, workspace, filePath, content);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err instanceof FileServiceError) {
|
||||
const status = err.code === "ENOENT" ? 404
|
||||
const status = err.code === "ENOTASK" ? 404
|
||||
: err.code === "ENOENT" ? 404
|
||||
: err.code === "EACCES" ? 403
|
||||
: err.code === "ETOOLARGE" ? 413
|
||||
: 400;
|
||||
|
||||
Reference in New Issue
Block a user