import type { CodeViewItem, DiffLineAnnotation, SelectedLineRange } from "@pierre/diffs";import type { CodeViewDiffItem } from "@pierre/diffs/react";import type { EnvironmentId, PullRequestDetailView, PullRequestDiffSide, PullRequestOmittedFileStat, PullRequestRef, PullRequestReviewPosition, PullRequestReviewThread, PullRequestThreadCommentsResult,} from "@t3tools/contracts";import { ChevronDownIcon, ChevronRightIcon, ChevronsDownUpIcon, ChevronsUpDownIcon, Columns2Icon, MessageSquareIcon, MessageSquareOffIcon, Rows3Icon, TextWrapIcon, TriangleAlertIcon, XIcon,} from "lucide-react";import { useAtomRefresh } from "@effect/atom-react";import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useClientSettings } from "~/hooks/useSettings";import { useTheme } from "~/hooks/useTheme";import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse";import { pullRequestFindingKey, type PullRequestFinding } from "./pullRequestDetail.logic";import { canEditPullRequestComment } from "./pullRequestEditing.logic";import { orderDiffFiles } from "./pullRequestFileOrder.logic";import { buildFileDiffRenderKey, fnv1a32, getDiffLineStat, getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, resolveFileDiffPreviousPath, type RenderablePatch,} from "~/lib/diffRendering";import { cn } from "~/lib/utils";import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents";import { buildDiffReviewComment, resolveDiffReviewPosition, type ReviewCommentContext,} from "~/reviewCommentContext";import { pullRequestEnvironment } from "~/state/pullRequests";import { useEnvironmentQuery } from "~/state/query";import { useAtomCommand } from "~/state/use-atom-command"; import { DiffPanelLoadingState } from "../DiffPanelShell";import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView";import { Button } from "../ui/button";import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,} from "../ui/menu";import { toastManager } from "../ui/toast";import { Toggle, ToggleGroup } from "../ui/toggle-group";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import { PendingReviewCommentCard, ReviewThreadCard } from "./PullRequestReviewAnnotation";import { PullRequestReviewBar } from "./PullRequestReviewBar";import { isFileDiffCollapsed, isLineInFileDiff, type DiffFoldOverride,} from "./pullRequestDiff.logic";import { PullRequestDiffStat, PullRequestMetaLine } from "./pullRequestPresentation";import { nextPendingReviewCommentId, pullRequestReviewKey, usePendingReviewComments, usePullRequestReviewStore, type PendingReviewComment,} from "./pullRequestReviewStore"; /** Everything pinned to one line of one file: what is already there, and what is being added. */interface ReviewAnnotationGroup { readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly pending: ReadonlyArray<PendingReviewComment>; readonly draft: boolean;} type ReviewAnnotation = DiffLineAnnotation<ReviewAnnotationGroup>; /** Commits per press of "Show more" in the scope menu. */const COMMIT_PAGE_SIZE = 10; /** One answer from the host: a whole number of files, and where the next one carries on. */interface DiffSlice { /** What was asked for, null being the first slice. Identifies the slice among the loaded ones. */ readonly cursor: string | null; readonly patch: string; readonly truncated: boolean; readonly nextCursor: string | null; readonly omittedFileStats: ReadonlyArray<PullRequestOmittedFileStat>;} /** * The viewer's own per-file counts are hidden and drawn from this side of its shadow root * instead: its counts are hunk sums, and a file whose hunks the host withheld would read as * an empty change rather than as the counts the host did report. */const REPLACE_FILE_COUNTS_CSS = `[data-diffs-header] [data-additions-count],[data-diffs-header] [data-deletions-count] { display: none !important;}`; /** Nothing loaded yet, as one identity, so the memos below do not see a new array every render. */const NO_SLICES: ReadonlyArray<DiffSlice> = []; /** A group while it is still gathering what belongs on its line. */interface MutableAnnotationGroup { readonly side: PullRequestDiffSide; readonly line: number; readonly threads: PullRequestReviewThread[]; readonly pending: PendingReviewComment[]; draft: boolean;} interface DraftAnchor { readonly fileKey: string; readonly path: string; /** What the file was called before the change, for the hosts that resolve a position by both. */ readonly oldPath: string | null; readonly position: PullRequestReviewPosition; /** The whole selection, which the comment collapses to one line but a question keeps. */ readonly range: SelectedLineRange;} /** A range of the diff and the reader's request for the agent. */export interface PullRequestAgentSelectionInput { /** The marked lines, already in the shape the composer draws and the agent reads. */ readonly comment: ReviewCommentContext; readonly request: string;} /** The contract's sides named the way the diff viewer names them, and back again. */function toViewerSide(side: PullRequestDiffSide) { return side === "left" ? ("deletions" as const) : ("additions" as const);} function getReviewPositionAnchor(position: PullRequestReviewPosition): { line: number; side: PullRequestDiffSide;} { switch (position.kind) { case "added": return { line: position.newLine, side: "right" }; case "deleted": return { line: position.oldLine, side: "left" }; case "context": return { line: position.side === "left" ? position.oldLine : position.newLine, side: position.side, }; }} /** * Whether the viewer draws this line at all. A line counts the new file on the right and the old * one on the left, and each hunk covers one run of each; a line outside every run — a * conversation the host could not mark outdated, or one under a hunk it withheld — has no row to * be pinned to, however much its file looks like a match. *//** * The pull request's patch, with the review written against it. Conversations already on the * host sit under the line they were written on, and a new comment joins the review being * drafted rather than being posted as it is typed. */export function PullRequestCodeTab({ environmentId, reference, detail, selectedCommitOid, onSelectedCommitChange, pendingFinding, fixFindingLabel = "Fix in a thread", onFixFinding, onAddToAgentSelection, onRefresh, refreshToken = 0,}: { environmentId: EnvironmentId; reference: PullRequestRef; detail: PullRequestDetailView; /** Commit whose diff is open. Null keeps the whole pull-request diff selected. */ selectedCommitOid: string | null; onSelectedCommitChange: (oid: string | null) => void; /** The hand-off currently preparing, if any, so only the finding it belongs to says so. */ pendingFinding?: string | null; fixFindingLabel?: string; onFixFinding?: (finding: PullRequestFinding) => void; /** Absent where there is no active agent composer to receive a local comment. */ onAddToAgentSelection?: (input: PullRequestAgentSelectionInput) => void; onRefresh: () => void; /** Bumped by the panel's refresh button: drop the accumulated pages and re-read the diff. */ refreshToken?: number;}) { const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const [toggledFiles, setToggledFiles] = useState<ReadonlySet<string>>(() => new Set()); // A change of any size can carry hundreds of commits, and a menu that long is a scroll rather // than a choice. The rest arrive ten at a time, on request. const [visibleCommitCount, setVisibleCommitCount] = useState(COMMIT_PAGE_SIZE); /** Set once the reader has asked for every file at once, until they pick a file apart again. */ const [foldOverride, setFoldOverride] = useState<DiffFoldOverride>(null); const [diffRenderMode, setDiffRenderMode] = useState<"stacked" | "split">("stacked"); const [wordWrap, setWordWrap] = useState(settings.wordWrap); const [selectedLines, setSelectedLines] = useState<{ id: string; range: SelectedLineRange; } | null>(null); const [draft, setDraft] = useState<DraftAnchor | null>(null); const [threadPending, setThreadPending] = useState(false); const [orphansOpen, setOrphansOpen] = useState(false); // Closed by default so the review form does not permanently eat vertical space below the // diff; opened on demand as a floating overlay instead. const [reviewOpen, setReviewOpen] = useState(false); // Which pull request the slices belong to travels with them, so a render taken before the // reset below cannot read the previous one's slices — or send its cursor to the host. const [sliceState, setSliceState] = useState<{ readonly key: string; readonly cursor: string | null; readonly slices: ReadonlyArray<DiffSlice>; }>({ key: "", cursor: null, slices: NO_SLICES }); const parseCache = useRef(new Map<string, RenderablePatch>()); const referenceKey = pullRequestReviewKey(reference); const commit = selectedCommitOid; // One commit's own changes and the whole change are two different diffs, paged separately, so // everything below is keyed by both. const scopeKey = commit === null ? referenceKey : `${referenceKey}@${commit}`; // The panel keeps this mounted across pull requests, so an open composer would otherwise // survive the switch and attach its comment to whichever one is on screen when it is sent. useEffect(() => { setDraft(null); setSelectedLines(null); setToggledFiles(new Set()); setFoldOverride(null); setVisibleCommitCount(COMMIT_PAGE_SIZE); setOrphansOpen(false); setSliceState({ key: scopeKey, cursor: null, slices: NO_SLICES }); parseCache.current.clear(); }, [scopeKey]); const loadedSlices = sliceState.key === scopeKey ? sliceState.slices : NO_SLICES; const cursor = sliceState.key === scopeKey ? sliceState.cursor : null; const diffQuery = useEnvironmentQuery( pullRequestEnvironment.diff({ environmentId, input: { ...reference, ...(cursor === null ? {} : { cursor }), ...(commit === null ? {} : { commit }), }, }), ); // Each answer is kept as its own slice. Concatenating the patches and re-parsing the growing // text would cost more with every slice, which is the wall the slicing exists to remove. useEffect(() => { const data = diffQuery.data; if (data === null) return; setSliceState((previous) => { const slices = previous.key === scopeKey ? previous.slices : NO_SLICES; const next = { cursor, patch: data.patch, truncated: data.truncated, nextCursor: data.nextCursor, omittedFileStats: data.omittedFileStats ?? [], }; const index = slices.findIndex((slice) => slice.cursor === cursor); if (index === -1) { return { key: scopeKey, cursor, slices: [...slices, next] }; } const existing = slices[index]; if ( existing !== undefined && existing.patch === next.patch && existing.truncated === next.truncated && existing.nextCursor === next.nextCursor && existing.omittedFileStats.length === next.omittedFileStats.length && existing.omittedFileStats.every((file, index) => { const refreshed = next.omittedFileStats[index]; return ( refreshed !== undefined && refreshed.path === file.path && refreshed.additions === file.additions && refreshed.deletions === file.deletions ); }) ) { return previous; } // A page that came back different means the diff moved under the review. The slices // after it go with the replacement: their cursors were positions in the old diff. return { key: scopeKey, cursor, slices: [...slices.slice(0, index), next] }; }); }, [cursor, diffQuery.data, scopeKey]); // The refresh button rereads from the first page rather than the page the reader is on: // pages are positions in one snapshot of the diff, and a fresh snapshot starts over. const refreshFirstDiffPage = useAtomRefresh( pullRequestEnvironment.diff({ environmentId, input: { ...reference, ...(commit === null ? {} : { commit }) }, }), ); const appliedRefreshToken = useRef(refreshToken); useEffect(() => { if (appliedRefreshToken.current === refreshToken) return; appliedRefreshToken.current = refreshToken; setSliceState({ key: scopeKey, cursor: null, slices: NO_SLICES }); refreshFirstDiffPage(); }, [refreshToken, scopeKey, refreshFirstDiffPage]); const reviewKey = referenceKey; const pendingComments = usePendingReviewComments(reference); const addComment = usePullRequestReviewStore((store) => store.addComment); const removeComment = usePullRequestReviewStore((store) => store.removeComment); const replyToThread = useAtomCommand(pullRequestEnvironment.replyToThread, { reportFailure: false, }); const setThreadResolution = useAtomCommand(pullRequestEnvironment.setThreadResolution, { reportFailure: false, }); const updateComment = useAtomCommand(pullRequestEnvironment.updateComment, { reportFailure: false, }); const loadThreadComments = useAtomCommand(pullRequestEnvironment.threadComments, { reportFailure: false, }); const getDiffFileContents = useAtomCommand(pullRequestEnvironment.diffFileContents); const loadDiffFiles = useMemo( () => createPullRequestDiffFileContentsLoader(getDiffFileContents, { environmentId, reference, commit, cacheKey: `pull-request:${referenceKey}:${detail.updatedAt}:${commit ?? "all"}`, }), [commit, detail.updatedAt, environmentId, getDiffFileContents, reference, referenceKey], ); // What is offered is the intersection of two different questions: what this host can do at // all, and what this account may do on this repository. Either one saying no means a control // that would only ever end in a refusal. const review = useMemo(() => { const hostReview = detail.capabilities.review; const viewer = detail.viewerPermissions; return { inlineComment: hostReview.inlineComment && viewer.comment, reply: hostReview.reply && viewer.comment, resolve: hostReview.resolve && viewer.resolve, verdicts: hostReview.verdicts.filter((verdict) => viewer.verdicts.includes(verdict)), }; }, [detail.capabilities.review, detail.viewerPermissions]); // A comment is posted against the pull request's head diff, so a line number taken from one // commit's own diff would land somewhere else entirely. Commenting waits for the whole change. const canCommentOnLines = review.inlineComment && commit === null; // Every slice is parsed on its own and the result held, so a slice arriving costs one parse // rather than one per slice already on screen. Its cache key carries the theme, which is what // the tokenizer caches against, so a theme change is still a fresh parse. const parsedSlices = useMemo( () => loadedSlices.map((slice) => { // The patch's own hash is part of the key: a refreshed page reuses its cursor, and a // key of position alone would keep handing back the parse of the patch it replaced. const cacheKey = `pull-request:${scopeKey}:${resolvedTheme}:${slice.cursor ?? "first"}:${fnv1a32(slice.patch)}`; const cached = parseCache.current.get(cacheKey); if (cached) return cached; const parsed = getRenderablePatch(slice.patch, cacheKey, { compactPartialHunkOffsets: true, }); if (parsed) parseCache.current.set(cacheKey, parsed); return parsed; }), [loadedSlices, resolvedTheme, scopeKey], ); // Ordered within a slice rather than across them: ordering the accumulated set would let a late // slice push a file the reader is part way through further down the page. const files = useMemo( () => parsedSlices.flatMap((parsed) => parsed?.kind === "files" ? orderDiffFiles(parsed.files) : [], ), [parsedSlices], ); const nextCursor = loadedSlices.at(-1)?.nextCursor ?? null; // What a slice withheld: the host declining to inline part of it, or a patch the viewer could // not structure and so dropped. Neither says anything about there being more to fetch. const withheldContent = loadedSlices.some((slice) => slice.truncated) || parsedSlices.some((parsed) => parsed?.kind === "raw"); // Placing a conversation takes more than its file being in the diff: its line has to fall // inside a hunk that was rendered. One that does not is drawn nowhere, so it belongs in the // off-diff list rather than disappearing between the two. const placedThreadIds = useMemo(() => { const placed = new Set<string>(); // A commit's diff counts lines within that commit; a review comment is anchored to the // pull request's head diff. Pinning one onto the other would put the remark against // whichever code happens to hold that line number in this commit, so while a commit is on // screen every conversation is listed rather than placed. if (commit !== null) return placed; for (const file of files) { const path = resolveFileDiffPath(file); for (const thread of detail.reviewThreads) { if ( thread.path === path && thread.line !== null && isLineInFileDiff(file, thread.side, thread.line) ) { placed.add(thread.id); } } } return placed; }, [commit, detail.reviewThreads, files]); const items = useMemo<CodeViewDiffItem<ReviewAnnotationGroup>[]>( () => files.map((fileDiff) => { const fileKey = buildFileDiffRenderKey(fileDiff); const path = resolveFileDiffPath(fileDiff); // One annotation per line, so a line that already carries a conversation shows a new // comment underneath it rather than in place of it. const groups = new Map<string, MutableAnnotationGroup>(); const groupAt = (side: PullRequestDiffSide, line: number) => { const key = `${side}:${line}`; const existing = groups.get(key); if (existing) return existing; const created: MutableAnnotationGroup = { side, line, threads: [], pending: [], draft: false, }; groups.set(key, created); return created; }; for (const thread of detail.reviewThreads) { if (thread.path !== path || thread.line === null) continue; if (!placedThreadIds.has(thread.id)) continue; groupAt(thread.side, thread.line).threads.push(thread); } // Pending comments anchor to the head diff exactly like host threads do, so a // commit's diff must not place them either — the same line means other code there. if (commit === null) { for (const comment of pendingComments) { if (comment.path !== path) continue; const anchor = getReviewPositionAnchor(comment.position); groupAt(anchor.side, anchor.line).pending.push(comment); } } if (draft?.fileKey === fileKey) { const anchor = getReviewPositionAnchor(draft.position); groupAt(anchor.side, anchor.line).draft = true; } const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles); const annotations: ReviewAnnotation[] = [...groups.values()].map((group) => ({ side: toViewerSide(group.side), lineNumber: group.line, metadata: { threads: group.threads, pending: group.pending, draft: group.draft }, })); return { id: fileKey, type: "diff" as const, fileDiff, annotations, collapsed, // The viewer re-renders an item only when its version changes, so everything the // annotations show has to be part of it. version: fnv1a32( `${collapsed ? "1" : "0"}:${annotations .map( ({ side, lineNumber, metadata }) => `${side}:${lineNumber}:${metadata.draft ? "d" : ""}:${metadata.pending .map((comment) => `${comment.id}:${comment.body}`) .join(",")}:${metadata.threads .map( (thread) => `${thread.id}:${thread.isResolved ? "r" : ""}:${ thread.isOutdated ? "o" : "" }:${thread.comments .map(Lines 1–500 of 1372. Load more
import type { CodeViewItem, DiffLineAnnotation, SelectedLineRange } from "@pierre/diffs"; import type { CodeViewDiffItem } from "@pierre/diffs/react"; import type { EnvironmentId, PullRequestDetailView, PullRequestDiffSide, PullRequestOmittedFileStat, PullRequestRef, PullRequestReviewPosition, PullRequestReviewThread, PullRequestThreadCommentsResult, } from "@t3tools/contracts"; import { ChevronDownIcon, ChevronRightIcon, ChevronsDownUpIcon, ChevronsUpDownIcon, Columns2Icon, MessageSquareIcon, MessageSquareOffIcon, Rows3Icon, TextWrapIcon, TriangleAlertIcon, XIcon, } from "lucide-react"; import { useAtomRefresh } from "@effect/atom-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse"; import { pullRequestFindingKey, type PullRequestFinding } from "./pullRequestDetail.logic"; import { canEditPullRequestComment } from "./pullRequestEditing.logic"; import { orderDiffFiles } from "./pullRequestFileOrder.logic"; import { buildFileDiffRenderKey, fnv1a32, getDiffLineStat, getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, resolveFileDiffPreviousPath, type RenderablePatch, } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents"; import { buildDiffReviewComment, resolveDiffReviewPosition, type ReviewCommentContext, } from "~/reviewCommentContext"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; import { DiffPanelLoadingState } from "../DiffPanelShell"; import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView"; import { Button } from "../ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "../ui/menu"; import { toastManager } from "../ui/toast"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PendingReviewCommentCard, ReviewThreadCard } from "./PullRequestReviewAnnotation"; import { PullRequestReviewBar } from "./PullRequestReviewBar"; import { isFileDiffCollapsed, isLineInFileDiff, type DiffFoldOverride, } from "./pullRequestDiff.logic"; import { PullRequestDiffStat, PullRequestMetaLine } from "./pullRequestPresentation"; import { nextPendingReviewCommentId, pullRequestReviewKey, usePendingReviewComments, usePullRequestReviewStore, type PendingReviewComment, } from "./pullRequestReviewStore"; /** Everything pinned to one line of one file: what is already there, and what is being added. */ interface ReviewAnnotationGroup { readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly pending: ReadonlyArray<PendingReviewComment>; readonly draft: boolean; } type ReviewAnnotation = DiffLineAnnotation<ReviewAnnotationGroup>; /** Commits per press of "Show more" in the scope menu. */ const COMMIT_PAGE_SIZE = 10; /** One answer from the host: a whole number of files, and where the next one carries on. */ interface DiffSlice { /** What was asked for, null being the first slice. Identifies the slice among the loaded ones. */ readonly cursor: string | null; readonly patch: string; readonly truncated: boolean; readonly nextCursor: string | null; readonly omittedFileStats: ReadonlyArray<PullRequestOmittedFileStat>; } /** * The viewer's own per-file counts are hidden and drawn from this side of its shadow root * instead: its counts are hunk sums, and a file whose hunks the host withheld would read as * an empty change rather than as the counts the host did report. */ const REPLACE_FILE_COUNTS_CSS = ` [data-diffs-header] [data-additions-count], [data-diffs-header] [data-deletions-count] { display: none !important; }`; /** Nothing loaded yet, as one identity, so the memos below do not see a new array every render. */ const NO_SLICES: ReadonlyArray<DiffSlice> = []; /** A group while it is still gathering what belongs on its line. */ interface MutableAnnotationGroup { readonly side: PullRequestDiffSide; readonly line: number; readonly threads: PullRequestReviewThread[]; readonly pending: PendingReviewComment[]; draft: boolean; } interface DraftAnchor { readonly fileKey: string; readonly path: string; /** What the file was called before the change, for the hosts that resolve a position by both. */ readonly oldPath: string | null; readonly position: PullRequestReviewPosition; /** The whole selection, which the comment collapses to one line but a question keeps. */ readonly range: SelectedLineRange; } /** A range of the diff and the reader's request for the agent. */ export interface PullRequestAgentSelectionInput { /** The marked lines, already in the shape the composer draws and the agent reads. */ readonly comment: ReviewCommentContext; readonly request: string; } /** The contract's sides named the way the diff viewer names them, and back again. */ function toViewerSide(side: PullRequestDiffSide) { return side === "left" ? ("deletions" as const) : ("additions" as const); } function getReviewPositionAnchor(position: PullRequestReviewPosition): { line: number; side: PullRequestDiffSide; } { switch (position.kind) { case "added": return { line: position.newLine, side: "right" }; case "deleted": return { line: position.oldLine, side: "left" }; case "context": return { line: position.side === "left" ? position.oldLine : position.newLine, side: position.side, }; } } /** * Whether the viewer draws this line at all. A line counts the new file on the right and the old * one on the left, and each hunk covers one run of each; a line outside every run — a * conversation the host could not mark outdated, or one under a hunk it withheld — has no row to * be pinned to, however much its file looks like a match. */ /** * The pull request's patch, with the review written against it. Conversations already on the * host sit under the line they were written on, and a new comment joins the review being * drafted rather than being posted as it is typed. */ export function PullRequestCodeTab({ environmentId, reference, detail, selectedCommitOid, onSelectedCommitChange, pendingFinding, fixFindingLabel = "Fix in a thread", onFixFinding, onAddToAgentSelection, onRefresh, refreshToken = 0, }: { environmentId: EnvironmentId; reference: PullRequestRef; detail: PullRequestDetailView; /** Commit whose diff is open. Null keeps the whole pull-request diff selected. */ selectedCommitOid: string | null; onSelectedCommitChange: (oid: string | null) => void; /** The hand-off currently preparing, if any, so only the finding it belongs to says so. */ pendingFinding?: string | null; fixFindingLabel?: string; onFixFinding?: (finding: PullRequestFinding) => void; /** Absent where there is no active agent composer to receive a local comment. */ onAddToAgentSelection?: (input: PullRequestAgentSelectionInput) => void; onRefresh: () => void; /** Bumped by the panel's refresh button: drop the accumulated pages and re-read the diff. */ refreshToken?: number; }) { const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const [toggledFiles, setToggledFiles] = useState<ReadonlySet<string>>(() => new Set()); // A change of any size can carry hundreds of commits, and a menu that long is a scroll rather // than a choice. The rest arrive ten at a time, on request. const [visibleCommitCount, setVisibleCommitCount] = useState(COMMIT_PAGE_SIZE); /** Set once the reader has asked for every file at once, until they pick a file apart again. */ const [foldOverride, setFoldOverride] = useState<DiffFoldOverride>(null); const [diffRenderMode, setDiffRenderMode] = useState<"stacked" | "split">("stacked"); const [wordWrap, setWordWrap] = useState(settings.wordWrap); const [selectedLines, setSelectedLines] = useState<{ id: string; range: SelectedLineRange; } | null>(null); const [draft, setDraft] = useState<DraftAnchor | null>(null); const [threadPending, setThreadPending] = useState(false); const [orphansOpen, setOrphansOpen] = useState(false); // Closed by default so the review form does not permanently eat vertical space below the // diff; opened on demand as a floating overlay instead. const [reviewOpen, setReviewOpen] = useState(false); // Which pull request the slices belong to travels with them, so a render taken before the // reset below cannot read the previous one's slices — or send its cursor to the host. const [sliceState, setSliceState] = useState<{ readonly key: string; readonly cursor: string | null; readonly slices: ReadonlyArray<DiffSlice>; }>({ key: "", cursor: null, slices: NO_SLICES }); const parseCache = useRef(new Map<string, RenderablePatch>()); const referenceKey = pullRequestReviewKey(reference); const commit = selectedCommitOid; // One commit's own changes and the whole change are two different diffs, paged separately, so // everything below is keyed by both. const scopeKey = commit === null ? referenceKey : `${referenceKey}@${commit}`; // The panel keeps this mounted across pull requests, so an open composer would otherwise // survive the switch and attach its comment to whichever one is on screen when it is sent. useEffect(() => { setDraft(null); setSelectedLines(null); setToggledFiles(new Set()); setFoldOverride(null); setVisibleCommitCount(COMMIT_PAGE_SIZE); setOrphansOpen(false); setSliceState({ key: scopeKey, cursor: null, slices: NO_SLICES }); parseCache.current.clear(); }, [scopeKey]); const loadedSlices = sliceState.key === scopeKey ? sliceState.slices : NO_SLICES; const cursor = sliceState.key === scopeKey ? sliceState.cursor : null; const diffQuery = useEnvironmentQuery( pullRequestEnvironment.diff({ environmentId, input: { ...reference, ...(cursor === null ? {} : { cursor }), ...(commit === null ? {} : { commit }), }, }), ); // Each answer is kept as its own slice. Concatenating the patches and re-parsing the growing // text would cost more with every slice, which is the wall the slicing exists to remove. useEffect(() => { const data = diffQuery.data; if (data === null) return; setSliceState((previous) => { const slices = previous.key === scopeKey ? previous.slices : NO_SLICES; const next = { cursor, patch: data.patch, truncated: data.truncated, nextCursor: data.nextCursor, omittedFileStats: data.omittedFileStats ?? [], }; const index = slices.findIndex((slice) => slice.cursor === cursor); if (index === -1) { return { key: scopeKey, cursor, slices: [...slices, next] }; } const existing = slices[index]; if ( existing !== undefined && existing.patch === next.patch && existing.truncated === next.truncated && existing.nextCursor === next.nextCursor && existing.omittedFileStats.length === next.omittedFileStats.length && existing.omittedFileStats.every((file, index) => { const refreshed = next.omittedFileStats[index]; return ( refreshed !== undefined && refreshed.path === file.path && refreshed.additions === file.additions && refreshed.deletions === file.deletions ); }) ) { return previous; } // A page that came back different means the diff moved under the review. The slices // after it go with the replacement: their cursors were positions in the old diff. return { key: scopeKey, cursor, slices: [...slices.slice(0, index), next] }; }); }, [cursor, diffQuery.data, scopeKey]); // The refresh button rereads from the first page rather than the page the reader is on: // pages are positions in one snapshot of the diff, and a fresh snapshot starts over. const refreshFirstDiffPage = useAtomRefresh( pullRequestEnvironment.diff({ environmentId, input: { ...reference, ...(commit === null ? {} : { commit }) }, }), ); const appliedRefreshToken = useRef(refreshToken); useEffect(() => { if (appliedRefreshToken.current === refreshToken) return; appliedRefreshToken.current = refreshToken; setSliceState({ key: scopeKey, cursor: null, slices: NO_SLICES }); refreshFirstDiffPage(); }, [refreshToken, scopeKey, refreshFirstDiffPage]); const reviewKey = referenceKey; const pendingComments = usePendingReviewComments(reference); const addComment = usePullRequestReviewStore((store) => store.addComment); const removeComment = usePullRequestReviewStore((store) => store.removeComment); const replyToThread = useAtomCommand(pullRequestEnvironment.replyToThread, { reportFailure: false, }); const setThreadResolution = useAtomCommand(pullRequestEnvironment.setThreadResolution, { reportFailure: false, }); const updateComment = useAtomCommand(pullRequestEnvironment.updateComment, { reportFailure: false, }); const loadThreadComments = useAtomCommand(pullRequestEnvironment.threadComments, { reportFailure: false, }); const getDiffFileContents = useAtomCommand(pullRequestEnvironment.diffFileContents); const loadDiffFiles = useMemo( () => createPullRequestDiffFileContentsLoader(getDiffFileContents, { environmentId, reference, commit, cacheKey: `pull-request:${referenceKey}:${detail.updatedAt}:${commit ?? "all"}`, }), [commit, detail.updatedAt, environmentId, getDiffFileContents, reference, referenceKey], ); // What is offered is the intersection of two different questions: what this host can do at // all, and what this account may do on this repository. Either one saying no means a control // that would only ever end in a refusal. const review = useMemo(() => { const hostReview = detail.capabilities.review; const viewer = detail.viewerPermissions; return { inlineComment: hostReview.inlineComment && viewer.comment, reply: hostReview.reply && viewer.comment, resolve: hostReview.resolve && viewer.resolve, verdicts: hostReview.verdicts.filter((verdict) => viewer.verdicts.includes(verdict)), }; }, [detail.capabilities.review, detail.viewerPermissions]); // A comment is posted against the pull request's head diff, so a line number taken from one // commit's own diff would land somewhere else entirely. Commenting waits for the whole change. const canCommentOnLines = review.inlineComment && commit === null; // Every slice is parsed on its own and the result held, so a slice arriving costs one parse // rather than one per slice already on screen. Its cache key carries the theme, which is what // the tokenizer caches against, so a theme change is still a fresh parse. const parsedSlices = useMemo( () => loadedSlices.map((slice) => { // The patch's own hash is part of the key: a refreshed page reuses its cursor, and a // key of position alone would keep handing back the parse of the patch it replaced. const cacheKey = `pull-request:${scopeKey}:${resolvedTheme}:${slice.cursor ?? "first"}:${fnv1a32(slice.patch)}`; const cached = parseCache.current.get(cacheKey); if (cached) return cached; const parsed = getRenderablePatch(slice.patch, cacheKey, { compactPartialHunkOffsets: true, }); if (parsed) parseCache.current.set(cacheKey, parsed); return parsed; }), [loadedSlices, resolvedTheme, scopeKey], ); // Ordered within a slice rather than across them: ordering the accumulated set would let a late // slice push a file the reader is part way through further down the page. const files = useMemo( () => parsedSlices.flatMap((parsed) => parsed?.kind === "files" ? orderDiffFiles(parsed.files) : [], ), [parsedSlices], ); const nextCursor = loadedSlices.at(-1)?.nextCursor ?? null; // What a slice withheld: the host declining to inline part of it, or a patch the viewer could // not structure and so dropped. Neither says anything about there being more to fetch. const withheldContent = loadedSlices.some((slice) => slice.truncated) || parsedSlices.some((parsed) => parsed?.kind === "raw"); // Placing a conversation takes more than its file being in the diff: its line has to fall // inside a hunk that was rendered. One that does not is drawn nowhere, so it belongs in the // off-diff list rather than disappearing between the two. const placedThreadIds = useMemo(() => { const placed = new Set<string>(); // A commit's diff counts lines within that commit; a review comment is anchored to the // pull request's head diff. Pinning one onto the other would put the remark against // whichever code happens to hold that line number in this commit, so while a commit is on // screen every conversation is listed rather than placed. if (commit !== null) return placed; for (const file of files) { const path = resolveFileDiffPath(file); for (const thread of detail.reviewThreads) { if ( thread.path === path && thread.line !== null && isLineInFileDiff(file, thread.side, thread.line) ) { placed.add(thread.id); } } } return placed; }, [commit, detail.reviewThreads, files]); const items = useMemo<CodeViewDiffItem<ReviewAnnotationGroup>[]>( () => files.map((fileDiff) => { const fileKey = buildFileDiffRenderKey(fileDiff); const path = resolveFileDiffPath(fileDiff); // One annotation per line, so a line that already carries a conversation shows a new // comment underneath it rather than in place of it. const groups = new Map<string, MutableAnnotationGroup>(); const groupAt = (side: PullRequestDiffSide, line: number) => { const key = `${side}:${line}`; const existing = groups.get(key); if (existing) return existing; const created: MutableAnnotationGroup = { side, line, threads: [], pending: [], draft: false, }; groups.set(key, created); return created; }; for (const thread of detail.reviewThreads) { if (thread.path !== path || thread.line === null) continue; if (!placedThreadIds.has(thread.id)) continue; groupAt(thread.side, thread.line).threads.push(thread); } // Pending comments anchor to the head diff exactly like host threads do, so a // commit's diff must not place them either — the same line means other code there. if (commit === null) { for (const comment of pendingComments) { if (comment.path !== path) continue; const anchor = getReviewPositionAnchor(comment.position); groupAt(anchor.side, anchor.line).pending.push(comment); } } if (draft?.fileKey === fileKey) { const anchor = getReviewPositionAnchor(draft.position); groupAt(anchor.side, anchor.line).draft = true; } const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles); const annotations: ReviewAnnotation[] = [...groups.values()].map((group) => ({ side: toViewerSide(group.side), lineNumber: group.line, metadata: { threads: group.threads, pending: group.pending, draft: group.draft }, })); return { id: fileKey, type: "diff" as const, fileDiff, annotations, collapsed, // The viewer re-renders an item only when its version changes, so everything the // annotations show has to be part of it. version: fnv1a32( `${collapsed ? "1" : "0"}:${annotations .map( ({ side, lineNumber, metadata }) => `${side}:${lineNumber}:${metadata.draft ? "d" : ""}:${metadata.pending .map((comment) => `${comment.id}:${comment.body}`) .join(",")}:${metadata.threads .map( (thread) => `${thread.id}:${thread.isResolved ? "r" : ""}:${ thread.isOutdated ? "o" : "" }:${thread.comments .map(