g1t/apps/web/app/components/blame-view.tsx

340 lines13,685 bytesCodeBlame
1import {
2 ArrowUpRight,
3 CircleDot,
4 GitCommitHorizontal,
5 GitPullRequest,
6 MessagesSquare,
7 Sparkles,
8 Terminal,
9 X,
10} from "lucide-react";
11import { Fragment, useEffect, useMemo, useState } from "react";
12import { Link, useFetcher } from "react-router";
13
14import type { Blame, Commit } from "@g1t/contracts";
15
16import type { loader as whyLoader } from "../routes/repo/why";
17import { repoAt } from "../lib/markdown-plugins";
18import { Markdown } from "./markdown";
19import { Avatar, TimeAgo } from "./ui";
20import { Skeleton } from "./ui/skeleton";
21
22/**
23 * A file with, beside each run of lines, the commit that last changed it.
24 * Picking a line opens why it is the way it is: the commit, the pull
25 * request and issue it came from, and what the agent was thinking.
26 */
27export function BlameView({
28 base,
29 path,
30 lines,
31 html,
32 blame,
33}: {
34 base: string;
35 path: string;
36 lines: string[];
37 /** Highlighted HTML per line, when the language is known. */
38 html: string[] | null;
39 blame: Blame;
40}) {
41 const commits = useMemo(
42 () => new Map<string, Commit>(blame.commits.map((commit) => [commit.hash, commit])),
43 [blame.commits],
44 );
45 // Newer commits are brighter: rank them by when they were written.
46 const age = useMemo(() => {
47 const sorted = [...blame.commits].sort((a, b) => b.authoredAt.localeCompare(a.authoredAt));
48 const rank = new Map(sorted.map((commit, index) => [commit.hash, index]));
49 return (hash: string) => 1 - (rank.get(hash) ?? 0) / Math.max(sorted.length, 1);
50 }, [blame.commits]);
51 // The run of lines picked, and the line clicked, under which the
52 // explanation opens.
53 const [picked, setPicked] = useState<{ commit: string; start: number; end: number; line: number } | null>(
54 null,
55 );
56 const [hovered, setHovered] = useState<string | null>(null);
57 const why = useFetcher<typeof whyLoader>();
58
59 useEffect(() => {
60 if (!picked) return;
61 why.load(`${base}/why/${picked.commit}?path=${encodeURIComponent(path)}`);
62 // `why` changes identity as it loads; the pick is what matters.
63 // eslint-disable-next-line react-hooks/exhaustive-deps
64 }, [picked?.commit, base, path]);
65
66 // A line named in the address is picked on arrival: `#L12`.
67 useEffect(() => {
68 const match = /^#L(\d+)$/.exec(window.location.hash);
69 if (!match) return;
70 const line = Number(match[1]);
71 const range = blame.ranges.find((r) => r.start <= line && line <= r.end);
72 if (range) {
73 setPicked({ ...range, line });
74 document.getElementById(`L${line}`)?.scrollIntoView({ block: "center" });
75 }
76 }, [blame.ranges]);
77
78 const pick = (start: number, end: number, commit: string, line: number) => {
79 // Clicking the open line again closes it.
80 if (picked?.line === line) {
81 close();
82 return;
83 }
84 setPicked({ commit, start, end, line });
85 window.history.replaceState(null, "", `#L${line}`);
86 };
87 const close = () => {
88 setPicked(null);
89 window.history.replaceState(null, "", window.location.pathname + window.location.search);
90 };
91
92 return (
93 <div>
94 <p className="flex items-center gap-2 border-b border-line px-4 py-2 text-xs text-muted">
95 <Sparkles size={13} className="text-merged" />
96 Pick any line to see why it is the way it is: the commit, the pull request and issue it
97 came from, and what the agent was thinking.
98 </p>
99 <div className="overflow-x-auto">
100 <table className="w-full border-collapse font-mono text-[0.8125rem] leading-6">
101 <tbody>
102 {blame.ranges.map((range) => {
103 const commit = commits.get(range.commit);
104 const selected = picked?.commit === range.commit;
105 const lit = hovered === range.commit;
106 const bright = age(range.commit);
107 const rows = [];
108 for (let line = range.start; line <= range.end; line++) {
109 const first = line === range.start;
110 rows.push(
111 <tr
112 key={line}
113 id={`L${line}`}
114 onMouseEnter={() => setHovered(range.commit)}
115 onMouseLeave={() => setHovered(null)}
116 onClick={() => pick(range.start, range.end, range.commit, line)}
117 className={`cursor-pointer transition-colors ${
118 selected ? "bg-accent/[0.07]" : lit ? "bg-raised/50" : ""
119 } ${first ? "border-t border-line" : ""}`}
120 >
121 <td className="w-72 max-w-72 border-r border-line px-3 align-top font-sans">
122 {first && commit && (
123 <span className="flex items-center gap-2 py-0.5 text-xs">
124 <Avatar name={commit.author.name} size={16} />
125 <span className={`min-w-0 grow truncate ${selected ? "text-fg" : "text-muted"}`}>
126 {commit.message.split("\n")[0]}
127 </span>
128 <span className="shrink-0 text-faint">
129 <TimeAgo at={commit.authoredAt} />
130 </span>
131 </span>
132 )}
133 </td>
134 <td className="w-1 p-0" aria-hidden="true">
135 <span
136 className="block h-6 w-0.5 bg-accent"
137 style={{ opacity: 0.15 + bright * 0.85 }}
138 />
139 </td>
140 <td className="w-12 px-2 text-right text-faint select-none">{line}</td>
141 <td className="pr-4 pl-2 whitespace-pre">
142 {html?.[line - 1] != null ? (
143 <span dangerouslySetInnerHTML={{ __html: html[line - 1] || " " }} />
144 ) : (
145 lines[line - 1] || " "
146 )}
147 </td>
148 </tr>,
149 );
150 // The explanation opens right under the line clicked.
151 if (picked?.line === line) {
152 rows.push(
153 <tr key={`why-${line}`}>
154 <td colSpan={4} className="border-y border-line bg-bg/60 px-3 py-3 font-sans whitespace-normal md:pl-80">
155 <div className="max-w-2xl overflow-hidden rounded-xl border border-merged/30 bg-surface shadow-xl shadow-black/40">
156 <WhyPanel
157 base={base}
158 picked={picked}
159 loading={why.state === "loading"}
160 data={why.data}
161 onClose={close}
162 />
163 </div>
164 </td>
165 </tr>,
166 );
167 }
168 }
169 return <Fragment key={range.start}>{rows}</Fragment>;
170 })}
171 </tbody>
172 </table>
173 {blame.partial && (
174 <p className="border-t border-line px-4 py-2 text-xs text-faint">
175 This file's history is long; its oldest lines are credited to the oldest commit read.
176 </p>
177 )}
178 </div>
179 </div>
180 );
181}
182
183type WhyData = Awaited<ReturnType<typeof whyLoader>>;
184
185function WhyPanel({
186 base,
187 picked,
188 loading,
189 data,
190 onClose,
191}: {
192 base: string;
193 picked: { commit: string; start: number; end: number; line: number };
194 loading: boolean;
195 data: WhyData | undefined;
196 onClose: () => void;
197}) {
198 const current = data && data.commit.hash === picked.commit ? data : null;
199 return (
200 <div>
201 <header className="flex items-center gap-2 border-b border-line px-4 py-3">
202 <Sparkles size={15} className="text-merged" />
203 <span className="grow text-sm font-medium">
204 Why line{picked.start === picked.end ? ` ${picked.start}` : `s ${picked.start}–${picked.end}`}
205 </span>
206 <button
207 type="button"
208 onClick={onClose}
209 aria-label="Close"
210 className="rounded p-1 text-faint hover:bg-raised hover:text-fg"
211 >
212 <X size={14} />
213 </button>
214 </header>
215 {!current ? (
216 <div className="space-y-3 p-4" aria-busy={loading}>
217 {[70, 90, 55].map((width) => (
218 <Skeleton key={width} className="h-3" style={{ width: `${width}%` }} />
219 ))}
220 </div>
221 ) : (
222 <div className="divide-y divide-line text-sm">
223 <section className="p-4">
224 <p className="flex items-center gap-1.5 text-xs text-faint">
225 <GitCommitHorizontal size={13} />
226 Last changed in
227 </p>
228 <Link
229 to={`${base}/commit/${current.commit.hash}`}
230 className="mt-1.5 block font-medium hover:text-accent"
231 >
232 {current.commit.message.split("\n")[0]}
233 </Link>
234 <p className="mt-1.5 flex items-center gap-2 text-xs text-muted">
235 <Avatar name={current.commit.author.name} size={14} />
236 {current.commit.author.name} · <TimeAgo at={current.commit.authoredAt} /> ·{" "}
237 <span className="font-mono">{current.commit.hash.slice(0, 7)}</span>
238 </p>
239 </section>
240
241 {current.pull ? (
242 <section className="p-4">
243 <p className="flex items-center gap-1.5 text-xs text-faint">
244 <GitPullRequest size={13} />
245 Arrived in
246 </p>
247 <Link
248 to={`${base}/pull/${current.pull.number}`}
249 className="mt-1.5 block font-medium hover:text-accent"
250 >
251 {current.pull.title} <span className="font-normal text-faint">#{current.pull.number}</span>
252 </Link>
253 <p className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">
254 {current.pull.runtime === "hosted" ? (
255 <span className="flex items-center gap-1 text-merged">
256 <Sparkles size={11} />
257 written by {current.pull.agent}
258 </span>
259 ) : (
260 <span>by {current.pull.author} with {current.pull.agent}</span>
261 )}
262 {current.pull.mergedBy && <span>· merged by {current.pull.mergedBy}</span>}
263 </p>
264 </section>
265 ) : (
266 <section className="p-4 text-xs text-muted">
267 Pushed straight to the branch, not through a pull request, so there is no
268 conversation behind it.
269 </section>
270 )}
271
272 {current.issue && (
273 <section className="p-4">
274 <p className="flex items-center gap-1.5 text-xs text-faint">
275 <CircleDot size={13} />
276 What was asked for
277 </p>
278 <Link
279 to={`${base}/issues/${current.issue.number}`}
280 className="mt-1.5 block font-medium hover:text-accent"
281 >
282 {current.issue.title} <span className="font-normal text-faint">#{current.issue.number}</span>
283 </Link>
284 {current.issue.body && (
285 <div className="mt-2 max-h-48 overflow-hidden text-muted [&_*]:text-[0.8125rem]! [&_*]:leading-5! mask-[linear-gradient(to_bottom,black_70%,transparent)]">
286 <Markdown source={current.issue.body} repo={repoAt(base)} />
287 </div>
288 )}
289 </section>
290 )}
291
292 {current.steps.length > 0 && current.pull && (
293 <section className="p-4">
294 <p className="flex items-center gap-1.5 text-xs text-faint">
295 <MessagesSquare size={13} />
296 In the agent's words
297 </p>
298 {current.steps
299 .filter((step) => step.kind === "message")
300 .map((step) => (
301 <blockquote
302 key={step.seq}
303 className="mt-3 border-l-2 border-merged/50 pl-3 text-[0.8125rem] whitespace-pre-line text-fg/90"
304 >
305 {step.text}
306 </blockquote>
307 ))}
308 {current.steps.some((step) => step.kind === "tool_call") && (
309 <details className="group mt-3">
310 <summary className="cursor-pointer text-xs text-muted hover:text-fg">
311 What it ran ({current.steps.filter((step) => step.kind === "tool_call").length})
312 </summary>
313 <ol className="mt-2 space-y-1.5">
314 {current.steps
315 .filter((step) => step.kind === "tool_call")
316 .map((step) => (
317 <li key={step.seq} className="flex items-start gap-1.5 font-mono text-xs text-muted">
318 <Terminal size={12} className="mt-0.5 shrink-0" />
319 <span className="line-clamp-2 break-all">
320 <span className="text-fg">{step.tool}</span> {step.text.split("\n")[0]}
321 </span>
322 </li>
323 ))}
324 </ol>
325 </details>
326 )}
327 <Link
328 to={`${base}/pull/${current.pull.number}?tab=session`}
329 className="mt-4 inline-flex items-center gap-1 text-xs text-accent hover:underline"
330 >
331 The whole session
332 <ArrowUpRight size={12} />
333 </Link>
334 </section>
335 )}
336 </div>
337 )}
338 </div>
339 );
340}