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. Load more
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.