| 1 | import type { |
| 2 | DiffLineAnnotation, |
| 3 | FileDiffContentsLoader, |
| 4 | FileDiffMetadata, |
| 5 | SelectedLineRange, |
| 6 | } from "@pierre/diffs"; |
| 7 | import { FileDiff } from "@pierre/diffs/react"; |
| 8 | import type { PullRequestReviewGuideExcerpt, PullRequestReviewPosition } from "@t3tools/contracts"; |
| 9 | import { ArrowUpRightIcon } from "lucide-react"; |
| 10 | import { useCallback, useMemo, useState } from "react"; |
| 11 | |
| 12 | import { useTheme } from "~/hooks/useTheme"; |
| 13 | import { |
| 14 | getRenderablePatch, |
| 15 | resolveDiffThemeName, |
| 16 | resolveFileDiffPreviousPath, |
| 17 | } from "~/lib/diffRendering"; |
| 18 | import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; |
| 19 | import { |
| 20 | buildDiffReviewComment, |
| 21 | resolveDiffReviewPosition, |
| 22 | type ReviewCommentContext, |
| 23 | } from "~/reviewCommentContext"; |
| 24 | |
| 25 | import { DiffCommentAnnotation } from "../../diffs/DiffCommentAnnotation"; |
| 26 | import { DIFF_VIEW_UNSAFE_CSS } from "../../diffs/StyledDiffCodeView"; |
| 27 | import { Button } from "../../ui/button"; |
| 28 | import { |
| 29 | getReviewPositionAnchor, |
| 30 | PendingReviewCommentCard, |
| 31 | toViewerSide, |
| 32 | } from "../PullRequestReviewAnnotation"; |
| 33 | import type { PendingReviewComment } from "../pullRequestReviewStore"; |
| 34 | import { |
| 35 | excerptExpansionOldContents, |
| 36 | excerptRows, |
| 37 | fileDiffRows, |
| 38 | rowsToPatch, |
| 39 | } from "@t3tools/client-runtime/review-guide-tour"; |
| 40 | import { GuideNoteMarker } from "./GuideNotes"; |
| 41 | import { 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. |
| 45 | const EXCERPT_UNSAFE_CSS = `${DIFF_VIEW_UNSAFE_CSS} |
| 46 | [data-code]::-webkit-scrollbar-thumb { |
| 47 | background-color: var(--diffs-bg-context); |
| 48 | } |
| 49 | `; |
| 50 | |
| 51 | export 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 | */ |
| 60 | export 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 | |
| 76 | interface ExcerptAnnotation { |
| 77 | readonly markers: ExcerptNoteMarker["markers"]; |
| 78 | readonly pending: ReadonlyArray<PendingReviewComment>; |
| 79 | readonly draft: boolean; |
| 80 | } |
| 81 | |
| 82 | interface 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 | */ |
| 93 | export 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. */ |
| 359 | function previousPathOf(file: FileDiffMetadata, path: string): string | null { |
| 360 | const previous = resolveFileDiffPreviousPath(file); |
| 361 | return previous === path ? null : previous; |
| 362 | } |