1import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
2import { parseDiffFromFile } from "@pierre/diffs";
3import type { FileDiffMetadata } from "@pierre/diffs/types";
4
5const DIFF_THEME_NAMES = {
6 light: "pierre-light",
7 dark: "pierre-dark",
8} as const;
9
10export type DiffThemeName = (typeof DIFF_THEME_NAMES)[keyof typeof DIFF_THEME_NAMES];
11
12export function resolveDiffThemeName(theme: "light" | "dark"): DiffThemeName {
13 return theme === "dark" ? DIFF_THEME_NAMES.dark : DIFF_THEME_NAMES.light;
14}
15
16const FNV_OFFSET_BASIS_32 = 0x811c9dc5;
17const FNV_PRIME_32 = 0x01000193;
18const SECONDARY_HASH_SEED = 0x9e3779b9;
19const SECONDARY_HASH_MULTIPLIER = 0x85ebca6b;
20
21export function fnv1a32(
22 input: string,
23 seed = FNV_OFFSET_BASIS_32,
24 multiplier = FNV_PRIME_32,
25): number {
26 let hash = seed >>> 0;
27 for (let index = 0; index < input.length; index += 1) {
28 hash ^= input.charCodeAt(index);
29 hash = Math.imul(hash, multiplier) >>> 0;
30 }
31 return hash >>> 0;
32}
33
34export function buildPatchCacheKey(patch: string, scope = "diff-panel"): string {
35 const normalizedPatch = patch.trim();
36 const primary = fnv1a32(normalizedPatch, FNV_OFFSET_BASIS_32, FNV_PRIME_32).toString(36);
37 const secondary = fnv1a32(
38 normalizedPatch,
39 SECONDARY_HASH_SEED,
40 SECONDARY_HASH_MULTIPLIER,
41 ).toString(36);
42 return `${scope}:${normalizedPatch.length}:${primary}:${secondary}`;
43}
44
45export type RenderablePatch =
46 | {
47 kind: "files";
48 files: FileDiffMetadata[];
49 sourceFiles: FileDiffMetadata[];
50 }
51 | {
52 kind: "raw";
53 text: string;
54 reason: string;
55 };
56
57export interface DiffLineStat {
58 additions: number;
59 deletions: number;
60}
61
62export function getDiffLineStat(files: ReadonlyArray<FileDiffMetadata>): DiffLineStat {
63 return files.reduce<DiffLineStat>(
64 (total, file) => {
65 for (const hunk of file.hunks) {
66 total.additions += hunk.additionLines;
67 total.deletions += hunk.deletionLines;
68 }
69
70 return total;
71 },
72 { additions: 0, deletions: 0 },
73 );
74}
75
76interface RenderablePatchOptions {
77 ignoreWhitespace?: boolean;
78 /**
79 * Pierre's partial-patch parser keeps hunk render starts in source-file
80 * coordinates. Its virtualizer iterates partial patches as compact rows, so
81 * review diffs need compact render starts while retaining collapsedBefore
82 * for the "N unmodified lines" separator.
83 */
84 compactPartialHunkOffsets?: boolean;
85}
86
87function hideWhitespaceChanges(file: FileDiffMetadata): FileDiffMetadata {
88 let splitDelta = 0;
89 let unifiedDelta = 0;
90 const hunks = file.hunks.map((hunk) => {
91 const oldContents = file.deletionLines
92 .slice(hunk.deletionLineIndex, hunk.deletionLineIndex + hunk.deletionCount)
93 .map((line) => `${line.replace(/\s/g, "")}\n`)
94 .join("");
95 const newContents = file.additionLines
96 .slice(hunk.additionLineIndex, hunk.additionLineIndex + hunk.additionCount)
97 .map((line) => `${line.replace(/\s/g, "")}\n`)
98 .join("");
99 const filtered = parseDiffFromFile(
100 { name: file.name, contents: oldContents },
101 { name: file.name, contents: newContents },
102 { context: Infinity },
103 ).hunks[0];
104 const next = {
105 ...hunk,
106 additionLines: filtered?.additionLines ?? 0,
107 deletionLines: filtered?.deletionLines ?? 0,
108 hunkContent: filtered
109 ? filtered.hunkContent.map((content) => ({
110 ...content,
111 additionLineIndex: content.additionLineIndex + hunk.additionLineIndex,
112 deletionLineIndex: content.deletionLineIndex + hunk.deletionLineIndex,
113 }))
114 : [
115 {
116 type: "context" as const,
117 lines: hunk.additionCount,
118 additionLineIndex: hunk.additionLineIndex,
119 deletionLineIndex: hunk.deletionLineIndex,
120 },
121 ],
122 splitLineStart: hunk.splitLineStart + splitDelta,
123 unifiedLineStart: hunk.unifiedLineStart + unifiedDelta,
124 splitLineCount: filtered?.splitLineCount ?? hunk.additionCount,
125 unifiedLineCount: filtered?.unifiedLineCount ?? hunk.additionCount,
126 };
127 splitDelta += next.splitLineCount - hunk.splitLineCount;
128 unifiedDelta += next.unifiedLineCount - hunk.unifiedLineCount;
129 return next;
130 });
131 return {
132 ...file,
133 hunks,
134 splitLineCount: file.splitLineCount + splitDelta,
135 unifiedLineCount: file.unifiedLineCount + unifiedDelta,
136 ...(file.cacheKey ? { cacheKey: `${file.cacheKey}:ignore-whitespace` } : {}),
137 };
138}
139
140function compactPartialHunkOffsets(file: FileDiffMetadata): FileDiffMetadata {
141 if (!file.isPartial) return file;
142
143 let splitLineStart = 0;
144 let unifiedLineStart = 0;
145 const hunks = file.hunks.map((hunk) => {
146 const compactHunk = {
147 ...hunk,
148 splitLineStart,
149 unifiedLineStart,
150 };
151 splitLineStart += hunk.splitLineCount;
152 unifiedLineStart += hunk.unifiedLineCount;
153 return compactHunk;
154 });
155
156 return {
157 ...file,
158 hunks,
159 splitLineCount: splitLineStart,
160 unifiedLineCount: unifiedLineStart,
161 ...(file.cacheKey ? { cacheKey: `${file.cacheKey}:compact-partial` } : {}),
162 };
163}
164
165export function getRenderablePatch(
166 patch: string | undefined,
167 cacheScope = "diff-panel",
168 options: RenderablePatchOptions = {},
169): RenderablePatch | null {
170 if (!patch) return null;
171 const normalizedPatch = patch.trim();
172 if (normalizedPatch.length === 0) return null;
173
174 try {
175 const parsedPatches = parsePatchFiles(
176 normalizedPatch,
177 buildPatchCacheKey(normalizedPatch, cacheScope),
178 );
179 const sourceFiles = parsedPatches.flatMap((parsedPatch) => parsedPatch.files);
180 const files = sourceFiles.map((file) => {
181 const filtered = options.ignoreWhitespace ? hideWhitespaceChanges(file) : file;
182 return options.compactPartialHunkOffsets ? compactPartialHunkOffsets(filtered) : filtered;
183 });
184 if (files.length > 0) {
185 return { kind: "files", files, sourceFiles };
186 }
187
188 return {
189 kind: "raw",
190 text: normalizedPatch,
191 reason: "Unsupported diff format. Showing raw patch.",
192 };
193 } catch {
194 return {
195 kind: "raw",
196 text: normalizedPatch,
197 reason: "Failed to parse patch. Showing raw patch.",
198 };
199 }
200}
201
202/**
203 * The file's own name. Git quotes and escapes a name holding a tab, a newline, a quote or a
204 * backslash, and the parser decodes it, so this is the name the host knows the file by.
205 */
206export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
207 return fileDiff.name ?? fileDiff.prevName ?? "";
208}
209
210/**
211 * What the file was called before the change. Only a rename makes it differ from the current
212 * path, and the hosts that resolve a diff position against both sides need both names.
213 */
214export function resolveFileDiffPreviousPath(fileDiff: FileDiffMetadata): string {
215 return fileDiff.prevName ?? fileDiff.name ?? "";
216}
217
218/**
219 * Stable across re-renders of the same file, distinct for every block in a
220 * patch. A type change (regular file to symlink) arrives as a deletion and an
221 * addition of the same path, so the change type is part of the identity.
222 */
223export function buildFileDiffIdentityKey(fileDiff: FileDiffMetadata): string {
224 return `${resolveFileDiffPreviousPath(fileDiff)}\u0000${resolveFileDiffPath(fileDiff)}\u0000${fileDiff.type}`;
225}
226
227export function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
228 const cacheKey = fileDiff.cacheKey;
229 if (!cacheKey) return `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
230
231 return cacheKey.endsWith(":hydrated") ? cacheKey.slice(0, -":hydrated".length) : cacheKey;
232}
233
234function hashFileDiffPart(hash: number, value: string | number | boolean | undefined): number {
235 const serialized = value === undefined ? "undefined" : String(value);
236 const withLength = fnv1a32(`${typeof value}:${serialized.length}:`, hash);
237 return fnv1a32(serialized, withLength);
238}
239
240/**
241 * Content-only version for CodeView reconciliation. Pierre's cache key includes
242 * the whole patch, so using it here would repaint every file when one changes.
243 */
244export function buildFileDiffContentVersion(fileDiff: FileDiffMetadata): number {
245 let hash = FNV_OFFSET_BASIS_32;
246 const append = (value: string | number | boolean | undefined) => {
247 hash = hashFileDiffPart(hash, value);
248 };
249
250 append(fileDiff.name);
251 append(fileDiff.prevName);
252 append(fileDiff.lang);
253 append(fileDiff.newObjectId);
254 append(fileDiff.prevObjectId);
255 append(fileDiff.mode);
256 append(fileDiff.prevMode);
257 append(fileDiff.type);
258 append(fileDiff.isPartial);
259 append(fileDiff.splitLineCount);
260 append(fileDiff.unifiedLineCount);
261
262 for (const line of fileDiff.additionLines) append(line);
263 for (const line of fileDiff.deletionLines) append(line);
264 for (const hunk of fileDiff.hunks) {
265 append(hunk.collapsedBefore);
266 append(hunk.additionStart);
267 append(hunk.additionCount);
268 append(hunk.additionLines);
269 append(hunk.additionLineIndex);
270 append(hunk.deletionStart);
271 append(hunk.deletionCount);
272 append(hunk.deletionLines);
273 append(hunk.deletionLineIndex);
274 append(hunk.hunkContext);
275 append(hunk.hunkSpecs);
276 append(hunk.splitLineStart);
277 append(hunk.splitLineCount);
278 append(hunk.unifiedLineStart);
279 append(hunk.unifiedLineCount);
280 append(hunk.noEOFCRAdditions);
281 append(hunk.noEOFCRDeletions);
282 for (const content of hunk.hunkContent) {
283 append(content.type);
284 append(content.additionLineIndex);
285 append(content.deletionLineIndex);
286 append(content.type === "change" ? content.additions : content.lines);
287 append(content.type === "change" ? content.deletions : undefined);
288 }
289 }
290
291 return hash;
292}
293
294export function getDiffCollapseIconClassName(fileDiff: FileDiffMetadata): string {
295 switch (fileDiff.type) {
296 case "new":
297 return "text-[var(--diffs-addition-base)]";
298 case "deleted":
299 return "text-[var(--diffs-deletion-base)]";
300 case "change":
301 case "rename-pure":
302 case "rename-changed":
303 return "text-[var(--diffs-modified-base)]";
304 default:
305 return "text-muted-foreground/80";
306 }
307}
308
309/**
310 * Maps every diff/file surface the @pierre/diffs renderer paints onto the
311 * app's code tokens, so themed palettes reach the code body, gutter, and
312 * row tints instead of the renderer's bundled colors. Shared by the diff
313 * panel and the file preview.
314 */
315export const DIFF_SURFACE_THEME_UNSAFE_CSS = `
316[data-diffs-header],
317[data-diff],
318[data-file],
319[data-error-wrapper],
320[data-virtualizer-buffer] {
321 --diffs-header-font-family: var(--font-sans) !important;
322 --diffs-font-family: var(--font-mono) !important;
323 --diffs-bg: var(--code-background) !important;
324 --diffs-light-bg: var(--code-background) !important;
325 --diffs-dark-bg: var(--code-background) !important;
326 --diffs-token-light-bg: transparent;
327 --diffs-token-dark-bg: transparent;
328
329 /* Gutter, context, and row tints all derive from the code surface the diff
330 body sits on — mixing from the canvas leaves the gutter looking unthemed
331 when a palette separates the two. */
332 --diffs-bg-context-override: color-mix(in srgb, var(--code-background) 97%, var(--code-foreground));
333 --diffs-bg-hover-override: color-mix(in srgb, var(--code-background) 94%, var(--code-foreground));
334 --diffs-bg-separator-override: color-mix(
335 in srgb,
336 var(--code-background) 95%,
337 var(--code-foreground)
338 );
339 --diffs-bg-buffer-override: color-mix(in srgb, var(--code-background) 90%, var(--code-foreground));
340
341 --diffs-bg-addition-override: light-dark(
342 color-mix(in srgb, var(--code-background) 50%, var(--diff-addition)),
343 color-mix(in srgb, var(--code-background) 70%, var(--diff-addition))
344 );
345 --diffs-bg-addition-number-override: light-dark(
346 color-mix(in srgb, var(--code-background) 35%, var(--diff-addition)),
347 color-mix(in srgb, var(--code-background) 60%, var(--diff-addition))
348 );
349 --diffs-bg-addition-hover-override: color-mix(in srgb, var(--code-background) 85%, var(--diff-addition));
350 --diffs-bg-addition-emphasis-override: color-mix(
351 in srgb,
352 var(--code-background) 80%,
353 var(--diff-addition)
354 );
355
356 --diffs-bg-deletion-override: light-dark(
357 color-mix(in srgb, var(--code-background) 50%, var(--diff-deletion)),
358 color-mix(in srgb, var(--code-background) 70%, var(--diff-deletion))
359 );
360 --diffs-bg-deletion-number-override: light-dark(
361 color-mix(in srgb, var(--code-background) 35%, var(--diff-deletion)),
362 color-mix(in srgb, var(--code-background) 60%, var(--diff-deletion))
363 );
364 --diffs-bg-deletion-hover-override: color-mix(
365 in srgb,
366 var(--code-background) 85%,
367 var(--diff-deletion)
368 );
369 --diffs-bg-deletion-emphasis-override: color-mix(
370 in srgb,
371 var(--code-background) 80%,
372 var(--diff-deletion)
373 );
374
375 background-color: var(--diffs-bg) !important;
376 color: var(--code-foreground) !important;
377}
378`;