1import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
2import { parseDiffFromFile } from "@pierre/diffs";
3import type { FileDiffMetadata } from "@pierre/diffs/types";
4import { READABLE_LIGHT_THEME_NAME } from "./readableSyntaxTheme";
5
6// Light mode uses a darker pierre-light so syntax stays readable on every code surface.
7export const DIFF_THEME_NAMES = {
8 light: READABLE_LIGHT_THEME_NAME,
9 dark: "pierre-dark",
10} as const;
11
12export type DiffThemeName = (typeof DIFF_THEME_NAMES)[keyof typeof DIFF_THEME_NAMES];
13
14export function resolveDiffThemeName(theme: "light" | "dark"): DiffThemeName {
15 return DIFF_THEME_NAMES[theme];
16}
17
18const FNV_OFFSET_BASIS_32 = 0x811c9dc5;
19const FNV_PRIME_32 = 0x01000193;
20const SECONDARY_HASH_SEED = 0x9e3779b9;
21const SECONDARY_HASH_MULTIPLIER = 0x85ebca6b;
22
23export function fnv1a32(
24 input: string,
25 seed = FNV_OFFSET_BASIS_32,
26 multiplier = FNV_PRIME_32,
27): number {
28 let hash = seed >>> 0;
29 for (let index = 0; index < input.length; index += 1) {
30 hash ^= input.charCodeAt(index);
31 hash = Math.imul(hash, multiplier) >>> 0;
32 }
33 return hash >>> 0;
34}
35
36function buildPatchCacheKey(patch: string, scope = "diff-panel"): string {
37 const primary = fnv1a32(patch, FNV_OFFSET_BASIS_32, FNV_PRIME_32).toString(36);
38 const secondary = fnv1a32(patch, SECONDARY_HASH_SEED, SECONDARY_HASH_MULTIPLIER).toString(36);
39 return `${scope}:${patch.length}:${primary}:${secondary}`;
40}
41
42export type RenderablePatch =
43 | {
44 kind: "files";
45 files: FileDiffMetadata[];
46 sourceFiles: FileDiffMetadata[];
47 }
48 | {
49 kind: "raw";
50 text: string;
51 reason: string;
52 };
53
54export interface DiffLineStat {
55 additions: number;
56 deletions: number;
57}
58
59export function getDiffLineStat(files: ReadonlyArray<FileDiffMetadata>): DiffLineStat {
60 return files.reduce<DiffLineStat>(
61 (total, file) => {
62 for (const hunk of file.hunks) {
63 total.additions += hunk.additionLines;
64 total.deletions += hunk.deletionLines;
65 }
66
67 return total;
68 },
69 { additions: 0, deletions: 0 },
70 );
71}
72
73interface RenderablePatchOptions {
74 ignoreWhitespace?: boolean;
75 /** Compact Pierre source offsets for partial-patch rows; retain collapsedBefore for separators. */
76 compactPartialHunkOffsets?: boolean;
77}
78
79function hideWhitespaceChanges(file: FileDiffMetadata): FileDiffMetadata {
80 let splitDelta = 0;
81 let unifiedDelta = 0;
82 const hunks = file.hunks.map((hunk) => {
83 const oldContents = file.deletionLines
84 .slice(hunk.deletionLineIndex, hunk.deletionLineIndex + hunk.deletionCount)
85 .map((line) => `${line.replace(/\s/g, "")}\n`)
86 .join("");
87 const newContents = file.additionLines
88 .slice(hunk.additionLineIndex, hunk.additionLineIndex + hunk.additionCount)
89 .map((line) => `${line.replace(/\s/g, "")}\n`)
90 .join("");
91 const filtered = parseDiffFromFile(
92 { name: file.name, contents: oldContents },
93 { name: file.name, contents: newContents },
94 { context: Infinity },
95 ).hunks[0];
96 const next = {
97 ...hunk,
98 additionLines: filtered?.additionLines ?? 0,
99 deletionLines: filtered?.deletionLines ?? 0,
100 hunkContent: filtered
101 ? filtered.hunkContent.map((content) => ({
102 ...content,
103 additionLineIndex: content.additionLineIndex + hunk.additionLineIndex,
104 deletionLineIndex: content.deletionLineIndex + hunk.deletionLineIndex,
105 }))
106 : [
107 {
108 type: "context" as const,
109 lines: hunk.additionCount,
110 additionLineIndex: hunk.additionLineIndex,
111 deletionLineIndex: hunk.deletionLineIndex,
112 },
113 ],
114 splitLineStart: hunk.splitLineStart + splitDelta,
115 unifiedLineStart: hunk.unifiedLineStart + unifiedDelta,
116 splitLineCount: filtered?.splitLineCount ?? hunk.additionCount,
117 unifiedLineCount: filtered?.unifiedLineCount ?? hunk.additionCount,
118 };
119 splitDelta += next.splitLineCount - hunk.splitLineCount;
120 unifiedDelta += next.unifiedLineCount - hunk.unifiedLineCount;
121 return next;
122 });
123 return {
124 ...file,
125 hunks,
126 splitLineCount: file.splitLineCount + splitDelta,
127 unifiedLineCount: file.unifiedLineCount + unifiedDelta,
128 ...(file.cacheKey ? { cacheKey: `${file.cacheKey}:ignore-whitespace` } : {}),
129 };
130}
131
132function compactPartialHunkOffsets(file: FileDiffMetadata): FileDiffMetadata {
133 if (!file.isPartial) return file;
134
135 let splitLineStart = 0;
136 let unifiedLineStart = 0;
137 const hunks = file.hunks.map((hunk) => {
138 const compactHunk = {
139 ...hunk,
140 splitLineStart,
141 unifiedLineStart,
142 };
143 splitLineStart += hunk.splitLineCount;
144 unifiedLineStart += hunk.unifiedLineCount;
145 return compactHunk;
146 });
147
148 return {
149 ...file,
150 hunks,
151 splitLineCount: splitLineStart,
152 unifiedLineCount: unifiedLineStart,
153 ...(file.cacheKey ? { cacheKey: `${file.cacheKey}:compact-partial` } : {}),
154 };
155}
156
157export function getRenderablePatch(
158 patch: string | undefined,
159 cacheScope = "diff-panel",
160 options: RenderablePatchOptions = {},
161): RenderablePatch | null {
162 if (!patch?.trim()) return null;
163
164 try {
165 const parsedPatches = parsePatchFiles(patch, {
166 cacheKeyPrefix: buildPatchCacheKey(patch, cacheScope),
167 });
168 const sourceFiles = parsedPatches.flatMap((parsedPatch) => parsedPatch.files);
169 const files = sourceFiles.map((file) => {
170 const filtered = options.ignoreWhitespace ? hideWhitespaceChanges(file) : file;
171 return options.compactPartialHunkOffsets ? compactPartialHunkOffsets(filtered) : filtered;
172 });
173 if (files.length > 0) {
174 return { kind: "files", files, sourceFiles };
175 }
176
177 return {
178 kind: "raw",
179 text: patch,
180 reason: "Unsupported diff format. Showing raw patch.",
181 };
182 } catch {
183 return {
184 kind: "raw",
185 text: patch,
186 reason: "Failed to parse patch. Showing raw patch.",
187 };
188 }
189}
190
191/** Uses the parser-decoded filename, including Git's quoted and escaped names. */
192export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
193 return fileDiff.name ?? fileDiff.prevName ?? "";
194}
195
196export function resolveFileDiffPreviousPath(fileDiff: FileDiffMetadata): string {
197 return fileDiff.prevName ?? fileDiff.name ?? "";
198}
199
200/** Includes change type to distinguish deletion/addition pairs for file-to-symlink changes. */
201export function buildFileDiffIdentityKey(fileDiff: FileDiffMetadata): string {
202 return `${resolveFileDiffPreviousPath(fileDiff)}\u0000${resolveFileDiffPath(fileDiff)}\u0000${fileDiff.type}`;
203}
204
205export function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
206 const cacheKey = fileDiff.cacheKey;
207 if (!cacheKey) return `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
208
209 return cacheKey.endsWith(":hydrated") ? cacheKey.slice(0, -":hydrated".length) : cacheKey;
210}
211
212function hashFileDiffPart(hash: number, value: string | number | boolean | undefined): number {
213 const serialized = value === undefined ? "undefined" : String(value);
214 const withLength = fnv1a32(`${typeof value}:${serialized.length}:`, hash);
215 return fnv1a32(serialized, withLength);
216}
217
218// Pierre's whole-patch cache key would repaint every file when one changes.
219export function buildFileDiffContentVersion(fileDiff: FileDiffMetadata): number {
220 let hash = FNV_OFFSET_BASIS_32;
221 const append = (value: string | number | boolean | undefined) => {
222 hash = hashFileDiffPart(hash, value);
223 };
224
225 append(fileDiff.name);
226 append(fileDiff.prevName);
227 append(fileDiff.lang);
228 append(fileDiff.newObjectId);
229 append(fileDiff.prevObjectId);
230 append(fileDiff.mode);
231 append(fileDiff.prevMode);
232 append(fileDiff.type);
233 append(fileDiff.isPartial);
234 append(fileDiff.splitLineCount);
235 append(fileDiff.unifiedLineCount);
236
237 for (const line of fileDiff.additionLines) append(line);
238 for (const line of fileDiff.deletionLines) append(line);
239 for (const hunk of fileDiff.hunks) {
240 append(hunk.collapsedBefore);
241 append(hunk.additionStart);
242 append(hunk.additionCount);
243 append(hunk.additionLines);
244 append(hunk.additionLineIndex);
245 append(hunk.deletionStart);
246 append(hunk.deletionCount);
247 append(hunk.deletionLines);
248 append(hunk.deletionLineIndex);
249 append(hunk.hunkContext);
250 append(hunk.hunkSpecs);
251 append(hunk.splitLineStart);
252 append(hunk.splitLineCount);
253 append(hunk.unifiedLineStart);
254 append(hunk.unifiedLineCount);
255 append(hunk.noEOFCRAdditions);
256 append(hunk.noEOFCRDeletions);
257 for (const content of hunk.hunkContent) {
258 append(content.type);
259 append(content.additionLineIndex);
260 append(content.deletionLineIndex);
261 append(content.type === "change" ? content.additions : content.lines);
262 append(content.type === "change" ? content.deletions : undefined);
263 }
264 }
265
266 return hash;
267}
268
269export const DIFF_SURFACE_THEME_UNSAFE_CSS = `
270[data-diffs-header],
271[data-diff],
272[data-file],
273[data-error-wrapper],
274[data-virtualizer-buffer] {
275 --diffs-header-font-family: var(--font-sans) !important;
276 --diffs-font-family: var(--font-mono) !important;
277 --diffs-bg: var(--code-background) !important;
278 --diffs-light-bg: var(--code-background) !important;
279 --diffs-dark-bg: var(--code-background) !important;
280 --diffs-token-light-bg: transparent;
281 --diffs-token-dark-bg: transparent;
282
283 /* Gutter, context, and row tints all derive from the code surface the diff
284 body sits on. Mixing from the canvas leaves the gutter looking unthemed
285 when a palette separates the two. */
286 --diffs-bg-context-override: color-mix(in srgb, var(--code-background) 97%, var(--code-foreground));
287 --diffs-bg-hover-override: color-mix(in srgb, var(--code-background) 94%, var(--code-foreground));
288 --diffs-bg-separator-override: color-mix(
289 in srgb,
290 var(--code-background) 95%,
291 var(--code-foreground)
292 );
293 --diffs-bg-buffer-override: color-mix(in srgb, var(--code-background) 90%, var(--code-foreground));
294
295 --diffs-bg-addition-override: light-dark(
296 color-mix(in srgb, var(--code-background) calc(50% - var(--appearance-contrast-boost, 0%) / 4), var(--diff-addition)),
297 color-mix(in srgb, var(--code-background) calc(70% - var(--appearance-contrast-boost, 0%) / 4), var(--diff-addition))
298 );
299 --diffs-bg-addition-number-override: light-dark(
300 color-mix(in srgb, var(--code-background) calc(35% - var(--appearance-contrast-boost, 0%) / 4), var(--diff-addition)),
301 color-mix(in srgb, var(--code-background) calc(60% - var(--appearance-contrast-boost, 0%) / 4), var(--diff-addition))
302 );
303 --diffs-bg-addition-hover-override: color-mix(in srgb, var(--code-background) 85%, var(--diff-addition));
304 /* Pierre resolves emphasis on the host, before the diff's overrides can inherit. */
305 /* Light emphasis stays above t3-light's readable syntax luminance floor. */
306 --diffs-bg-addition-emphasis: light-dark(
307 rgb(from var(--diffs-addition-base) r g b / 15%),
308 rgb(from var(--diffs-addition-base) r g b / calc(20% + var(--appearance-contrast-boost, 0%) / 4))
309 );
310
311 --diffs-bg-deletion-override: light-dark(
312 color-mix(in srgb, var(--code-background) calc(50% - var(--appearance-contrast-boost, 0%) / 4), var(--diff-deletion)),
313 color-mix(in srgb, var(--code-background) calc(70% - var(--appearance-contrast-boost, 0%) / 4), var(--diff-deletion))
314 );
315 --diffs-bg-deletion-number-override: light-dark(
316 color-mix(in srgb, var(--code-background) calc(35% - var(--appearance-contrast-boost, 0%) / 4), var(--diff-deletion)),
317 color-mix(in srgb, var(--code-background) calc(60% - var(--appearance-contrast-boost, 0%) / 4), var(--diff-deletion))
318 );
319 --diffs-bg-deletion-hover-override: color-mix(
320 in srgb,
321 var(--code-background) 85%,
322 var(--diff-deletion)
323 );
324 --diffs-bg-deletion-emphasis: light-dark(
325 rgb(from var(--diffs-deletion-base) r g b / 15%),
326 rgb(from var(--diffs-deletion-base) r g b / calc(20% + var(--appearance-contrast-boost, 0%) / 4))
327 );
328
329 background-color: var(--diffs-bg) !important;
330 color: var(--code-foreground) !important;
331}
332
333/* The library's change-icon colors are tuned for dark canvases; light mode takes the app's darker tokens. */
334[data-change-icon="new"] {
335 color: var(
336 --diff-added-icon,
337 light-dark(var(--diff-addition-foreground), var(--diffs-addition-base))
338 ) !important;
339}
340[data-change-icon="deleted"] {
341 color: light-dark(var(--diff-deletion-foreground), var(--diffs-deletion-base)) !important;
342}
343/* The page sets --diff-modified-icon to a muted T3 blue, so the common case stays quiet. */
344[data-change-icon="change"],
345[data-change-icon="rename-pure"],
346[data-change-icon="rename-changed"] {
347 color: var(
348 --diff-modified-icon,
349 light-dark(var(--info-foreground), var(--diffs-modified-base))
350 ) !important;
351}
352`;