1// Collapsible diff view for a single file in a commit.
2// Diff is fetched lazily on first expand via GraphQL.
3
4import { gql } from "@apollo/client";
5import { useLazyQuery } from "@apollo/client/react";
6import { ChevronRight, FilePlus, FileMinus, FileEdit } from "lucide-react";
7import { useState } from "react";
8
9import { useRepo } from "@/lib/repo";
10import { cn } from "@/lib/utils";
11
12const DIFF_QUERY = gql`
13 query FileDiff($repo: String, $hash: String!, $path: String!) {
14 repository(ref: $repo) {
15 commit(hash: $hash) {
16 diff(path: $path) {
17 path
18 oldPath
19 isBinary
20 isNew
21 isDelete
22 hunks {
23 oldStart
24 oldLines
25 newStart
26 newLines
27 lines {
28 type
29 content
30 oldLine
31 newLine
32 }
33 }
34 }
35 }
36 }
37 }
38`;
39
40interface DiffQueryData {
41 repository: {
42 commit: {
43 diff: {
44 path: string;
45 oldPath: string | null;
46 isBinary: boolean;
47 isNew: boolean;
48 isDelete: boolean;
49 hunks: HunkType[];
50 } | null;
51 } | null;
52 } | null;
53}
54
55interface FileDiffViewProps {
56 hash: string;
57 path: string;
58 oldPath?: string;
59 status: string;
60}
61
62const statusIcon: Record<string, React.ReactNode> = {
63 ADDED: <FilePlus className="size-3.5 text-green-600 dark:text-green-400" />,
64 DELETED: <FileMinus className="size-3.5 text-red-500 dark:text-red-400" />,
65 MODIFIED: <FileEdit className="size-3.5 text-yellow-500 dark:text-yellow-400" />,
66 RENAMED: <FileEdit className="size-3.5 text-blue-500 dark:text-blue-400" />,
67};
68const statusBadge: Record<string, string> = {
69 ADDED: "A",
70 DELETED: "D",
71 MODIFIED: "M",
72 RENAMED: "R",
73};
74
75export function FileDiffView({ hash, path, oldPath, status }: FileDiffViewProps) {
76 const repo = useRepo();
77 const [open, setOpen] = useState(false);
78 const [fetchDiff, { data, loading, error }] = useLazyQuery<DiffQueryData>(DIFF_QUERY);
79
80 function toggle() {
81 if (!open && !data && !loading) {
82 void fetchDiff({ variables: { repo, hash, path } });
83 }
84 setOpen((v) => !v);
85 }
86
87 const diff = data?.repository?.commit?.diff;
88
89 return (
90 <div className="divide-border divide-y">
91 <button
92 onClick={toggle}
93 className="hover:bg-muted/50 flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors"
94 >
95 <ChevronRight
96 className={cn(
97 "size-3.5 shrink-0 text-muted-foreground transition-transform duration-150",
98 open && "rotate-90",
99 )}
100 />
101 {statusIcon[status] ?? <FileEdit className="text-muted-foreground size-3.5" />}
102 <span className="min-w-0 flex-1 font-mono text-sm">
103 {status === "RENAMED" ? (
104 <>
105 <span className="text-muted-foreground line-through">{oldPath}</span>
106 {" → "}
107 <span>{path}</span>
108 </>
109 ) : (
110 path
111 )}
112 </span>
113 <span className="border-border text-muted-foreground shrink-0 rounded-sm border px-1.5 py-0.5 font-mono text-xs">
114 {statusBadge[status] ?? "?"}
115 </span>
116 </button>
117
118 {open && (
119 <div className="overflow-x-auto">
120 {loading && <div className="text-muted-foreground px-4 py-3 text-xs">Loading diff…</div>}
121 {error && (
122 <div className="text-destructive px-4 py-3 text-xs">
123 Failed to load diff: {error.message}
124 </div>
125 )}
126 {diff &&
127 (diff.isBinary ? (
128 <div className="text-muted-foreground px-4 py-3 text-xs">Binary file</div>
129 ) : diff.hunks.length === 0 ? (
130 <div className="text-muted-foreground px-4 py-3 text-xs">No changes</div>
131 ) : (
132 diff.hunks.map((hunk: HunkType, i: number) => <Hunk key={i} hunk={hunk} />)
133 ))}
134 </div>
135 )}
136 </div>
137 );
138}
139
140type LineType = { type: string; content: string; oldLine: number; newLine: number };
141type HunkType = {
142 oldStart: number;
143 oldLines: number;
144 newStart: number;
145 newLines: number;
146 lines: LineType[];
147};
148
149function Hunk({ hunk }: { hunk: HunkType }) {
150 return (
151 <div className="font-mono text-xs leading-5">
152 <div className="bg-blue-50 px-4 py-0.5 text-blue-600 select-none dark:bg-blue-950/40 dark:text-blue-400">
153 @@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
154 </div>
155 {hunk.lines.map((line, i) => (
156 <div
157 key={i}
158 className={cn(
159 "flex",
160 line.type === "ADDED" && "bg-green-50 dark:bg-green-950/30",
161 line.type === "DELETED" && "bg-red-50 dark:bg-red-950/30",
162 )}
163 >
164 <span className="border-border/50 text-muted-foreground/50 w-10 shrink-0 border-r px-2 text-right select-none">
165 {line.oldLine || ""}
166 </span>
167 <span className="border-border/50 text-muted-foreground/50 w-10 shrink-0 border-r px-2 text-right select-none">
168 {line.newLine || ""}
169 </span>
170 <span
171 className={cn(
172 "w-5 shrink-0 select-none text-center",
173 line.type === "ADDED" && "text-green-600 dark:text-green-400",
174 line.type === "DELETED" && "text-red-500 dark:text-red-400",
175 line.type === "CONTEXT" && "text-muted-foreground/40",
176 )}
177 >
178 {line.type === "ADDED" ? "+" : line.type === "DELETED" ? "-" : " "}
179 </span>
180 <pre
181 className={cn(
182 "flex-1 overflow-visible whitespace-pre px-2",
183 line.type === "ADDED" && "text-green-900 dark:text-green-200",
184 line.type === "DELETED" && "text-red-900 dark:text-red-200",
185 )}
186 >
187 {line.content}
188 </pre>
189 </div>
190 ))}
191 </div>
192 );
193}