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