authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-20 14:49:04+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 17:19:04+05:30
log4a560b4e4ebb37efb7f57805ba79e37f5500bdca
treed42cb0bd16b65fa93b43fed38a33e5ea370f4587
parent7445aa733ada33e45289e5aa5055f79142556513
signature Signed by PGP key B5690EEEBB952194

fix(web): honor whitespace settings in pull request diffs (#12438)

Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

3 files changed, 230 insertions(+), 15 deletions(-)

apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+37-9
......@@ -20,6 +20,7 @@ import {
2020 InfoIcon,
2121 MessageSquareIcon,
2222 MessageSquareOffIcon,
23 PilcrowIcon,
2324 Rows3Icon,
2425 TextWrapIcon,
2526 TriangleAlertIcon,
......@@ -233,6 +234,7 @@ function PullRequestCodeTab({
233234 const diffLayout = settings.diffLayout;
234235 const updateClientSettings = useUpdateClientSettings();
235236 const [wordWrap, setWordWrap] = useState(settings.wordWrap);
237 const [ignoreWhitespace, setIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace);
236238 const [fileTreeOpen, setFileTreeOpen] = useLocalStorage(
237239 PULL_REQUEST_FILE_TREE_STORAGE_KEY,
238240 false,
......@@ -390,16 +392,17 @@ function PullRequestCodeTab({
390392 loadedSlices.map((slice) => {
391393 // The patch's own hash is part of the key: a refreshed page reuses its cursor, and a
392394 // key of position alone would keep handing back the parse of the patch it replaced.
393 const cacheKey = `pull-request:${scopeKey}:${resolvedTheme}:${slice.cursor ?? "first"}:${fnv1a32(slice.patch)}`;
395 const cacheKey = `pull-request:${scopeKey}:${resolvedTheme}:${ignoreWhitespace}:${slice.cursor ?? "first"}:${fnv1a32(slice.patch)}`;
394396 const cached = parseCache.current.get(cacheKey);
395397 if (cached) return cached;
396398 const parsed = getRenderablePatch(slice.patch, cacheKey, {
397399 compactPartialHunkOffsets: true,
400 ignoreWhitespace,
398401 });
399402 if (parsed) parseCache.current.set(cacheKey, parsed);
400403 return parsed;
401404 }),
402 [loadedSlices, resolvedTheme, scopeKey],
405 [loadedSlices, resolvedTheme, scopeKey, ignoreWhitespace],
403406 );
404407 // Ordered within a slice rather than across them: ordering the accumulated set would let a late
405408 // slice push a file the reader is part way through further down the page.
......@@ -702,7 +705,13 @@ function PullRequestCodeTab({
702705 // that silently lost its first line on the other hosts would be worse than one line.
703706 const path = resolveFileDiffPath(file);
704707 const previousPath = resolveFileDiffPreviousPath(file);
705 const position = resolveDiffReviewPosition(file, range.end, range.endSide ?? range.side);
708 const sourceFile = parsedSlices
709 .flatMap((slice) => (slice?.kind === "files" ? slice.sourceFiles : []))
710 .find((candidate) => resolveFileDiffPath(candidate) === path);
711 const side = range.endSide ?? range.side;
712 const position =
713 (sourceFile && resolveDiffReviewPosition(sourceFile, range.end, side)) ??
714 resolveDiffReviewPosition(file, range.end, side);
706715 if (position === null) return;
707716 setDraft({
708717 fileKey: item.id,
......@@ -712,7 +721,7 @@ function PullRequestCodeTab({
712721 range,
713722 });
714723 },
715 [canCommentOnLines, files],
724 [canCommentOnLines, files, parsedSlices],
716725 );
717726
718727 // Built here because the parsed diff only lives here, and built by the same function the
......@@ -1123,11 +1132,6 @@ function PullRequestCodeTab({
11231132 }
11241133 }, [commit, onSelectedCommitChange, selectedCommit]);
11251134 const scopeLabel = selectedCommit ? selectedCommit.messageHeadline : "All commits";
1126 /**
1127 * The same controls the thread diff panel carries, in the same order, minus the
1128 * ignore-whitespace toggle: that is `git diff -w` on the server, and no host's pull request
1129 * diff API offers it.
1130 */
11311135 const toolbar = (
11321136 <div className="flex h-10 min-h-10 shrink-0 items-center justify-between gap-2 border-b border-border/60 bg-background px-4 text-xs text-muted-foreground">
11331137 <div className="flex min-w-0 flex-1 items-center gap-3">
......@@ -1276,6 +1280,30 @@ function PullRequestCodeTab({
12761280 </PullRequestMetaLine>
12771281 </div>
12781282 <div className="flex shrink-0 items-center gap-1">
1283 <Tooltip>
1284 <TooltipTrigger
1285 render={
1286 <Toggle
1287 aria-label={
1288 ignoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"
1289 }
1290 variant="ghost"
1291 size="sm"
1292 pressed={ignoreWhitespace}
1293 onPressedChange={(pressed) => {
1294 setIgnoreWhitespace(Boolean(pressed));
1295 setDraft(null);
1296 setSelectedLines(null);
1297 }}
1298 />
1299 }
1300 >
1301 <PilcrowIcon className="size-3.5" />
1302 </TooltipTrigger>
1303 <TooltipPopup side="top">
1304 {ignoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}
1305 </TooltipPopup>
1306 </Tooltip>
12791307 {fileKeys.length > 0 ? (
12801308 <Tooltip>
12811309 <TooltipTrigger
apps/web/src/lib/diffRendering.test.ts+131
......@@ -1,4 +1,6 @@
1import { hydratePartialDiff } from "@pierre/diffs";
12import { describe, expect, it } from "vite-plus/test";
3import { resolveDiffReviewPosition } from "../reviewCommentContext";
24import {
35 buildFileDiffContentVersion,
46 buildFileDiffIdentityKey,
......@@ -34,6 +36,135 @@ describe("buildPatchCacheKey", () => {
3436});
3537
3638describe("getRenderablePatch", () => {
39 it("hides indentation changes around inserted JSX without moving review lines", () => {
40 const patch = [
41 "diff --git a/item.tsx b/item.tsx",
42 "--- a/item.tsx",
43 "+++ b/item.tsx",
44 "@@ -40,5 +40,7 @@",
45 ' <ItemContent className="min-w-0">',
46 "- <ItemTitle>",
47 '- <h4 className="wrap-break-word">{name}</h4>',
48 "- </ItemTitle>",
49 "+ {showName && (",
50 "+ <ItemTitle>",
51 '+ <h4 className="wrap-break-word">{name}</h4>',
52 "+ </ItemTitle>",
53 "+ )}",
54 " </ItemContent>",
55 "@@ -80 +82 @@",
56 "-const value = 1;",
57 "+const value = 2;",
58 ].join("\n");
59 const shown = getRenderablePatch(patch, "pr", { compactPartialHunkOffsets: true });
60 const hidden = getRenderablePatch(patch, "pr", {
61 compactPartialHunkOffsets: true,
62 ignoreWhitespace: true,
63 });
64 expect(shown?.kind).toBe("files");
65 expect(hidden?.kind).toBe("files");
66 if (shown?.kind !== "files" || hidden?.kind !== "files") return;
67 expect(getDiffLineStat(shown.files)).toEqual({ additions: 6, deletions: 4 });
68 expect(getDiffLineStat(hidden.files)).toEqual({ additions: 3, deletions: 1 });
69 const file = hidden.files[0]!;
70 expect(file.additionLines).toEqual(shown.files[0]!.additionLines);
71 expect(file.deletionLines).toEqual(shown.files[0]!.deletionLines);
72 expect(file.cacheKey).not.toBe(shown.files[0]!.cacheKey);
73 expect(resolveDiffReviewPosition(hidden.sourceFiles[0]!, 43, "additions")).toEqual({
74 kind: "added",
75 newLine: 43,
76 });
77 expect(resolveDiffReviewPosition(hidden.sourceFiles[0]!, 42, "deletions")).toEqual({
78 kind: "deleted",
79 oldLine: 42,
80 });
81 expect(file.hunks[0]?.hunkContent).toContainEqual({
82 type: "context",
83 lines: 3,
84 additionLineIndex: 2,
85 deletionLineIndex: 1,
86 });
87 expect(resolveDiffReviewPosition(file, 41, "additions")).toEqual({
88 kind: "added",
89 newLine: 41,
90 });
91 expect(resolveDiffReviewPosition(file, 43, "additions")).toEqual({
92 kind: "context",
93 oldLine: 42,
94 newLine: 43,
95 side: "right",
96 });
97 expect(resolveDiffReviewPosition(file, 42, "deletions")).toEqual({
98 kind: "context",
99 oldLine: 42,
100 newLine: 43,
101 side: "left",
102 });
103 expect(file.hunks[1]).toMatchObject({
104 additionStart: 82,
105 deletionStart: 80,
106 splitLineStart: 7,
107 unifiedLineStart: 7,
108 });
109 const prefix = "unchanged\n".repeat(39);
110 const gap = "unchanged\n".repeat(35);
111 const hydrated = hydratePartialDiff("clone", file, {
112 oldFile: {
113 name: file.name,
114 contents: prefix + file.deletionLines.slice(0, 5).join("") + gap + "const value = 1;\n",
115 },
116 newFile: {
117 name: file.name,
118 contents: prefix + file.additionLines.slice(0, 7).join("") + gap + "const value = 2;\n",
119 },
120 });
121 expect(getDiffLineStat([hydrated])).toEqual({ additions: 3, deletions: 1 });
122 expect(resolveDiffReviewPosition(hydrated, 43, "additions")).toEqual(
123 resolveDiffReviewPosition(file, 43, "additions"),
124 );
125 });
126
127 it.each([
128 [" const x = 1;\t", "\tconst x=1;", 0, 0],
129 ["const x = 1;", "const x = 2;", 1, 1],
130 ["const x = 1;", "const x = 1;\n", 1, 0],
131 ])("filters whitespace in %j to %j", (before, after, additions, deletions) => {
132 const patch = [
133 "diff --git a/example.ts b/example.ts",
134 "--- a/example.ts",
135 "+++ b/example.ts",
136 `@@ -1 +1,${after.split("\n").length} @@`,
137 `-${before}`,
138 ...after.split("\n").map((line) => `+${line}`),
139 "",
140 ].join("\n");
141 const filtered = getRenderablePatch(patch, "pr", { ignoreWhitespace: true });
142 expect(filtered?.kind).toBe("files");
143 if (filtered?.kind !== "files") return;
144 expect(getDiffLineStat(filtered.files)).toEqual({ additions, deletions });
145 });
146
147 it.each(["+", "-"])("keeps %s blank lines without a final newline", (sign) => {
148 const parsed = getRenderablePatch(
149 [
150 "diff --git a/blank.txt b/blank.txt",
151 "--- a/blank.txt",
152 "+++ b/blank.txt",
153 sign === "+" ? "@@ -0,0 +1 @@" : "@@ -1 +0,0 @@",
154 `${sign} `,
155 "\\ No newline at end of file",
156 ].join("\n"),
157 "pr",
158 { ignoreWhitespace: true },
159 );
160 expect(parsed?.kind).toBe("files");
161 if (parsed?.kind !== "files") return;
162 expect(getDiffLineStat(parsed.files)).toEqual({
163 additions: sign === "+" ? 1 : 0,
164 deletions: sign === "-" ? 1 : 0,
165 });
166 });
167
37168 it.each([
38169 ["a/example.ts", "a/example.ts", "change"],
39170 ["b/example.ts", "b/example.ts", "change"],
apps/web/src/lib/diffRendering.ts+62-6
......@@ -1,4 +1,5 @@
11import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
2import { parseDiffFromFile } from "@pierre/diffs";
23import type { FileDiffMetadata } from "@pierre/diffs/types";
34import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
45
......@@ -46,6 +47,7 @@ export type RenderablePatch =
4647 | {
4748 kind: "files";
4849 files: FileDiffMetadata[];
50 sourceFiles: FileDiffMetadata[];
4951 }
5052 | {
5153 kind: "raw";
......@@ -73,6 +75,7 @@ export function getDiffLineStat(files: ReadonlyArray<FileDiffMetadata>): DiffLin
7375}
7476
7577interface RenderablePatchOptions {
78 ignoreWhitespace?: boolean;
7679 /**
7780 * Pierre's partial-patch parser keeps hunk render starts in source-file
7881 * coordinates. Its virtualizer iterates partial patches as compact rows, so
......@@ -82,6 +85,59 @@ interface RenderablePatchOptions {
8285 compactPartialHunkOffsets?: boolean;
8386}
8487
88function hideWhitespaceChanges(file: FileDiffMetadata): FileDiffMetadata {
89 let splitDelta = 0;
90 let unifiedDelta = 0;
91 const hunks = file.hunks.map((hunk) => {
92 const oldContents = file.deletionLines
93 .slice(hunk.deletionLineIndex, hunk.deletionLineIndex + hunk.deletionCount)
94 .map((line) => `${line.replace(/\s/g, "")}\n`)
95 .join("");
96 const newContents = file.additionLines
97 .slice(hunk.additionLineIndex, hunk.additionLineIndex + hunk.additionCount)
98 .map((line) => `${line.replace(/\s/g, "")}\n`)
99 .join("");
100 const filtered = parseDiffFromFile(
101 { name: file.name, contents: oldContents },
102 { name: file.name, contents: newContents },
103 { context: Infinity },
104 ).hunks[0];
105 const next = {
106 ...hunk,
107 additionLines: filtered?.additionLines ?? 0,
108 deletionLines: filtered?.deletionLines ?? 0,
109 hunkContent: filtered
110 ? filtered.hunkContent.map((content) => ({
111 ...content,
112 additionLineIndex: content.additionLineIndex + hunk.additionLineIndex,
113 deletionLineIndex: content.deletionLineIndex + hunk.deletionLineIndex,
114 }))
115 : [
116 {
117 type: "context" as const,
118 lines: hunk.additionCount,
119 additionLineIndex: hunk.additionLineIndex,
120 deletionLineIndex: hunk.deletionLineIndex,
121 },
122 ],
123 splitLineStart: hunk.splitLineStart + splitDelta,
124 unifiedLineStart: hunk.unifiedLineStart + unifiedDelta,
125 splitLineCount: filtered?.splitLineCount ?? hunk.additionCount,
126 unifiedLineCount: filtered?.unifiedLineCount ?? hunk.additionCount,
127 };
128 splitDelta += next.splitLineCount - hunk.splitLineCount;
129 unifiedDelta += next.unifiedLineCount - hunk.unifiedLineCount;
130 return next;
131 });
132 return {
133 ...file,
134 hunks,
135 splitLineCount: file.splitLineCount + splitDelta,
136 unifiedLineCount: file.unifiedLineCount + unifiedDelta,
137 ...(file.cacheKey ? { cacheKey: `${file.cacheKey}:ignore-whitespace` } : {}),
138 };
139}
140
85141function compactPartialHunkOffsets(file: FileDiffMetadata): FileDiffMetadata {
86142 if (!file.isPartial) return file;
87143
......@@ -121,13 +177,13 @@ export function getRenderablePatch(
121177 normalizedPatch,
122178 buildPatchCacheKey(normalizedPatch, cacheScope),
123179 );
124 const files = parsedPatches.flatMap((parsedPatch) =>
125 options.compactPartialHunkOffsets
126 ? parsedPatch.files.map(compactPartialHunkOffsets)
127 : parsedPatch.files,
128 );
180 const sourceFiles = parsedPatches.flatMap((parsedPatch) => parsedPatch.files);
181 const files = sourceFiles.map((file) => {
182 const filtered = options.ignoreWhitespace ? hideWhitespaceChanges(file) : file;
183 return options.compactPartialHunkOffsets ? compactPartialHunkOffsets(filtered) : filtered;
184 });
129185 if (files.length > 0) {
130 return { kind: "files", files };
186 return { kind: "files", files, sourceFiles };
131187 }
132188
133189 return {