feat(KB-651): add changed files diff viewer for tasks
- Add backend API endpoint to get changed files and diffs for a task worktree - Add useChangedFiles hook for fetching and managing diff data - Create ChangedFilesModal component with file list and diff viewer - Integrate modal into TaskCard with click handler to view changes - Add frontend API integration and type definitions - Include unit tests for hook and modal components - Add changeset for the new feature - Remove deprecated mission-related test files
This commit is contained in:
@@ -46,6 +46,7 @@ AI-guided interactive planning for creating well-specified tasks from high-level
|
||||
- **Model Selection at Creation**: Choose executor and validator AI models while creating tasks from the board or list view, or leave them unset to use the global defaults.
|
||||
- **Bulk Model Editing**: Update AI model configuration for multiple tasks at once in the list view. Select tasks via checkboxes (archived tasks excluded), then use the "Bulk Edit Models" toolbar to apply executor and/or validator model changes to all selected tasks. Selection persists in localStorage across page reloads.
|
||||
- **Task Details**: View full task specifications, agent logs, and attachments
|
||||
- **Changed Files Viewer**: Click a task card's "files changed" button to open a dedicated diff viewer showing only files changed in that task worktree, with per-file statuses and sidebar navigation
|
||||
- **GitHub Import**: Import issues directly from GitHub repositories
|
||||
- **PR Management**: Create, monitor, and merge pull requests for in-review tasks
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SetupWizardModal } from "./components/SetupWizardModal";
|
||||
import { TaskDetailModal } from "./components/TaskDetailModal";
|
||||
import { TerminalModal } from "./components/TerminalModal";
|
||||
import { FileBrowserModal } from "./components/FileBrowserModal";
|
||||
import { ChangedFilesModal } from "./components/ChangedFilesModal";
|
||||
import { SettingsModal } from "./components/SettingsModal";
|
||||
import { PlanningModeModal } from "./components/PlanningModeModal";
|
||||
import { SubtaskBreakdownModal } from "./components/SubtaskBreakdownModal";
|
||||
@@ -45,6 +46,7 @@ function AppInner() {
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project");
|
||||
const [changedFilesState, setChangedFilesState] = useState<{ taskId: string; worktree: string | undefined; column: string } | null>(null);
|
||||
const [activityLogOpen, setActivityLogOpen] = useState(false);
|
||||
const [gitManagerOpen, setGitManagerOpen] = useState(false);
|
||||
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
|
||||
@@ -339,9 +341,12 @@ function AppInner() {
|
||||
setFilesOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleOpenFilesForTask = useCallback((taskId: string) => {
|
||||
setFileBrowserWorkspace(taskId);
|
||||
setFilesOpen(true);
|
||||
const handleOpenChangedFiles = useCallback((taskId: string, worktree: string | undefined, column: string) => {
|
||||
setChangedFilesState({ taskId, worktree, column });
|
||||
}, []);
|
||||
|
||||
const handleCloseChangedFiles = useCallback(() => {
|
||||
setChangedFilesState(null);
|
||||
}, []);
|
||||
|
||||
const handleWorkspaceChange = useCallback((workspace: string) => {
|
||||
@@ -427,7 +432,7 @@ function AppInner() {
|
||||
onArchiveAllDone={archiveAllDone}
|
||||
searchQuery={searchQuery}
|
||||
availableModels={availableModels}
|
||||
onOpenFilesForTask={handleOpenFilesForTask}
|
||||
onOpenFilesForTask={handleOpenChangedFiles}
|
||||
projectId={currentProject?.id}
|
||||
projectName={currentProject?.name}
|
||||
/>
|
||||
@@ -550,6 +555,15 @@ function AppInner() {
|
||||
onWorkspaceChange={handleWorkspaceChange}
|
||||
/>
|
||||
)}
|
||||
{changedFilesState && (
|
||||
<ChangedFilesModal
|
||||
taskId={changedFilesState.taskId}
|
||||
worktree={changedFilesState.worktree}
|
||||
column={changedFilesState.column}
|
||||
isOpen={true}
|
||||
onClose={handleCloseChangedFiles}
|
||||
/>
|
||||
)}
|
||||
<UsageIndicator
|
||||
isOpen={usageOpen}
|
||||
onClose={handleCloseUsage}
|
||||
|
||||
@@ -283,6 +283,17 @@ export function fetchSessionFiles(taskId: string): Promise<string[]> {
|
||||
return api<string[]>(`/tasks/${taskId}/session-files`);
|
||||
}
|
||||
|
||||
export interface TaskFileDiff {
|
||||
path: string;
|
||||
status: "added" | "modified" | "deleted" | "renamed";
|
||||
diff: string;
|
||||
oldPath?: string;
|
||||
}
|
||||
|
||||
export function fetchTaskFileDiffs(taskId: string): Promise<TaskFileDiff[]> {
|
||||
return api<TaskFileDiff[]>(`/tasks/${taskId}/file-diffs`);
|
||||
}
|
||||
|
||||
export function fetchTaskComments(id: string): Promise<TaskComment[]> {
|
||||
return api<TaskComment[]>(`/tasks/${id}/comments`);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ interface BoardProps {
|
||||
* Called when the user clicks the "Subtask" button in the inline create card.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
|
||||
/** Project context for multi-project mode */
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
|
||||
134
packages/dashboard/app/components/ChangedFilesModal.tsx
Normal file
134
packages/dashboard/app/components/ChangedFilesModal.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { FileEdit, FileMinus, FilePlus, FileSymlink, FolderGit2, X } from "lucide-react";
|
||||
import { useChangedFiles } from "../hooks/useChangedFiles";
|
||||
import type { TaskFileDiff } from "../api";
|
||||
|
||||
interface ChangedFilesModalProps {
|
||||
taskId: string;
|
||||
worktree: string | undefined;
|
||||
column: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function getStatusLabel(status: TaskFileDiff["status"]): string {
|
||||
switch (status) {
|
||||
case "added":
|
||||
return "A";
|
||||
case "deleted":
|
||||
return "D";
|
||||
case "renamed":
|
||||
return "R";
|
||||
default:
|
||||
return "M";
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status: TaskFileDiff["status"]) {
|
||||
switch (status) {
|
||||
case "added":
|
||||
return <FilePlus size={16} />;
|
||||
case "deleted":
|
||||
return <FileMinus size={16} />;
|
||||
case "renamed":
|
||||
return <FileSymlink size={16} />;
|
||||
default:
|
||||
return <FileEdit size={16} />;
|
||||
}
|
||||
}
|
||||
|
||||
function getDiffStat(diff: string): string {
|
||||
const lines = diff.split("\n");
|
||||
const statLines = lines.filter((line) => line.startsWith("diff --git") || line.startsWith("index ") || line.startsWith("--- ") || line.startsWith("+++ "));
|
||||
return statLines.join("\n").trim();
|
||||
}
|
||||
|
||||
export function ChangedFilesModal({ taskId, worktree, column, isOpen, onClose }: ChangedFilesModalProps) {
|
||||
const { files, loading, error, selectedFile, setSelectedFile } = useChangedFiles(taskId, worktree, column);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const selectedStat = useMemo(() => (selectedFile ? getDiffStat(selectedFile.diff) : ""), [selectedFile]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={onClose}>
|
||||
<div className="modal file-browser-modal changed-files-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-header file-browser-modal-header">
|
||||
<div className="file-browser-header-title">
|
||||
<FolderGit2 size={18} />
|
||||
<span>Changed Files — {taskId}</span>
|
||||
</div>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close changed files viewer">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="file-browser-body">
|
||||
<aside className="file-browser-sidebar" style={{ flex: "0 0 30%" }}>
|
||||
{loading ? (
|
||||
<div className="gm-diff-loading">Loading changed files…</div>
|
||||
) : error ? (
|
||||
<div className="gm-diff-error">{error}</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="file-browser-empty-state">No files changed</div>
|
||||
) : (
|
||||
<div className="file-browser-list" role="list" aria-label="Changed files list">
|
||||
{files.map((file) => {
|
||||
const active = selectedFile?.path === file.path && selectedFile?.oldPath === file.oldPath;
|
||||
return (
|
||||
<button
|
||||
key={`${file.oldPath ?? ""}:${file.path}`}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className={`file-browser-entry ${active ? "active" : ""}`}
|
||||
onClick={() => setSelectedFile(file)}
|
||||
>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||
{getStatusIcon(file.status)}
|
||||
<span>{file.path}</span>
|
||||
</span>
|
||||
<span className="badge">{getStatusLabel(file.status)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="file-browser-content" style={{ flex: "0 0 70%" }}>
|
||||
{!loading && !error && files.length > 0 && !selectedFile ? (
|
||||
<div className="file-browser-empty-state">Select a file to view changes</div>
|
||||
) : null}
|
||||
|
||||
{selectedFile ? (
|
||||
<div className="gm-diff-section" aria-label={`Diff for ${selectedFile.path}`}>
|
||||
<div className="file-browser-toolbar">
|
||||
<div className="file-browser-file-info">
|
||||
<strong>{selectedFile.path}</strong>
|
||||
<span className="badge">{getStatusLabel(selectedFile.status)}</span>
|
||||
{selectedFile.oldPath ? <span>Renamed from {selectedFile.oldPath}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="gm-diff-viewer">
|
||||
{selectedStat ? <pre className="gm-diff-stat">{selectedStat}</pre> : null}
|
||||
<pre className="gm-diff-patch">{selectedFile.diff || "No diff available"}</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ interface ColumnProps {
|
||||
* Called when the user clicks the "Subtask" button in the inline create card.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: 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, onOpenFilesForTask }: ColumnProps) {
|
||||
|
||||
@@ -43,7 +43,7 @@ interface TaskCardProps {
|
||||
) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
onOpenFilesForTask?: (taskId: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
|
||||
}
|
||||
|
||||
function areTaskBadgeInfosEqual(
|
||||
@@ -694,7 +694,7 @@ function TaskCardComponent({
|
||||
className="card-session-files"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenFilesForTask?.(task.id);
|
||||
onOpenFilesForTask?.(task.id, task.worktree, task.column);
|
||||
}}
|
||||
disabled={!onOpenFilesForTask}
|
||||
>
|
||||
|
||||
@@ -15,7 +15,7 @@ interface WorktreeGroupProps {
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onOpenFilesForTask?: (taskId: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
|
||||
}
|
||||
|
||||
function WorktreeGroupComponent({
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { ChangedFilesModal } from "../ChangedFilesModal";
|
||||
import * as changedFilesHook from "../../hooks/useChangedFiles";
|
||||
|
||||
vi.mock("../../hooks/useChangedFiles");
|
||||
|
||||
const mockUseChangedFiles = vi.mocked(changedFilesHook.useChangedFiles);
|
||||
|
||||
describe("ChangedFilesModal", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockSetSelectedFile = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockUseChangedFiles.mockReturnValue({
|
||||
files: [
|
||||
{ path: "src/a.ts", status: "modified", diff: "diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n+hello" },
|
||||
{ path: "src/b.ts", status: "added", diff: "diff --git a/src/b.ts b/src/b.ts" },
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
selectedFile: { path: "src/a.ts", status: "modified", diff: "diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n+hello" },
|
||||
setSelectedFile: mockSetSelectedFile,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders changed files and selected diff", () => {
|
||||
render(
|
||||
<ChangedFilesModal
|
||||
taskId="KB-651"
|
||||
worktree="/repo/.worktrees/kb-651"
|
||||
column="in-progress"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Changed Files — KB-651")).toBeInTheDocument();
|
||||
expect(screen.getByText("src/a.ts")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Diff for src/a.ts")).toBeInTheDocument();
|
||||
expect(screen.getByText(/\+hello/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows selecting another file from the sidebar", () => {
|
||||
render(
|
||||
<ChangedFilesModal
|
||||
taskId="KB-651"
|
||||
worktree="/repo/.worktrees/kb-651"
|
||||
column="in-progress"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /src\/b.ts/i }));
|
||||
|
||||
expect(mockSetSelectedFile).toHaveBeenCalledWith({ path: "src/b.ts", status: "added", diff: "diff --git a/src/b.ts b/src/b.ts" });
|
||||
});
|
||||
|
||||
it("shows an empty state when there are no changed files", () => {
|
||||
mockUseChangedFiles.mockReturnValue({
|
||||
files: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
selectedFile: null,
|
||||
setSelectedFile: mockSetSelectedFile,
|
||||
});
|
||||
|
||||
render(
|
||||
<ChangedFilesModal
|
||||
taskId="KB-651"
|
||||
worktree="/repo/.worktrees/kb-651"
|
||||
column="in-progress"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("No files changed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes on Escape", () => {
|
||||
render(
|
||||
<ChangedFilesModal
|
||||
taskId="KB-651"
|
||||
worktree="/repo/.worktrees/kb-651"
|
||||
column="in-progress"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { useChangedFiles } from "../useChangedFiles";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchTaskFileDiffs: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchTaskFileDiffs = vi.mocked(api.fetchTaskFileDiffs);
|
||||
|
||||
describe("useChangedFiles", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchTaskFileDiffs.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("fetches changed files for active tasks with a worktree and auto-selects first file", async () => {
|
||||
mockFetchTaskFileDiffs.mockResolvedValueOnce([
|
||||
{ path: "src/a.ts", status: "modified", diff: "diff --git a/src/a.ts b/src/a.ts" },
|
||||
{ path: "src/b.ts", status: "added", diff: "diff --git a/src/b.ts b/src/b.ts" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useChangedFiles("KB-651", "/repo/.worktrees/kb-651", "in-progress"));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.files).toHaveLength(2);
|
||||
expect(result.current.selectedFile?.path).toBe("src/a.ts");
|
||||
expect(mockFetchTaskFileDiffs).toHaveBeenCalledWith("KB-651");
|
||||
});
|
||||
|
||||
it("does not fetch for tasks without worktrees or inactive columns", async () => {
|
||||
const { result: noWorktree } = renderHook(() => useChangedFiles("KB-651", undefined, "in-progress"));
|
||||
const { result: inactive } = renderHook(() => useChangedFiles("KB-651", "/repo/.worktrees/kb-651", "todo"));
|
||||
|
||||
await waitFor(() => expect(noWorktree.current.loading).toBe(false));
|
||||
await waitFor(() => expect(inactive.current.loading).toBe(false));
|
||||
|
||||
expect(noWorktree.current.files).toEqual([]);
|
||||
expect(noWorktree.current.selectedFile).toBeNull();
|
||||
expect(inactive.current.files).toEqual([]);
|
||||
expect(inactive.current.selectedFile).toBeNull();
|
||||
expect(mockFetchTaskFileDiffs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns an error state on fetch failure", async () => {
|
||||
mockFetchTaskFileDiffs.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
const { result } = renderHook(() => useChangedFiles("KB-651", "/repo/.worktrees/kb-651", "in-review"));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.files).toEqual([]);
|
||||
expect(result.current.selectedFile).toBeNull();
|
||||
expect(result.current.error).toBe("boom");
|
||||
});
|
||||
|
||||
it("allows selecting a different file after data loads", async () => {
|
||||
mockFetchTaskFileDiffs.mockResolvedValueOnce([
|
||||
{ path: "src/a.ts", status: "modified", diff: "first" },
|
||||
{ path: "src/b.ts", status: "added", diff: "second" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useChangedFiles("KB-651", "/repo/.worktrees/kb-651", "in-progress"));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedFile(result.current.files[1]!);
|
||||
});
|
||||
|
||||
expect(result.current.selectedFile?.path).toBe("src/b.ts");
|
||||
});
|
||||
});
|
||||
66
packages/dashboard/app/hooks/useChangedFiles.ts
Normal file
66
packages/dashboard/app/hooks/useChangedFiles.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchTaskFileDiffs, type TaskFileDiff } from "../api";
|
||||
|
||||
const ACTIVE_COLUMNS = new Set(["in-progress", "in-review"]);
|
||||
|
||||
interface UseChangedFilesResult {
|
||||
files: TaskFileDiff[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
selectedFile: TaskFileDiff | null;
|
||||
setSelectedFile: (file: TaskFileDiff) => void;
|
||||
}
|
||||
|
||||
export function useChangedFiles(taskId: string, worktree: string | undefined, column: string): UseChangedFilesResult {
|
||||
const [files, setFiles] = useState<TaskFileDiff[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<TaskFileDiff | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId || !worktree || !ACTIVE_COLUMNS.has(column)) {
|
||||
setFiles([]);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setSelectedFile(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchTaskFileDiffs(taskId);
|
||||
if (cancelled) return;
|
||||
setFiles(result);
|
||||
setSelectedFile((current) => {
|
||||
if (result.length === 0) return null;
|
||||
if (current) {
|
||||
const match = result.find((file) => file.path === current.path && file.oldPath === current.oldPath);
|
||||
if (match) return match;
|
||||
}
|
||||
return result[0] ?? null;
|
||||
});
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setFiles([]);
|
||||
setSelectedFile(null);
|
||||
setError(err instanceof Error ? err.message : "Failed to load changed files");
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [taskId, worktree, column]);
|
||||
|
||||
return { files, loading, error, selectedFile, setSelectedFile };
|
||||
}
|
||||
@@ -3651,6 +3651,92 @@ describe("POST /tasks/:id/reject-plan", () => {
|
||||
// --- Git Management route tests ---
|
||||
// These are integration tests that run against the actual git repository
|
||||
|
||||
describe("GET /tasks/:id/file-diffs", () => {
|
||||
let store: TaskStore;
|
||||
let worktreeDir: string;
|
||||
let testRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testRoot = mkdtempSync(join(tmpdir(), "kb-dashboard-file-diffs-"));
|
||||
worktreeDir = join(testRoot, "repo");
|
||||
mkdirSync(worktreeDir, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main", worktreeDir]);
|
||||
execFileSync("git", ["-C", worktreeDir, "config", "user.email", "kb-tests@example.com"]);
|
||||
execFileSync("git", ["-C", worktreeDir, "config", "user.name", "KB Tests"]);
|
||||
writeFileSync(join(worktreeDir, "README.md"), "base\n");
|
||||
writeFileSync(join(worktreeDir, "keep.txt"), "keep\n");
|
||||
execFileSync("git", ["-C", worktreeDir, "add", "."]);
|
||||
execFileSync("git", ["-C", worktreeDir, "commit", "-m", "base"]);
|
||||
|
||||
store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "KB-651",
|
||||
worktree: worktreeDir,
|
||||
baseBranch: "main",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns changed files with statuses and diffs", async () => {
|
||||
writeFileSync(join(worktreeDir, "README.md"), "base\nchanged\n");
|
||||
writeFileSync(join(worktreeDir, "added.txt"), "new file\n");
|
||||
execFileSync("git", ["-C", worktreeDir, "mv", "keep.txt", "renamed.txt"]);
|
||||
execFileSync("git", ["-C", worktreeDir, "rm", "renamed.txt"]);
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: "README.md", status: "modified", diff: expect.stringContaining("+changed") }),
|
||||
expect.objectContaining({ path: "added.txt", status: "added", diff: expect.stringContaining("+++ b/added.txt") }),
|
||||
expect.objectContaining({ path: "keep.txt", status: "deleted", diff: expect.stringContaining("--- a/keep.txt") }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns renamed files with oldPath", async () => {
|
||||
execFileSync("git", ["-C", worktreeDir, "mv", "keep.txt", "renamed.txt"]);
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([
|
||||
expect.objectContaining({ path: "renamed.txt", oldPath: "keep.txt", status: "renamed" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array when worktree is missing", async () => {
|
||||
store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "KB-651", worktree: join(testRoot, "missing"), baseBranch: "main" }),
|
||||
});
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when there are no changes", async () => {
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Git Management endpoints", () => {
|
||||
let store: TaskStore;
|
||||
let gitRepoDir: string;
|
||||
|
||||
@@ -1062,6 +1062,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
console.debug("[planning:routes:registered]", planningRoutes);
|
||||
}
|
||||
const sessionFilesCache = new Map<string, { files: string[]; expiresAt: number }>();
|
||||
const taskFileDiffsCache = new Map<
|
||||
string,
|
||||
{
|
||||
files: Array<{ path: string; status: "added" | "modified" | "deleted" | "renamed"; diff: string; oldPath?: string }>;
|
||||
expiresAt: number;
|
||||
}
|
||||
>();
|
||||
|
||||
// Get GitHub token from options or env
|
||||
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
|
||||
@@ -1841,6 +1848,114 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/tasks/:id/file-diffs", async (req, res) => {
|
||||
try {
|
||||
const task = await store.getTask(req.params.id);
|
||||
if (!task.worktree || !existsSync(task.worktree)) {
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = taskFileDiffsCache.get(task.id);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
res.json(cached.files);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseBranch = task.baseBranch ?? "main";
|
||||
type TaskFileDiff = { path: string; status: "added" | "modified" | "deleted" | "renamed"; diff: string; oldPath?: string };
|
||||
let files: TaskFileDiff[] = [];
|
||||
|
||||
const parseNameStatus = (output: string): TaskFileDiff[] => {
|
||||
return output
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split("\t");
|
||||
const rawStatus = parts[0] ?? "M";
|
||||
const statusCode = rawStatus[0];
|
||||
|
||||
if (statusCode === "R") {
|
||||
const oldPath = parts[1];
|
||||
const path = parts[2];
|
||||
return {
|
||||
path,
|
||||
oldPath,
|
||||
status: "renamed" as const,
|
||||
diff: "",
|
||||
};
|
||||
}
|
||||
|
||||
const path = parts[1];
|
||||
return {
|
||||
path,
|
||||
status:
|
||||
statusCode === "A"
|
||||
? ("added" as const)
|
||||
: statusCode === "D"
|
||||
? ("deleted" as const)
|
||||
: ("modified" as const),
|
||||
diff: "",
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is TaskFileDiff => Boolean(entry.path));
|
||||
};
|
||||
|
||||
try {
|
||||
const output = execSync(`git diff --name-status ${baseBranch}...HEAD`, {
|
||||
cwd: task.worktree,
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
|
||||
files = output ? parseNameStatus(output) : [];
|
||||
} catch {
|
||||
const fallback = execSync("git diff --name-status HEAD", {
|
||||
cwd: task.worktree,
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
files = fallback ? parseNameStatus(fallback) : [];
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
taskFileDiffsCache.set(task.id, {
|
||||
files: [],
|
||||
expiresAt: Date.now() + 10000,
|
||||
});
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const filesWithDiffs = files.map((file) => {
|
||||
try {
|
||||
const diff = execSync(`git diff ${baseBranch}...HEAD -- "${file.path.replace(/"/g, '\\"')}"`, {
|
||||
cwd: task.worktree,
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
});
|
||||
return { ...file, diff };
|
||||
} catch {
|
||||
return file;
|
||||
}
|
||||
});
|
||||
|
||||
taskFileDiffsCache.set(task.id, {
|
||||
files: filesWithDiffs,
|
||||
expiresAt: Date.now() + 10000,
|
||||
});
|
||||
|
||||
res.json(filesWithDiffs);
|
||||
} 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 /api/tasks/:id/diff
|
||||
* Get detailed diff information for files modified during task execution.
|
||||
|
||||
Reference in New Issue
Block a user