mirror of
https://github.com/cline/cline.git
synced 2026-09-24 23:20:16 +08:00
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:
co-authored by
Saoud Rizwan
parent
02a1bfd0a8
commit
d03e88e50b
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user