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:
264
packages/dashboard/app/components/ChangesDiffModal.tsx
Normal file
264
packages/dashboard/app/components/ChangesDiffModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
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 type { MergeDetails, Column } from "@fusion/core";
|
||||||
import { fetchTaskDiff, type TaskDiff } from "../api";
|
import { fetchTaskDiff, type TaskDiff } from "../api";
|
||||||
import { highlightDiff } from "../utils/highlightDiff";
|
import { highlightDiff } from "../utils/highlightDiff";
|
||||||
import { truncateMiddle } from "../utils/truncatePath";
|
import { truncateMiddle } from "../utils/truncatePath";
|
||||||
|
import { ChangesDiffModal } from "./ChangesDiffModal";
|
||||||
|
|
||||||
interface TaskChangesTabProps {
|
interface TaskChangesTabProps {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@@ -51,6 +52,7 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
|
|||||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||||
const [currentFileIndex, setCurrentFileIndex] = useState<number | null>(null);
|
const [currentFileIndex, setCurrentFileIndex] = useState<number | null>(null);
|
||||||
const [wordWrap, setWordWrap] = useState(true);
|
const [wordWrap, setWordWrap] = useState(true);
|
||||||
|
const [expandedViewOpen, setExpandedViewOpen] = useState(false);
|
||||||
|
|
||||||
const canLoad = column === "in-progress" || column === "in-review" || column === "done";
|
const canLoad = column === "in-progress" || column === "in-review" || column === "done";
|
||||||
|
|
||||||
@@ -243,6 +245,14 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
|
|||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -290,6 +300,17 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ChangesDiffModal
|
||||||
|
isOpen={expandedViewOpen}
|
||||||
|
taskId={taskId}
|
||||||
|
files={files}
|
||||||
|
stats={stats}
|
||||||
|
mergeDetails={mergeDetails}
|
||||||
|
column={column}
|
||||||
|
onClose={() => setExpandedViewOpen(false)}
|
||||||
|
onRefresh={loadDiff}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,12 @@ vi.mock("lucide-react", () => ({
|
|||||||
AlertCircle: () => null,
|
AlertCircle: () => null,
|
||||||
GitCommit: () => null,
|
GitCommit: () => null,
|
||||||
WrapText: ({ size }: any) => <span data-testid="wrap-text-icon" />,
|
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", () => ({
|
vi.mock("../../utils/highlightDiff", () => ({
|
||||||
@@ -735,3 +741,72 @@ describe("TaskChangesTab — word wrap toggle", () => {
|
|||||||
expect(toggle.getAttribute("title")).toBe("Enable word wrap");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10578,6 +10578,236 @@ html .column.drag-over * {
|
|||||||
position: static;
|
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 {
|
.file-browser-header-path {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|||||||
Reference in New Issue
Block a user