| 1 | import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; |
| 2 | import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; |
| 3 | import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; |
| 4 | import type { ReviewDiffPreviewSource } from "@t3tools/contracts"; |
| 5 | import * as Arr from "effect/Array"; |
| 6 | import { pipe } from "effect/Function"; |
| 7 | import * as Order from "effect/Order"; |
| 8 | |
| 9 | export type ReviewSectionKind = "turn" | "working-tree" | "branch-range"; |
| 10 | |
| 11 | const CHANGES_SECTION_ID = "git:branch-range"; |
| 12 | const CHANGES_TITLE = "Changes"; |
| 13 | const UNCOMMITTED_SUBTITLE = "Staged, unstaged, and untracked files"; |
| 14 | |
| 15 | export interface ReviewSectionItem { |
| 16 | readonly id: string; |
| 17 | readonly kind: ReviewSectionKind; |
| 18 | readonly title: string; |
| 19 | readonly subtitle: string | null; |
| 20 | readonly diff: string | null; |
| 21 | readonly isLoading: boolean; |
| 22 | readonly files?: ReviewDiffPreviewSource["files"]; |
| 23 | readonly truncated?: boolean; |
| 24 | readonly source?: ReviewDiffPreviewSource; |
| 25 | } |
| 26 | |
| 27 | export interface ReviewRenderableHunkRow { |
| 28 | readonly kind: "hunk"; |
| 29 | readonly id: string; |
| 30 | readonly header: string; |
| 31 | readonly context: string | null; |
| 32 | } |
| 33 | |
| 34 | export interface ReviewRenderableLineRow { |
| 35 | readonly kind: "line"; |
| 36 | readonly id: string; |
| 37 | readonly change: "context" | "add" | "delete"; |
| 38 | readonly oldLineNumber: number | null; |
| 39 | readonly newLineNumber: number | null; |
| 40 | readonly content: string; |
| 41 | readonly additionTokenIndex: number | null; |
| 42 | readonly deletionTokenIndex: number | null; |
| 43 | readonly comparison: { |
| 44 | readonly change: "add" | "delete"; |
| 45 | readonly tokenIndex: number; |
| 46 | } | null; |
| 47 | } |
| 48 | |
| 49 | export type ReviewRenderableRow = ReviewRenderableHunkRow | ReviewRenderableLineRow; |
| 50 | |
| 51 | export interface ReviewRenderableFile { |
| 52 | readonly id: string; |
| 53 | readonly cacheKey: string; |
| 54 | readonly notice?: string; |
| 55 | readonly path: string; |
| 56 | readonly previousPath: string | null; |
| 57 | readonly changeType: ChangeTypes; |
| 58 | readonly additions: number; |
| 59 | readonly deletions: number; |
| 60 | readonly languageHint: string | null; |
| 61 | readonly additionLines: ReadonlyArray<string>; |
| 62 | readonly deletionLines: ReadonlyArray<string>; |
| 63 | readonly rows: ReadonlyArray<ReviewRenderableRow>; |
| 64 | } |
| 65 | |
| 66 | export type ReviewFilePreviewState = |
| 67 | | { |
| 68 | readonly kind: "render"; |
| 69 | } |
| 70 | | { |
| 71 | readonly kind: "suppressed"; |
| 72 | readonly reason: "non-text" | "large"; |
| 73 | readonly title: string; |
| 74 | readonly message: string; |
| 75 | readonly actionLabel: string | null; |
| 76 | }; |
| 77 | |
| 78 | export type ReviewParsedDiff = |
| 79 | | { |
| 80 | readonly kind: "empty"; |
| 81 | } |
| 82 | | { |
| 83 | readonly kind: "raw"; |
| 84 | readonly text: string; |
| 85 | readonly reason: string; |
| 86 | readonly notice: string | null; |
| 87 | } |
| 88 | | { |
| 89 | readonly kind: "files"; |
| 90 | readonly files: ReadonlyArray<ReviewRenderableFile>; |
| 91 | readonly fileCount: number; |
| 92 | readonly additions: number; |
| 93 | readonly deletions: number; |
| 94 | readonly notice: string | null; |
| 95 | }; |
| 96 | |
| 97 | function checkpointTitle(checkpoint: ThreadCheckpointSummary): string { |
| 98 | return `Turn ${checkpoint.checkpointTurnCount}`; |
| 99 | } |
| 100 | |
| 101 | function checkpointSubtitle(checkpoint: ThreadCheckpointSummary): string { |
| 102 | const fileCount = checkpoint.files.length; |
| 103 | if (checkpoint.status !== "ready") { |
| 104 | return `Diff ${checkpoint.status}`; |
| 105 | } |
| 106 | return `${fileCount} file${fileCount === 1 ? "" : "s"} changed`; |
| 107 | } |
| 108 | |
| 109 | function compareCheckpointTurnCountDescending( |
| 110 | left: ThreadCheckpointSummary, |
| 111 | right: ThreadCheckpointSummary, |
| 112 | ): -1 | 0 | 1 { |
| 113 | if (left.checkpointTurnCount === right.checkpointTurnCount) { |
| 114 | return 0; |
| 115 | } |
| 116 | |
| 117 | return left.checkpointTurnCount > right.checkpointTurnCount ? -1 : 1; |
| 118 | } |
| 119 | |
| 120 | const readyCheckpointOrder = Order.make<ThreadCheckpointSummary>( |
| 121 | compareCheckpointTurnCountDescending, |
| 122 | ); |
| 123 | |
| 124 | function gitSubtitle(section: ReviewDiffPreviewSource): string | null { |
| 125 | if (section.kind === "working-tree") { |
| 126 | return UNCOMMITTED_SUBTITLE; |
| 127 | } |
| 128 | if (section.baseRef) { |
| 129 | return `${section.baseRef} ... ${section.headRef ?? "HEAD"}`; |
| 130 | } |
| 131 | return "Base branch unavailable"; |
| 132 | } |
| 133 | |
| 134 | function stripTrailingNewline(value: string): string { |
| 135 | return value.endsWith("\n") ? value.slice(0, -1) : value; |
| 136 | } |
| 137 | |
| 138 | function splitTruncationMarker(diff: string): { |
| 139 | readonly text: string; |
| 140 | readonly truncated: boolean; |
| 141 | } { |
| 142 | const trimmed = diff.trimEnd(); |
| 143 | if (!trimmed.endsWith("[truncated]")) { |
| 144 | return { text: trimmed, truncated: false }; |
| 145 | } |
| 146 | |
| 147 | return { |
| 148 | text: trimmed.replace(/\n*\[truncated\]\s*$/, "").trimEnd(), |
| 149 | truncated: true, |
| 150 | }; |
| 151 | } |
| 152 | |
| 153 | function runDiffParserSilently<T>(callback: () => T): T { |
| 154 | const originalConsoleError = console.error; |
| 155 | console.error = () => undefined; |
| 156 | try { |
| 157 | return callback(); |
| 158 | } finally { |
| 159 | console.error = originalConsoleError; |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | const FNV_OFFSET_BASIS_32 = 0x811c9dc5; |
| 164 | const FNV_PRIME_32 = 0x01000193; |
| 165 | const SECONDARY_HASH_SEED = 0x9e3779b9; |
| 166 | const SECONDARY_HASH_MULTIPLIER = 0x85ebca6b; |
| 167 | const LARGE_DIFF_LINE_THRESHOLD = 400; |
| 168 | const LARGE_DIFF_CHARACTER_THRESHOLD = 24_000; |
| 169 | const NON_TEXT_FILE_EXTENSIONS = new Set([ |
| 170 | "png", |
| 171 | "jpg", |
| 172 | "jpeg", |
| 173 | "gif", |
| 174 | "webp", |
| 175 | "bmp", |
| 176 | "ico", |
| 177 | "icns", |
| 178 | "avif", |
| 179 | "heic", |
| 180 | "tif", |
| 181 | "tiff", |
| 182 | "mp3", |
| 183 | "wav", |
| 184 | "flac", |
| 185 | "ogg", |
| 186 | "m4a", |
| 187 | "aac", |
| 188 | "mp4", |
| 189 | "mov", |
| 190 | "avi", |
| 191 | "mkv", |
| 192 | "webm", |
| 193 | "pdf", |
| 194 | "zip", |
| 195 | "gz", |
| 196 | "tgz", |
| 197 | "bz2", |
| 198 | "7z", |
| 199 | "rar", |
| 200 | "woff", |
| 201 | "woff2", |
| 202 | "ttf", |
| 203 | "otf", |
| 204 | "eot", |
| 205 | "wasm", |
| 206 | "exe", |
| 207 | "dll", |
| 208 | "so", |
| 209 | "dylib", |
| 210 | ]); |
| 211 | |
| 212 | function fnv1a32(input: string, seed: number, multiplier: number): number { |
| 213 | let hash = seed >>> 0; |
| 214 | for (let index = 0; index < input.length; index += 1) { |
| 215 | hash ^= input.charCodeAt(index); |
| 216 | hash = Math.imul(hash, multiplier) >>> 0; |
| 217 | } |
| 218 | return hash >>> 0; |
| 219 | } |
| 220 | |
| 221 | function buildPatchCacheKey(patch: string, scope: string): string { |
| 222 | const normalizedPatch = patch.trim(); |
| 223 | const primary = fnv1a32(normalizedPatch, FNV_OFFSET_BASIS_32, FNV_PRIME_32).toString(36); |
| 224 | const secondary = fnv1a32( |
| 225 | normalizedPatch, |
| 226 | SECONDARY_HASH_SEED, |
| 227 | SECONDARY_HASH_MULTIPLIER, |
| 228 | ).toString(36); |
| 229 | return `${scope}:${normalizedPatch.length}:${primary}:${secondary}`; |
| 230 | } |
| 231 | |
| 232 | function getFileExtension(path: string): string | null { |
| 233 | const match = /\.([a-z0-9]+)$/i.exec(path); |
| 234 | return match?.[1]?.toLowerCase() ?? null; |
| 235 | } |
| 236 | |
| 237 | function countReviewRenderableLineRows(file: ReviewRenderableFile): number { |
| 238 | return file.rows.reduce((total, row) => total + (row.kind === "line" ? 1 : 0), 0); |
| 239 | } |
| 240 | |
| 241 | function countReviewRenderableCharacters(file: ReviewRenderableFile): number { |
| 242 | return file.rows.reduce( |
| 243 | (total, row) => total + (row.kind === "line" ? row.content.length : row.header.length), |
| 244 | 0, |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | export function getReviewFilePreviewState(file: ReviewRenderableFile): ReviewFilePreviewState { |
| 249 | const extension = getFileExtension(file.path); |
| 250 | if (extension && NON_TEXT_FILE_EXTENSIONS.has(extension)) { |
| 251 | return { |
| 252 | kind: "suppressed", |
| 253 | reason: "non-text", |
| 254 | title: "Non-text file", |
| 255 | message: "Diff preview is not available for this file format.", |
| 256 | actionLabel: null, |
| 257 | }; |
| 258 | } |
| 259 | |
| 260 | const lineCount = countReviewRenderableLineRows(file); |
| 261 | const characterCount = countReviewRenderableCharacters(file); |
| 262 | if (lineCount > LARGE_DIFF_LINE_THRESHOLD || characterCount > LARGE_DIFF_CHARACTER_THRESHOLD) { |
| 263 | return { |
| 264 | kind: "suppressed", |
| 265 | reason: "large", |
| 266 | title: "Large diff", |
| 267 | message: "Large diffs are not rendered by default.", |
| 268 | actionLabel: "Load diff", |
| 269 | }; |
| 270 | } |
| 271 | |
| 272 | return { kind: "render" }; |
| 273 | } |
| 274 | |
| 275 | function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string { |
| 276 | return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`; |
| 277 | } |
| 278 | |
| 279 | function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRenderableRow> { |
| 280 | const rows: ReviewRenderableRow[] = []; |
| 281 | let rowIndex = 0; |
| 282 | |
| 283 | file.hunks.forEach((hunk, hunkIndex) => { |
| 284 | rows.push({ |
| 285 | kind: "hunk", |
| 286 | id: `${file.cacheKey ?? file.name}:hunk:${hunkIndex}`, |
| 287 | header: fallbackHunkHeader(hunk), |
| 288 | context: hunk.hunkContext ? stripTrailingNewline(hunk.hunkContext) : null, |
| 289 | }); |
| 290 | |
| 291 | let deletionLineNumber = hunk.deletionStart; |
| 292 | let additionLineNumber = hunk.additionStart; |
| 293 | let deletionTokenIndex = hunk.deletionLineIndex; |
| 294 | let additionTokenIndex = hunk.additionLineIndex; |
| 295 | |
| 296 | hunk.hunkContent.forEach((segment) => { |
| 297 | if (segment.type === "context") { |
| 298 | for (let index = 0; index < segment.lines; index += 1) { |
| 299 | rows.push({ |
| 300 | kind: "line", |
| 301 | id: `${file.cacheKey ?? file.name}:row:${rowIndex++}`, |
| 302 | change: "context", |
| 303 | oldLineNumber: deletionLineNumber, |
| 304 | newLineNumber: additionLineNumber, |
| 305 | content: stripTrailingNewline( |
| 306 | file.additionLines[additionTokenIndex] ?? |
| 307 | file.deletionLines[deletionTokenIndex] ?? |
| 308 | "", |
| 309 | ), |
| 310 | additionTokenIndex, |
| 311 | deletionTokenIndex, |
| 312 | comparison: null, |
| 313 | }); |
| 314 | deletionLineNumber += 1; |
| 315 | additionLineNumber += 1; |
| 316 | deletionTokenIndex += 1; |
| 317 | additionTokenIndex += 1; |
| 318 | } |
| 319 | return; |
| 320 | } |
| 321 | |
| 322 | const pairedLineCount = Math.min(segment.deletions, segment.additions); |
| 323 | const deletionTokenIndexStart = deletionTokenIndex; |
| 324 | const additionTokenIndexStart = additionTokenIndex; |
| 325 | |
| 326 | for (let index = 0; index < segment.deletions; index += 1) { |
| 327 | rows.push({ |
| 328 | kind: "line", |
| 329 | id: `${file.cacheKey ?? file.name}:row:${rowIndex++}`, |
| 330 | change: "delete", |
| 331 | oldLineNumber: deletionLineNumber, |
| 332 | newLineNumber: null, |
| 333 | content: stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""), |
| 334 | additionTokenIndex: null, |
| 335 | deletionTokenIndex, |
| 336 | comparison: |
| 337 | index < pairedLineCount |
| 338 | ? { |
| 339 | change: "add", |
| 340 | tokenIndex: additionTokenIndexStart + index, |
| 341 | } |
| 342 | : null, |
| 343 | }); |
| 344 | deletionLineNumber += 1; |
| 345 | deletionTokenIndex += 1; |
| 346 | } |
| 347 | |
| 348 | for (let index = 0; index < segment.additions; index += 1) { |
| 349 | rows.push({ |
| 350 | kind: "line", |
| 351 | id: `${file.cacheKey ?? file.name}:row:${rowIndex++}`, |
| 352 | change: "add", |
| 353 | oldLineNumber: null, |
| 354 | newLineNumber: additionLineNumber, |
| 355 | content: stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""), |
| 356 | additionTokenIndex, |
| 357 | deletionTokenIndex: null, |
| 358 | comparison: |
| 359 | index < pairedLineCount |
| 360 | ? { |
| 361 | change: "delete", |
| 362 | tokenIndex: deletionTokenIndexStart + index, |
| 363 | } |
| 364 | : null, |
| 365 | }); |
| 366 | additionLineNumber += 1; |
| 367 | additionTokenIndex += 1; |
| 368 | } |
| 369 | }); |
| 370 | }); |
| 371 | |
| 372 | return rows; |
| 373 | } |
| 374 | |
| 375 | function mapRenderableFile(file: FileDiffMetadata): ReviewRenderableFile { |
| 376 | const path = file.name || file.prevName || ""; |
| 377 | const previousPath = file.prevName || null; |
| 378 | const additions = file.hunks.reduce((total, hunk) => total + hunk.additionLines, 0); |
| 379 | const deletions = file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0); |
| 380 | const cacheKey = file.cacheKey ?? `${previousPath ?? "none"}:${path}:${file.type}`; |
| 381 | |
| 382 | return { |
| 383 | id: cacheKey, |
| 384 | cacheKey, |
| 385 | path, |
| 386 | previousPath, |
| 387 | changeType: file.type, |
| 388 | additions, |
| 389 | deletions, |
| 390 | languageHint: file.lang ?? null, |
| 391 | additionLines: file.additionLines, |
| 392 | deletionLines: file.deletionLines, |
| 393 | rows: buildRenderableRows(file), |
| 394 | }; |
| 395 | } |
| 396 | |
| 397 | export function getReviewSectionIdForCheckpoint( |
| 398 | checkpoint: Pick<ThreadCheckpointSummary, "checkpointTurnCount">, |
| 399 | ): string { |
| 400 | return `turn:${checkpoint.checkpointTurnCount}`; |
| 401 | } |
| 402 | |
| 403 | export function getReadyReviewCheckpoints( |
| 404 | checkpoints: ReadonlyArray<ThreadCheckpointSummary>, |
| 405 | ): ReadonlyArray<ThreadCheckpointSummary> { |
| 406 | return pipe( |
| 407 | checkpoints, |
| 408 | Arr.filter((checkpoint) => checkpoint.status === "ready"), |
| 409 | Arr.sort(readyCheckpointOrder), |
| 410 | ); |
| 411 | } |
| 412 | |
| 413 | export function buildReviewSectionItems(input: { |
| 414 | readonly checkpoints: ReadonlyArray<ThreadCheckpointSummary>; |
| 415 | readonly gitSections: ReadonlyArray<ReviewDiffPreviewSource>; |
| 416 | readonly turnDiffById: Readonly<Record<string, string | undefined>>; |
| 417 | readonly loadingTurnIds: Readonly<Record<string, boolean | undefined>>; |
| 418 | readonly loadingGitSections: boolean; |
| 419 | }): ReadonlyArray<ReviewSectionItem> { |
| 420 | const turnItems = getReadyReviewCheckpoints(input.checkpoints).map<ReviewSectionItem>( |
| 421 | (checkpoint) => { |
| 422 | const id = getReviewSectionIdForCheckpoint(checkpoint); |
| 423 | return { |
| 424 | id, |
| 425 | kind: "turn", |
| 426 | title: checkpointTitle(checkpoint), |
| 427 | subtitle: checkpointSubtitle(checkpoint), |
| 428 | diff: input.turnDiffById[id] ?? null, |
| 429 | isLoading: input.loadingTurnIds[id] === true, |
| 430 | }; |
| 431 | }, |
| 432 | ); |
| 433 | |
| 434 | const gitItems = input.gitSections.map<ReviewSectionItem>((section) => ({ |
| 435 | id: `git:${section.kind}`, |
| 436 | kind: section.kind, |
| 437 | title: section.title, |
| 438 | subtitle: gitSubtitle(section), |
| 439 | diff: section.diff, |
| 440 | source: section, |
| 441 | ...(section.files ? { files: section.files } : {}), |
| 442 | truncated: section.truncated, |
| 443 | isLoading: false, |
| 444 | })); |
| 445 | // Changes is the default section, so it holds the place while git sources load. |
| 446 | const hasChangesItem = gitItems.some((item) => item.id === CHANGES_SECTION_ID); |
| 447 | const visibleGitItems = |
| 448 | input.loadingGitSections && !hasChangesItem |
| 449 | ? [ |
| 450 | { |
| 451 | id: CHANGES_SECTION_ID, |
| 452 | kind: "branch-range", |
| 453 | title: CHANGES_TITLE, |
| 454 | subtitle: null, |
| 455 | diff: null, |
| 456 | isLoading: true, |
| 457 | } satisfies ReviewSectionItem, |
| 458 | ...gitItems, |
| 459 | ] |
| 460 | : gitItems; |
| 461 | |
| 462 | return [...turnItems, ...visibleGitItems]; |
| 463 | } |
| 464 | |
| 465 | /** Prefers Changes, then the first section (a turn when the project is not a git repo). */ |
| 466 | export function getDefaultReviewSectionId( |
| 467 | sections: ReadonlyArray<ReviewSectionItem>, |
| 468 | ): string | null { |
| 469 | return (sections.find((section) => section.id === CHANGES_SECTION_ID) ?? sections[0])?.id ?? null; |
| 470 | } |
| 471 | |
| 472 | export function buildReviewParsedDiff( |
| 473 | diff: string | null | undefined, |
| 474 | cacheScope: string, |
| 475 | ): ReviewParsedDiff { |
| 476 | const normalized = diff?.trim(); |
| 477 | if (!normalized) { |
| 478 | return { kind: "empty" }; |
| 479 | } |
| 480 | |
| 481 | const { text, truncated } = splitTruncationMarker(normalized); |
| 482 | if (text.length === 0) { |
| 483 | return { kind: "empty" }; |
| 484 | } |
| 485 | |
| 486 | const notice = truncated |
| 487 | ? "Diff output hit the server size cap. Showing the available excerpt." |
| 488 | : null; |
| 489 | |
| 490 | try { |
| 491 | const parsedPatches = runDiffParserSilently(() => |
| 492 | parsePatchFiles(text, buildPatchCacheKey(text, cacheScope)), |
| 493 | ); |
| 494 | const files = pipe( |
| 495 | parsedPatches, |
| 496 | Arr.flatMap((patch) => patch.files), |
| 497 | Arr.map(mapRenderableFile), |
| 498 | ); |
| 499 | |
| 500 | if (files.length === 0) { |
| 501 | return { |
| 502 | kind: "raw", |
| 503 | text, |
| 504 | reason: truncated |
| 505 | ? "Diff was truncated before it could be parsed completely. Showing the raw excerpt." |
| 506 | : "Unsupported diff format. Showing raw patch.", |
| 507 | notice, |
| 508 | }; |
| 509 | } |
| 510 | |
| 511 | return { |
| 512 | kind: "files", |
| 513 | files, |
| 514 | fileCount: files.length, |
| 515 | additions: files.reduce((total, file) => total + file.additions, 0), |
| 516 | deletions: files.reduce((total, file) => total + file.deletions, 0), |
| 517 | notice, |
| 518 | }; |
| 519 | } catch { |
| 520 | return { |
| 521 | kind: "raw", |
| 522 | text, |
| 523 | reason: truncated |
| 524 | ? "Diff was truncated before it could be parsed completely. Showing the raw excerpt." |
| 525 | : "Failed to parse patch. Showing raw patch.", |
| 526 | notice, |
| 527 | }; |
| 528 | } |
| 529 | } |
| 530 | |
| 531 | export function applyReviewDiffMetadata( |
| 532 | previewDiff: ReviewParsedDiff, |
| 533 | selectedSection: Pick<ReviewSectionItem, "files" | "truncated"> | null, |
| 534 | ): ReviewParsedDiff { |
| 535 | if (previewDiff.kind === "empty") return previewDiff; |
| 536 | const notice = selectedSection?.truncated |
| 537 | ? `This preview exceeds the size limit. Changes shown are incomplete.${selectedSection.files ? " Counts include all changes." : ""}` |
| 538 | : previewDiff.notice; |
| 539 | if (previewDiff.kind !== "files" || !selectedSection?.files) return { ...previewDiff, notice }; |
| 540 | const totals = selectedSection.files.reduce( |
| 541 | (total, file) => ({ |
| 542 | additions: total.additions + file.additions, |
| 543 | deletions: total.deletions + file.deletions, |
| 544 | }), |
| 545 | { additions: 0, deletions: 0 }, |
| 546 | ); |
| 547 | const stats = new Map(selectedSection.files.map((file) => [file.path, file])); |
| 548 | const files = previewDiff.files.map((file) => { |
| 549 | const stat = stats.get(file.path); |
| 550 | return stat ? { ...file, additions: stat.additions, deletions: stat.deletions } : file; |
| 551 | }); |
| 552 | return { ...previewDiff, ...totals, files, fileCount: selectedSection.files.length, notice }; |
| 553 | } |