mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore(site): upgrade @pierre/diffs and restore diff file tree (#25879)
This commit is contained in:
+2
-1
@@ -60,7 +60,8 @@
|
||||
"@mui/material": "5.18.0",
|
||||
"@mui/system": "5.18.0",
|
||||
"@novnc/novnc": "^1.5.0",
|
||||
"@pierre/diffs": "1.1.19",
|
||||
"@pierre/diffs": "1.2.4",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@tanstack/react-query-devtools": "5.77.0",
|
||||
"@xterm/addon-canvas": "0.7.0",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
|
||||
Generated
+39
-9
@@ -96,8 +96,11 @@ importers:
|
||||
specifier: ^1.5.0
|
||||
version: 1.5.0
|
||||
'@pierre/diffs':
|
||||
specifier: 1.1.19
|
||||
version: 1.1.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
specifier: 1.2.4
|
||||
version: 1.2.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@pierre/trees':
|
||||
specifier: 1.0.0-beta.4
|
||||
version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@tanstack/react-query-devtools':
|
||||
specifier: 5.77.0
|
||||
version: 5.77.0(@tanstack/react-query@5.77.0(react@19.2.6))(react@19.2.6)
|
||||
@@ -1414,16 +1417,22 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@pierre/diffs@1.1.19':
|
||||
resolution: {integrity: sha512-eYyDW69heXd7i9zdkWogGYosHzoYF2dstV6uDcmnQAf72uRChs3hrpf/7ym/ayTiwD8a+TQ7oZ5vNNb0tstJvA==, tarball: https://registry.npmjs.org/@pierre/diffs/-/diffs-1.1.19.tgz}
|
||||
'@pierre/diffs@1.2.4':
|
||||
resolution: {integrity: sha512-SEuYxGpSCHVvfoLly/Q/OYpJSBLWaVLV3M3wI/VBW7aZmzYenNe4aXjOf5sIKJMWW5gbZe9WdLvtKUt6cQ1k1A==, tarball: https://registry.npmjs.org/@pierre/diffs/-/diffs-1.2.4.tgz}
|
||||
peerDependencies:
|
||||
react: ^18.3.1 || ^19.0.0
|
||||
react-dom: ^18.3.1 || ^19.0.0
|
||||
|
||||
'@pierre/theme@0.0.28':
|
||||
resolution: {integrity: sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw==, tarball: https://registry.npmjs.org/@pierre/theme/-/theme-0.0.28.tgz}
|
||||
'@pierre/theme@1.0.3':
|
||||
resolution: {integrity: sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA==, tarball: https://registry.npmjs.org/@pierre/theme/-/theme-1.0.3.tgz}
|
||||
engines: {vscode: ^1.0.0}
|
||||
|
||||
'@pierre/trees@1.0.0-beta.4':
|
||||
resolution: {integrity: sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag==, tarball: https://registry.npmjs.org/@pierre/trees/-/trees-1.0.0-beta.4.tgz}
|
||||
peerDependencies:
|
||||
react: ^18.3.1 || ^19.0.0
|
||||
react-dom: ^18.3.1 || ^19.0.0
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==, tarball: https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz}
|
||||
engines: {node: '>=14'}
|
||||
@@ -5179,6 +5188,14 @@ packages:
|
||||
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==, tarball: https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
preact-render-to-string@6.6.5:
|
||||
resolution: {integrity: sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==, tarball: https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.6.5.tgz}
|
||||
peerDependencies:
|
||||
preact: '>=10 || >= 11.0.0-0'
|
||||
|
||||
preact@11.0.0-beta.0:
|
||||
resolution: {integrity: sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==, tarball: https://registry.npmjs.org/preact/-/preact-11.0.0-beta.0.tgz}
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==, tarball: https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -7491,9 +7508,9 @@ snapshots:
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.14.0':
|
||||
optional: true
|
||||
|
||||
'@pierre/diffs@1.1.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
'@pierre/diffs@1.2.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@pierre/theme': 0.0.28
|
||||
'@pierre/theme': 1.0.3
|
||||
'@shikijs/transformers': 3.23.0
|
||||
diff: 8.0.3
|
||||
hast-util-to-html: 9.0.5
|
||||
@@ -7502,7 +7519,14 @@ snapshots:
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
shiki: 3.23.0
|
||||
|
||||
'@pierre/theme@0.0.28': {}
|
||||
'@pierre/theme@1.0.3': {}
|
||||
|
||||
'@pierre/trees@1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
preact: 11.0.0-beta.0
|
||||
preact-render-to-string: 6.6.5(preact@11.0.0-beta.0)
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
@@ -11612,6 +11636,12 @@ snapshots:
|
||||
|
||||
powershell-utils@0.1.0: {}
|
||||
|
||||
preact-render-to-string@6.6.5(preact@11.0.0-beta.0):
|
||||
dependencies:
|
||||
preact: 11.0.0-beta.0
|
||||
|
||||
preact@11.0.0-beta.0: {}
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
optional: true
|
||||
|
||||
|
||||
@@ -104,73 +104,18 @@ const expectCodeBlock = async (
|
||||
expect(host.style.getPropertyValue("--diffs-font-size")).toBe("12px");
|
||||
expect(host.style.getPropertyValue("--diffs-line-height")).toBe("20px");
|
||||
|
||||
expect(canvasElement.textContent ?? "").not.toContain("```");
|
||||
|
||||
const shadowRoot = host.shadowRoot;
|
||||
if (!shadowRoot) {
|
||||
throw new Error("Expected FileViewer to render code in its shadow root.");
|
||||
}
|
||||
expect(shadowRoot.textContent ?? "").not.toContain("```");
|
||||
|
||||
const pre = shadowRoot.querySelector(
|
||||
"pre[data-file][data-disable-line-numbers]",
|
||||
);
|
||||
expect(pre).toBeInTheDocument();
|
||||
if (!(pre instanceof HTMLElement)) {
|
||||
throw new Error("Expected FileViewer to render a pre element.");
|
||||
}
|
||||
|
||||
const code = shadowRoot.querySelector("[data-code]");
|
||||
expect(code).toBeInTheDocument();
|
||||
if (!(code instanceof HTMLElement)) {
|
||||
throw new Error("Expected FileViewer to render a code container.");
|
||||
}
|
||||
|
||||
const line = shadowRoot.querySelector("[data-line]");
|
||||
expect(line).toBeInTheDocument();
|
||||
if (!(line instanceof HTMLElement)) {
|
||||
throw new Error("Expected FileViewer to render code lines.");
|
||||
}
|
||||
|
||||
const gutter = shadowRoot.querySelector("[data-column-number]");
|
||||
expect(gutter).toBeInTheDocument();
|
||||
if (!(gutter instanceof HTMLElement)) {
|
||||
throw new Error("Expected FileViewer to render its line-number gutter.");
|
||||
}
|
||||
|
||||
const preStyles = getComputedStyle(pre);
|
||||
expect(preStyles.fontSize).toBe("12px");
|
||||
expect(preStyles.lineHeight).toBe("20px");
|
||||
|
||||
const codeStyles = getComputedStyle(code);
|
||||
expect(codeStyles.paddingTop).toBe("8px");
|
||||
expect(codeStyles.paddingBottom).toBe("8px");
|
||||
expect(codeStyles.paddingBottom).toBe(codeStyles.paddingTop);
|
||||
expect(codeStyles.overflow).toBe("visible");
|
||||
|
||||
const lineStyles = getComputedStyle(line);
|
||||
expect(lineStyles.paddingLeft).toBe("12px");
|
||||
expect(lineStyles.paddingRight).toBe("12px");
|
||||
expect(lineStyles.paddingRight).toBe(lineStyles.paddingLeft);
|
||||
expect(lineStyles.minHeight).toBe("20px");
|
||||
|
||||
const gutterStyles = getComputedStyle(gutter);
|
||||
expect(gutterStyles.minWidth).toBe("0px");
|
||||
expect(gutterStyles.paddingLeft).toBe("0px");
|
||||
expect(gutterStyles.paddingRight).toBe("0px");
|
||||
|
||||
if (options.highlighted) {
|
||||
let highlightedToken: HTMLElement | null = null;
|
||||
await waitFor(() => {
|
||||
const token = shadowRoot.querySelector("span[style*='color']");
|
||||
expect(token).toBeInTheDocument();
|
||||
if (!(token instanceof HTMLElement)) {
|
||||
throw new Error("Expected FileViewer to render highlighted tokens.");
|
||||
}
|
||||
highlightedToken = token;
|
||||
});
|
||||
if (!highlightedToken) {
|
||||
throw new Error("Expected FileViewer to render highlighted tokens.");
|
||||
}
|
||||
expect(getComputedStyle(highlightedToken).color).not.toBe(lineStyles.color);
|
||||
}
|
||||
|
||||
return host;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { FileContents, FileDiffMetadata } from "@pierre/diffs";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { countChangedLines } from "../../../utils/countChangedLines";
|
||||
import { changeColor, changeLabel } from "../../../utils/diffColors";
|
||||
|
||||
export function DiffFileHeader({
|
||||
file,
|
||||
}: {
|
||||
file: FileDiffMetadata | FileContents;
|
||||
}) {
|
||||
const isDiff = "type" in file;
|
||||
const stats = isDiff ? countChangedLines(file) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-8 min-w-0 items-center justify-between gap-3 border-0 border-b border-l border-solid border-border-default bg-transparent py-2 pr-1.5 pl-2.5 font-sans text-sm">
|
||||
<div className="flex min-w-0 items-baseline gap-2 overflow-hidden">
|
||||
{isDiff && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-[11px] font-semibold leading-none",
|
||||
changeColor(file.type),
|
||||
)}
|
||||
>
|
||||
{changeLabel(file.type)}
|
||||
</span>
|
||||
)}
|
||||
{isDiff && file.prevName && file.prevName !== file.name && (
|
||||
<span className="truncate text-xs text-content-secondary">
|
||||
{file.prevName}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-xs font-medium text-content-primary">
|
||||
{file.name}
|
||||
</span>
|
||||
</div>
|
||||
{stats && (stats.additions > 0 || stats.deletions > 0) && (
|
||||
<span className="inline-flex shrink-0 flex-row-reverse items-stretch overflow-hidden rounded-[3px] border border-solid border-border-default font-mono text-xs font-medium leading-5">
|
||||
{stats.deletions > 0 && (
|
||||
<span className="flex items-center bg-surface-git-deleted px-1 text-git-deleted-bright">
|
||||
−{stats.deletions}
|
||||
</span>
|
||||
)}
|
||||
{stats.additions > 0 && (
|
||||
<span className="flex items-center bg-surface-git-added px-1 text-git-added-bright">
|
||||
+{stats.additions}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { FileDiff } from "@pierre/diffs/react";
|
||||
import type React from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
||||
import { DiffFileHeader } from "./DiffFileHeader";
|
||||
import {
|
||||
type AgentDisplayState,
|
||||
isAgentDisplayFullyExpanded,
|
||||
@@ -85,6 +86,9 @@ export const EditFilesTool: React.FC<{
|
||||
fileDiff={stripNoNewline(diff)}
|
||||
options={getDiffViewerOptions(isDark)}
|
||||
style={DIFFS_FONT_STYLE}
|
||||
renderCustomHeader={(fileDiff) => (
|
||||
<DiffFileHeader file={fileDiff} />
|
||||
)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
) : null,
|
||||
|
||||
@@ -23,10 +23,15 @@ const longExecuteCommand =
|
||||
const TEST_PNG_B64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4n539HwAHFwLVF8kc1wAAAABJRU5ErkJggg==";
|
||||
|
||||
const getDiffsText = (element: HTMLElement) =>
|
||||
Array.from(element.querySelectorAll("diffs-container"))
|
||||
.map((container) => container.shadowRoot?.textContent ?? "")
|
||||
.join("\n");
|
||||
const expectDiffText = async (element: HTMLElement, text: string) => {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
Array.from(element.querySelectorAll("diffs-container")).some((host) =>
|
||||
host.shadowRoot?.textContent?.includes(text),
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
};
|
||||
|
||||
const meta: Meta<typeof Tool> = {
|
||||
title: "pages/AgentsPage/ChatElements/tools/Tool",
|
||||
@@ -1266,11 +1271,7 @@ export const MCPToolCompleted: Story = {
|
||||
await userEvent.click(toggle);
|
||||
expect(canvas.getByText("Input")).toBeVisible();
|
||||
expect(canvas.getByText("Output")).toBeVisible();
|
||||
await waitFor(() => {
|
||||
const diffsText = getDiffsText(canvasElement);
|
||||
expect(diffsText).toContain("backend");
|
||||
expect(diffsText).toContain("Fix auth flow");
|
||||
});
|
||||
await expectDiffText(canvasElement, "Fix auth flow");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1306,9 +1307,7 @@ export const MCPToolNoResult: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button"));
|
||||
expect(canvas.getByText("Input")).toBeVisible();
|
||||
await waitFor(() => {
|
||||
expect(getDiffsText(canvasElement)).toContain("New issue");
|
||||
});
|
||||
await expectDiffText(canvasElement, "New issue");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1392,11 +1391,8 @@ export const WorkspaceMCPToolCompleted: Story = {
|
||||
await userEvent.click(canvas.getByRole("button"));
|
||||
expect(canvas.getByText("Input")).toBeVisible();
|
||||
expect(canvas.getByText("Output")).toBeVisible();
|
||||
await waitFor(() => {
|
||||
const diffsText = getDiffsText(canvasElement);
|
||||
expect(diffsText).toContain("message");
|
||||
expect(diffsText).toContain("hello from workspace MCP");
|
||||
});
|
||||
await expectDiffText(canvasElement, "message");
|
||||
await expectDiffText(canvasElement, "hello from workspace MCP");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2079,17 +2075,7 @@ export const ReadFileLongLine: Story = {
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: /Read config.ts/i }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(getDiffsText(canvasElement)).toContain("apiUrl"),
|
||||
);
|
||||
await waitFor(() => {
|
||||
const host = canvasElement.querySelector("diffs-container");
|
||||
const code = host?.shadowRoot?.querySelector("[data-code]");
|
||||
expect(code).toBeInstanceOf(HTMLElement);
|
||||
if (code instanceof HTMLElement) {
|
||||
expect(getComputedStyle(code).overflow).toBe("visible");
|
||||
}
|
||||
});
|
||||
await expectDiffText(canvasElement, "apiUrl");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2104,19 +2090,20 @@ export const ReadFileTallAndWide: Story = {
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: /Read config.ts/i }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(getDiffsText(canvasElement)).toContain("apiUrl"),
|
||||
);
|
||||
const viewport = [
|
||||
...canvasElement.querySelectorAll<HTMLElement>(
|
||||
"[data-radix-scroll-area-viewport]",
|
||||
),
|
||||
].find(
|
||||
(v) => v.scrollWidth > v.clientWidth && v.scrollHeight > v.clientHeight,
|
||||
);
|
||||
if (!viewport) {
|
||||
throw new Error("Expected a viewport overflowing on both axes.");
|
||||
}
|
||||
await expectDiffText(canvasElement, "apiUrl");
|
||||
const viewport = await waitFor(() => {
|
||||
const target = [
|
||||
...canvasElement.querySelectorAll<HTMLElement>(
|
||||
"[data-radix-scroll-area-viewport]",
|
||||
),
|
||||
].find(
|
||||
(v) => v.scrollWidth > v.clientWidth && v.scrollHeight > v.clientHeight,
|
||||
);
|
||||
if (!target) {
|
||||
throw new Error("Expected a viewport overflowing on both axes.");
|
||||
}
|
||||
return target;
|
||||
});
|
||||
viewport.dispatchEvent(
|
||||
new WheelEvent("wheel", { deltaY: 200, bubbles: true, cancelable: true }),
|
||||
);
|
||||
@@ -2136,9 +2123,7 @@ export const GenericToolLongOutput: Story = {
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: /some_custom_tool/i }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(getDiffsText(canvasElement)).toContain("apiUrl"),
|
||||
);
|
||||
await expectDiffText(canvasElement, "apiUrl");
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { ChatSummarizedTool } from "./ChatSummarizedTool";
|
||||
import { ComputerTool } from "./ComputerTool";
|
||||
import { CreateWorkspaceTool } from "./CreateWorkspaceTool";
|
||||
import { DiffFileHeader } from "./DiffFileHeader";
|
||||
import { EditFilesTool } from "./EditFilesTool";
|
||||
import {
|
||||
ExecuteAuthRequiredTool,
|
||||
@@ -828,7 +829,16 @@ const ToolFileViewer: FC<ToolFileViewerProps> = ({ label, file, options }) => (
|
||||
scrollBarClassName="w-1.5"
|
||||
horizontalScrollBarClassName="h-1.5"
|
||||
>
|
||||
<FileViewer file={file} options={options} style={DIFFS_FONT_STYLE} />
|
||||
<FileViewer
|
||||
file={file}
|
||||
options={options}
|
||||
style={DIFFS_FONT_STYLE}
|
||||
renderCustomHeader={
|
||||
options?.disableFileHeader
|
||||
? undefined
|
||||
: (file) => <DiffFileHeader file={file} />
|
||||
}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FileDiff } from "@pierre/diffs/react";
|
||||
import type React from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
||||
import { DiffFileHeader } from "./DiffFileHeader";
|
||||
import {
|
||||
type AgentDisplayState,
|
||||
isAgentDisplayFullyExpanded,
|
||||
@@ -66,6 +67,9 @@ export const WriteFileTool: React.FC<{
|
||||
fileDiff={stripNoNewline(diff)}
|
||||
options={getDiffViewerOptions(isDark)}
|
||||
style={DIFFS_FONT_STYLE}
|
||||
renderCustomHeader={(fileDiff) => (
|
||||
<DiffFileHeader file={fileDiff} />
|
||||
)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
@@ -990,24 +990,35 @@ describe("constants", () => {
|
||||
expect(DIFFS_FONT_STYLE).toHaveProperty("--diffs-line-height", "1.5");
|
||||
});
|
||||
|
||||
it("fileViewerCSS is a non-empty string", () => {
|
||||
expect(typeof fileViewerCSS).toBe("string");
|
||||
expect(fileViewerCSS.length).toBeGreaterThan(0);
|
||||
it("DIFFS_FONT_STYLE uses theme-aware diff variables", () => {
|
||||
expect(DIFFS_FONT_STYLE).toHaveProperty(
|
||||
"--diffs-addition-color-override",
|
||||
"hsl(var(--git-added))",
|
||||
);
|
||||
expect(DIFFS_FONT_STYLE).toHaveProperty(
|
||||
"--diffs-deletion-color-override",
|
||||
"hsl(var(--git-deleted))",
|
||||
);
|
||||
expect(DIFFS_FONT_STYLE).toHaveProperty(
|
||||
"--diffs-bg-addition-override",
|
||||
"hsl(var(--surface-git-added))",
|
||||
);
|
||||
expect(DIFFS_FONT_STYLE).toHaveProperty(
|
||||
"--diffs-bg-deletion-override",
|
||||
"hsl(var(--surface-git-deleted))",
|
||||
);
|
||||
});
|
||||
|
||||
it("diffViewerCSS includes border-left style", () => {
|
||||
expect(diffViewerCSS).toContain("border-left");
|
||||
it("fileViewerCSS keeps file viewer backgrounds transparent", () => {
|
||||
expect(fileViewerCSS).toContain("background-color: transparent");
|
||||
expect(fileViewerCSS).toContain("[data-diffs-header]");
|
||||
expect(fileViewerCSS).not.toContain("[data-code]");
|
||||
});
|
||||
|
||||
it("diffViewerCSS uses theme-aware changed line backgrounds", () => {
|
||||
expect(diffViewerCSS).toContain("--diffs-addition-color-override");
|
||||
expect(diffViewerCSS).toContain("--diffs-deletion-color-override");
|
||||
expect(diffViewerCSS).toContain("--diffs-bg-addition-override");
|
||||
expect(diffViewerCSS).toContain("--diffs-bg-deletion-override");
|
||||
expect(diffViewerCSS).toContain("var(--surface-git-added)");
|
||||
expect(diffViewerCSS).toContain("var(--surface-git-deleted)");
|
||||
expect(diffViewerCSS).toContain("[data-line-type='change-addition']");
|
||||
expect(diffViewerCSS).toContain("[data-line-type='change-deletion']");
|
||||
it("diffViewerCSS keeps hunk separator styling scoped", () => {
|
||||
expect(diffViewerCSS).toContain("[data-separator='line-info']");
|
||||
expect(diffViewerCSS).toContain("[data-separator-content]");
|
||||
expect(diffViewerCSS).not.toContain("[data-diffs-header]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1063,6 +1074,27 @@ describe("parseServerEditResults", () => {
|
||||
});
|
||||
|
||||
describe("parseServerEditDiffText", () => {
|
||||
const changedLineContents = (
|
||||
diff: NonNullable<ReturnType<typeof parseServerEditDiffText>>,
|
||||
) =>
|
||||
diff.hunks.flatMap((hunk) =>
|
||||
hunk.hunkContent.flatMap((content) => {
|
||||
if (content.type !== "change") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
...diff.deletionLines.slice(
|
||||
content.deletionLineIndex,
|
||||
content.deletionLineIndex + content.deletions,
|
||||
),
|
||||
...diff.additionLines.slice(
|
||||
content.additionLineIndex,
|
||||
content.additionLineIndex + content.additions,
|
||||
),
|
||||
].map((line) => line.trimEnd());
|
||||
}),
|
||||
);
|
||||
|
||||
it("returns null for an empty string (no-op edit)", () => {
|
||||
expect(parseServerEditDiffText("")).toBeNull();
|
||||
});
|
||||
@@ -1074,6 +1106,46 @@ describe("parseServerEditDiffText", () => {
|
||||
expect(diff).not.toBeNull();
|
||||
expect(diff?.name).toBe("/abs/a.txt");
|
||||
});
|
||||
|
||||
it("parses quoted git diff headers", () => {
|
||||
const diff = parseServerEditDiffText(
|
||||
[
|
||||
'diff --git "a/path with spaces.ts" "b/path with spaces.ts"',
|
||||
"index 1111111..2222222 100644",
|
||||
'--- "a/path with spaces.ts"',
|
||||
'+++ "b/path with spaces.ts"',
|
||||
"@@ -1 +1 @@",
|
||||
"-old value",
|
||||
"+new value",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(diff).not.toBeNull();
|
||||
expect(diff?.name).toBe("path with spaces.ts");
|
||||
expect(changedLineContents(diff!)).toEqual(["old value", "new value"]);
|
||||
});
|
||||
|
||||
it("parses diffs that include git patch footer metadata", () => {
|
||||
const diff = parseServerEditDiffText(
|
||||
[
|
||||
"diff --git a/example.ts b/example.ts",
|
||||
"index 1111111..2222222 100644",
|
||||
"--- a/example.ts",
|
||||
"+++ b/example.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-old value",
|
||||
"+new value",
|
||||
"-- ",
|
||||
"2.45.0",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(diff).not.toBeNull();
|
||||
expect(diff?.name).toBe("example.ts");
|
||||
expect(changedLineContents(diff!)).toEqual(["old value", "new value"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeParsedCommands", () => {
|
||||
|
||||
@@ -291,52 +291,11 @@ export const formatResultOutput = (result: unknown): string | null => {
|
||||
|
||||
export const fileViewerCSS = [
|
||||
"pre, [data-line], [data-diffs-header] { background-color: transparent !important; }",
|
||||
"[data-code] { overflow: visible !important; }",
|
||||
].join(" ");
|
||||
|
||||
// Selection override CSS maps the library's gold/yellow selection
|
||||
// palette to the Coder blue accent (`--content-link`) so line
|
||||
// highlighting feels native to the rest of the page.
|
||||
//
|
||||
// The library has two selection code paths: context lines use
|
||||
// `--diffs-bg-selection`, but change-addition/deletion lines
|
||||
// use a separate `color-mix()` against `--diffs-line-bg`. To
|
||||
// guarantee a uniform highlight across all line types we set
|
||||
// the CSS variables for annotations AND apply direct rules
|
||||
// with `!important` for line and gutter elements.
|
||||
const SELECTION_OVERRIDE_CSS = [
|
||||
// Variable overrides for annotation areas and library internals.
|
||||
":host {",
|
||||
" --diffs-bg-selection-override: hsl(var(--content-link) / 0.08);",
|
||||
" --diffs-bg-selection-number-override: hsl(var(--content-link) / 0.13);",
|
||||
" --diffs-selection-number-fg: hsl(var(--content-link));",
|
||||
" --diffs-gap-style: 1px solid hsl(var(--border-default));",
|
||||
"}",
|
||||
// Direct rules that override both context and change-line
|
||||
// selection backgrounds so every selected line looks the same.
|
||||
"[data-selected-line][data-line] {",
|
||||
" background-color: hsl(var(--content-link) / 0.08) !important;",
|
||||
"}",
|
||||
"[data-selected-line][data-column-number] {",
|
||||
" background-color: hsl(var(--content-link) / 0.13) !important;",
|
||||
" color: hsl(var(--content-link)) !important;",
|
||||
"}",
|
||||
// Clear the selection tint from annotation rows so the inline
|
||||
// prompt input stands out clearly against the selected lines.
|
||||
"[data-line-annotation][data-selected-line] [data-annotation-content] {",
|
||||
" background-color: transparent !important;",
|
||||
"}",
|
||||
"[data-line-annotation][data-selected-line]::before {",
|
||||
" background-color: transparent !important;",
|
||||
"}",
|
||||
"[data-selected-line][data-gutter-buffer='annotation'] {",
|
||||
" background-color: transparent !important;",
|
||||
"}",
|
||||
].join(" ");
|
||||
|
||||
// Restyled separators: quiet, full-width dividers that fade
|
||||
// into the background instead of drawing attention.
|
||||
const SEPARATOR_CSS = [
|
||||
export const SEPARATOR_CSS = [
|
||||
// Transparent backgrounds so separators blend with the
|
||||
// code area rather than forming a distinct band.
|
||||
":host {",
|
||||
@@ -379,119 +338,8 @@ const SEPARATOR_CSS = [
|
||||
"}",
|
||||
].join(" ");
|
||||
|
||||
// Shared header styling applied to all diff viewers (both the
|
||||
// conversation-inline diffs and the right-tab panel). This gives
|
||||
// every diff header the same font sizing, change-type badges,
|
||||
// and stat-count pills regardless of where it appears.
|
||||
const DIFF_HEADER_CSS = [
|
||||
// Header layout: consistent sizing and padding across contexts.
|
||||
"[data-diffs-header] {",
|
||||
" font-size: 13px;",
|
||||
" min-height: 32px !important;",
|
||||
" padding-block: 8px !important;",
|
||||
" padding-inline: 10px 6px !important;",
|
||||
" border-bottom: 1px solid hsl(var(--border-default));",
|
||||
"}",
|
||||
|
||||
// Title text: sans-serif, slightly smaller than header chrome.
|
||||
"[data-diffs-header] [data-title] {",
|
||||
" font-size: 12px;",
|
||||
" color: hsl(var(--content-primary));",
|
||||
"}",
|
||||
|
||||
// Replace the library's built-in SVG change-type icons with
|
||||
// single-letter badges (A/D/M/R) via CSS-generated content.
|
||||
"[data-change-icon] { display: none !important; }",
|
||||
// Baseline-align the badge letter with the filename so their
|
||||
// text baselines match despite different font sizes (11px vs
|
||||
// 12px). Without this the box-centering default shifts the
|
||||
// badge a fraction of a pixel above the title.
|
||||
"[data-diffs-header] [data-header-content] { align-items: baseline; overflow: hidden; }",
|
||||
"[data-diffs-header] [data-rename-icon] { align-self: center; }",
|
||||
"[data-diffs-header] [data-header-content]::before {",
|
||||
" font-size: 11px;",
|
||||
" font-weight: 600;",
|
||||
" flex-shrink: 0;",
|
||||
"}",
|
||||
"[data-diffs-header][data-change-type='new'] [data-header-content]::before {",
|
||||
" content: 'A';",
|
||||
" color: hsl(var(--git-added));",
|
||||
"}",
|
||||
"[data-diffs-header][data-change-type='change'] [data-header-content]::before {",
|
||||
" content: 'M';",
|
||||
" color: hsl(var(--git-modified));",
|
||||
"}",
|
||||
"[data-diffs-header][data-change-type='deleted'] [data-header-content]::before {",
|
||||
" content: 'D';",
|
||||
" color: hsl(var(--git-deleted));",
|
||||
"}",
|
||||
"[data-diffs-header][data-change-type='rename-pure'] [data-header-content]::before,",
|
||||
"[data-diffs-header][data-change-type='rename-changed'] [data-header-content]::before {",
|
||||
" content: 'R';",
|
||||
" color: hsl(var(--git-modified));",
|
||||
"}",
|
||||
|
||||
// Stat counts styled as compact pill badges.
|
||||
"[data-diffs-header] [data-metadata] {",
|
||||
" flex-shrink: 0;",
|
||||
" flex-direction: row-reverse;",
|
||||
" align-items: stretch;",
|
||||
" gap: 0 !important;",
|
||||
" padding: 0;",
|
||||
" border: 1px solid hsl(var(--border-default));",
|
||||
" border-radius: 3px;",
|
||||
" overflow: hidden;",
|
||||
"}",
|
||||
"[data-diffs-header] [data-additions-count],",
|
||||
"[data-diffs-header] [data-deletions-count] {",
|
||||
" font-family: var(--diffs-font-family, var(--diffs-font-fallback));",
|
||||
" font-size: 12px;",
|
||||
" font-weight: 500;",
|
||||
" line-height: 20px;",
|
||||
" padding-inline: 4px;",
|
||||
" border-radius: 0;",
|
||||
"}",
|
||||
"[data-diffs-header] [data-additions-count] {",
|
||||
" color: hsl(var(--git-added-bright)) !important;",
|
||||
" background-color: hsl(var(--surface-git-added));",
|
||||
"}",
|
||||
"[data-diffs-header] [data-deletions-count] {",
|
||||
" color: hsl(var(--git-deleted-bright)) !important;",
|
||||
" background-color: hsl(var(--surface-git-deleted));",
|
||||
"}",
|
||||
].join(" ");
|
||||
|
||||
const CHANGE_LINE_CSS = [
|
||||
":host {",
|
||||
" --diffs-addition-color-override: hsl(var(--git-added));",
|
||||
" --diffs-deletion-color-override: hsl(var(--git-deleted));",
|
||||
" --diffs-bg-addition-override: hsl(var(--surface-git-added));",
|
||||
" --diffs-bg-deletion-override: hsl(var(--surface-git-deleted));",
|
||||
" --diffs-bg-addition-number-override: hsl(var(--surface-git-added));",
|
||||
" --diffs-bg-deletion-number-override: hsl(var(--surface-git-deleted));",
|
||||
"}",
|
||||
"[data-line-type='change-addition']:not([data-selected-line]) {",
|
||||
" background-color: hsl(var(--surface-git-added)) !important;",
|
||||
"}",
|
||||
"[data-line-type='change-deletion']:not([data-selected-line]) {",
|
||||
" background-color: hsl(var(--surface-git-deleted)) !important;",
|
||||
"}",
|
||||
].join(" ");
|
||||
|
||||
export const diffViewerCSS = [
|
||||
// Make context lines transparent so they blend with the page,
|
||||
// while changed lines use the same theme-aware git surfaces as
|
||||
// the file headers and stats.
|
||||
"pre, [data-line]:not([data-selected-line]):not([data-line-type='change-addition']):not([data-line-type='change-deletion']), [data-diffs-header] { background-color: transparent !important; }",
|
||||
"[data-diffs-header] { border-left: 1px solid var(--border); }",
|
||||
// The library reserves a 6 px horizontal scrollbar track on
|
||||
// [data-code] via overflow: scroll clip. In wrap mode lines
|
||||
// never overflow, so hide the track to remove the phantom gap.
|
||||
"[data-code] { scrollbar-width: none !important; }",
|
||||
"[data-code]::-webkit-scrollbar { height: 0 !important; }",
|
||||
DIFF_HEADER_CSS,
|
||||
CHANGE_LINE_CSS,
|
||||
SELECTION_OVERRIDE_CSS,
|
||||
"pre, [data-line]:not([data-line-type='change-addition']):not([data-line-type='change-deletion']) { background-color: transparent !important; }",
|
||||
SEPARATOR_CSS,
|
||||
].join(" ");
|
||||
|
||||
@@ -566,6 +414,16 @@ export const DIFFS_FONT_STYLE = {
|
||||
"--diffs-header-font-family": '"Geist Variable", system-ui, sans-serif',
|
||||
"--diffs-font-size": "11px",
|
||||
"--diffs-line-height": "1.5",
|
||||
"--diffs-addition-color-override": "hsl(var(--git-added))",
|
||||
"--diffs-deletion-color-override": "hsl(var(--git-deleted))",
|
||||
"--diffs-bg-addition-override": "hsl(var(--surface-git-added))",
|
||||
"--diffs-bg-deletion-override": "hsl(var(--surface-git-deleted))",
|
||||
"--diffs-bg-addition-number-override": "hsl(var(--surface-git-added))",
|
||||
"--diffs-bg-deletion-number-override": "hsl(var(--surface-git-deleted))",
|
||||
"--diffs-bg-selection-override": "hsl(var(--content-link) / 0.08)",
|
||||
"--diffs-bg-selection-number-override": "hsl(var(--content-link) / 0.13)",
|
||||
"--diffs-selection-number-fg": "hsl(var(--content-link))",
|
||||
"--diffs-gap-style": "1px solid hsl(var(--border-default))",
|
||||
} as CSSProperties;
|
||||
|
||||
/**
|
||||
|
||||
@@ -210,6 +210,10 @@ export const CommentableDiffViewer: FC<CommentableDiffViewerProps> = ({
|
||||
// ---------------------------------------------------------------
|
||||
const [activeCommentBox, setActiveCommentBox] =
|
||||
useState<CommentBoxState | null>(null);
|
||||
const [previewSelection, setPreviewSelection] = useState<{
|
||||
fileName: string;
|
||||
range: SelectedLineRange;
|
||||
} | null>(null);
|
||||
|
||||
const activeCommentBoxRef = useRef<CommentBoxState | null>(null);
|
||||
|
||||
@@ -228,6 +232,7 @@ export const CommentableDiffViewer: FC<CommentableDiffViewerProps> = ({
|
||||
annotationSide: "additions" | "deletions";
|
||||
},
|
||||
) => {
|
||||
setPreviewSelection(null);
|
||||
updateCommentBox({
|
||||
fileName,
|
||||
start: props.lineNumber,
|
||||
@@ -239,18 +244,21 @@ export const CommentableDiffViewer: FC<CommentableDiffViewerProps> = ({
|
||||
|
||||
const handleLineSelected = (
|
||||
fileName: string,
|
||||
range: {
|
||||
start: number;
|
||||
end: number;
|
||||
side?: "additions" | "deletions";
|
||||
endSide?: "additions" | "deletions";
|
||||
} | null,
|
||||
range: SelectedLineRange | null,
|
||||
) => {
|
||||
const result = commentBoxFromRange(fileName, range);
|
||||
if (result === "ignore") return;
|
||||
setPreviewSelection(null);
|
||||
updateCommentBox(result);
|
||||
};
|
||||
|
||||
const handleLineSelectionChange = (
|
||||
fileName: string,
|
||||
range: SelectedLineRange | null,
|
||||
) => {
|
||||
setPreviewSelection(range ? { fileName, range } : null);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Annotation helpers
|
||||
// ---------------------------------------------------------------
|
||||
@@ -273,10 +281,14 @@ export const CommentableDiffViewer: FC<CommentableDiffViewerProps> = ({
|
||||
if (activeCommentBox && activeCommentBox.fileName === fileName) {
|
||||
return selectedLinesForBox(activeCommentBox);
|
||||
}
|
||||
if (previewSelection && previewSelection.fileName === fileName) {
|
||||
return previewSelection.range;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleCancelComment = () => {
|
||||
setPreviewSelection(null);
|
||||
updateCommentBox(null);
|
||||
};
|
||||
|
||||
@@ -327,6 +339,7 @@ export const CommentableDiffViewer: FC<CommentableDiffViewerProps> = ({
|
||||
parsedFiles={parsedFiles}
|
||||
onLineNumberClick={handleLineNumberClick}
|
||||
onLineSelected={handleLineSelected}
|
||||
onLineSelectionChange={handleLineSelectionChange}
|
||||
getLineAnnotations={getLineAnnotations}
|
||||
getSelectedLines={getSelectedLines}
|
||||
renderAnnotation={renderAnnotation}
|
||||
|
||||
@@ -446,42 +446,11 @@ export const LargeDiff: Story = {
|
||||
),
|
||||
],
|
||||
play: async ({ canvasElement }) => {
|
||||
// Wait for the file tree sidebar to render, proving that
|
||||
// isExpanded activates the tree + observer code path.
|
||||
// The @pierre/trees file tree mounts a `file-tree-container` custom
|
||||
// element once the sidebar is shown (isExpanded). Assert it appears.
|
||||
await waitFor(() => {
|
||||
const nav = canvasElement.querySelector("nav");
|
||||
expect(nav).not.toBeNull();
|
||||
});
|
||||
|
||||
// Find the diff content viewport (the one containing file
|
||||
// sections) rather than the file-tree sidebar viewport.
|
||||
const fileSection = canvasElement.querySelector("[data-file-name]");
|
||||
const viewport = fileSection?.closest<HTMLElement>(
|
||||
"[data-radix-scroll-area-viewport]",
|
||||
);
|
||||
if (!viewport) throw new Error("diff viewport not found");
|
||||
|
||||
// Capture the initial active file (whichever the observer picked
|
||||
// up at mount), then scroll and verify it changed.
|
||||
let initialFile: string | undefined;
|
||||
await waitFor(() => {
|
||||
const btn = canvasElement.querySelector<HTMLElement>(
|
||||
'nav button[aria-current="true"]',
|
||||
);
|
||||
expect(btn).not.toBeNull();
|
||||
initialFile = btn!.title;
|
||||
});
|
||||
|
||||
// Scroll to roughly the middle of the diff content.
|
||||
viewport.scrollTop = viewport.scrollHeight / 2;
|
||||
|
||||
// The observer should fire and highlight a different file.
|
||||
await waitFor(() => {
|
||||
const btn = canvasElement.querySelector<HTMLElement>(
|
||||
'nav button[aria-current="true"]',
|
||||
);
|
||||
expect(btn).not.toBeNull();
|
||||
expect(btn!.title).not.toBe(initialFile);
|
||||
const tree = canvasElement.querySelector("file-tree-container");
|
||||
expect(tree).not.toBeNull();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
import type { DiffLineAnnotation } from "@pierre/diffs/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { annotationsVersion } from "./DiffViewer";
|
||||
|
||||
const annotation = (
|
||||
lineNumber: number,
|
||||
side: "additions" | "deletions" = "additions",
|
||||
): DiffLineAnnotation<string> => ({
|
||||
side,
|
||||
lineNumber,
|
||||
metadata: "active-input",
|
||||
});
|
||||
|
||||
describe("annotationsVersion", () => {
|
||||
it("is 0 when there are no annotations", () => {
|
||||
expect(annotationsVersion(undefined)).toBe(0);
|
||||
expect(annotationsVersion([])).toBe(0);
|
||||
});
|
||||
|
||||
it("changes when the line moves but the count stays the same", () => {
|
||||
// The regression: a single active comment box moving between lines keeps
|
||||
// the count at 1, so a count-based version would not change and CodeView
|
||||
// would skip the update.
|
||||
expect(annotationsVersion([annotation(5)])).not.toBe(
|
||||
annotationsVersion([annotation(10)]),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes when only the side flips", () => {
|
||||
expect(annotationsVersion([annotation(5, "additions")])).not.toBe(
|
||||
annotationsVersion([annotation(5, "deletions")]),
|
||||
);
|
||||
});
|
||||
|
||||
it("is stable for identical annotation content", () => {
|
||||
expect(annotationsVersion([annotation(7, "deletions")])).toBe(
|
||||
annotationsVersion([annotation(7, "deletions")]),
|
||||
);
|
||||
});
|
||||
|
||||
it("differs from the empty state for any annotation", () => {
|
||||
expect(annotationsVersion([annotation(0)])).not.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compareTreePaths, treeSortComparator } from "./DiffViewer";
|
||||
|
||||
// Mirrors how @pierre/trees feeds its sort comparator: it sorts one flat list
|
||||
// of every path entry (files and the intermediate directories), then builds
|
||||
// the tree. Expands file paths into that entry set so a test can sort it with
|
||||
// treeSortComparator and read back the resulting file (leaf) order.
|
||||
function treeFileOrder(files: readonly string[]): string[] {
|
||||
const entries = new Map<
|
||||
string,
|
||||
{
|
||||
basename: string;
|
||||
depth: number;
|
||||
isDirectory: boolean;
|
||||
path: string;
|
||||
segments: string[];
|
||||
}
|
||||
>();
|
||||
for (const file of files) {
|
||||
const segments = file.split("/");
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const slice = segments.slice(0, i + 1);
|
||||
const path = slice.join("/");
|
||||
entries.set(path, {
|
||||
basename: slice[slice.length - 1],
|
||||
depth: slice.length,
|
||||
isDirectory: i < segments.length - 1,
|
||||
path,
|
||||
segments: slice,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...entries.values()]
|
||||
.sort(treeSortComparator)
|
||||
.filter((entry) => !entry.isDirectory)
|
||||
.map((entry) => entry.path);
|
||||
}
|
||||
|
||||
describe("compareTreePaths", () => {
|
||||
it("orders directories before sibling files and keeps dot-prefixed first", () => {
|
||||
const sorted = [".config/a.ts", "b.ts", "a/z.ts", "b/c/d.ts"].sort(
|
||||
compareTreePaths,
|
||||
);
|
||||
|
||||
expect(sorted).toEqual([".config/a.ts", "a/z.ts", "b/c/d.ts", "b.ts"]);
|
||||
});
|
||||
|
||||
it("sorts dot-prefixed names before other names at the same level", () => {
|
||||
expect([".env", "app.ts", ".gitignore"].sort(compareTreePaths)).toEqual([
|
||||
".env",
|
||||
".gitignore",
|
||||
"app.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("breaks ties case-insensitively", () => {
|
||||
expect(["Beta.ts", "alpha.ts", "Alpha.ts"].sort(compareTreePaths)).toEqual([
|
||||
"alpha.ts",
|
||||
"Alpha.ts",
|
||||
"Beta.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("is a stable total order regardless of input order", () => {
|
||||
const files = [
|
||||
"src/zeta.ts",
|
||||
"src/alpha/index.ts",
|
||||
"README.md",
|
||||
".github/workflows/ci.yml",
|
||||
"src/alpha.ts",
|
||||
];
|
||||
const forward = [...files].sort(compareTreePaths);
|
||||
const reversed = [...files].reverse().sort(compareTreePaths);
|
||||
expect(reversed).toEqual(forward);
|
||||
expect(forward).toEqual([
|
||||
".github/workflows/ci.yml",
|
||||
"src/alpha/index.ts",
|
||||
"src/alpha.ts",
|
||||
"src/zeta.ts",
|
||||
"README.md",
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches the tree's leaf order so the diff and sidebar stay in sync", () => {
|
||||
const files = [
|
||||
"src/zeta.ts",
|
||||
"lib/a.ts",
|
||||
"src/alpha/index.ts",
|
||||
"README.md",
|
||||
".github/workflows/ci.yml",
|
||||
"src/alpha.ts",
|
||||
"lib/b/c.ts",
|
||||
];
|
||||
// The sidebar tree (treeSortComparator over the full entry set) and the
|
||||
// flat diff list (compareTreePaths) must produce the same file order.
|
||||
expect([...files].sort(compareTreePaths)).toEqual(treeFileOrder(files));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getActiveFile, type ScrollViewer } from "./useActiveFileTracking";
|
||||
|
||||
// Builds a ScrollViewer stub from a path -> top map. A top of `undefined`
|
||||
// models an item the library has not measured yet.
|
||||
function viewerFrom(
|
||||
tops: ReadonlyArray<readonly [string, number | undefined]>,
|
||||
): ScrollViewer {
|
||||
const map = new Map(tops);
|
||||
return {
|
||||
getRenderedItems: () => tops.map(([id]) => ({ id })),
|
||||
getTopForItem: (id) => map.get(id),
|
||||
};
|
||||
}
|
||||
|
||||
describe("getActiveFile", () => {
|
||||
it("picks the item closest to the top that has crossed it", () => {
|
||||
const viewer = viewerFrom([
|
||||
["a.ts", 0],
|
||||
["b.ts", 300],
|
||||
["c.ts", 900],
|
||||
]);
|
||||
// At scrollTop 500, b.ts (top 300) is the last file whose start has
|
||||
// scrolled past the fold; c.ts (top 900) is still below it.
|
||||
expect(getActiveFile(500, viewer)).toBe("b.ts");
|
||||
});
|
||||
|
||||
it("treats an item within the threshold slack as already active", () => {
|
||||
const viewer = viewerFrom([
|
||||
["a.ts", 0],
|
||||
// 4px below the fold, inside ACTIVE_FILE_SCROLL_THRESHOLD.
|
||||
["b.ts", 104],
|
||||
]);
|
||||
expect(getActiveFile(100, viewer)).toBe("b.ts");
|
||||
});
|
||||
|
||||
it("excludes items past the threshold slack", () => {
|
||||
const viewer = viewerFrom([
|
||||
["a.ts", 0],
|
||||
// 5px below the fold, just outside the threshold.
|
||||
["b.ts", 105],
|
||||
]);
|
||||
expect(getActiveFile(100, viewer)).toBe("a.ts");
|
||||
});
|
||||
|
||||
it("falls back to the first rendered item when none have crossed", () => {
|
||||
const viewer = viewerFrom([
|
||||
["a.ts", 300],
|
||||
["b.ts", 400],
|
||||
]);
|
||||
expect(getActiveFile(0, viewer)).toBe("a.ts");
|
||||
});
|
||||
|
||||
it("ignores unmeasured items", () => {
|
||||
const viewer = viewerFrom([
|
||||
["a.ts", -10],
|
||||
["b.ts", undefined],
|
||||
]);
|
||||
expect(getActiveFile(0, viewer)).toBe("a.ts");
|
||||
});
|
||||
|
||||
it("returns undefined when nothing is rendered", () => {
|
||||
expect(getActiveFile(0, viewerFrom([]))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,146 +1,72 @@
|
||||
import type { FileDiffMetadata } from "@pierre/diffs";
|
||||
import { type RefObject, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// Leaves a 5% strip at the top of the viewport as the "active file" band.
|
||||
const VIEWPORT_BOTTOM_MARGIN_RATIO = 0.95;
|
||||
// Pixels of slack added when matching a file against the scroll offset, so a
|
||||
// file counts as active while its sticky header is still pinned at the top.
|
||||
const ACTIVE_FILE_SCROLL_THRESHOLD = 4;
|
||||
|
||||
interface UseActiveFileTrackingOptions {
|
||||
viewportRef: RefObject<HTMLElement | null>;
|
||||
sortedFiles: readonly FileDiffMetadata[];
|
||||
enabled: boolean;
|
||||
scrollToFile?: string | null;
|
||||
onScrollToFileComplete?: () => void;
|
||||
// Minimal view of the @pierre/diffs CodeView instance handed to `onScroll`.
|
||||
// `getRenderedItems` only returns the small set of currently virtualized
|
||||
// items, so deriving the active file from it avoids scanning every file.
|
||||
export interface ScrollViewer {
|
||||
getRenderedItems(): readonly { id: string }[];
|
||||
getTopForItem(id: string): number | undefined;
|
||||
}
|
||||
|
||||
interface UseActiveFileTrackingReturn {
|
||||
treeActiveFile: string | null;
|
||||
setFileRef: (name: string, el: HTMLDivElement | null) => void;
|
||||
handleFileClick: (name: string) => void;
|
||||
// The active file is the rendered item closest to the top edge that has
|
||||
// already crossed it (largest top still at or above the fold). Exported for
|
||||
// unit tests that pin the closest-to-top selection logic.
|
||||
export function getActiveFile(
|
||||
scrollTop: number,
|
||||
viewer: ScrollViewer,
|
||||
): string | undefined {
|
||||
const rendered = viewer.getRenderedItems();
|
||||
const limit = scrollTop + ACTIVE_FILE_SCROLL_THRESHOLD;
|
||||
let activePath: string | undefined;
|
||||
let activeTop = Number.NEGATIVE_INFINITY;
|
||||
for (const item of rendered) {
|
||||
const top = viewer.getTopForItem(item.id);
|
||||
if (top !== undefined && top <= limit && top > activeTop) {
|
||||
activeTop = top;
|
||||
activePath = item.id;
|
||||
}
|
||||
}
|
||||
return activePath ?? rendered[0]?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the diff file scrolled to the top as the user scrolls. Returns a
|
||||
* CodeView `onScroll` handler. Tree-agnostic: callers decide what to do with
|
||||
* the active path (the diff viewer feeds it to the sidebar selection).
|
||||
*/
|
||||
export function useActiveFileTracking({
|
||||
viewportRef,
|
||||
sortedFiles,
|
||||
enabled,
|
||||
scrollToFile,
|
||||
onScrollToFileComplete,
|
||||
}: UseActiveFileTrackingOptions): UseActiveFileTrackingReturn {
|
||||
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const [treeActiveFile, setTreeActiveFile] = useState<string | null>(null);
|
||||
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
|
||||
// viewportRef is a stable RefObject whose identity never changes, so
|
||||
// an effect that depends on it won't re-run when .current transitions
|
||||
// from null to the actual DOM node (e.g. after a loading state).
|
||||
// Keep a state mirror that flips exactly once when the element mounts.
|
||||
const [viewportEl, setViewportEl] = useState<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
setViewportEl(viewportRef.current);
|
||||
});
|
||||
onActiveFileChange,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
onActiveFileChange: (path: string) => void;
|
||||
}) {
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewportEl) return;
|
||||
setViewportHeight(viewportEl.clientHeight);
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setViewportHeight(Math.round(entry.contentRect.height));
|
||||
return () => {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (scrollTop: number, viewer: ScrollViewer) => {
|
||||
if (!enabled) return;
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
// Coalesce bursts of scroll events into one update per frame.
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
const next = getActiveFile(scrollTop, viewer);
|
||||
if (next) {
|
||||
onActiveFileChange(next);
|
||||
}
|
||||
});
|
||||
ro.observe(viewportEl);
|
||||
return () => ro.disconnect();
|
||||
}, [viewportEl]);
|
||||
|
||||
const sortedFilesRef = useRef(sortedFiles);
|
||||
useEffect(() => {
|
||||
sortedFilesRef.current = sortedFiles;
|
||||
});
|
||||
|
||||
const fileListKey = sortedFiles.map((f) => f.name).join("\0");
|
||||
|
||||
const setFileRef = (name: string, el: HTMLDivElement | null) => {
|
||||
if (el) {
|
||||
fileRefs.current.set(name, el);
|
||||
} else {
|
||||
fileRefs.current.delete(name);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || fileListKey === "" || viewportHeight === 0) return;
|
||||
if (!viewportEl) return;
|
||||
|
||||
const bottomMargin = Math.round(
|
||||
viewportHeight * VIEWPORT_BOTTOM_MARGIN_RATIO,
|
||||
);
|
||||
|
||||
const intersecting = new Set<string>();
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const name = (entry.target as HTMLElement).dataset.fileName;
|
||||
if (!name) continue;
|
||||
if (entry.isIntersecting) {
|
||||
intersecting.add(name);
|
||||
} else {
|
||||
intersecting.delete(name);
|
||||
}
|
||||
}
|
||||
for (const file of sortedFilesRef.current) {
|
||||
if (intersecting.has(file.name)) {
|
||||
setTreeActiveFile(file.name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
root: viewportEl,
|
||||
// Observe only the top ~5% strip of the viewport height.
|
||||
rootMargin: `0px 0px -${bottomMargin}px 0px`,
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
|
||||
for (const [, el] of fileRefs.current.entries()) {
|
||||
observer.observe(el);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [enabled, fileListKey, viewportEl, viewportHeight]);
|
||||
|
||||
const handleFileClick = (name: string) => {
|
||||
const el = fileRefs.current.get(name);
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: "start", behavior: "instant" });
|
||||
setTreeActiveFile(name);
|
||||
}
|
||||
};
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: fileListKey is an intentional trigger dep. The effect reads fileRefs (a mutable ref) and must retry when the file list changes so a previously-unmounted element can be found.
|
||||
useEffect(() => {
|
||||
if (!scrollToFile) return;
|
||||
const el = fileRefs.current.get(scrollToFile);
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: "start", behavior: "instant" });
|
||||
setTreeActiveFile(scrollToFile);
|
||||
onScrollToFileComplete?.();
|
||||
return;
|
||||
}
|
||||
// Element not found. If the target isn't even in the current file
|
||||
// list (e.g. stale chip after the diff changed), complete the
|
||||
// request so the parent can clear its scroll target. Otherwise
|
||||
// the target is present but not yet mounted; wait for fileListKey
|
||||
// to change again.
|
||||
const existsInFileList = sortedFilesRef.current.some(
|
||||
(f) => f.name === scrollToFile,
|
||||
);
|
||||
if (!existsInFileList) {
|
||||
onScrollToFileComplete?.();
|
||||
}
|
||||
}, [scrollToFile, onScrollToFileComplete, fileListKey]);
|
||||
|
||||
return {
|
||||
treeActiveFile,
|
||||
setFileRef,
|
||||
handleFileClick,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, spyOn, userEvent, within } from "storybook/test";
|
||||
import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||
import { API } from "#/api/api";
|
||||
import type {
|
||||
ChatDiffContents,
|
||||
@@ -334,35 +334,19 @@ export const InlineCommentInput: Story = {
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
// Wait for the diff to load and render inside Shadow DOM.
|
||||
// The line numbers live inside @pierre/diffs FileDiff web
|
||||
// components, so we need to wait a bit for them to mount.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Find a line number element inside a Shadow DOM diff viewer.
|
||||
// The diff renders in shadow roots, so we look for the
|
||||
// host elements and query inside their shadow DOMs.
|
||||
const diffHosts = canvasElement.querySelectorAll("[data-diffs]");
|
||||
|
||||
for (const host of diffHosts) {
|
||||
const shadow = host.shadowRoot;
|
||||
if (!shadow) continue;
|
||||
|
||||
// Look for a line number cell — they have data-line-number.
|
||||
const lineNumber = shadow.querySelector(
|
||||
"[data-line-number]",
|
||||
) as HTMLElement | null;
|
||||
if (lineNumber) {
|
||||
await userEvent.click(lineNumber);
|
||||
break;
|
||||
const canvas = within(canvasElement);
|
||||
const lineNumber = await waitFor(() => {
|
||||
for (const host of canvasElement.querySelectorAll("diffs-container")) {
|
||||
const target = host.shadowRoot?.querySelector(
|
||||
"[data-column-number]",
|
||||
) as HTMLElement | null;
|
||||
if (target) return target;
|
||||
}
|
||||
}
|
||||
throw new Error("No rendered diff line number found");
|
||||
});
|
||||
|
||||
// Verify the inline prompt appeared.
|
||||
const textarea = canvasElement.querySelector("textarea");
|
||||
if (textarea) {
|
||||
expect(textarea).toBeInTheDocument();
|
||||
}
|
||||
await userEvent.click(lineNumber);
|
||||
expect(canvas.getByRole("textbox")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { FileDiffMetadata } from "@pierre/diffs";
|
||||
|
||||
/** Sums added and deleted line counts across every hunk in a file diff. */
|
||||
export function countChangedLines(fileDiff: FileDiffMetadata) {
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
for (const hunk of fileDiff.hunks) {
|
||||
additions += hunk.additionLines;
|
||||
deletions += hunk.deletionLines;
|
||||
}
|
||||
return { additions, deletions };
|
||||
}
|
||||
Reference in New Issue
Block a user