import { reviewPositionLabel } from "@t3tools/client-runtime/review-selection"; import type { DiffLineAnnotation, FileDiffContentsLoader, FileDiffMetadata, SelectedLineRange, } from "@pierre/diffs"; import { FileDiff } from "@pierre/diffs/react"; import type { EnvironmentId, PullRequestRef, PullRequestReviewThread, PullRequestReviewGuideExcerpt, PullRequestReviewPosition, } from "@t3tools/contracts"; import { ArrowUpRightIcon } from "lucide-react"; import { useState, type ReactNode } from "react"; import { useRenderedAppearance } from "~/hooks/useTheme"; import { PierreEntryIcon } from "../../chat/PierreEntryIcon"; import { useClientSettings } from "~/hooks/useSettings"; import { getRenderablePatch, resolveDiffThemeName, resolveFileDiffPreviousPath, } from "~/lib/diffRendering"; import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; import { buildDiffReviewComment, resolveDiffReviewAnchor, type ReviewCommentContext, } from "~/reviewCommentContext"; import { DiffCommentAnnotation } from "../../diffs/DiffCommentAnnotation"; import { INLINE_RENAME_HEADER_CSS } from "../../diffs/StyledDiffCodeView"; import { GUIDE_DIFF_UNSAFE_CSS } from "./guideDiffStyles"; import { Button } from "../../ui/button"; import { Checkbox } from "../../ui/checkbox"; import { DiffFileRename } from "../../diffs/DiffFileRename"; import { getReviewPositionAnchor, PendingReviewCommentCard, HostedReviewThreadCard, toViewerSide, } from "../PullRequestReviewAnnotation"; import type { PendingReviewComment } from "../pullRequestReviewStore"; import { excerptRows, fileDiffRows, rowsToPatch } from "@t3tools/client-runtime/review-guide-tour"; import type { PullRequestCompletionScope } from "../PullRequestMarkdownTextarea"; import { excerptLoader } from "./GuideExcerpt.logic"; import { GuideNoteMarker } from "./GuideNotes"; import { GuideProse } from "./GuideProse"; // When wrapping is off, keep the excerpt's scrollbar visible so long lines do not look cut off. const EXCERPT_UNSAFE_CSS = `${GUIDE_DIFF_UNSAFE_CSS} ${INLINE_RENAME_HEADER_CSS} [data-diffs-header] [data-change-icon] { display: none; } [data-code]::-webkit-scrollbar-thumb { background-color: var(--diffs-bg-context); } `; export interface ExcerptNoteMarker { readonly line: number; readonly markers: ReadonlyArray<{ readonly marker: string; readonly title: string }>; } /** * What a reader can do with lines of an excerpt, the same two things the Code tab offers: queue a * comment for the review, or ask an agent about them. Null where neither is possible. */ export interface ExcerptCommenting { /** Whether a comment can be queued for the review; asking an agent needs no host support. */ readonly canComment: boolean; /** The host anchors a comment to a range of lines rather than to its last one. */ readonly rangeComments: boolean; readonly completionScope: PullRequestCompletionScope | null; readonly renderAttachments?: ( onTextChange: (text: string) => void, onBusyChange: (busy: boolean) => void, textarea: HTMLTextAreaElement | null, ) => ReactNode; /** Comments already queued for the review, placed on the lines they belong to. */ readonly pending: ReadonlyArray; readonly threads?: ReadonlyArray; readonly pendingCommentTarget: { environmentId: EnvironmentId; reference: PullRequestRef; canCopyLink: boolean; }; readonly onComment: (comment: { readonly path: string; readonly oldPath: string | null; readonly position: PullRequestReviewPosition; readonly startPosition?: PullRequestReviewPosition | undefined; readonly body: string; }) => void; readonly onRemove: (commentId: string) => void; /** Opens an agent thread about the lines; null where no thread can be opened. */ readonly onAskAgent: ((comment: ReviewCommentContext, request: string) => void) | null; } interface ExcerptAnnotation { readonly threads: ReadonlyArray; readonly markers: ExcerptNoteMarker["markers"]; readonly pending: ReadonlyArray; readonly draft: boolean; } interface ExcerptDraft { readonly range: SelectedLineRange; readonly position: PullRequestReviewPosition; readonly startPosition?: PullRequestReviewPosition; } type ExcerptSlice = NonNullable>; function excerptSlice( file: FileDiffMetadata | null, excerpt: PullRequestReviewGuideExcerpt, ignoreWhitespace: boolean, ) { if (file === null) return null; const rows = fileDiffRows(file); const shown = excerptRows(rows, excerpt); if (shown.length === 0) return null; const parsed = getRenderablePatch( rowsToPatch(excerpt.path, resolveFileDiffPreviousPath(file), shown), `pull-request-guide-excerpt:${excerpt.path}:${excerpt.side}:${excerpt.start}-${excerpt.end}:${ignoreWhitespace}`, { ignoreWhitespace }, ); const fileDiff = parsed?.kind === "files" ? parsed.files[0] : undefined; if (fileDiff === undefined) return null; const drawn = { additions: new Set(shown.flatMap((row) => (row.newLine === null ? [] : [row.newLine]))), deletions: new Set(shown.flatMap((row) => (row.oldLine === null ? [] : [row.oldLine]))), }; return { fileDiff, rows: shown, shown: shown.length, total: rows.length, drawn }; } export function GuideExcerpt({ excerpt, file, loading, notes, commenting, loadDiffFiles, onOpenFile, viewed, onSetViewed, }: { excerpt: PullRequestReviewGuideExcerpt; /** The file's parsed diff, null while it loads or when the host withheld it. */ file: FileDiffMetadata | null; loading: boolean; notes: ReadonlyArray; commenting: ExcerptCommenting | null; /** Loads the file's unchanged lines for the bars around the excerpt. */ loadDiffFiles: FileDiffContentsLoader; onOpenFile: (path: string) => void; viewed: boolean; onSetViewed: ((viewed: boolean) => void) | null; }) { // Keep selections outside ExcerptDiff to survive head reloads. const [draft, setDraft] = useState(null); const settings = useClientSettings(); const slice = excerptSlice(file, excerpt, settings.diffIgnoreWhitespace); return (
{excerpt.caption.trim() !== "" ? (
) : null} {slice !== null && file !== null ? ( ) : loading ? (

Loading {excerpt.path}...

) : (
{excerpt.path} lines {excerpt.start}- {excerpt.end} are not in the loaded diff.
)}
); } function ExcerptDiff({ excerpt, file, slice, draft, setDraft, loadDiffFiles, notes, commenting, onOpenFile, viewed, onSetViewed, }: { excerpt: PullRequestReviewGuideExcerpt; file: FileDiffMetadata; slice: ExcerptSlice; draft: ExcerptDraft | null; setDraft: (draft: ExcerptDraft | null) => void; loadDiffFiles: FileDiffContentsLoader; notes: ReadonlyArray; commenting: ExcerptCommenting | null; onOpenFile: (path: string) => void; viewed: boolean; onSetViewed: ((viewed: boolean) => void) | null; }) { const resolvedTheme = useRenderedAppearance(); const settings = useClientSettings(); const loadExcerptFiles = excerptLoader(excerpt, file, slice.rows, loadDiffFiles); // Notes, queued comments and the draft share one annotation per line, as in the Code tab. const annotations = (() => { const groups = new Map>(); const at = (side: "additions" | "deletions", line: number) => { const key = `${side}:${line}`; const existing = groups.get(key); if (existing !== undefined) return existing; const created: DiffLineAnnotation = { side, lineNumber: line, metadata: { markers: [], pending: [], threads: [], draft: false }, }; groups.set(key, created); return created; }; const update = ( side: "additions" | "deletions", line: number, change: (metadata: ExcerptAnnotation) => ExcerptAnnotation, ) => { const group = at(side, line); groups.set(`${side}:${line}`, { ...group, metadata: change(group.metadata) }); }; const noteSide = excerpt.side === "new" ? "additions" : "deletions"; for (const note of notes) { update(noteSide, note.line, (metadata) => ({ ...metadata, markers: [...metadata.markers, ...note.markers], })); } for (const thread of commenting?.threads ?? []) { if (thread.path !== excerpt.path || thread.isOutdated || thread.line === null) continue; const side = toViewerSide(thread.side); if (!slice?.drawn[side].has(thread.line)) continue; update(side, thread.line, (metadata) => ({ ...metadata, threads: [...metadata.threads, thread], })); } for (const comment of commenting?.pending ?? []) { if (comment.path !== excerpt.path) continue; const anchor = getReviewPositionAnchor(comment.position); const side = toViewerSide(anchor.side); if (!slice?.drawn[side].has(anchor.line)) continue; update(side, anchor.line, (metadata) => ({ ...metadata, pending: [...metadata.pending, comment], })); } if (draft !== null) { const anchor = getReviewPositionAnchor(draft.position); update(toViewerSide(anchor.side), anchor.line, (metadata) => ({ ...metadata, draft: true })); } return [...groups.values()]; })(); // Positions and the agent's copy of the lines come from the whole file's diff, so a comment // lands where the Code tab would put it rather than on the excerpt's cut-down patch. const beginComment = (range: SelectedLineRange | null) => { if (range === null || file === null) return; const anchor = resolveDiffReviewAnchor(file, range, commenting?.rangeComments === true); if (anchor === null) return; setDraft({ range, ...anchor }); }; const interactive = commenting !== null && draft === null && (commenting.canComment || commenting.onAskAgent !== null); const canQueueComment = commenting?.canComment === true; const onAskAgent = commenting?.onAskAgent ?? null; const askAgent = (request: string) => { if (draft === null || file === null || onAskAgent === null) return; const comment = buildDiffReviewComment({ id: `pull-request-guide:${excerpt.path}:${draft.range.start}:${draft.range.end}`, sectionId: `pull-request-guide:${excerpt.path}`, sectionTitle: "Review guide", filePath: excerpt.path, fileDiff: file, range: draft.range, text: request, }); setDraft(null); if (comment !== null) onAskAgent(comment, request); }; const options = { diffStyle: settings.diffLayout === "split" ? ("split" as const) : ("unified" as const), lineDiffType: "none" as const, overflow: settings.wordWrap ? ("wrap" as const) : ("scroll" as const), theme: resolveDiffThemeName(resolvedTheme), themeType: resolvedTheme, preferredHighlighter: PREFERRED_HIGHLIGHTER, unsafeCSS: EXCERPT_UNSAFE_CSS, // Hover a line for the gutter button, or drag across line numbers, as in the Code tab. enableGutterUtility: interactive, enableLineSelection: interactive, onGutterUtilityClick: beginComment, onLineSelectionEnd: beginComment, // Without a loader there is nothing to expand into, so the bars stay plain. ...(loadExcerptFiles === null ? { hunkSeparators: "simple" as const } : { loadDiffFiles: loadExcerptFiles }), }; return (
renderHeaderPrefix={() => ( )} renderHeaderFilenameSuffix={() => (file ? : null)} fileDiff={slice.fileDiff} options={options} lineAnnotations={annotations} selectedLines={draft?.range ?? null} renderAnnotation={(annotation) => (
{annotation.metadata.markers.length > 0 ? (
{annotation.metadata.markers.map((entry) => ( {entry.title} ))}
) : null} {commenting !== null ? annotation.metadata.threads.map((thread) => ( )) : null} {commenting !== null ? annotation.metadata.pending.map((comment) => ( )) : null} {annotation.metadata.draft && draft !== null && commenting !== null ? ( askAgent(text), }, } : {})} onCancel={() => setDraft(null)} onComment={(body) => { if (!canQueueComment) { askAgent(body); return; } commenting.onComment({ path: excerpt.path, oldPath: file === null ? null : previousPathOf(file, excerpt.path), position: draft.position, startPosition: draft.startPosition, body, }); setDraft(null); }} /> ) : null}
)} renderHeaderMetadata={() => ( {onSetViewed ? ( ) : null} {excerpt.side === "old" ? "removed code, " : ""}showing {slice.shown} of {slice.total}{" "} diff lines )} />
); } /** A renamed file's comment carries its old path too, which hosts need to place it. */ function previousPathOf(file: FileDiffMetadata, path: string): string | null { const previous = resolveFileDiffPreviousPath(file); return previous === path ? null : previous; }