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}