1443 lines · 59.1 KB · TypeScript
RawDownload
import type { CodeViewItem, DiffLineAnnotation, SelectedLineRange } from "@pierre/diffs";import type { CodeViewDiffItem, CodeViewHandle } from "@pierre/diffs/react";import type {  EnvironmentId,  PullRequestDetailView,  PullRequestDiffSide,  PullRequestOmittedFileStat,  PullRequestRef,  PullRequestReviewPosition,  PullRequestReviewThread,  PullRequestThreadCommentsResult,} from "@t3tools/contracts";import {  ChevronDownIcon,  ChevronRightIcon,  ChevronsDownUpIcon,  ChevronsUpDownIcon,  Columns2Icon,  FolderTreeIcon,  MessageSquareIcon,  MessageSquareOffIcon,  Rows3Icon,  TextWrapIcon,  TriangleAlertIcon,  XIcon,} from "lucide-react";import { useAtomRefresh } from "@effect/atom-react";import * as Schema from "effect/Schema";import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useLocalStorage } from "~/hooks/useLocalStorage";import { useClientSettings, useUpdateClientSettings } 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 { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";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 { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";import { DiffFileTree } from "../diffs/DiffFileTree";import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal";import { diffFileTreeEntries } from "../diffs/diffFileTree.logic";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; const PULL_REQUEST_FILE_TREE_STORAGE_KEY = "t3code.pullRequestFileTreeOpen"; /** 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. */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 diffLayout = settings.diffLayout;  const updateClientSettings = useUpdateClientSettings();  const [wordWrap, setWordWrap] = useState(settings.wordWrap);  const [fileTreeOpen, setFileTreeOpen] = useLocalStorage(    PULL_REQUEST_FILE_TREE_STORAGE_KEY,    false,    Schema.Boolean,  );  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 [viewer, setViewer] = useState<CodeViewHandle<ReviewAnnotationGroup> | null>(null);   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,

Lines 1–500 of 1443.