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:
gsxdsm
2026-03-31 01:26:57 -07:00
parent 18c4f0cb25
commit 9e03c47b91
30 changed files with 1779 additions and 829 deletions

View File

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

View File

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

View File

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

View File

@@ -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 } : {})}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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([]);
});
});

View File

@@ -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([]);
});
});

View File

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

View 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([]);
});
});

View File

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

View File

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

View 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 };
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View File

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

View File

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

View File

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