'use client' import * as React from 'react' import { FileText, GitBranch, Loader2, RefreshCw } from 'lucide-react' import { cn } from '../../lib/utils' export interface DiffSummary { totalFiles: number staged?: number unstaged?: number } export interface DiffViewerProps { /** A raw unified diff (concatenated `git diff` output). */ diff: string /** The ref/branch the diff is computed against (shown in the header). */ baseRef?: string /** Optional counts summary rendered next to the base ref. */ summary?: DiffSummary /** Raw `git status` text, shown in a collapsible "Working tree status" block. */ status?: string isLoading?: boolean error?: string | null onRefresh?: () => void /** Message shown when the diff is empty. Default "No changes yet". */ emptyLabel?: string className?: string } export interface ParsedDiffFile { filePath: string /** All lines of the file's diff block, including headers. */ lines: string[] } /** * Split a raw unified diff into per-file blocks. Ported from dev-hq's GitDiffViewer: * split on `diff --git`, derive the file path from the `b/…` side of the header. * Pure + exported so it can be unit-tested without rendering. */ export function parseDiffFiles(diff: string): ParsedDiffFile[] { if (!diff || !diff.trim()) return [] return diff .split(/(?=diff --git)/g) .filter((block) => block.trim().length > 0) .map((block) => { const lines = block.split('\n') const header = lines.find((l) => l.startsWith('diff --git')) const filePath = header?.match(/ b\/(.+)$/)?.[1] ?? header?.match(/b\/(.+)$/)?.[1] ?? 'unknown' return { filePath, lines } }) } /** Tailwind-token class for a single diff line based on its leading character. */ export function diffLineClass(line: string): string { if (line.startsWith('@@')) return 'text-cyan-600 dark:text-cyan-400 bg-cyan-500/10 font-semibold' if (line.startsWith('+')) return 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10' if (line.startsWith('-')) return 'text-red-600 dark:text-red-400 bg-red-500/10' return 'text-muted-foreground' } /** Metadata lines that carry no visual value in the rendered diff. */ function isMetadataLine(line: string): boolean { return ( line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ') || line.startsWith('new file mode') || line.startsWith('deleted file mode') || line.startsWith('similarity index') || line.startsWith('rename from') || line.startsWith('rename to') ) } /** * A presentational unified-diff viewer. It does NO fetching — pass `diff` plus * loading/error flags and an `onRefresh` callback; the parent owns the data. * Colourised with tailwind tokens (works in light + dark). */ export function DiffViewer({ diff, baseRef, summary, status, isLoading = false, error = null, onRefresh, emptyLabel = 'No changes yet', className, }: DiffViewerProps) { const files = React.useMemo(() => parseDiffFiles(diff), [diff]) if (isLoading) { return (
Loading diff…
{error}
{onRefresh && ( )}
No changes compared to{' '}
{baseRef}
{baseRef}
{status}
{file.lines.map((line, index) =>
isMetadataLine(line) ? null : (
{line || ' '}
),
)}