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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user