2690 lines · 115.4 KB · TypeScript
RawDownload
import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";import { usePullRequestStack } from "~/state/usePullRequestStack";import { RefreshIcon } from "~/components/ui/refresh-icon";import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";import {  type EnvironmentId,  type PullRequestAction,  type PullRequestMergeMethod,  type PullRequestListEntry,  type PullRequestUpdateMethod,  type PullRequestRef,  resolveEnvironmentMachineKind,  type ScopedThreadRef,} from "@t3tools/contracts";import {  ArrowDownUpIcon,  ArrowLeftIcon,  ArrowUpRightIcon,  BookOpenIcon,  CircleDotIcon,  ChevronDownIcon,  ExternalLinkIcon,  FileDiffIcon,  FolderGit2Icon,  GitBranchIcon,  GitCommitHorizontalIcon,  GitMergeIcon,  GitPullRequestClosedIcon,  GitPullRequestDraftIcon,  GitPullRequestIcon,  HammerIcon,  LayersIcon,  MessageCircleQuestionIcon,  MessageSquareIcon,  LinkIcon,  MoreHorizontalIcon,  PanelRightIcon,  PencilIcon,  PlayIcon,  RotateCcwIcon,  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 { useClientSettings } from "~/hooks/useSettings";import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";import {  deriveLogicalProjectKeyFromSettings,  derivePhysicalProjectKey,  selectProjectGroupingSettings,} from "~/logicalProject";import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink";import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";import { cn } from "~/lib/utils";import { readLocalApi } from "~/localApi";import type { ReviewCommentContext } from "~/reviewCommentContext";import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping";import { useProjects, useServerConfigs } from "~/state/entities";import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";import { useEnvironmentQuery } from "~/state/query";import { useLiveRefresh } from "~/hooks/useLiveRefresh";import { pullRequestEnvironment } from "~/state/pullRequests";import { usePullRequestTurnRefresh, useSharedPullRequestSummary } from "~/state/pullRequests";import { useAtomCommand } from "~/state/use-atom-command";import { PullRequestStackMenu } from "./PullRequestStackMenu";import { PullRequestThreadLinks } from "./PullRequestThreadLinks";import { vcsEnvironment } from "~/state/vcs";import { formatRelativeTimeLabel } from "~/timestampFormat";import { useUiStateStore } from "~/uiStateStore"; import {  AlertDialog,  AlertDialogClose,  AlertDialogDescription,  AlertDialogFooter,  AlertDialogHeader,  AlertDialogPopup,  AlertDialogTitle,} from "../ui/alert-dialog";import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";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, TooltipProvider, 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 { PullRequestMarkdownContext } from "./PullRequestMarkdown";import { PullRequestSummaryTab } from "./PullRequestSummaryTab";import { PullRequestTimelineTab } from "./PullRequestTimelineTab";import {  buildAddSelectionToAgentHandoff,  buildAskAboutPullRequestHandoff,  buildExplainPullRequestHandoff,  buildFixFindingHandoff,  buildFixFindingsHandoff,  buildResolveConflictsPrompt,  handoffPrompt,  handoffReviewComments,  latestPullRequestReviewOutcomes,  isStackedPullRequestBase,  pullRequestActionMenuHasGroup,  pullRequestActionNeedsHostRefresh,  pullRequestComposerTarget,  pullRequestCheckoutCommand,  pullRequestFindingKey,  pullRequestHandoffLabels,  PULL_REQUEST_MERGE_METHOD_LABELS,  readableFailure,  readPullRequestDetailSnapshot,  resolveDisplayedPullRequestDetail,  resolvePullRequestPrimaryControl,  allowsSinglePullRequestMerge,  resolveBaseFreshness,  resolvePullRequestMergeMethod,  type PullRequestFinding,  shouldRefreshPullRequestActivity,  writePullRequestDetailSnapshot,} from "./pullRequestDetail.logic";import { canEditPullRequestChangeRequest } from "./pullRequestEditing.logic";import {  resolvePickableEnvironments,  type PickableEnvironment,} from "./pullRequestProjectAssignment.logic";import { PullRequestChecksPopover } from "./PullRequestChecksPopover";import {  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",  revert: "Revert pull request opened",  "approve-workflows": "Workflows approved",}; /** 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",  revert: "Could not open a revert pull request",  "approve-workflows": "Could not approve workflows",}; /** 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.",  revert:    "The host refused it. Check that you have write access and that this pull request was merged on the host.",  "approve-workflows":    "The host refused it. Check that you have Actions write access and that these workflow runs are still awaiting approval.",}; /** * 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 load it only when the reader approaches Code.// Start the download on tab hover or focus, before the click, without loading it for every PR.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">              <EnvironmentMachineIcon                kind={environment.machine ?? "server"}                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 },  });}; function PullRequestCopyableCode({  value,  target,  copyLabel,  copiedLabel,  className,  tooltipSide = "top",  onError,}: {  readonly value: string;  readonly target: string;  readonly copyLabel: string;  readonly copiedLabel: string;  readonly className?: string;  readonly tooltipSide?: "top" | "bottom";  readonly onError?: (error: Error) => void;}) {  const { copyToClipboard, isCopied } = useCopyToClipboard({    target,    timeout: 1600,    ...(onError ? { onError } : {}),  });  return (    <Tooltip>      <TooltipTrigger        render={          <button            type="button"            className={cn(              "relative grid w-fit min-w-0 max-w-full shrink cursor-pointer rounded px-1 py-0.5 text-left outline-none transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent/45 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",              className,            )}            aria-label={isCopied ? copiedLabel : copyLabel}            onClick={() => copyToClipboard(value)}          />        }      >        <code          className={cn(            "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none",            isCopied ? "opacity-0" : "opacity-100",          )}        >          {value}        </code>        <span          aria-hidden="true"          className={cn(            "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none",            isCopied ? "opacity-100" : "opacity-0",          )}        >          Copied        </span>      </TooltipTrigger>      <TooltipPopup className="max-w-96 wrap-anywhere font-mono" side={tooltipSide}>        {`${isCopied ? "Copied" : copyLabel}: ${value}`}      </TooltipPopup>    </Tooltip>  );} /** * 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,  threadRef = null,  reference: requestedReference,  listEntry = null,  refreshToken: forcedRefreshToken = 0,  onActed,  onClose,  context = "page",  composerDraftTarget,  onBack,  onSelectPullRequest,}: {  environmentId: EnvironmentId;  onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined;  /**   * The thread this panel sits beside, if any. Links that are not the pull   * request itself (check details, host permalinks) can open in that thread's   * in-app browser when the user has asked for it; the page has no thread, so   * there they always go to the system browser.   */  threadRef?: ScopedThreadRef | null;  reference: PullRequestRef;  /** Row fields already loaded by the pull-request list, used while richer detail arrives. */  listEntry?: PullRequestListEntry | null;  /**   * 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;  /**   * 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.

Lines 1–500 of 2690.