2027 lines · 87.5 KB · TypeScript
RawDownload
import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";import type {  EnvironmentId,  PullRequestAction,  PullRequestMergeMethod,  PullRequestUpdateMethod,  PullRequestRef,  PullRequestState,  ScopedThreadRef,} from "@t3tools/contracts";import {  ArrowDownUpIcon,  ArrowLeftIcon,  ArrowUpRightIcon,  BookOpenIcon,  CircleDotIcon,  ChevronDownIcon,  FileDiffIcon,  FolderGit2Icon,  GitBranchIcon,  GitCommitHorizontalIcon,  GitMergeIcon,  GitPullRequestClosedIcon,  GitPullRequestDraftIcon,  GitPullRequestIcon,  HammerIcon,  LayersIcon,  MessageCircleQuestionIcon,  MessageSquareIcon,  LinkIcon,  MoreHorizontalIcon,  PanelRightIcon,  PencilIcon,  RefreshCwIcon,  ServerIcon,  TriangleAlertIcon,} from "lucide-react";import {  lazy,  Suspense,  type MouseEvent as ReactMouseEvent,  useCallback,  useEffect,  useLayoutEffect,  useMemo,  useRef,  useState,} from "react"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";import { useNewThreadHandler } from "~/hooks/useHandleNewThread";import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink";import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";import { cn } from "~/lib/utils";import { readLocalApi } from "~/localApi";import type { ReviewCommentContext } from "~/reviewCommentContext";import { useProjects } from "~/state/entities";import { useEnvironments } from "~/state/environments";import { useEnvironmentQuery } from "~/state/query";import { useLiveRefresh } from "~/hooks/useLiveRefresh";import { pullRequestEnvironment } from "~/state/pullRequests";import { useAtomCommand } from "~/state/use-atom-command";import { vcsEnvironment } from "~/state/vcs";import { formatRelativeTimeLabel } from "~/timestampFormat"; import {  AlertDialog,  AlertDialogClose,  AlertDialogDescription,  AlertDialogFooter,  AlertDialogHeader,  AlertDialogPopup,  AlertDialogTitle,} from "../ui/alert-dialog";import { Badge } from "../ui/badge";import { Button } from "../ui/button";import { Input } from "../ui/input";import { Toggle, ToggleGroup } from "../ui/toggle-group";import {  Menu,  MenuItem,  MenuPopup,  MenuRadioGroup,  MenuRadioItem,  MenuSeparator,  MenuTrigger,} from "../ui/menu";import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";import { toastManager } from "../ui/toast";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";import { DiffPanelLoadingState } from "../DiffPanelShell";import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab";import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";import { PullRequestSummaryTab } from "./PullRequestSummaryTab";import { PullRequestTimelineTab } from "./PullRequestTimelineTab";import {  buildAddSelectionToAgentHandoff,  buildAskAboutPullRequestHandoff,  buildExplainPullRequestHandoff,  buildFixFindingHandoff,  buildFixFindingsHandoff,  buildResolveConflictsPrompt,  handoffPrompt,  handoffReviewComments,  latestPullRequestReviewOutcomes,  isStackedPullRequestBase,  pullRequestActionMenuHasGroup,  pullRequestActionNeedsHostRefresh,  pullRequestComposerTarget,  pullRequestFindingKey,  pullRequestHandoffLabels,  readableFailure,  resolveBaseFreshness,  type PullRequestFinding,  shouldRefreshPullRequestActivity,} from "./pullRequestDetail.logic";import { canEditPullRequestChangeRequest } from "./pullRequestEditing.logic";import {  resolvePickableEnvironments,  type PickableEnvironment,} from "./pullRequestProjectAssignment.logic";import { PullRequestChecksPopover } from "./PullRequestChecksPopover";import {  PullRequestActorAvatar,  PullRequestActorLabel,  PullRequestDiffStat,  PullRequestMetaLine,  PullRequestReviewOutcomeIcon,  pullRequestChecksState,  pullRequestReviewOutcomeToneClassName,  resolvePullRequestState,  summarizePullRequestChecks,} from "./pullRequestPresentation"; type DetailTab = "summary" | "timeline" | "code"; const ACTION_SUCCESS_LABELS: Record<PullRequestAction, string> = {  merge: "Pull request merged",  ready: "Marked ready for review",  draft: "Converted to draft",  close: "Pull request closed",  reopen: "Pull request reopened",  "update-branch": "Branch updated with the base branch",  // True whichever it did: a pull request that was already mergeable merges the moment this is  // armed, and the client has no way to tell that apart from one still waiting on something.  "enable-auto-merge":    "Auto-merge turned on — merges as soon as this is ready, sooner if it already is",  "disable-auto-merge": "Auto-merge turned off",}; const MERGE_METHOD_LABELS: Record<PullRequestMergeMethod, string> = {  merge: "Merge",  squash: "Squash",  rebase: "Rebase",}; /** Said as the thing that did not happen, rather than as the operation that returned an error. */const ACTION_FAILURE_LABELS: Record<PullRequestAction, string> = {  merge: "Could not merge this pull request",  ready: "Could not mark this ready for review",  draft: "Could not convert this to a draft",  close: "Could not close this pull request",  reopen: "Could not reopen this pull request",  "update-branch": "Could not update this branch",  "enable-auto-merge": "Could not turn on auto-merge",  "disable-auto-merge": "Could not turn off auto-merge",}; /** What to try, for the times the host says only that it refused. */const ACTION_FAILURE_HINTS: Record<PullRequestAction, string> = {  merge:    "The host refused the merge. Check that you have write access, that the checks it requires have passed, and that the branch is not conflicting.",  ready: "The host refused it. Check that you have write access to this repository.",  draft: "The host refused it. Check that you have write access to this repository.",  close: "The host refused it. Check that you have write access, or that you opened it.",  reopen:    "The host refused it. Check that you have write access, and that the branch still exists.",  // Said for the merge commit, which is what an update is unless a rebase was asked for. The  // rebase has its own reasons to fail and its own sentence below.  "update-branch":    "The host refused it. Check that you have write access to the branch — one from a fork also needs its author to allow edits from maintainers — and that it does not conflict with the base.",  // The one refusal that is usually a repository setting rather than anything about this branch:  // GitHub will not arm an auto-merge at all unless the repository has the feature switched on.  "enable-auto-merge":    "The host refused it. Check that this repository allows auto-merge, that you have write access, and that there is something left for it to wait on.",  "disable-auto-merge":    "The host refused it. Check that you have write access, and that the merge has not already happened.",}; /** * Said instead of the update hint when the reader asked for a rebase: it is the one that fails on * its own merits, because GitHub replays the commits and stops at the first that does not apply. * Offering the merge commit only makes sense to somebody who did not already choose it. */const UPDATE_BRANCH_REBASE_FAILURE_HINT =  "The host refused it. A rebase stops at the first commit that does not apply cleanly; updating with a merge commit may still work."; const TABS: ReadonlyArray<{ value: DetailTab; label: string }> = [  { value: "summary", label: "Summary" },  { value: "timeline", label: "Timeline" },  { value: "code", label: "Code" },]; // The diff viewer pulls in its worker pool, so it stays out of the bundle until Code is opened.// Named rather than inlined so the panel can also call it itself, to start the download before// anyone has clicked the tab.const loadCodeTab = () => import("./PullRequestCodeTab");const PullRequestCodeTab = lazy(loadCodeTab); /** * What the last hand-off wrote into each draft, kept outside React because the panel that wrote it * is closed by the time the next one opens. It is how a prompt the reader has since edited is told * apart from the one they were handed: only the sentence still exactly as written may be replaced. */const lastHandoffPromptByDraft = new Map<string, string>(); const composerTargetKey = (target: ScopedThreadRef | DraftId): string =>  typeof target === "string" ? target : scopedThreadKey(target); /** * Which server the checkout and the hand-offs land on, where more than one of them holds this * repository. The list picked one of them to show the pull request under, so that everything on * it is read from somewhere; where the reader wants to work is a separate answer, and this is * where they give it. */function ActOnEnvironmentPicker({  environments,  value,  onChange,  disabled,}: {  environments: ReadonlyArray<PickableEnvironment>;  value: EnvironmentId;  onChange: (environmentId: EnvironmentId) => void;  disabled: boolean;}) {  return (    <>      <MenuSeparator />      <MenuRadioGroup        value={value}        onValueChange={(environmentId) => onChange(environmentId as EnvironmentId)}      >        {environments.map((environment) => (          <MenuRadioItem            key={environment.environmentId}            value={environment.environmentId}            disabled={disabled}          >            {/* The radio item lays its children out as one block, so the icon and the label                need their own row to share a line. */}            <span className="flex min-w-0 items-center gap-2">              <ServerIcon className="size-3.5 shrink-0" />              <span className="truncate">{environment.label}</span>            </span>          </MenuRadioItem>        ))}      </MenuRadioGroup>    </>  );} /** The number is a link in every place the host writes it, so the right-click that copies one    has to answer here too — otherwise the platform's own cut/paste menu opens over it. */const openNumberContextMenu = (  event: ReactMouseEvent,  detail: { readonly url: string; readonly provider: string },): void => {  event.preventDefault();  event.stopPropagation();  void showPullRequestLinkContextMenu({    url: detail.url,    openLabel: openOnHostLabel(detail.provider),    position: { x: event.clientX, y: event.clientY },  });}; /** * The stale-branch warning, said beside the branch it is about rather than as a bar of its own. * The banner this replaces held a row of chrome open across the top of every pull request that * had fallen behind, pushing the reading down to say something that is true of the base branch * and nothing else; as a mark on the base branch it is where a reader would look for it, and the * sentence and the way out of it arrive together the moment the mark is pointed at. * * A popover rather than a tooltip because what it holds can be pressed: a tooltip's layer takes * no pointer, and a control nobody can reach is worse than no control. */function PullRequestBaseFreshnessWarning({  baseBranch,  freshness,  pending,  onUpdate,  iconClassName,}: {  readonly baseBranch: string;  readonly freshness: {    readonly behindBy: number | null;    readonly methods: ReadonlyArray<PullRequestUpdateMethod>;  };  readonly pending: boolean;  readonly onUpdate: (method: PullRequestUpdateMethod) => void;  readonly iconClassName?: string;}) {  const behind =    freshness.behindBy === null      ? ""      : ` by ${freshness.behindBy.toLocaleString()} ${          freshness.behindBy === 1 ? "commit" : "commits"        }`;  const summary = `This branch is out-of-date with ${baseBranch}${behind}.`;  return (    <Popover>      <PopoverTrigger        openOnHover        delay={0}        closeDelay={120}        render={          <button            type="button"            aria-label={summary}            className="inline-flex shrink-0 cursor-help items-center rounded-sm text-amber-600 outline-none focus-visible:ring-2 focus-visible:ring-ring"          />        }      >        <TriangleAlertIcon aria-hidden className={cn("size-3.5", iconClassName)} />      </PopoverTrigger>      <PopoverPopup        align="start"        side="bottom"        className="max-w-80"        viewportClassName="py-2.5 [--viewport-inline-padding:--spacing(3)]"      >        <p className="text-xs text-foreground">{summary}</p>        <p className="mt-0.5 text-xs text-muted-foreground">Changes can be cleanly merged.</p>        {/* Each way the host offers and this reader may take, as its own button: a split button            would need a menu inside a popover, and two buttons say the same thing in one layer. */}        {freshness.methods.length > 0 ? (          <span className="mt-2 flex flex-wrap items-center gap-1.5">            {freshness.methods.map((method) => (              <Button                key={method}                size="xs"                variant="outline"                disabled={pending}                onClick={() => onUpdate(method)}              >                <GitMergeIcon aria-hidden className="size-3" />                {method === "rebase" ? "Update with rebase" : "Update branch"}              </Button>            ))}          </span>        ) : null}      </PopoverPopup>    </Popover>  );} export function PullRequestDetailPanel({  environmentId,  reference,  refreshToken: forcedRefreshToken = 0,  onActed,  onClose,  onStateChange,  context = "page",  composerDraftTarget,}: {  environmentId: EnvironmentId;  reference: PullRequestRef;  /**   * Bumped by whatever holds the panel when a reader asks for everything on screen to be read   * again. The panel owns its own reads, so the page cannot refresh them for it — it says when,   * and this says it.   */  refreshToken?: number;  /**   * An action changed this pull request on the host, so a list showing it is now out of date.   * Told rather than assumed: only the page knows whether it is showing one.   */  onActed?: () => void;  /** Page-owned detail columns use this to clear the selected pull request. */  onClose?: () => void;  /** Keeps compact chrome, such as the right-panel tab, in step with refreshed host state. */  onStateChange?: (status: {    projectId: string;    repository: string;    number: number;    state: PullRequestState;    isDraft: boolean;  }) => void;  /**   * Beside a thread, the checkout affordance disappears: the panel is showing that thread's   * own pull request, so the branch is already under the reader's feet — and checking it out   * again is at best a no-op and at worst git refusing a branch two checkouts.   */  context?: "page" | "thread";  /**   * The open thread's composer. Beside the thread whose own pull request this is, hand-offs   * land here instead of opening a new thread — the branch is already under the reader's feet.   */  composerDraftTarget?: ScopedThreadRef | DraftId;}) {  const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`;  const [tab, setTab] = useState<DetailTab>("summary");  const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest");  const [codeCommitScope, setCodeCommitScope] = useState<{    readonly pullRequestKey: string;    readonly oid: string | null;  }>(() => ({ pullRequestKey, oid: null }));  const selectedCodeCommitOid =    codeCommitScope.pullRequestKey === pullRequestKey ? codeCommitScope.oid : null;  const selectCodeCommit = (oid: string | null) => {    setCodeCommitScope({ pullRequestKey, oid });  };  const openCommit = (oid: string) => {    selectCodeCommit(oid);    setTab("code");  };  // Every tab the reader has opened stays mounted behind the active one. The diff viewer  // always needed this (it virtualizes against its own scroll position); the trace showed the  // summary needs it too — a large description re-parses its whole markdown on every return  // to the tab. `visibility` keeps boxes, sizes and scroll offsets, and takes hidden content  // out of the tab order and the accessibility tree.  const [mountedTabs, setMountedTabs] = useState<ReadonlySet<DetailTab>>(    () => new Set<DetailTab>(["summary"]),  );  useEffect(() => {    setMountedTabs((previous) =>      previous.has(tab) ? previous : new Set<DetailTab>(previous).add(tab),    );  }, [tab]);  const [chromeCondensed, setChromeCondensed] = useState(false);  // Each mounted tab remembers its own scroll chrome; short tabs cannot scroll to reopen it.  const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({});  useEffect(() => {    setChromeCondensed(chromeStateByTab.current[tab] ?? false);  }, [tab]);  const condensed = chromeCondensed;  const scrollerRef = useRef<HTMLElement | null>(null);  const foldRef = useRef<HTMLDivElement | null>(null);  const condensedRowRef = useRef<HTMLDivElement | null>(null);  // Refund after the fold commits so the content under the reader does not jump with its height.  const compensationRef = useRef<number | null>(null);  useLayoutEffect(() => {    if (compensationRef.current === null) return;    const scroller = scrollerRef.current;    const delta = compensationRef.current;    compensationRef.current = null;    if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta);  }, [condensed]);  const [mergeMethod, setMergeMethod] = useState<PullRequestMergeMethod>("merge");  const [confirmation, setConfirmation] = useState<{    readonly open: boolean;    readonly action: "merge" | "close" | "enable-auto-merge";  }>({ open: false, action: "merge" });  const confirmAction = confirmation.action;  // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself  // alone. One at a time whatever the key: they all check the same pull request out.  const [handoff, setHandoff] = useState<string | null>(null);  const { copyToClipboard: copyBranchToClipboard, isCopied: isBranchCopied } = useCopyToClipboard({    target: "branch name",    timeout: 1600,  });  // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be  // clicked, so a reader who does click it lands on a chunk already in the module cache.  useEffect(() => {    void loadCodeTab();  }, []);   const detailQuery = useEnvironmentQuery(    pullRequestEnvironment.detail({ environmentId, input: reference }),  );  const activityQuery = useEnvironmentQuery(    pullRequestEnvironment.activity({ environmentId, input: reference }),  );  // Detail and diff are independent server reads, so the diff for the default view (no commit,  // no cursor) is started here too rather than waiting for the Code tab to mount. This is one  // extra cached read per opened pull request even for readers who never open the tab, but it  // turns the tab's first paint from a cold request into a cache hit.  const _diffWarmUpQuery = useEnvironmentQuery(    pullRequestEnvironment.diff({ environmentId, input: { ...reference } }),  );  const coreDetail = detailQuery.data;  const activity = activityQuery.data;  const detail = useMemo(    () =>      coreDetail === null        ? null        : {            ...coreDetail,            author: activity?.author ?? coreDetail.author,            reviewers: activity?.reviewers ?? coreDetail.reviewers,            comments: activity?.comments ?? [],            commentCount: activity?.commentCount ?? 0,            commentsTruncated: activity?.commentsTruncated ?? false,

Lines 1–500 of 2027.