1import type {
2 DiffLineAnnotation,
3 FileDiffContentsLoader,
4 FileDiffMetadata,
5 SelectedLineRange,
6} from "@pierre/diffs";
7import { FileDiff } from "@pierre/diffs/react";
8import type { PullRequestReviewGuideExcerpt, PullRequestReviewPosition } from "@t3tools/contracts";
9import { ArrowUpRightIcon } from "lucide-react";
10import { useCallback, useMemo, useState } from "react";
11
12import { useTheme } from "~/hooks/useTheme";
13import {
14 getRenderablePatch,
15 resolveDiffThemeName,
16 resolveFileDiffPreviousPath,
17} from "~/lib/diffRendering";
18import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";
19import {
20 buildDiffReviewComment,
21 resolveDiffReviewPosition,
22 type ReviewCommentContext,
23} from "~/reviewCommentContext";
24
25import { DiffCommentAnnotation } from "../../diffs/DiffCommentAnnotation";
26import { DIFF_VIEW_UNSAFE_CSS } from "../../diffs/StyledDiffCodeView";
27import { Button } from "../../ui/button";
28import {
29 getReviewPositionAnchor,
30 PendingReviewCommentCard,
31 toViewerSide,
32} from "../PullRequestReviewAnnotation";
33import type { PendingReviewComment } from "../pullRequestReviewStore";
34import {
35 excerptExpansionOldContents,
36 excerptRows,
37 fileDiffRows,
38 rowsToPatch,
39} from "@t3tools/client-runtime/review-guide-tour";
40import { GuideNoteMarker } from "./GuideNotes";
41import { GuideProse } from "./GuideProse";
42
43// Long lines scroll inside the excerpt. The renderer only shows its scrollbar on hover, which
44// reads as code cut off at the edge, so the excerpt keeps it visible.
45const EXCERPT_UNSAFE_CSS = `${DIFF_VIEW_UNSAFE_CSS}
46[data-code]::-webkit-scrollbar-thumb {
47 background-color: var(--diffs-bg-context);
48}
49`;
50
51export interface ExcerptNoteMarker {
52 readonly line: number;
53 readonly markers: ReadonlyArray<{ readonly marker: string; readonly title: string }>;
54}
55
56/**
57 * What a reader can do with lines of an excerpt, the same two things the Code tab offers: queue a
58 * comment for the review, or ask an agent about them. Null where neither is possible.
59 */
60export interface ExcerptCommenting {
61 /** Whether a comment can be queued for the review; asking an agent needs no host support. */
62 readonly canComment: boolean;
63 /** Comments already queued for the review, placed on the lines they belong to. */
64 readonly pending: ReadonlyArray<PendingReviewComment>;
65 readonly onComment: (comment: {
66 readonly path: string;
67 readonly oldPath: string | null;
68 readonly position: PullRequestReviewPosition;
69 readonly body: string;
70 }) => void;
71 readonly onRemove: (commentId: string) => void;
72 /** Opens an agent thread about the lines; null where no thread can be opened. */
73 readonly onAskAgent: ((comment: ReviewCommentContext, request: string) => void) | null;
74}
75
76interface ExcerptAnnotation {
77 readonly markers: ExcerptNoteMarker["markers"];
78 readonly pending: ReadonlyArray<PendingReviewComment>;
79 readonly draft: boolean;
80}
81
82interface ExcerptDraft {
83 readonly range: SelectedLineRange;
84 readonly position: PullRequestReviewPosition;
85}
86
87/**
88 * The lines of one excerpt, drawn by the same renderer as the Code tab from a patch cut down to
89 * the excerpt's range, so line numbers, colors and highlighting match the full diff. The cut keeps
90 * the file's real line numbers, so the bars above and below it load the code around the excerpt
91 * from the file itself, as they do between hunks in the Code tab.
92 */
93export function GuideExcerpt({
94 excerpt,
95 file,
96 loading,
97 notes,
98 commenting,
99 loadDiffFiles,
100 onOpenFile,
101}: {
102 excerpt: PullRequestReviewGuideExcerpt;
103 /** The file's parsed diff, null while it loads or when the host withheld it. */
104 file: FileDiffMetadata | null;
105 loading: boolean;
106 notes: ReadonlyArray<ExcerptNoteMarker>;
107 commenting: ExcerptCommenting | null;
108 /** Loads the file's unchanged lines for the bars around the excerpt. */
109 loadDiffFiles: FileDiffContentsLoader;
110 onOpenFile: (path: string) => void;
111}) {
112 const [draft, setDraft] = useState<ExcerptDraft | null>(null);
113 const { resolvedTheme } = useTheme();
114 const slice = useMemo(() => {
115 if (file === null) return null;
116 const rows = fileDiffRows(file);
117 const shown = excerptRows(rows, excerpt);
118 if (shown.length === 0) return null;
119 const parsed = getRenderablePatch(
120 rowsToPatch(excerpt.path, resolveFileDiffPreviousPath(file), shown),
121 `pull-request-guide-excerpt:${excerpt.path}:${excerpt.side}:${excerpt.start}-${excerpt.end}`,
122 );
123 const fileDiff = parsed?.kind === "files" ? parsed.files[0] : undefined;
124 if (fileDiff === undefined) return null;
125 // Lines the excerpt draws, by side, so only comments that land on one are placed in it.
126 const drawn = {
127 additions: new Set(shown.flatMap((row) => (row.newLine === null ? [] : [row.newLine]))),
128 deletions: new Set(shown.flatMap((row) => (row.oldLine === null ? [] : [row.oldLine]))),
129 };
130 return { fileDiff, rows: shown, shown: shown.length, total: rows.length, drawn };
131 }, [excerpt, file]);
132
133 // The excerpt expands against an old side of its own: the file's real old side differs from
134 // the new one wherever another hunk changed it, and the renderer needs the two to agree around
135 // the lines it shows. A slice across hunks has nothing consistent to expand into.
136 const sliceRows = slice?.rows ?? null;
137 const loadExcerptFiles = useMemo<FileDiffContentsLoader | null>(() => {
138 if (sliceRows === null || sliceRows.some((row) => row.hunk !== sliceRows[0]?.hunk)) {
139 return null;
140 }
141 return async (fileDiff) => {
142 const loaded = await loadDiffFiles(fileDiff);
143 const oldContents = excerptExpansionOldContents(loaded.newFile.contents, sliceRows);
144 if (oldContents === null) return loaded;
145 return {
146 newFile: loaded.newFile,
147 oldFile: {
148 name: loaded.oldFile?.name ?? loaded.newFile.name,
149 contents: oldContents,
150 cacheKey: `${loaded.newFile.cacheKey ?? loaded.newFile.name}:excerpt:${excerpt.side}:${excerpt.start}-${excerpt.end}`,
151 },
152 };
153 };
154 }, [excerpt.end, excerpt.side, excerpt.start, loadDiffFiles, sliceRows]);
155
156 // Notes, queued comments and the draft share one annotation per line, as in the Code tab.
157 const annotations = useMemo(() => {
158 const groups = new Map<string, DiffLineAnnotation<ExcerptAnnotation>>();
159 const at = (side: "additions" | "deletions", line: number) => {
160 const key = `${side}:${line}`;
161 const existing = groups.get(key);
162 if (existing !== undefined) return existing;
163 const created: DiffLineAnnotation<ExcerptAnnotation> = {
164 side,
165 lineNumber: line,
166 metadata: { markers: [], pending: [], draft: false },
167 };
168 groups.set(key, created);
169 return created;
170 };
171 const update = (
172 side: "additions" | "deletions",
173 line: number,
174 change: (metadata: ExcerptAnnotation) => ExcerptAnnotation,
175 ) => {
176 const group = at(side, line);
177 groups.set(`${side}:${line}`, { ...group, metadata: change(group.metadata) });
178 };
179 const noteSide = excerpt.side === "new" ? "additions" : "deletions";
180 for (const note of notes) {
181 update(noteSide, note.line, (metadata) => ({
182 ...metadata,
183 markers: [...metadata.markers, ...note.markers],
184 }));
185 }
186 for (const comment of commenting?.pending ?? []) {
187 if (comment.path !== excerpt.path) continue;
188 const anchor = getReviewPositionAnchor(comment.position);
189 const side = toViewerSide(anchor.side);
190 if (!slice?.drawn[side].has(anchor.line)) continue;
191 update(side, anchor.line, (metadata) => ({
192 ...metadata,
193 pending: [...metadata.pending, comment],
194 }));
195 }
196 if (draft !== null) {
197 const anchor = getReviewPositionAnchor(draft.position);
198 update(toViewerSide(anchor.side), anchor.line, (metadata) => ({ ...metadata, draft: true }));
199 }
200 return [...groups.values()];
201 }, [commenting?.pending, draft, excerpt.path, excerpt.side, notes, slice]);
202
203 // Positions and the agent's copy of the lines come from the whole file's diff, so a comment
204 // lands where the Code tab would put it rather than on the excerpt's cut-down patch.
205 const beginComment = useCallback(
206 (range: SelectedLineRange | null) => {
207 if (range === null || file === null) return;
208 const position = resolveDiffReviewPosition(file, range.end, range.endSide ?? range.side);
209 if (position === null) return;
210 setDraft({ range, position });
211 },
212 [file],
213 );
214 const interactive = commenting !== null && draft === null;
215 const onAskAgent = commenting?.onAskAgent ?? null;
216 const askAgent = (request: string) => {
217 if (draft === null || file === null || onAskAgent === null) return;
218 const comment = buildDiffReviewComment({
219 id: `pull-request-guide:${excerpt.path}:${draft.range.start}:${draft.range.end}`,
220 sectionId: `pull-request-guide:${excerpt.path}`,
221 sectionTitle: "Review guide",
222 filePath: excerpt.path,
223 fileDiff: file,
224 range: draft.range,
225 text: request,
226 });
227 setDraft(null);
228 if (comment !== null) onAskAgent(comment, request);
229 };
230
231 const options = useMemo(
232 () => ({
233 diffStyle: "unified" as const,
234 lineDiffType: "none" as const,
235 overflow: "scroll" as const,
236 theme: resolveDiffThemeName(resolvedTheme),
237 themeType: resolvedTheme,
238 preferredHighlighter: PREFERRED_HIGHLIGHTER,
239 unsafeCSS: EXCERPT_UNSAFE_CSS,
240 // Hover a line for the gutter button, or drag across line numbers, as in the Code tab.
241 enableGutterUtility: interactive,
242 enableLineSelection: interactive,
243 onGutterUtilityClick: beginComment,
244 onLineSelectionEnd: beginComment,
245 // Without a loader there is nothing to expand into, so the bars stay plain.
246 ...(loadExcerptFiles === null
247 ? { hunkSeparators: "simple" as const }
248 : { loadDiffFiles: loadExcerptFiles }),
249 }),
250 [resolvedTheme, interactive, beginComment, loadExcerptFiles],
251 );
252
253 return (
254 <figure className="flex min-w-0 flex-col gap-1.5">
255 {excerpt.caption.trim() !== "" ? (
256 <figcaption className="text-xs text-muted-foreground">
257 <GuideProse text={excerpt.caption} plain />
258 </figcaption>
259 ) : null}
260 {slice !== null ? (
261 <div className="min-w-0 overflow-hidden rounded-lg border border-border/60">
262 <FileDiff<ExcerptAnnotation>
263 fileDiff={slice.fileDiff}
264 options={options}
265 lineAnnotations={annotations}
266 selectedLines={draft?.range ?? null}
267 renderAnnotation={(annotation) => (
268 <div className="font-sans">
269 {annotation.metadata.markers.length > 0 ? (
270 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1 text-xs">
271 {annotation.metadata.markers.map((entry) => (
272 <span key={entry.marker} className="inline-flex items-center gap-1.5">
273 <GuideNoteMarker marker={entry.marker} />
274 <span className="text-foreground">{entry.title}</span>
275 </span>
276 ))}
277 </div>
278 ) : null}
279 {annotation.metadata.pending.map((comment) => (
280 <PendingReviewCommentCard
281 key={comment.id}
282 comment={comment}
283 onRemove={() => commenting?.onRemove(comment.id)}
284 />
285 ))}
286 {annotation.metadata.draft && draft !== null && commenting !== null ? (
287 <DiffCommentAnnotation
288 kind="draft"
289 rangeLabel={`${excerpt.path}:${getReviewPositionAnchor(draft.position).line}`}
290 text=""
291 placeholder={
292 commenting.canComment ? "Add a comment…" : "Ask the agent about these lines…"
293 }
294 submitLabel={commenting.canComment ? "Add to review" : "Ask agent"}
295 {...(commenting.canComment && onAskAgent !== null
296 ? {
297 secondaryAction: {
298 label: "Ask agent",
299 onAction: (text: string) => askAgent(text),
300 },
301 }
302 : {})}
303 onCancel={() => setDraft(null)}
304 onComment={(body) => {
305 if (!commenting.canComment) {
306 askAgent(body);
307 return;
308 }
309 commenting.onComment({
310 path: excerpt.path,
311 oldPath: file === null ? null : previousPathOf(file, excerpt.path),
312 position: draft.position,
313 body,
314 });
315 setDraft(null);
316 }}
317 />
318 ) : null}
319 </div>
320 )}
321 renderHeaderMetadata={() => (
322 <span className="flex items-center gap-2 text-xs text-muted-foreground">
323 <span className="tabular-nums">
324 {excerpt.side === "old" ? "removed code, " : ""}showing {slice.shown} of{" "}
325 {slice.total} diff lines
326 </span>
327 <Button
328 size="icon-xs"
329 variant="ghost-muted"
330 aria-label={`Open ${excerpt.path} in Code`}
331 onClick={() => onOpenFile(excerpt.path)}
332 >
333 <ArrowUpRightIcon aria-hidden />
334 </Button>
335 </span>
336 )}
337 />
338 </div>
339 ) : loading ? (
340 <p className="rounded-lg border border-border/60 px-3 py-2 text-xs text-muted-foreground">
341 Loading {excerpt.path}...
342 </p>
343 ) : (
344 <div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-xs text-muted-foreground">
345 <span className="min-w-0 truncate">
346 <span className="font-mono text-foreground">{excerpt.path}</span> lines {excerpt.start}-
347 {excerpt.end} are not in the loaded diff.
348 </span>
349 <Button size="xs" variant="outline" onClick={() => onOpenFile(excerpt.path)}>
350 Open in Code
351 </Button>
352 </div>
353 )}
354 </figure>
355 );
356}
357
358/** A renamed file's comment carries its old path too, which hosts need to place it. */
359function previousPathOf(file: FileDiffMetadata, path: string): string | null {
360 const previous = resolveFileDiffPreviousPath(file);
361 return previous === path ? null : previous;
362}