/** * DiffRenderer Component * * Renders unified diff with syntax highlighting. * Shows filename, +N/-M stats, and colored diff lines. */ import { useState } from 'react'; import { FileEdit, ChevronDown, ChevronRight, Copy, Check } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { DiffDisplayData } from '@dexto/core'; interface DiffRendererProps { /** Diff display data from tool result */ data: DiffDisplayData; /** Maximum lines before truncation (default: 50) */ maxLines?: number; /** Whether to start expanded (default: false) */ defaultExpanded?: boolean; } // ============================================================================= // Diff Parsing (ported from CLI) // ============================================================================= interface ParsedHunk { oldStart: number; newStart: number; lines: ParsedLine[]; } interface ParsedLine { type: 'context' | 'addition' | 'deletion'; content: string; lineNum: number; } /** * Parse unified diff into structured hunks. */ function parseUnifiedDiff(unified: string): ParsedHunk[] { const lines = unified.split('\n'); const hunks: ParsedHunk[] = []; let currentHunk: ParsedHunk | null = null; let oldLine = 0; let newLine = 0; for (const line of lines) { if (line.startsWith('---') || line.startsWith('+++') || line.startsWith('Index:')) { continue; } const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); if (hunkMatch) { if (currentHunk) { hunks.push(currentHunk); } oldLine = parseInt(hunkMatch[1]!, 10); newLine = parseInt(hunkMatch[3]!, 10); currentHunk = { oldStart: oldLine, newStart: newLine, lines: [], }; continue; } if (!currentHunk) continue; if (line.startsWith('+')) { currentHunk.lines.push({ type: 'addition', content: line.slice(1), lineNum: newLine++, }); } else if (line.startsWith('-')) { currentHunk.lines.push({ type: 'deletion', content: line.slice(1), lineNum: oldLine++, }); } else if (line.startsWith(' ') || line === '') { currentHunk.lines.push({ type: 'context', content: line.startsWith(' ') ? line.slice(1) : line, lineNum: newLine, }); oldLine++; newLine++; } } if (currentHunk) { hunks.push(currentHunk); } return hunks; } /** * Get line number width for consistent alignment. */ function getLineNumWidth(maxLineNum: number): number { return Math.max(3, String(maxLineNum).length); } /** * Format line number with padding. */ function formatLineNum(num: number, width: number): string { return String(num).padStart(width, ' '); } // ============================================================================= // Line Components // ============================================================================= interface DiffLineProps { type: 'context' | 'addition' | 'deletion'; lineNum: number; lineNumWidth: number; content: string; } /** * Render a single diff line with gutter and content. */ function DiffLine({ type, lineNum, lineNumWidth, content }: DiffLineProps) { const lineNumStr = formatLineNum(lineNum, lineNumWidth); const getStyles = () => { switch (type) { case 'deletion': return { bg: 'bg-red-100/50 dark:bg-red-900/20', text: 'text-red-800 dark:text-red-300', symbol: '-', symbolColor: 'text-red-600 dark:text-red-400', }; case 'addition': return { bg: 'bg-green-100/50 dark:bg-green-900/20', text: 'text-green-800 dark:text-green-300', symbol: '+', symbolColor: 'text-green-600 dark:text-green-400', }; default: return { bg: '', text: 'text-foreground/60', symbol: ' ', symbolColor: 'text-transparent', }; } }; const styles = getStyles(); return (
{/* Gutter: line number + symbol */}
{lineNumStr} {styles.symbol}
{/* Content */}
                {content || ' '}
            
); } /** * Hunk separator. */ function HunkSeparator() { return (
ยทยทยท
); } // ============================================================================= // Main Component // ============================================================================= /** * Extract relative path from full path. */ function getRelativePath(path: string): string { const parts = path.split('/').filter(Boolean); if (parts.length <= 3) return path; return `.../${parts.slice(-3).join('/')}`; } /** * Renders unified diff with syntax highlighting and line numbers. */ export function DiffRenderer({ data, maxLines = 50, defaultExpanded = false }: DiffRendererProps) { const { unified, filename, additions, deletions } = data; const [expanded, setExpanded] = useState(defaultExpanded); const [showAll, setShowAll] = useState(false); const [copied, setCopied] = useState(false); const hunks = parseUnifiedDiff(unified); // Calculate max line number for width let maxLineNum = 1; let totalLines = 0; for (const hunk of hunks) { for (const line of hunk.lines) { maxLineNum = Math.max(maxLineNum, line.lineNum); totalLines++; } } const lineNumWidth = getLineNumWidth(maxLineNum); const shouldTruncate = totalLines > maxLines && !showAll; const handleCopy = async () => { await navigator.clipboard.writeText(unified); setCopied(true); setTimeout(() => setCopied(false), 2000); }; return (
{/* Header with filename and stats */}
{getRelativePath(filename)}
+{additions} -{deletions}
{/* Diff content */}
{!expanded ? ( ) : (
{(() => { let linesRendered = 0; return hunks.map((hunk, hunkIndex) => { if (shouldTruncate && linesRendered >= maxLines) { return null; } return (
{hunkIndex > 0 && } {hunk.lines.map((line, lineIndex) => { if ( shouldTruncate && linesRendered >= maxLines ) { return null; } linesRendered++; return ( ); })}
); }); })()}
{shouldTruncate && ( )}
)}
); }