1import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
2import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types";
3import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints";
4import type { ReviewDiffPreviewSource } from "@t3tools/contracts";
5import * as Arr from "effect/Array";
6import { pipe } from "effect/Function";
7import * as Order from "effect/Order";
8
9export type ReviewSectionKind = "turn" | "working-tree" | "branch-range";
10
11const CHANGES_SECTION_ID = "git:branch-range";
12const CHANGES_TITLE = "Changes";
13const UNCOMMITTED_SUBTITLE = "Staged, unstaged, and untracked files";
14
15export 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
27export interface ReviewRenderableHunkRow {
28 readonly kind: "hunk";
29 readonly id: string;
30 readonly header: string;
31 readonly context: string | null;
32}
33
34export 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
49export type ReviewRenderableRow = ReviewRenderableHunkRow | ReviewRenderableLineRow;
50
51export 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
66export 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
78export 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
97function checkpointTitle(checkpoint: ThreadCheckpointSummary): string {
98 return `Turn ${checkpoint.checkpointTurnCount}`;
99}
100
101function 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
109function 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
120const readyCheckpointOrder = Order.make<ThreadCheckpointSummary>(
121 compareCheckpointTurnCountDescending,
122);
123
124function 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
134function stripTrailingNewline(value: string): string {
135 return value.endsWith("\n") ? value.slice(0, -1) : value;
136}
137
138function 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
153function 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
163const FNV_OFFSET_BASIS_32 = 0x811c9dc5;
164const FNV_PRIME_32 = 0x01000193;
165const SECONDARY_HASH_SEED = 0x9e3779b9;
166const SECONDARY_HASH_MULTIPLIER = 0x85ebca6b;
167const LARGE_DIFF_LINE_THRESHOLD = 400;
168const LARGE_DIFF_CHARACTER_THRESHOLD = 24_000;
169const 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
212function 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
221function 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
232function getFileExtension(path: string): string | null {
233 const match = /\.([a-z0-9]+)$/i.exec(path);
234 return match?.[1]?.toLowerCase() ?? null;
235}
236
237function countReviewRenderableLineRows(file: ReviewRenderableFile): number {
238 return file.rows.reduce((total, row) => total + (row.kind === "line" ? 1 : 0), 0);
239}
240
241function 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
248export 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
275function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string {
276 return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`;
277}
278
279function 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
375function 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
397export function getReviewSectionIdForCheckpoint(
398 checkpoint: Pick<ThreadCheckpointSummary, "checkpointTurnCount">,
399): string {
400 return `turn:${checkpoint.checkpointTurnCount}`;
401}
402
403export 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
413export 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). */
466export 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
472export 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
531export 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}