import { useMemo, useRef, useEffect, useState } from 'react'; import { codeToHtml } from 'shiki'; import type { GitDiffFile } from '@/api/types'; import { parseDiff, buildSplitRows, reconstructNewContent, type SplitRow } from '@/utils/diff-layout'; import { inferLanguage } from '@/lib/attachments'; import { cn } from '@/lib/utils'; interface DiffSplitViewProps { file: GitDiffFile; wrapLines?: boolean; } /** Side-by-side split diff renderer. Left = deletions, right = additions. */ export function DiffSplitView({ file, wrapLines = false }: DiffSplitViewProps) { // ── Edge cases (rendered before hooks) ────────────────────────────────── if (file.is_binary) { return

Binary file

; } if (file.is_too_large) { return

Diff too large to display

; } if (file.change_type === 'untracked' && !file.diff_body) { return

Untracked file

; } if (!file.diff_body) { return

No diff content

; } return ; } /** * Inner component — assumes file.diff_body is non-null. * Separated so the early-return edge cases above don't violate rules of hooks. */ function DiffSplitViewInner({ file, wrapLines }: { file: GitDiffFile; wrapLines: boolean }) { // ── Parse diff ─────────────────────────────────────────────────────────── const parsed = useMemo(() => parseDiff(file.diff_body!), [file.diff_body]); const parsedFile = parsed[0]; const rows = useMemo(() => { if (!parsedFile) return [] as SplitRow[]; return buildSplitRows(parsedFile); }, [parsedFile]); const newContent = useMemo(() => { if (!parsedFile) return ''; return reconstructNewContent(parsedFile.hunks); }, [parsedFile]); // ── Syntax highlighting ────────────────────────────────────────────────── const [highlightedLines, setHighlightedLines] = useState(null); const [highlighting, setHighlighting] = useState(false); const highlightKeyRef = useRef(null); useEffect(() => { if (!newContent) return; if (highlightKeyRef.current === newContent) return; highlightKeyRef.current = newContent; let cancelled = false; setHighlighting(true); setHighlightedLines(null); const lang = inferLanguage(file.path) ?? 'plaintext'; void codeToHtml(newContent, { lang, theme: 'github-dark' }) .then((html) => { if (cancelled) return; const container = document.createElement('div'); // eslint-disable-next-line no-unsanitized/property container.innerHTML = html; const codeEl = container.querySelector('code'); if (codeEl) { const lineSpans = codeEl.querySelectorAll('.line'); setHighlightedLines(Array.from(lineSpans, (span) => span.innerHTML)); } else { setHighlightedLines(null); } }) .catch(() => { if (!cancelled) setHighlightedLines(null); }) .finally(() => { if (!cancelled) setHighlighting(false); }); return () => { cancelled = true; }; }, [newContent, file.path]); // ── Build new-line-number → highlighted-HTML map ─────────────────────── // Walk the hunks counting only add/context lines (which form the new file) // and map each 1-based new-line-number to its highlighted HTML string. const newLineHtmlMap = useMemo(() => { if (!highlightedLines || !parsedFile) return new Map(); const map = new Map(); let idx = 0; for (const hunk of parsedFile.hunks) { let newLineNo = hunk.newStart; for (const line of hunk.lines) { if (line.type === 'header') continue; if (line.type === 'add' || line.type === 'context') { if (idx < highlightedLines.length) { map.set(newLineNo, highlightedLines[idx]!); } idx++; newLineNo++; } } } return map; }, [highlightedLines, parsedFile]); // ── Render ─────────────────────────────────────────────────────────────── return (
{highlighting && (

Highlighting…

)} {rows.map((row, idx) => { if (row.kind === 'header') { return ( ); } const left = row.left; const right = row.right; const leftBg = left?.type === 'remove' ? 'bg-red-950/30' : ''; const rightBg = right?.type === 'add' ? 'bg-green-950/30' : ''; const leftHtml = left?.lineNumber != null ? newLineHtmlMap.get(left.lineNumber) : undefined; const rightHtml = right?.lineNumber != null ? newLineHtmlMap.get(right.lineNumber) : undefined; return ( ); })}
{row.content}
{left?.lineNumber ?? ''}
{left ? ( leftHtml ? ( // eslint-disable-next-line no-unsanitized/property ) : ( {left.content} ) ) : null}
{right?.lineNumber ?? ''}
{right ? ( rightHtml ? ( // eslint-disable-next-line no-unsanitized/property ) : ( {right.content} ) ) : null}
); }