import { ChevronDown, MoreHorizontal, Minus, Plus } from 'lucide-react'; import type { ReactElement } from 'react'; import { useMemo, useState } from 'react'; import { Decoration, Diff, Hunk, parseDiff } from 'react-diff-view'; import type { ChangeData, DiffType, GutterOptions, HunkData } from 'react-diff-view'; import 'react-diff-view/style/index.css'; const MAX_VISIBLE_DIFF_LINES = 18; interface DiffPreviewProps { /** * Pre-computed unified-diff text (produced by the backend Edit / Write * tools and delivered via ``ToolResultBlock.metadata.diff``). It carries * absolute file line numbers and naturally handles multi-hunk diffs from * ``replace_all``. The component renders nothing if this is empty. */ unifiedDiff: string; } function hunkLineCount(hunk: HunkData): number { return hunk.changes.length; } function getVisibleHunks(hunks: HunkData[], expanded: boolean): HunkData[] { if (expanded) return hunks; let visibleLineCount = 0; const visibleHunks: HunkData[] = []; for (const hunk of hunks) { const remaining = MAX_VISIBLE_DIFF_LINES - visibleLineCount; if (remaining <= 0) break; const hunkLines = hunkLineCount(hunk); if (hunkLines <= remaining) { visibleHunks.push(hunk); visibleLineCount += hunkLines; continue; } // Hunk doesn't fit in the remaining budget. If we haven't shown // anything yet (typical for new-file / full-rewrite diffs that // produce a single very large hunk), include a truncated slice so // the user still sees the start of the change. Otherwise stop so // we never overshoot ``MAX_VISIBLE_DIFF_LINES``. if (visibleHunks.length === 0) { visibleHunks.push(truncateHunkChanges(hunk, remaining)); } break; } return visibleHunks; } /** * Build a new ``HunkData`` containing only the first ``maxLines`` changes of * ``hunk``. ``oldLines`` / ``newLines`` are recomputed from the slice so * react-diff-view's line-number accounting stays internally consistent. */ function truncateHunkChanges(hunk: HunkData, maxLines: number): HunkData { if (hunk.changes.length <= maxLines) return hunk; const changes = hunk.changes.slice(0, maxLines); let oldLines = 0; let newLines = 0; for (const change of changes) { if (change.type === 'normal') { oldLines += 1; newLines += 1; } else if (change.type === 'delete') { oldLines += 1; } else if (change.type === 'insert') { newLines += 1; } } return { ...hunk, changes, oldLines, newLines }; } function countHiddenLines(allHunks: HunkData[], visibleHunks: HunkData[]): number { const total = allHunks.reduce((sum, hunk) => sum + hunkLineCount(hunk), 0); const visible = visibleHunks.reduce((sum, hunk) => sum + hunkLineCount(hunk), 0); return Math.max(0, total - visible); } function getLineClassName({ changes }: { changes: Array<{ type: string }> }): string { if (changes.some((change) => change.type === 'insert')) { return 'bg-emerald-500/10'; } if (changes.some((change) => change.type === 'delete')) { return 'bg-red-500/10'; } return 'bg-transparent'; } // Pick the line number we want to display in the single visible gutter column. // - normal rows show the new-side line number (mirrors GitHub's unified view) // - insert / delete rows only have one `lineNumber` field, so use it directly function getDisplayedLineNumber(change: ChangeData): number | undefined { if (change.type === 'normal') return change.newLineNumber; return change.lineNumber; } // Render only the "new" side of each row. The "old" side is removed // from the table layout via `visibility: collapse` (see arbitrary variants // on the table className below), so the unified diff effectively renders // as a single gutter column showing ` +/-`. function renderGutter({ change, side }: GutterOptions) { if (side === 'old') return null; let marker = null; if (change.type === 'insert') { marker = ; } else if (change.type === 'delete') { marker = ; } return ( {getDisplayedLineNumber(change)} {marker} ); } // Lines between two consecutive hunks that the unified diff omitted. // ``hunk.oldStart`` is 1-based and ``oldLines`` is the count of old-side // lines covered (including context). So the next hunk's ``oldStart`` minus // the end of the previous hunk gives the gap. function gapLinesBetween(prev: HunkData, next: HunkData): number { return next.oldStart - (prev.oldStart + prev.oldLines); } export function DiffPreview({ unifiedDiff }: DiffPreviewProps) { const [expanded, setExpanded] = useState(false); const diffFile = useMemo( () => parseDiff(unifiedDiff, { nearbySequences: 'zip' })[0], [unifiedDiff], ); if (!diffFile || diffFile.hunks.length === 0) { return
No textual changes detected.
; } const visibleHunks = getVisibleHunks(diffFile.hunks, expanded); const hiddenLines = countHiddenLines(diffFile.hunks, visibleHunks); return ( {(hunks) => { const children: ReactElement[] = []; hunks.forEach((hunk, idx) => { if (idx > 0) { // Insert an ellipsis decoration between hunks so it's // visually obvious there are skipped lines between // e.g. an edit at line 20 and another at line 70. const gap = gapLinesBetween(hunks[idx - 1], hunk); children.push(
{gap > 0 ? `${gap} unchanged line${gap === 1 ? '' : 's'}` : 'unchanged lines'}
, ); } children.push( , ); }); if (hiddenLines > 0) { children.push( , ); } return children; }}
); }