Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 57 additions & 11 deletions apps/web/src/components/DiffPanel.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const state = vi.hoisted(() => ({
localRefs: null as unknown as ResultAtom<VcsListRefsResult>,
remoteRefs: null as unknown as ResultAtom<VcsListRefsResult>,
commits: null as unknown as ResultAtom<ReviewBranchCommitsResult>,
viewerFiles: [] as Array<ReadonlyArray<{ fileDiff: object; collapsed: boolean }>>,
}));

vi.mock("~/state/server", () => ({
Expand Down Expand Up @@ -76,8 +77,14 @@ vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => async () => {
vi.mock("~/editorPreferences", () => ({ useOpenInPreferredEditor: () => async () => {} }));
vi.mock("~/fileContextMenu", () => ({ useFileContextMenuHandler: () => () => {} }));
vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => async () => {} }));
// The test drives comparison controls, without mounting virtualized diff viewers or their workers.
vi.mock("./diffs/AnnotatableCodeView", () => ({ AnnotatableCodeView: () => null }));
// The tests drive comparison controls and record the viewer's files, without mounting virtualized
// diff viewers or their workers.
vi.mock("./diffs/AnnotatableCodeView", () => ({
AnnotatableCodeView: ({ files }: { files: (typeof state.viewerFiles)[number] }) => {
state.viewerFiles.push(files);
return null;
},
}));
vi.mock("./diffs/DiffFileTree", () => ({ DiffFileTree: () => null }));
vi.mock("./DiffFilePathCopyButton", () => ({ DiffFilePathCopyButton: () => null }));

Expand All @@ -87,7 +94,20 @@ import { useDiffPanelSessionStore, useDiffPanelStore } from "~/diffPanelStore";
const cwd = "/project-worktree";
const errorMessage = "Could not find a common commit between 'origin/orphan' and HEAD.";

function preview(): ReviewDiffPreviewResult {
const valuesPatch = [
"diff --git a/src/values.ts b/src/values.ts",
"index 1111111..2222222 100644",
"--- a/src/values.ts",
"+++ b/src/values.ts",
"@@ -40,3 +40,3 @@",
" export const line40 = 40;",
"-export const line41 = 41;",
"+export const line41 = 410;",
" export const line42 = 42;",
"",
].join("\n");

function preview(diff: string): ReviewDiffPreviewResult {
return {
cwd,
generatedAt: DateTime.makeUnsafe("2026-10-30T00:00:00Z"),
Expand All @@ -98,15 +118,15 @@ function preview(): ReviewDiffPreviewResult {
title: "Changes",
baseRef: "main",
headRef: "dev",
diff: "",
diffHash: "empty",
diff,
diffHash: diff === "" ? "empty" : "values",
truncated: false,
},
],
};
}

function setup(failedBaseRef: string | null) {
function setup({ failedBaseRef, diff = "" }: { failedBaseRef?: string | null; diff?: string }) {
const reset = () => {
useDiffPanelStore.setState({
byThreadKey: {},
Expand All @@ -128,9 +148,18 @@ function setup(failedBaseRef: string | null) {
worktreePath: cwd,
});
state.previews.clear();
state.previews.set(null, Atom.make(AsyncResult.success(preview())));
state.previews.set("main", Atom.make(AsyncResult.success(preview())));
state.previews.set(failedBaseRef, Atom.make(AsyncResult.fail(new Error(errorMessage))));
state.previews.set(
null,
Atom.make(() => AsyncResult.success(preview(diff))),
);
state.previews.set(
"main",
Atom.make(() => AsyncResult.success(preview(diff))),
);
if (failedBaseRef !== undefined) {
state.previews.set(failedBaseRef, Atom.make(AsyncResult.fail(new Error(errorMessage))));
}
state.viewerFiles = [];
state.config = Atom.make(null);
state.status = Atom.make(
AsyncResult.success({
Expand Down Expand Up @@ -163,7 +192,7 @@ function setup(failedBaseRef: string | null) {
state.localRefs = Atom.make(AsyncResult.success(refs(["dev", "main"], false)));
state.remoteRefs = Atom.make(AsyncResult.success(refs(["origin/orphan"], true)));
state.commits = Atom.make(AsyncResult.success({ commits: [], truncated: false }));
useDiffPanelStore.getState().selectBranchBaseRef(state.threadRef, failedBaseRef);
useDiffPanelStore.getState().selectBranchBaseRef(state.threadRef, failedBaseRef ?? null);
const registry = AtomRegistry.make();
return {
registry,
Expand Down Expand Up @@ -191,7 +220,7 @@ it.each([
])(
"recovers from $name without leaving the diff panel",
async ({ failedBaseRef, recovery, baseRef }) => {
using context = setup(failedBaseRef);
using context = setup({ failedBaseRef });
await using screen = await render(
<RegistryContext.Provider value={context.registry}>
<DiffPanel composerDraftTarget={context.threadRef} workspaceMutationId={null} visible />
Expand All @@ -212,3 +241,20 @@ it.each([
});
},
);

it("hands the viewer the same parsed file when it expands after a refetch", async () => {
using context = setup({ diff: valuesPatch });
await using screen = await render(
<RegistryContext.Provider value={context.registry}>
<DiffPanel composerDraftTarget={context.threadRef} workspaceMutationId={null} visible />
</RegistryContext.Provider>,
);
await vi.waitFor(() => expect(state.viewerFiles.at(-1)?.[0]?.collapsed).toBe(true));
const collapsedFile = state.viewerFiles.at(-1)![0]!.fileDiff;
// A refetch returns the same patch in a new result.
context.registry.refresh(state.previews.get(null)!);
await userEvent.click(screen.getByRole("button", { name: "Expand all files" }));
await vi.waitFor(() => expect(state.viewerFiles.at(-1)?.[0]?.collapsed).toBe(false));
// Pierre loads unmodified lines into the parsed file it was given; a re-parse would drop them.
expect(state.viewerFiles.at(-1)![0]!.fileDiff).toBe(collapsedFile);
});
24 changes: 23 additions & 1 deletion apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
getRenderablePatch,
resolveDiffThemeName,
resolveFileDiffPath,
type RenderablePatch,
} from "../lib/diffRendering";
import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting";
import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse";
Expand Down Expand Up @@ -129,6 +130,27 @@ function getCachedFileEntry(fileDiff: FileDiffMetadata) {
return entry;
}

// Pierre loads expanded lines into the parsed file it was given, so a patch has to keep its parse
// across re-renders and refetches that return the same text. The four most recent are kept.
const recentPatchParses: Array<{ key: string; patch: string; parse: RenderablePatch | null }> = [];

function getCachedRenderablePatch(
patch: string | undefined,
cacheScope: string,
options: { compactPartialHunkOffsets: boolean; ignoreWhitespace?: boolean },
) {
if (patch === undefined) return null;
const key = `${cacheScope}:${options.compactPartialHunkOffsets}:${options.ignoreWhitespace}`;
const index = recentPatchParses.findIndex((entry) => entry.key === key && entry.patch === patch);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const entry =
index === -1
? { key, patch, parse: getRenderablePatch(patch, cacheScope, options) }
: recentPatchParses.splice(index, 1)[0]!;
recentPatchParses.unshift(entry);
recentPatchParses.splice(4);
return entry.parse;
}

const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet<string> = new Set();

function DiffFileCollapseToggle({
Expand Down Expand Up @@ -477,7 +499,7 @@ export default function DiffPanel({
isGitSelection && selectedGitSource?.truncated && selectedGitSource.files
? selectedGitSource
: null;
const renderablePatch = getRenderablePatch(
const renderablePatch = getCachedRenderablePatch(
lazySource ? undefined : selectedPatch,
`diff-panel:${resolvedTheme}`,
{
Expand Down
Loading