feat(FN-961): add ChangesDiffModal for expanded task change viewing

- Create ChangesDiffModal component with unified/split diff views, syntax highlighting, and file stats
- Add expand button to TaskChangesTab to open changes in full-screen modal
- Add comprehensive CSS styles for diff modal, line numbers, and responsive layout
- Add full test coverage for ChangesDiffModal (rendering, diff modes, navigation, keyboard shortcuts)
- Add expand button tests to TaskChangesTab test suite
This commit is contained in:
gsxdsm
2026-04-05 14:03:18 -07:00
parent 856ceebe88
commit fa725616cb
5 changed files with 1115 additions and 1 deletions

View File

@@ -0,0 +1,264 @@
import { useState, useEffect, useCallback } from "react";
import {
X,
FileCode,
ChevronLeft,
ChevronRight,
WrapText,
RefreshCw,
GitCommit,
} from "lucide-react";
import type { MergeDetails, Column } from "@fusion/core";
import { highlightDiff } from "../utils/highlightDiff";
/** Normalized file entry — re-exported from TaskChangesTab for shared use */
export interface NormalizedFile {
path: string;
status: "added" | "modified" | "deleted" | "unknown";
additions: number;
deletions: number;
patch: string;
}
interface ChangesDiffModalProps {
isOpen: boolean;
taskId: string;
files: NormalizedFile[];
stats: { filesChanged: number; additions: number; deletions: number };
mergeDetails?: MergeDetails;
column?: Column;
onClose: () => void;
onRefresh?: () => void;
}
function getStatusLabel(
status: "added" | "modified" | "deleted" | "unknown"
): string {
switch (status) {
case "added":
return "A";
case "deleted":
return "D";
case "modified":
return "M";
default:
return "?";
}
}
/**
* ChangesDiffModal is a two-panel file browser + diff viewer modal.
*
* The left panel lists changed files with status badges (A/M/D) and +/- stats.
* The right panel displays the syntax-highlighted diff for the selected file.
*/
export function ChangesDiffModal({
isOpen,
taskId,
files,
stats,
mergeDetails,
column,
onClose,
onRefresh,
}: ChangesDiffModalProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [wordWrap, setWordWrap] = useState(true);
// Auto-select first file when files change
useEffect(() => {
if (files.length > 0 && selectedIndex === null) {
setSelectedIndex(0);
}
}, [files, selectedIndex]);
const navigatePrev = useCallback(() => {
setSelectedIndex((prev) => (prev !== null && prev > 0 ? prev - 1 : prev));
}, []);
const navigateNext = useCallback(() => {
setSelectedIndex((prev) =>
prev !== null && prev < files.length - 1 ? prev + 1 : prev
);
}, []);
// Keyboard handler
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key === "ArrowUp" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
navigatePrev();
}
if (e.key === "ArrowDown" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
navigateNext();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose, navigatePrev, navigateNext]);
if (!isOpen) return null;
const selectedFile =
selectedIndex !== null ? files[selectedIndex] : null;
const isDone = column === "done";
return (
<div className="modal-overlay open" onClick={onClose}>
<div
className="modal changes-diff-modal"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="modal-header changes-diff-modal-header">
<div className="changes-diff-header-title">
<FileCode size={18} />
<span>Changes {taskId}</span>
<span className="changes-stat-summary">
<span className="diff-add">+{stats.additions}</span>{" "}
<span className="diff-del">-{stats.deletions}</span>
</span>
</div>
<div className="changes-diff-header-actions">
{files.length > 0 && (
<div className="changes-nav">
<button
className="btn btn-sm btn-icon"
onClick={navigatePrev}
disabled={selectedIndex === null || selectedIndex <= 0}
title="Previous file (Ctrl+↑)"
aria-label="Previous file"
>
<ChevronLeft size={14} />
</button>
<span className="changes-nav-indicator" aria-live="polite">
{selectedIndex !== null
? `${selectedIndex + 1}/${files.length}`
: `—/${files.length}`}
</span>
<button
className="btn btn-sm btn-icon"
onClick={navigateNext}
disabled={
selectedIndex === null || selectedIndex >= files.length - 1
}
title="Next file (Ctrl+↓)"
aria-label="Next file"
>
<ChevronRight size={14} />
</button>
</div>
)}
<button
className={`btn btn-sm ${wordWrap ? "btn-primary" : ""}`}
onClick={() => setWordWrap((prev) => !prev)}
title={wordWrap ? "Disable word wrap" : "Enable word wrap"}
aria-label="Toggle word wrap"
>
<WrapText size={14} />
</button>
{onRefresh && (
<button className="btn btn-sm" onClick={onRefresh}>
<RefreshCw size={14} />
Refresh
</button>
)}
<button className="modal-close" onClick={onClose}>
<X size={20} />
</button>
</div>
</div>
{/* Body */}
<div className="changes-diff-body">
{/* Left panel — file list */}
<div className="changes-diff-sidebar">
{/* Commit metadata for done tasks */}
{isDone && mergeDetails && (
<div className="commit-diff-meta">
{mergeDetails.commitSha && (
<div className="commit-diff-sha">
<GitCommit size={14} />
<code>{mergeDetails.commitSha.slice(0, 7)}</code>
</div>
)}
{mergeDetails.mergeCommitMessage && (
<div className="commit-diff-message">
{mergeDetails.mergeCommitMessage}
</div>
)}
{mergeDetails.mergedAt && (
<div className="commit-diff-timestamp">
Merged {new Date(mergeDetails.mergedAt).toLocaleString()}
</div>
)}
</div>
)}
<div className="changes-diff-file-list">
{files.map((file, index) => (
<button
key={file.path}
className={`changes-diff-file-item ${selectedIndex === index ? "selected" : ""}`}
onClick={() => setSelectedIndex(index)}
title={file.path}
>
<span
className={`changes-file-status changes-file-status--${file.status}`}
>
{getStatusLabel(file.status)}
</span>
<span className="changes-diff-file-path">{file.path}</span>
<span className="changes-diff-file-stat">
+{file.additions} -{file.deletions}
</span>
</button>
))}
</div>
</div>
{/* Right panel — diff viewer */}
<div className="changes-diff-content">
{selectedFile ? (
<>
<div className="changes-diff-file-header-bar">
<span className="changes-diff-file-header-name">
{selectedFile.path}
</span>
<span className="changes-diff-file-header-stats">
+{selectedFile.additions} -{selectedFile.deletions}
</span>
</div>
{selectedFile.patch ? (
<div className="changes-diff-viewer">
<pre
className={`changes-diff-patch ${wordWrap ? "changes-diff-patch--wrap" : "changes-diff-patch--nowrap"}`}
>
<code>{highlightDiff(selectedFile.patch)}</code>
</pre>
</div>
) : (
<div className="changes-diff-empty">
No diff available for this file.
</div>
)}
</>
) : (
<div className="changes-diff-empty">
<FileCode size={48} opacity={0.3} />
<p>Select a file to view its diff</p>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,10 @@
import { useState, useEffect, useCallback } from "react";
import { FileCode, ChevronDown, ChevronRight, ChevronLeft, AlertCircle, GitCommit, WrapText } from "lucide-react";
import { FileCode, ChevronDown, ChevronRight, ChevronLeft, AlertCircle, GitCommit, WrapText, Maximize2 } from "lucide-react";
import type { MergeDetails, Column } from "@fusion/core";
import { fetchTaskDiff, type TaskDiff } from "../api";
import { highlightDiff } from "../utils/highlightDiff";
import { truncateMiddle } from "../utils/truncatePath";
import { ChangesDiffModal } from "./ChangesDiffModal";
interface TaskChangesTabProps {
taskId: string;
@@ -51,6 +52,7 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const [currentFileIndex, setCurrentFileIndex] = useState<number | null>(null);
const [wordWrap, setWordWrap] = useState(true);
const [expandedViewOpen, setExpandedViewOpen] = useState(false);
const canLoad = column === "in-progress" || column === "in-review" || column === "done";
@@ -243,6 +245,14 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
>
Refresh
</button>
<button
className="btn btn-sm btn-icon"
onClick={() => setExpandedViewOpen(true)}
title="Expand to full-screen diff view"
aria-label="Expand diff view"
>
<Maximize2 size={14} />
</button>
</div>
</div>
@@ -290,6 +300,17 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
);
})}
</div>
<ChangesDiffModal
isOpen={expandedViewOpen}
taskId={taskId}
files={files}
stats={stats}
mergeDetails={mergeDetails}
column={column}
onClose={() => setExpandedViewOpen(false)}
onRefresh={loadDiff}
/>
</div>
);
}

View File

@@ -0,0 +1,524 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ChangesDiffModal, type NormalizedFile } from "../ChangesDiffModal";
import type { MergeDetails } from "@fusion/core";
vi.mock("lucide-react", () => ({
X: ({ size }: any) => <span data-testid="icon-x">X</span>,
FileCode: ({ size, opacity }: any) => (
<span data-testid="icon-filecode">FileCode</span>
),
ChevronLeft: ({ size }: any) => <span data-testid="icon-chevron-left" />,
ChevronRight: ({ size }: any) => <span data-testid="icon-chevron-right" />,
WrapText: ({ size }: any) => <span data-testid="icon-wraptext" />,
RefreshCw: ({ size }: any) => <span data-testid="icon-refresh" />,
GitCommit: ({ size }: any) => <span data-testid="icon-gitcommit" />,
}));
vi.mock("../../utils/highlightDiff", () => ({
highlightDiff: (diff: string) => diff,
}));
const FILES: NormalizedFile[] = [
{
path: "src/app.ts",
status: "modified",
additions: 5,
deletions: 2,
patch: "@@ -1,3 +1,6 @@\n-old line\n+new line\n+another new line",
},
{
path: "src/new-file.ts",
status: "added",
additions: 10,
deletions: 0,
patch: "@@ -0,0 +1,10 @@\n+export function hello() {\n+ return 'world';\n+}",
},
{
path: "src/deleted.ts",
status: "deleted",
additions: 0,
deletions: 8,
patch: "@@ -1,8 +0,0 @@\n-old line 1\n-old line 2",
},
];
const STATS = { filesChanged: 3, additions: 15, deletions: 10 };
const MERGE_DETAILS: MergeDetails = {
commitSha: "abc1234567890def",
filesChanged: 3,
insertions: 15,
deletions: 10,
mergeCommitMessage: "Merge branch 'fusion/fn-001' into main",
mergedAt: "2026-01-15T10:30:00Z",
};
const defaultProps = {
isOpen: true,
taskId: "FN-001",
files: FILES,
stats: STATS,
onClose: vi.fn(),
onRefresh: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("ChangesDiffModal", () => {
describe("rendering", () => {
it("renders nothing when isOpen is false", () => {
const { container } = render(
<ChangesDiffModal {...defaultProps} isOpen={false} />,
);
expect(container.innerHTML).toBe("");
});
it("renders the modal when isOpen is true", () => {
render(<ChangesDiffModal {...defaultProps} />);
expect(screen.getByText(/Changes — FN-001/)).toBeTruthy();
});
it("shows total additions and deletions in header", () => {
render(<ChangesDiffModal {...defaultProps} />);
expect(screen.getByText("+15")).toBeTruthy();
expect(screen.getByText("-10")).toBeTruthy();
});
it("shows file list in sidebar", () => {
render(<ChangesDiffModal {...defaultProps} />);
// File paths appear in both sidebar and diff header, so use getAllByText
expect(screen.getAllByText("src/app.ts").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("src/new-file.ts").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("src/deleted.ts").length).toBeGreaterThanOrEqual(1);
});
it("shows status badges with correct labels", () => {
const { container } = render(
<ChangesDiffModal {...defaultProps} />,
);
const modifiedBadge = container.querySelector(
".changes-file-status--modified",
);
expect(modifiedBadge).toBeTruthy();
expect(modifiedBadge?.textContent).toBe("M");
const addedBadge = container.querySelector(
".changes-file-status--added",
);
expect(addedBadge).toBeTruthy();
expect(addedBadge?.textContent).toBe("A");
const deletedBadge = container.querySelector(
".changes-file-status--deleted",
);
expect(deletedBadge).toBeTruthy();
expect(deletedBadge?.textContent).toBe("D");
});
it("shows per-file stats in the sidebar", () => {
render(<ChangesDiffModal {...defaultProps} />);
// Each file item shows +X -Y stats
const statElements = screen.getAllByText(/\+\d+ -\d+/);
// 3 files in sidebar + 1 in diff header = at least 4
expect(statElements.length).toBeGreaterThanOrEqual(4);
});
it("auto-selects the first file on open", () => {
render(<ChangesDiffModal {...defaultProps} />);
// Should show the first file's name in the diff header (selected by useEffect)
expect(
screen.getByText("src/app.ts", {
selector: ".changes-diff-file-header-name",
}),
).toBeTruthy();
});
it("shows diff content for selected file", () => {
const { container } = render(
<ChangesDiffModal {...defaultProps} />,
);
// The first file's patch should be visible
const patchEl = container.querySelector(".changes-diff-patch");
expect(patchEl).toBeTruthy();
expect(patchEl?.textContent).toContain("@@ -1,3 +1,6 @@");
});
it("shows 'No diff available' for file without patch", () => {
const filesNoPatch: NormalizedFile[] = [
{
path: "src/binary.png",
status: "added",
additions: 0,
deletions: 0,
patch: "",
},
];
render(
<ChangesDiffModal
{...defaultProps}
files={filesNoPatch}
/>,
);
expect(screen.getByText("No diff available for this file.")).toBeTruthy();
});
it("shows Select a file placeholder when files are initially empty", () => {
render(
<ChangesDiffModal {...defaultProps} files={[]} />,
);
// No files → "Select a file" placeholder
expect(screen.getByText("Select a file to view its diff")).toBeTruthy();
});
});
describe("file selection", () => {
it("selects a file when clicking in sidebar", () => {
render(<ChangesDiffModal {...defaultProps} />);
// Click on the second file in the sidebar
const sidebarFileItems = screen.getAllByText("src/new-file.ts");
// Click the one in the sidebar (the first match should be in the sidebar)
fireEvent.click(sidebarFileItems[0]);
// The diff header should now show the second file
expect(
screen.getByText("src/new-file.ts", {
selector: ".changes-diff-file-header-name",
}),
).toBeTruthy();
});
it("highlights the selected file in sidebar", () => {
const { container } = render(
<ChangesDiffModal {...defaultProps} />,
);
// First file should be selected by default
const selectedItems = container.querySelectorAll(
".changes-diff-file-item.selected",
);
expect(selectedItems.length).toBe(1);
expect(selectedItems[0].getAttribute("title")).toBe("src/app.ts");
// Click on second file
fireEvent.click(screen.getByText("src/new-file.ts"));
const newSelectedItems = container.querySelectorAll(
".changes-diff-file-item.selected",
);
expect(newSelectedItems.length).toBe(1);
expect(newSelectedItems[0].getAttribute("title")).toBe(
"src/new-file.ts",
);
});
});
describe("navigation buttons", () => {
it("renders Previous and Next navigation buttons", () => {
render(<ChangesDiffModal {...defaultProps} />);
expect(screen.getByLabelText("Previous file")).toBeTruthy();
expect(screen.getByLabelText("Next file")).toBeTruthy();
});
it("shows file position indicator", () => {
render(<ChangesDiffModal {...defaultProps} />);
// First file selected by default → 1/3
expect(screen.getByText("1/3")).toBeTruthy();
});
it("disables Previous button on first file", () => {
render(<ChangesDiffModal {...defaultProps} />);
expect(screen.getByLabelText("Previous file")).toBeDisabled();
});
it("enables Next button on first file", () => {
render(<ChangesDiffModal {...defaultProps} />);
expect(screen.getByLabelText("Next file")).not.toBeDisabled();
});
it("navigates to next file when Next is clicked", () => {
render(<ChangesDiffModal {...defaultProps} />);
fireEvent.click(screen.getByLabelText("Next file"));
expect(screen.getByText("2/3")).toBeTruthy();
});
it("disables Next button on last file", () => {
render(<ChangesDiffModal {...defaultProps} />);
// Navigate to last file
fireEvent.click(screen.getByLabelText("Next file")); // 2/3
fireEvent.click(screen.getByLabelText("Next file")); // 3/3
expect(screen.getByText("3/3")).toBeTruthy();
expect(screen.getByLabelText("Next file")).toBeDisabled();
});
it("navigates to previous file when Previous is clicked", () => {
render(<ChangesDiffModal {...defaultProps} />);
// Go to second file
fireEvent.click(screen.getByLabelText("Next file"));
expect(screen.getByText("2/3")).toBeTruthy();
// Go back
fireEvent.click(screen.getByLabelText("Previous file"));
expect(screen.getByText("1/3")).toBeTruthy();
});
it("does not navigate below 0", () => {
render(<ChangesDiffModal {...defaultProps} />);
// Already at first file, Previous is disabled
expect(screen.getByLabelText("Previous file")).toBeDisabled();
expect(screen.getByText("1/3")).toBeTruthy();
});
it("updates the diff content when navigating", () => {
render(<ChangesDiffModal {...defaultProps} />);
// Navigate to second file
fireEvent.click(screen.getByLabelText("Next file"));
// Diff header should show second file name
expect(
screen.getByText("src/new-file.ts", {
selector: ".changes-diff-file-header-name",
}),
).toBeTruthy();
});
});
describe("word wrap toggle", () => {
it("renders the word wrap toggle button", () => {
render(<ChangesDiffModal {...defaultProps} />);
expect(screen.getByLabelText("Toggle word wrap")).toBeTruthy();
});
it("defaults to word wrap ON", () => {
render(<ChangesDiffModal {...defaultProps} />);
const toggle = screen.getByLabelText("Toggle word wrap");
expect(toggle.className).toContain("btn-primary");
});
it("toggles word wrap OFF when clicked", () => {
const { container } = render(
<ChangesDiffModal {...defaultProps} />,
);
const toggle = screen.getByLabelText("Toggle word wrap");
fireEvent.click(toggle);
// Should now have nowrap class
const patchEl = container.querySelector(".changes-diff-patch");
expect(patchEl?.classList.contains("changes-diff-patch--nowrap")).toBe(
true,
);
expect(patchEl?.classList.contains("changes-diff-patch--wrap")).toBe(
false,
);
});
it("updates tooltip based on wrap state", () => {
render(<ChangesDiffModal {...defaultProps} />);
const toggle = screen.getByLabelText("Toggle word wrap");
expect(toggle.getAttribute("title")).toBe("Disable word wrap");
fireEvent.click(toggle);
expect(toggle.getAttribute("title")).toBe("Enable word wrap");
});
});
describe("close behavior", () => {
it("calls onClose when close button is clicked", () => {
const onClose = vi.fn();
render(<ChangesDiffModal {...defaultProps} onClose={onClose} />);
// Click the close button (X icon)
const closeBtn = screen.getByTestId("icon-x").closest("button");
expect(closeBtn).toBeTruthy();
fireEvent.click(closeBtn!);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("calls onClose when clicking the modal overlay", () => {
const onClose = vi.fn();
const { container } = render(
<ChangesDiffModal {...defaultProps} onClose={onClose} />,
);
const overlay = container.querySelector(".modal-overlay");
expect(overlay).toBeTruthy();
fireEvent.click(overlay!);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does NOT call onClose when clicking inside the modal body", () => {
const onClose = vi.fn();
const { container } = render(
<ChangesDiffModal {...defaultProps} onClose={onClose} />,
);
const modal = container.querySelector(".modal.changes-diff-modal");
expect(modal).toBeTruthy();
fireEvent.click(modal!);
expect(onClose).not.toHaveBeenCalled();
});
it("calls onClose on Escape key", () => {
const onClose = vi.fn();
render(<ChangesDiffModal {...defaultProps} onClose={onClose} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does not listen for Escape when modal is closed", () => {
const onClose = vi.fn();
const { rerender } = render(
<ChangesDiffModal {...defaultProps} isOpen={false} onClose={onClose} />,
);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
});
});
describe("refresh button", () => {
it("renders refresh button when onRefresh is provided", () => {
const onRefresh = vi.fn();
render(<ChangesDiffModal {...defaultProps} onRefresh={onRefresh} />);
expect(screen.getByText("Refresh")).toBeTruthy();
});
it("does not render refresh button when onRefresh is not provided", () => {
render(
<ChangesDiffModal
{...defaultProps}
onRefresh={undefined}
/>,
);
expect(screen.queryByText("Refresh")).toBeNull();
});
it("calls onRefresh when refresh button is clicked", () => {
const onRefresh = vi.fn();
render(<ChangesDiffModal {...defaultProps} onRefresh={onRefresh} />);
fireEvent.click(screen.getByText("Refresh"));
expect(onRefresh).toHaveBeenCalledTimes(1);
});
});
describe("commit metadata (done tasks)", () => {
it("shows commit metadata when column is done and mergeDetails provided", () => {
render(
<ChangesDiffModal
{...defaultProps}
column="done"
mergeDetails={MERGE_DETAILS}
/>,
);
expect(screen.getByText("abc1234")).toBeTruthy(); // short SHA
expect(
screen.getByText("Merge branch 'fusion/fn-001' into main"),
).toBeTruthy();
expect(screen.getByText(/Merged .+/)).toBeTruthy();
});
it("does not show commit metadata when column is not done", () => {
const { container } = render(
<ChangesDiffModal
{...defaultProps}
column="in-progress"
mergeDetails={MERGE_DETAILS}
/>,
);
expect(container.querySelector(".commit-diff-meta")).toBeNull();
});
it("does not show commit metadata when mergeDetails not provided", () => {
const { container } = render(
<ChangesDiffModal {...defaultProps} column="done" />,
);
expect(container.querySelector(".commit-diff-meta")).toBeNull();
});
it("shows partial commit metadata (SHA only)", () => {
render(
<ChangesDiffModal
{...defaultProps}
column="done"
mergeDetails={{ commitSha: "def4567890abcdef" }}
/>,
);
expect(screen.getByText("def4567")).toBeTruthy();
});
});
describe("keyboard navigation", () => {
it("navigates to next file with Ctrl+ArrowDown", () => {
render(<ChangesDiffModal {...defaultProps} />);
expect(screen.getByText("1/3")).toBeTruthy();
fireEvent.keyDown(document, { key: "ArrowDown", ctrlKey: true });
expect(screen.getByText("2/3")).toBeTruthy();
});
it("navigates to previous file with Ctrl+ArrowUp", () => {
render(<ChangesDiffModal {...defaultProps} />);
// Go to second file first
fireEvent.keyDown(document, { key: "ArrowDown", ctrlKey: true });
expect(screen.getByText("2/3")).toBeTruthy();
// Go back
fireEvent.keyDown(document, { key: "ArrowUp", ctrlKey: true });
expect(screen.getByText("1/3")).toBeTruthy();
});
it("navigates with Cmd+ArrowDown (macOS)", () => {
render(<ChangesDiffModal {...defaultProps} />);
fireEvent.keyDown(document, { key: "ArrowDown", metaKey: true });
expect(screen.getByText("2/3")).toBeTruthy();
});
it("does not navigate on plain arrow keys", () => {
render(<ChangesDiffModal {...defaultProps} />);
fireEvent.keyDown(document, { key: "ArrowDown" });
expect(screen.getByText("1/3")).toBeTruthy();
});
});
describe("empty state", () => {
it("renders without errors with empty files array", () => {
render(
<ChangesDiffModal
{...defaultProps}
files={[]}
stats={{ filesChanged: 0, additions: 0, deletions: 0 }}
/>,
);
expect(screen.getByText(/Changes — FN-001/)).toBeTruthy();
// With empty files, the navigation section is not rendered at all
// The "Select a file" placeholder should appear since no file is selected
expect(screen.getByText("Select a file to view its diff")).toBeTruthy();
});
});
});

View File

@@ -17,6 +17,12 @@ vi.mock("lucide-react", () => ({
AlertCircle: () => null,
GitCommit: () => null,
WrapText: ({ size }: any) => <span data-testid="wrap-text-icon" />,
Maximize2: ({ size }: any) => <span data-testid="maximize-icon" />,
}));
vi.mock("../ChangesDiffModal", () => ({
ChangesDiffModal: ({ isOpen, onClose }: any) =>
isOpen ? <div data-testid="changes-diff-modal">Modal</div> : null,
}));
vi.mock("../../utils/highlightDiff", () => ({
@@ -735,3 +741,72 @@ describe("TaskChangesTab — word wrap toggle", () => {
expect(toggle.getAttribute("title")).toBe("Enable word wrap");
});
});
describe("TaskChangesTab — expand button", () => {
it("renders the expand button", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
render(
<TaskChangesTab
taskId="FN-001"
worktree={undefined}
column="done"
mergeDetails={MERGE_DETAILS}
/>,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
expect(screen.getByLabelText("Expand diff view")).toBeTruthy();
});
it("opens the ChangesDiffModal when expand button is clicked", async () => {
mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
render(
<TaskChangesTab
taskId="FN-001"
worktree={undefined}
column="done"
mergeDetails={MERGE_DETAILS}
/>,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
// Modal should not be visible initially
expect(screen.queryByTestId("changes-diff-modal")).toBeNull();
// Click expand
fireEvent.click(screen.getByLabelText("Expand diff view"));
// Modal should now be visible
expect(screen.getByTestId("changes-diff-modal")).toBeTruthy();
});
it("does not render expand button when no files are loaded", async () => {
mockFetchTaskDiff.mockResolvedValue({
files: [],
stats: { filesChanged: 0, additions: 0, deletions: 0 },
});
render(
<TaskChangesTab
taskId="FN-001"
worktree={undefined}
column="done"
mergeDetails={MERGE_DETAILS}
/>,
);
await waitFor(() => {
expect(screen.getByText("No files modified.")).toBeTruthy();
});
expect(screen.queryByLabelText("Expand diff view")).toBeNull();
});
});

View File

@@ -10578,6 +10578,236 @@ html .column.drag-over * {
position: static;
}
/* Commit metadata for task changes (done tasks with merge details) */
.commit-diff-meta {
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 2px;
background: var(--surface);
}
.commit-diff-sha {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: 12px;
color: var(--text-muted);
}
.commit-diff-sha code {
font-family: var(--font-mono);
background: var(--card);
padding: 1px 6px;
border-radius: var(--radius-sm);
}
.commit-diff-message {
font-size: 12px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.commit-diff-timestamp {
font-size: 11px;
color: var(--text-dim);
}
/* ChangesDiffModal — two-panel diff browser */
.changes-diff-modal {
width: 90vw;
max-width: 1200px;
height: 85vh;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.changes-diff-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
}
.changes-diff-modal-header .modal-close {
position: static;
}
.changes-diff-header-title {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 15px;
font-weight: 600;
}
.changes-diff-header-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.changes-diff-body {
display: flex;
flex: 1;
overflow: hidden;
}
.changes-diff-sidebar {
width: 280px;
min-width: 280px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--surface);
}
.changes-diff-file-list {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.changes-diff-file-item {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%;
padding: var(--space-sm) var(--space-md);
background: transparent;
border: none;
border-left: 3px solid transparent;
color: var(--text);
text-align: left;
cursor: pointer;
font-size: 12px;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.changes-diff-file-item:hover {
background: var(--card-hover);
}
.changes-diff-file-item.selected {
background: var(--card-hover);
border-left-color: var(--todo);
}
.changes-diff-file-path {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-family: var(--font-mono);
font-size: 12px;
}
.changes-diff-file-stat {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
}
.changes-diff-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
min-height: 0;
}
.changes-diff-file-header-bar {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border);
background: var(--surface);
min-height: 40px;
}
.changes-diff-file-header-name {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 500;
color: var(--text);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.changes-diff-file-header-stats {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-muted);
flex-shrink: 0;
}
.changes-diff-viewer {
flex: 1;
overflow: auto;
padding: var(--space-sm) 0;
}
.changes-diff-viewer .changes-diff-patch {
border: none;
border-radius: 0;
}
.changes-diff-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-md);
flex: 1;
color: var(--text-muted);
padding: var(--space-xl);
text-align: center;
}
.changes-diff-empty p {
margin: 0;
font-size: 14px;
}
/* Mobile responsive for changes-diff-modal */
@media (max-width: 768px) {
.changes-diff-modal {
width: 100vw;
height: 100vh;
max-width: 100%;
max-height: 100%;
border-radius: 0;
}
.changes-diff-body {
flex-direction: column;
}
.changes-diff-sidebar {
width: 100%;
min-width: unset;
max-height: 40%;
border-right: none;
border-bottom: 1px solid var(--border);
}
.changes-diff-file-list {
padding: 4px 0;
}
}
.file-browser-header-path {
color: var(--text-muted);
font-weight: 400;