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