FileDiffView.tsx

  1// Collapsible diff view for a single file in a commit.
  2// Diff is fetched lazily on first expand to avoid loading large diffs upfront.
  3
  4import { useState } from 'react'
  5import { ChevronRight, FilePlus, FileMinus, FileEdit } from 'lucide-react'
  6import { cn } from '@/lib/utils'
  7import { getCommitDiff } from '@/lib/gitApi'
  8import type { FileDiff, DiffHunk } from '@/lib/gitApi'
  9
 10interface FileDiffViewProps {
 11  sha: string
 12  path: string
 13  oldPath?: string
 14  status: 'added' | 'modified' | 'deleted' | 'renamed'
 15}
 16
 17const statusIcon = {
 18  added:    <FilePlus  className="size-3.5 text-green-600 dark:text-green-400" />,
 19  deleted:  <FileMinus className="size-3.5 text-red-500  dark:text-red-400" />,
 20  modified: <FileEdit  className="size-3.5 text-yellow-500 dark:text-yellow-400" />,
 21  renamed:  <FileEdit  className="size-3.5 text-blue-500  dark:text-blue-400" />,
 22}
 23
 24const statusBadge = { added: 'A', deleted: 'D', modified: 'M', renamed: 'R' }
 25
 26export function FileDiffView({ sha, path, oldPath, status }: FileDiffViewProps) {
 27  const [open, setOpen] = useState(false)
 28  const [diff, setDiff] = useState<FileDiff | null>(null)
 29  const [loading, setLoading] = useState(false)
 30  const [error, setError] = useState<string | null>(null)
 31
 32  function toggle() {
 33    if (!open && diff === null && !loading) {
 34      setLoading(true)
 35      getCommitDiff(sha, path)
 36        .then(setDiff)
 37        .catch((e: Error) => setError(e.message))
 38        .finally(() => setLoading(false))
 39    }
 40    setOpen((v) => !v)
 41  }
 42
 43  return (
 44    <div className="divide-y divide-border">
 45      {/* File header row — always visible, click to toggle */}
 46      <button
 47        onClick={toggle}
 48        className="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-muted/50 transition-colors"
 49      >
 50        <ChevronRight
 51          className={cn(
 52            'size-3.5 shrink-0 text-muted-foreground transition-transform duration-150',
 53            open && 'rotate-90',
 54          )}
 55        />
 56        {statusIcon[status]}
 57        <span className="min-w-0 flex-1 font-mono text-sm">
 58          {status === 'renamed' ? (
 59            <>
 60              <span className="text-muted-foreground line-through">{oldPath}</span>
 61              {' → '}
 62              <span>{path}</span>
 63            </>
 64          ) : path}
 65        </span>
 66        <span className="shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
 67          {statusBadge[status]}
 68        </span>
 69      </button>
 70
 71      {/* Diff body */}
 72      {open && (
 73        <div className="overflow-x-auto">
 74          {loading && (
 75            <div className="px-4 py-3 text-xs text-muted-foreground">Loading diff</div>
 76          )}
 77          {error && (
 78            <div className="px-4 py-3 text-xs text-destructive">Failed to load diff: {error}</div>
 79          )}
 80          {diff && (
 81            diff.isBinary ? (
 82              <div className="px-4 py-3 text-xs text-muted-foreground">Binary file</div>
 83            ) : diff.hunks.length === 0 ? (
 84              <div className="px-4 py-3 text-xs text-muted-foreground">No changes</div>
 85            ) : (
 86              diff.hunks.map((hunk, i) => <Hunk key={i} hunk={hunk} />)
 87            )
 88          )}
 89        </div>
 90      )}
 91    </div>
 92  )
 93}
 94
 95function Hunk({ hunk }: { hunk: DiffHunk }) {
 96  return (
 97    <div className="font-mono text-xs leading-5">
 98      {/* Hunk header */}
 99      <div className="bg-blue-50 px-4 py-0.5 text-blue-600 dark:bg-blue-950/40 dark:text-blue-400 select-none">
100        @@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
101      </div>
102      {hunk.lines.map((line, i) => (
103        <div
104          key={i}
105          className={cn(
106            'flex',
107            line.type === 'added'   && 'bg-green-50  dark:bg-green-950/30',
108            line.type === 'deleted' && 'bg-red-50    dark:bg-red-950/30',
109          )}
110        >
111          {/* Old line number */}
112          <span className="w-10 shrink-0 select-none border-r border-border/50 px-2 text-right text-muted-foreground/50">
113            {line.oldLine || ''}
114          </span>
115          {/* New line number */}
116          <span className="w-10 shrink-0 select-none border-r border-border/50 px-2 text-right text-muted-foreground/50">
117            {line.newLine || ''}
118          </span>
119          {/* Sign */}
120          <span className={cn(
121            'w-5 shrink-0 select-none text-center',
122            line.type === 'added'   && 'text-green-600 dark:text-green-400',
123            line.type === 'deleted' && 'text-red-500   dark:text-red-400',
124            line.type === 'context' && 'text-muted-foreground/40',
125          )}>
126            {line.type === 'added' ? '+' : line.type === 'deleted' ? '-' : ' '}
127          </span>
128          {/* Content */}
129          <pre className={cn(
130            'flex-1 overflow-visible whitespace-pre px-2',
131            line.type === 'added'   && 'text-green-900 dark:text-green-200',
132            line.type === 'deleted' && 'text-red-900   dark:text-red-200',
133          )}>
134            {line.content}
135          </pre>
136        </div>
137      ))}
138    </div>
139  )
140}