Render desktop diff view hunks with the shared @pierre/diffs renderer (#13201)

* Render desktop diff view hunks with shared @pierre/diffs renderer

Replace DiffView's hand-rolled DiffHunk +/- line rows with ToolFileDiff
from @cline/ui (backed by @pierre/diffs), matching the chat tool rows.
Hunks carrying complete new contents (created files) render with real
line numbers; fragment hunks hide them, mirroring ToolCallRow. All of
DiffView's chrome (collapse, copy, open-in-editor, counts) is unchanged.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Make ToolFileDiff syntax palette follow the app theme, not browser preference

@pierre/diffs declares 'color-scheme: light dark' on its shadow :host, so
its light-dark() token colors resolve from the browser's preferred scheme.
Apps themed by the .dark class (desktop app) got the light palette's
near-black text on dark surfaces. Inline colorScheme: inherit on the host
wins over the :host rule and follows the app's color-scheme, which the
@cline/ui theme already flips with .dark. Skipped when a caller pins an
explicit themeType.

Also key diff-view hunks by index so repeated same-shaped hunks (a file
created twice with identical contents) don't collide.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
Saoud Rizwan
2026-08-12 18:06:44 -07:00
committed by GitHub
co-authored by Saoud Rizwan
parent 02a1bfd0a8
commit d03e88e50b
4 changed files with 207 additions and 61 deletions
@@ -18,6 +18,11 @@ vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke: invokeMock },
}));
// @pierre/diffs' custom element adopts constructable stylesheets, which jsdom
// does not implement; without this the suite exits nonzero on an unhandled
// error even with every test passing.
CSSStyleSheet.prototype.replaceSync ??= function replaceSync() {} as never;
let container: HTMLDivElement;
let root: Root;
let writeText: ReturnType<typeof vi.fn>;
@@ -100,6 +105,38 @@ const FILE_DIFF: SessionFileDiff = {
hunks: [],
};
const MODIFIED_FILE_DIFF: SessionFileDiff = {
path: "src/app.ts",
additions: 1,
deletions: 1,
hunks: [
{
oldStart: 4,
newStart: 4,
old: "const total = 1;",
new: "const total = 2;",
},
],
};
const CREATED_FILE_DIFF: SessionFileDiff = {
path: "src/created.ts",
additions: 2,
deletions: 0,
hunks: [
{
oldStart: 1,
newStart: 1,
old: "",
new: "export const a = 1;\nexport const b = 2;",
},
],
};
function diffContainers(): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>("diffs-container"));
}
describe("DiffView file actions", () => {
it("copies the cwd-resolved absolute file path", async () => {
await act(async () => {
@@ -180,3 +217,58 @@ describe("DiffView file actions", () => {
expect(writeText).toHaveBeenCalledWith("docs/a.mdx");
});
});
describe("DiffView hunk rendering", () => {
it("renders each hunk through the shared @pierre/diffs renderer", async () => {
await act(async () => {
root.render(
<DiffView
fileDiffs={[MODIFIED_FILE_DIFF, CREATED_FILE_DIFF]}
onClose={vi.fn()}
/>,
);
});
expect(diffContainers()).toHaveLength(2);
});
it("shows the empty-hunks placeholder instead of a diff renderer", async () => {
await act(async () => {
root.render(<DiffView fileDiffs={[FILE_DIFF]} onClose={vi.fn()} />);
});
expect(diffContainers()).toHaveLength(0);
expect(container.textContent).toContain("No hunk details available.");
});
it("removes the diff body when a file is collapsed and restores it on expand", async () => {
await act(async () => {
root.render(
<DiffView fileDiffs={[MODIFIED_FILE_DIFF]} onClose={vi.fn()} />,
);
});
expect(diffContainers()).toHaveLength(1);
const toggle = container.querySelector<HTMLButtonElement>(
"button:not([aria-label])",
);
expect(toggle?.textContent).toContain("src/app.ts");
await click(toggle as Element);
expect(diffContainers()).toHaveLength(0);
await click(toggle as Element);
expect(diffContainers()).toHaveLength(1);
});
it("keeps the per-file add/del counts in the header", async () => {
await act(async () => {
root.render(
<DiffView fileDiffs={[MODIFIED_FILE_DIFF]} onClose={vi.fn()} />,
);
});
expect(container.textContent).toContain("+1");
expect(container.textContent).toContain("-1");
});
});
@@ -1,5 +1,6 @@
"use client";
import { ToolFileDiff } from "@cline/ui/components/agent-chat/tool-diff";
import {
AppWindow,
Check,
@@ -7,8 +8,6 @@ import {
ChevronRight,
Copy,
ExternalLink,
Minus,
Plus,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -23,7 +22,7 @@ import {
import { ScrollArea } from "@/components/ui/scroll-area";
import { toast } from "@/hooks/use-toast";
import { desktopClient } from "@/lib/desktop-client";
import type { SessionFileDiff } from "@/lib/session-diff";
import type { SessionDiffHunk, SessionFileDiff } from "@/lib/session-diff";
import { cn } from "@/lib/utils";
import { resolveWorkspaceFilePath } from "@/lib/workspace-paths";
import { EditorIcon } from "./editor-icons";
@@ -285,10 +284,14 @@ function DiffFileSection({
No hunk details available.
</p>
) : (
file.hunks.map((hunk) => (
// The index disambiguates repeated same-shaped hunks (e.g.
// a file created twice with identical contents); hunks
// never reorder within a file, so it is a stable key.
file.hunks.map((hunk, index) => (
<DiffHunk
hunk={hunk}
key={`${file.path}-${hunk.oldStart}-${hunk.newStart}-${hunk.old.length}-${hunk.new.length}`}
key={`${file.path}-${index}-${hunk.oldStart}-${hunk.newStart}-${hunk.old.length}-${hunk.new.length}`}
path={file.path}
/>
))
)}
@@ -298,63 +301,24 @@ function DiffFileSection({
);
}
function DiffHunk({ hunk }: { hunk: SessionFileDiff["hunks"][number] }) {
const oldLines = hunk.old.length > 0 ? hunk.old.split("\n") : [];
const newLines = hunk.new.length > 0 ? hunk.new.split("\n") : [];
const oldOccurrences = new Map<string, number>();
const oldLineEntries = oldLines.map((line, offset) => {
const occurrence = (oldOccurrences.get(line) ?? 0) + 1;
oldOccurrences.set(line, occurrence);
return {
key: `old-${hunk.oldStart + offset}-${occurrence}-${line}`,
line,
lineNumber: hunk.oldStart + offset,
};
});
const newOccurrences = new Map<string, number>();
const newLineEntries = newLines.map((line, offset) => {
const occurrence = (newOccurrences.get(line) ?? 0) + 1;
newOccurrences.set(line, occurrence);
return {
key: `new-${hunk.newStart + offset}-${occurrence}-${line}`,
line,
lineNumber: hunk.newStart + offset,
};
});
function DiffHunk({ hunk, path }: { hunk: SessionDiffHunk; path: string }) {
// A hunk with no old side that starts at line 1 on both sides carries the
// complete new contents (editor `create`, apply_patch Add File). Chat tool
// rows render those with complete-file semantics (real line numbers);
// everything else is a file fragment, which hides line numbers — see
// ToolCallRow in chat-messages.tsx. Matching that keeps both surfaces
// visually in agreement.
const isCompleteNewContents =
hunk.old.length === 0 && hunk.oldStart === 1 && hunk.newStart === 1;
return (
<div className="cline-chat-selectable overflow-x-auto rounded-md border border-border bg-background font-mono text-[11px] leading-5">
{oldLineEntries.map((entry) => (
<div className="flex bg-destructive/10" key={entry.key}>
<span className="hidden w-12 shrink-0 select-none items-center justify-end border-r border-border px-2 text-muted-foreground/40 sm:flex">
{entry.lineNumber}
</span>
<span className="flex w-6 shrink-0 items-center justify-center text-destructive">
<Minus className="h-2.5 w-2.5" />
</span>
<span className="min-w-0 flex-1 whitespace-pre px-2 text-destructive/90">
{entry.line || " "}
</span>
</div>
))}
{newLineEntries.map((entry) => (
<div className="flex bg-primary/10" key={entry.key}>
<span className="hidden w-12 shrink-0 select-none items-center justify-end border-r border-border px-2 text-muted-foreground/40 sm:flex">
{entry.lineNumber}
</span>
<span className="flex w-6 shrink-0 items-center justify-center text-primary">
<Plus className="h-2.5 w-2.5" />
</span>
<span className="min-w-0 flex-1 whitespace-pre px-2 text-primary">
{entry.line || " "}
</span>
</div>
))}
{oldLines.length === 0 && newLines.length === 0 && (
<div className={cn("px-3 py-2 text-xs text-muted-foreground")}>
No line diff content.
</div>
)}
</div>
<ToolFileDiff
background="var(--background)"
className="cline-chat-selectable"
fragment={!isCompleteNewContents}
newText={hunk.new}
oldText={isCompleteNewContents ? undefined : hunk.old}
path={path}
/>
);
}