Repository Public
1'use client';2import { useState } from 'react';3import { FileDiff } from 'lucide-react';4import type { DiffFile, DiffLine } from '@/lib/diff';5import { cn } from '@/lib/cn';67const ROW: Record<DiffLine['type'], string> = {8 ctx: 'text-text-2',9 add: 'bg-diff-add text-text',10 del: 'bg-diff-del text-text',11 meta: 'text-muted italic',12};13const SIGN: Record<DiffLine['type'], string> = { ctx: ' ', add: '+', del: '-', meta: '\\' };14const SIGN_TONE: Record<DiffLine['type'], string> = { ctx: 'text-faint', add: 'text-success', del: 'text-error', meta: 'text-muted' };15const MAX_LINES = 400;1617/** Unified diff view: line numbers, +/- gutter, added/removed colouring. Content is rendered as text only. */18export function DiffView({ files, className }: { files: DiffFile[]; className?: string }) {19 const [all, setAll] = useState(false);20 const total = files.reduce((n, f) => n + f.hunks.reduce((m, h) => m + h.lines.length, 0), 0);21 let budget = all ? Number.POSITIVE_INFINITY : MAX_LINES;22 return (23 <div className={cn('flex flex-col gap-2', className)} data-testid="diff-view">24 {files.map((f, fi) => (25 <div key={fi} className="overflow-hidden rounded-sm border border-line-2 bg-surface">26 <div className="flex items-center gap-2 border-b border-line bg-surface-2 px-3 py-1.5 text-xs">27 <FileDiff className="size-3.5 shrink-0 text-muted" aria-hidden />28 <span className="min-w-0 flex-1 truncate font-mono text-[12px] font-medium text-text" title={f.path ?? undefined}>{f.path ?? f.oldPath ?? 'changes'}</span>29 {f.note && <span className="shrink-0 text-[11px] text-muted">{f.note}</span>}30 <span className="tnum shrink-0 font-mono text-[11px]">31 <span className="text-success" data-testid="diff-added">+{f.added}</span> <span className="text-error" data-testid="diff-removed">−{f.removed}</span>32 </span>33 </div>34 <div className="overflow-x-auto">35 <table className="w-full border-collapse font-mono text-[12px] leading-[1.55]">36 <tbody>37 {f.hunks.map((h, hi) => {38 const lines = h.lines.slice(0, Math.max(0, budget));39 budget -= lines.length;40 return [41 h.header && lines.length > 0 ? (42 <tr key={`h${hi}`} className="bg-surface-2/60 text-muted">43 <td colSpan={4} className="px-3 py-0.5 text-[11px]">{h.header}</td>44 </tr>45 ) : null,46 ...lines.map((l, li) => (47 <tr key={`${hi}.${li}`} className={ROW[l.type]} data-line={l.type}>48 <td className="tnum w-9 select-none border-r border-line px-1.5 text-right text-[11px] text-faint">{l.oldNo ?? ''}</td>49 <td className="tnum w-9 select-none border-r border-line px-1.5 text-right text-[11px] text-faint">{l.newNo ?? ''}</td>50 <td className={cn('w-5 select-none pl-2 text-center font-bold', SIGN_TONE[l.type])} aria-hidden>{SIGN[l.type]}</td>51 <td className="whitespace-pre pr-3">{l.text === '' ? ' ' : l.text}</td>52 </tr>53 )),54 ];55 })}56 </tbody>57 </table>58 </div>59 </div>60 ))}61 {!all && total > MAX_LINES && (62 <button onClick={() => setAll(true)} className="self-start rounded-sm px-2 py-1 text-xs font-medium text-accent hover:bg-hover pointer-coarse:min-h-11">63 Show all {total} lines64 </button>65 )}66 </div>67 );68}69