| 1 | import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; |
| 2 | import { parseDiffFromFile } from "@pierre/diffs"; |
| 3 | import type { FileDiffMetadata } from "@pierre/diffs/types"; |
| 4 | |
| 5 | const DIFF_THEME_NAMES = { |
| 6 | light: "pierre-light", |
| 7 | dark: "pierre-dark", |
| 8 | } as const; |
| 9 | |
| 10 | export type DiffThemeName = (typeof DIFF_THEME_NAMES)[keyof typeof DIFF_THEME_NAMES]; |
| 11 | |
| 12 | export function resolveDiffThemeName(theme: "light" | "dark"): DiffThemeName { |
| 13 | return theme === "dark" ? DIFF_THEME_NAMES.dark : DIFF_THEME_NAMES.light; |
| 14 | } |
| 15 | |
| 16 | const FNV_OFFSET_BASIS_32 = 0x811c9dc5; |
| 17 | const FNV_PRIME_32 = 0x01000193; |
| 18 | const SECONDARY_HASH_SEED = 0x9e3779b9; |
| 19 | const SECONDARY_HASH_MULTIPLIER = 0x85ebca6b; |
| 20 | |
| 21 | export 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 | |
| 34 | export 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 | |
| 45 | export 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 | |
| 57 | export interface DiffLineStat { |
| 58 | additions: number; |
| 59 | deletions: number; |
| 60 | } |
| 61 | |
| 62 | export 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 | |
| 76 | interface 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 | |
| 87 | function 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 | |
| 140 | function 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 | |
| 165 | export 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 | */ |
| 206 | export 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 | */ |
| 214 | export 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 | */ |
| 223 | export function buildFileDiffIdentityKey(fileDiff: FileDiffMetadata): string { |
| 224 | return `${resolveFileDiffPreviousPath(fileDiff)}\u0000${resolveFileDiffPath(fileDiff)}\u0000${fileDiff.type}`; |
| 225 | } |
| 226 | |
| 227 | export 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 | |
| 234 | function 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 | */ |
| 244 | export 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 | |
| 294 | export 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 | */ |
| 315 | export 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 | `; |