| 1 | import { |
| 2 | mergebotPullRequestUrl, |
| 3 | mergebotPullRequestBrowserThreadId, |
| 4 | ThreadId, |
| 5 | } from "@t3tools/contracts"; |
| 6 | import { MergebotQueueBadge } from "./MergebotQueueBadge"; |
| 7 | import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; |
| 8 | import { useAtomValue } from "@effect/atom-react"; |
| 9 | import { usePullRequestStack } from "~/state/usePullRequestStack"; |
| 10 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 11 | import { AnimatedWidth } from "~/components/AnimatedWidth"; |
| 12 | import { |
| 13 | scopedThreadKey, |
| 14 | scopeProjectRef, |
| 15 | scopeThreadRef, |
| 16 | } from "@t3tools/client-runtime/environment"; |
| 17 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 18 | import { |
| 19 | AuthOrchestrationOperateScope, |
| 20 | AuthSourceControlWriteScope, |
| 21 | type EnvironmentId, |
| 22 | type PullRequestAction, |
| 23 | type PullRequestMergeMethod, |
| 24 | type PullRequestListEntry, |
| 25 | type PullRequestUpdateMethod, |
| 26 | type PullRequestRef, |
| 27 | type PullRequestReviewGuideStop, |
| 28 | resolveEnvironmentMachineKind, |
| 29 | type ScopedThreadRef, |
| 30 | } from "@t3tools/contracts"; |
| 31 | import { |
| 32 | ArrowDownUpIcon, |
| 33 | ArrowLeftIcon, |
| 34 | ArrowUpRightIcon, |
| 35 | BookOpenIcon, |
| 36 | CheckIcon, |
| 37 | CircleDotIcon, |
| 38 | CopyIcon, |
| 39 | ChevronDownIcon, |
| 40 | ExternalLinkIcon, |
| 41 | FileDiffIcon, |
| 42 | FolderGit2Icon, |
| 43 | GitBranchIcon, |
| 44 | GitCommitHorizontalIcon, |
| 45 | HammerIcon, |
| 46 | MessageCircleQuestionIcon, |
| 47 | MessageSquareIcon, |
| 48 | LinkIcon, |
| 49 | MoreHorizontalIcon, |
| 50 | PanelRightIcon, |
| 51 | PlayIcon, |
| 52 | RotateCcwIcon, |
| 53 | SparklesIcon, |
| 54 | Trash2Icon, |
| 55 | TriangleAlertIcon, |
| 56 | } from "lucide-react"; |
| 57 | import { |
| 58 | lazy, |
| 59 | Suspense, |
| 60 | type MouseEvent as ReactMouseEvent, |
| 61 | useCallback, |
| 62 | useEffect, |
| 63 | useEffectEvent, |
| 64 | useLayoutEffect, |
| 65 | useMemo, |
| 66 | useRef, |
| 67 | useState, |
| 68 | type ReactNode, |
| 69 | } from "react"; |
| 70 | |
| 71 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 72 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 73 | import { useEnvironmentSettings } from "~/hooks/useSettings"; |
| 74 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; |
| 75 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 76 | import { isCommandPaletteOpen } from "~/commandPaletteBus"; |
| 77 | import { |
| 78 | resolveShortcutCommand, |
| 79 | shortcutLabelForCommand, |
| 80 | type ShortcutMatchContext, |
| 81 | } from "~/keybindings"; |
| 82 | import { primaryServerKeybindingsAtom } from "~/state/server"; |
| 83 | import { usePullRequestDefaultMergeMethodResolver } from "./usePullRequestActions"; |
| 84 | import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink"; |
| 85 | import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; |
| 86 | import { cn } from "~/lib/utils"; |
| 87 | import { readLocalApi } from "~/localApi"; |
| 88 | import type { ReviewCommentContext } from "~/reviewCommentContext"; |
| 89 | import { useProjects, useServerConfigs } from "~/state/entities"; |
| 90 | import { useEnvironments } from "~/state/environments"; |
| 91 | import { useEnvironmentQuery } from "~/state/query"; |
| 92 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; |
| 93 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| 94 | import { |
| 95 | pullRequestEnvironment, |
| 96 | pullRequestListEntryToSummary, |
| 97 | newestPullRequestSummary, |
| 98 | usePullRequestTurnRefresh, |
| 99 | useSharedPullRequestSummary, |
| 100 | } from "~/state/pullRequests"; |
| 101 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 102 | import { PullRequestStackMenu } from "./PullRequestStackMenu"; |
| 103 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; |
| 104 | import { PullRequestThreadLinks } from "./PullRequestThreadLinks"; |
| 105 | import { vcsEnvironment } from "~/state/vcs"; |
| 106 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 107 | import { useUiStateStore } from "~/uiStateStore"; |
| 108 | |
| 109 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; |
| 110 | import { Badge } from "../ui/badge"; |
| 111 | import { Button } from "../ui/button"; |
| 112 | import { PullRequestEditButton } from "./PullRequestEditButton"; |
| 113 | import { Input } from "../ui/input"; |
| 114 | import { |
| 115 | Menu, |
| 116 | MenuItem, |
| 117 | MenuPopup, |
| 118 | MenuRadioGroup, |
| 119 | MenuRadioItem, |
| 120 | MenuSeparator, |
| 121 | MenuShortcut, |
| 122 | MenuTrigger, |
| 123 | } from "../ui/menu"; |
| 124 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 125 | import { toastManager } from "../ui/toast"; |
| 126 | import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; |
| 127 | import { MiddleTruncate } from "../ui/middle-truncate"; |
| 128 | import { |
| 129 | PullRequestChecksStatusLine, |
| 130 | PullRequestDetailHeaderBody, |
| 131 | PullRequestDetailTabBar, |
| 132 | PullRequestDetailTitleRow, |
| 133 | } from "./PullRequestDetailLayout"; |
| 134 | import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; |
| 135 | import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; |
| 136 | import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; |
| 137 | import { DiffPanelLoadingState } from "../DiffPanelShell"; |
| 138 | import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; |
| 139 | import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab"; |
| 140 | import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; |
| 141 | import { PullRequestMarkdownContext } from "./PullRequestMarkdown"; |
| 142 | import { PullRequestComposer } from "./PullRequestComposer"; |
| 143 | import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; |
| 144 | import { PullRequestTimelineTab } from "./PullRequestTimelineTab"; |
| 145 | import { |
| 146 | buildAddSelectionToAgentHandoff, |
| 147 | buildAskAboutGuideStopHandoff, |
| 148 | buildAskAboutPullRequestHandoff, |
| 149 | buildExplainPullRequestHandoff, |
| 150 | buildFixFindingHandoff, |
| 151 | buildFixFindingsHandoff, |
| 152 | buildResolveConflictsPrompt, |
| 153 | handoffPrompt, |
| 154 | handoffReviewComments, |
| 155 | latestPullRequestReviewOutcomes, |
| 156 | loadingPullRequestCheckoutCommand, |
| 157 | isPullRequestNotFound, |
| 158 | isStackedPullRequestBase, |
| 159 | pullRequestActionMenuHasGroup, |
| 160 | pullRequestActionNeedsHostRefresh, |
| 161 | pullRequestCheckoutCommand, |
| 162 | pullRequestFindingKey, |
| 163 | pullRequestHandoffLabels, |
| 164 | PULL_REQUEST_MERGE_METHOD_LABELS, |
| 165 | readableFailure, |
| 166 | readPullRequestDetailSnapshot, |
| 167 | resolvePullRequestReferenceHost, |
| 168 | resolveDisplayedPullRequestDetail, |
| 169 | resolvePullRequestPrimaryControl, |
| 170 | allowsSinglePullRequestMerge, |
| 171 | canDeletePullRequestBranch, |
| 172 | resolveBaseFreshness, |
| 173 | resolvePullRequestMergeMethod, |
| 174 | type PullRequestFinding, |
| 175 | shouldRefreshPullRequestActivity, |
| 176 | stripPullRequestHandoffReferences, |
| 177 | writePullRequestDetailSnapshot, |
| 178 | } from "./pullRequestDetail.logic"; |
| 179 | import { canEditPullRequestChangeRequest } from "./pullRequestEditing.logic"; |
| 180 | import { |
| 181 | resolvePickableEnvironments, |
| 182 | type PickableEnvironment, |
| 183 | } from "./pullRequestProjectAssignment.logic"; |
| 184 | import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; |
| 185 | import { |
| 186 | PullRequestActorLabel, |
| 187 | PullRequestDiffStat, |
| 188 | PullRequestMetaLine, |
| 189 | PullRequestReviewOutcomeIcon, |
| 190 | pullRequestChecksState, |
| 191 | pullRequestChecksStatePresentation, |
| 192 | pullRequestReviewOutcomeToneClassName, |
| 193 | resolvePullRequestState, |
| 194 | summarizePullRequestChecks, |
| 195 | } from "./pullRequestPresentation"; |
| 196 | import { PullRequestGlyph } from "./pullRequestIcons"; |
| 197 | import { PullRequestGuideTab } from "./PullRequestGuideTab"; |
| 198 | import { guideRunKey, useGuideRunStore } from "./guide/guideRunStore"; |
| 199 | import { useReviewGuideWriter } from "./guide/useReviewGuideWriter"; |
| 200 | import { useInlineActionConfirm } from "./useInlineActionConfirm"; |
| 201 | |
| 202 | type DetailTab = "summary" | "guide" | "timeline" | "code"; |
| 203 | |
| 204 | /** Said as the thing that did not happen, rather than as the operation that returned an error. */ |
| 205 | const ACTION_FAILURE_LABELS: Record<PullRequestAction, string> = { |
| 206 | merge: "Could not merge this pull request", |
| 207 | ready: "Could not mark this ready for review", |
| 208 | draft: "Could not convert this to a draft", |
| 209 | close: "Could not close this pull request", |
| 210 | reopen: "Could not reopen this pull request", |
| 211 | "update-branch": "Could not update this branch", |
| 212 | "enable-auto-merge": "Could not turn on auto-merge", |
| 213 | "disable-auto-merge": "Could not turn off auto-merge", |
| 214 | revert: "Could not open a revert pull request", |
| 215 | "delete-branch": "Could not delete this branch", |
| 216 | "approve-workflows": "Could not approve workflows", |
| 217 | }; |
| 218 | |
| 219 | /** What to try, for the times the host says only that it refused. */ |
| 220 | const ACTION_FAILURE_HINTS: Record<PullRequestAction, string> = { |
| 221 | merge: |
| 222 | "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.", |
| 223 | ready: "The host refused it. Check that you have write access to this repository.", |
| 224 | draft: "The host refused it. Check that you have write access to this repository.", |
| 225 | close: "The host refused it. Check that you have write access, or that you opened it.", |
| 226 | reopen: |
| 227 | "The host refused it. Check that you have write access, and that the branch still exists.", |
| 228 | // Said for the merge commit, which is what an update is unless a rebase was asked for. The |
| 229 | // rebase has its own reasons to fail and its own sentence below. |
| 230 | "update-branch": |
| 231 | "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.", |
| 232 | // The one refusal that is usually a repository setting rather than anything about this branch: |
| 233 | // GitHub will not arm an auto-merge at all unless the repository has the feature switched on. |
| 234 | "enable-auto-merge": |
| 235 | "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.", |
| 236 | "disable-auto-merge": |
| 237 | "The host refused it. Check that you have write access, and that the merge has not already happened.", |
| 238 | revert: |
| 239 | "The host refused it. Check that you have write access and that this pull request was merged on the host.", |
| 240 | "delete-branch": |
| 241 | "The host refused it. Check that you have write access to the source repository and that the branch has not moved since this page loaded.", |
| 242 | "approve-workflows": |
| 243 | "The host refused it. Check that you have Actions write access and that these workflow runs are still awaiting approval.", |
| 244 | }; |
| 245 | |
| 246 | /** |
| 247 | * Said instead of the update hint when the reader asked for a rebase: it is the one that fails on |
| 248 | * its own merits, because GitHub replays the commits and stops at the first that does not apply. |
| 249 | * Offering the merge commit only makes sense to somebody who did not already choose it. |
| 250 | */ |
| 251 | const UPDATE_BRANCH_REBASE_FAILURE_HINT = |
| 252 | "The host refused it. A rebase stops at the first commit that does not apply cleanly; updating with a merge commit may still work."; |
| 253 | |
| 254 | const TABS: ReadonlyArray<{ value: DetailTab; label: string }> = [ |
| 255 | { value: "summary", label: "Summary" }, |
| 256 | { value: "guide", label: "Guide" }, |
| 257 | { value: "timeline", label: "Timeline" }, |
| 258 | { value: "code", label: "Code" }, |
| 259 | ]; |
| 260 | |
| 261 | // The diff viewer pulls in its worker pool, so load it only when the reader approaches Code. |
| 262 | // Start the download on tab hover or focus, before the click, without loading it for every PR. |
| 263 | const loadCodeTab = () => import("./PullRequestCodeTab"); |
| 264 | const PullRequestCodeTab = lazy(loadCodeTab); |
| 265 | |
| 266 | /** |
| 267 | * What the last hand-off wrote into each draft, kept outside React because the panel that wrote it |
| 268 | * is closed by the time the next one opens. It is how a prompt the reader has since edited is told |
| 269 | * apart from the one they were handed: only the sentence still exactly as written may be replaced. |
| 270 | */ |
| 271 | const lastHandoffPromptByDraft = new Map<string, string>(); |
| 272 | |
| 273 | const composerTargetKey = (target: ScopedThreadRef | DraftId): string => |
| 274 | typeof target === "string" ? target : scopedThreadKey(target); |
| 275 | |
| 276 | /** |
| 277 | * Which server the checkout and the hand-offs land on, where more than one of them holds this |
| 278 | * repository. The list picked one of them to show the pull request under, so that everything on |
| 279 | * it is read from somewhere; where the reader wants to work is a separate answer, and this is |
| 280 | * where they give it. |
| 281 | */ |
| 282 | function ActOnEnvironmentPicker({ |
| 283 | environments, |
| 284 | value, |
| 285 | onChange, |
| 286 | disabled, |
| 287 | }: { |
| 288 | environments: ReadonlyArray<PickableEnvironment>; |
| 289 | value: EnvironmentId; |
| 290 | onChange: (environmentId: EnvironmentId) => void; |
| 291 | disabled: boolean; |
| 292 | }) { |
| 293 | return ( |
| 294 | <> |
| 295 | <MenuSeparator /> |
| 296 | <MenuRadioGroup |
| 297 | value={value} |
| 298 | onValueChange={(environmentId) => onChange(environmentId as EnvironmentId)} |
| 299 | > |
| 300 | {environments.map((environment) => ( |
| 301 | <MenuRadioItem |
| 302 | key={environment.environmentId} |
| 303 | value={environment.environmentId} |
| 304 | disabled={disabled} |
| 305 | > |
| 306 | {/* The radio item lays its children out as one block, so the icon and the label |
| 307 | need their own row to share a line. */} |
| 308 | <span className="flex min-w-0 items-center gap-2"> |
| 309 | <EnvironmentMachineIcon |
| 310 | kind={environment.machine ?? "server"} |
| 311 | className="size-3.5 shrink-0" |
| 312 | /> |
| 313 | <span className="truncate">{environment.label}</span> |
| 314 | </span> |
| 315 | </MenuRadioItem> |
| 316 | ))} |
| 317 | </MenuRadioGroup> |
| 318 | </> |
| 319 | ); |
| 320 | } |
| 321 | |
| 322 | /** The number is a link in every place the host writes it, so the right-click that copies one |
| 323 | has to answer here too — otherwise the platform's own cut/paste menu opens over it. */ |
| 324 | const openNumberContextMenu = ( |
| 325 | event: ReactMouseEvent, |
| 326 | detail: { readonly url: string; readonly provider: string }, |
| 327 | ): void => { |
| 328 | event.preventDefault(); |
| 329 | event.stopPropagation(); |
| 330 | void showPullRequestLinkContextMenu({ |
| 331 | url: detail.url, |
| 332 | openLabel: openOnHostLabel(sourceControlClients.get(detail.provider)), |
| 333 | position: { x: event.clientX, y: event.clientY }, |
| 334 | }); |
| 335 | }; |
| 336 | |
| 337 | /** |
| 338 | * The stale-branch warning, said beside the branch it is about rather than as a bar of its own. |
| 339 | * The banner this replaces held a row of chrome open across the top of every pull request that |
| 340 | * had fallen behind, pushing the reading down to say something that is true of the base branch |
| 341 | * and nothing else; as a mark on the base branch it is where a reader would look for it, and the |
| 342 | * sentence and the way out of it arrive together the moment the mark is pointed at. |
| 343 | * |
| 344 | * A popover rather than a tooltip because what it holds can be pressed: a tooltip's layer takes |
| 345 | * no pointer, and a control nobody can reach is worse than no control. |
| 346 | */ |
| 347 | function PullRequestBaseFreshnessWarning({ |
| 348 | baseBranch, |
| 349 | freshness, |
| 350 | pending, |
| 351 | onUpdate, |
| 352 | iconClassName, |
| 353 | className, |
| 354 | children, |
| 355 | }: { |
| 356 | readonly baseBranch: string; |
| 357 | readonly freshness: { |
| 358 | readonly behindBy: number | null; |
| 359 | readonly methods: ReadonlyArray<PullRequestUpdateMethod>; |
| 360 | }; |
| 361 | readonly pending: boolean; |
| 362 | readonly onUpdate: (method: PullRequestUpdateMethod) => void; |
| 363 | readonly iconClassName?: string; |
| 364 | readonly className?: string; |
| 365 | /** What the warning is about, drawn in the same amber before the mark: the base branch. */ |
| 366 | readonly children?: ReactNode; |
| 367 | }) { |
| 368 | const behind = |
| 369 | freshness.behindBy === null |
| 370 | ? "" |
| 371 | : ` by ${freshness.behindBy.toLocaleString()} ${ |
| 372 | freshness.behindBy === 1 ? "commit" : "commits" |
| 373 | }`; |
| 374 | const summary = `This branch is out-of-date with ${baseBranch}${behind}.`; |
| 375 | return ( |
| 376 | <Popover> |
| 377 | <PopoverTrigger |
| 378 | openOnHover |
| 379 | delay={0} |
| 380 | closeDelay={120} |
| 381 | render={ |
| 382 | <button |
| 383 | type="button" |
| 384 | aria-label={summary} |
| 385 | className={cn( |
| 386 | "inline-flex min-w-0 shrink-0 cursor-help items-center gap-1 rounded-sm text-warning-foreground outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", |
| 387 | className, |
| 388 | )} |
| 389 | /> |
| 390 | } |
| 391 | > |
| 392 | {children} |
| 393 | <TriangleAlertIcon aria-hidden className={cn("size-3.5 shrink-0", iconClassName)} /> |
| 394 | </PopoverTrigger> |
| 395 | <PopoverPopup align="start" side="bottom" className="max-w-80" padding="compact"> |
| 396 | <p className="text-xs text-foreground">{summary}</p> |
| 397 | <p className="mt-0.5 text-xs text-muted-foreground">Changes can be cleanly merged.</p> |
| 398 | {/* Each way the host offers and this reader may take, as its own button: a split button |
| 399 | would need a menu inside a popover, and two buttons say the same thing in one layer. */} |
| 400 | {freshness.methods.length > 0 ? ( |
| 401 | <span className="mt-2 flex flex-wrap items-center gap-1.5"> |
| 402 | {freshness.methods.map((method) => ( |
| 403 | <Button |
| 404 | key={method} |
| 405 | size="xs" |
| 406 | variant="outline" |
| 407 | disabled={pending} |
| 408 | onClick={() => onUpdate(method)} |
| 409 | > |
| 410 | <PullRequestGlyph.merged aria-hidden className="size-3" /> |
| 411 | {method === "rebase" ? "Update with rebase" : "Update branch"} |
| 412 | </Button> |
| 413 | ))} |
| 414 | </span> |
| 415 | ) : null} |
| 416 | </PopoverPopup> |
| 417 | </Popover> |
| 418 | ); |
| 419 | } |
| 420 | |
| 421 | const MergebotBrowserPanel = lazy(() => |
| 422 | import("./MergebotBrowserPanel").then((module) => ({ default: module.MergebotBrowserPanel })), |
| 423 | ); |
| 424 | |
| 425 | export function PullRequestDetailPanel(props: Parameters<typeof NativePullRequestDetailPanel>[0]) { |
| 426 | const configurations = useServerConfigs(); |
| 427 | const projects = useProjects(); |
| 428 | const project = projects.find( |
| 429 | (candidate) => |
| 430 | candidate.id === props.reference.projectId && candidate.environmentId === props.environmentId, |
| 431 | ); |
| 432 | const reference = resolvePullRequestReferenceHost(props.reference, project?.repositoryIdentity); |
| 433 | const url = mergebotPullRequestUrl(configurations.get(props.environmentId)?.mergebot, reference); |
| 434 | return url ? ( |
| 435 | <div className="flex h-full min-h-0 flex-col bg-background"> |
| 436 | {props.onBack || props.onClose ? ( |
| 437 | <div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1"> |
| 438 | {props.onBack ? ( |
| 439 | <Button |
| 440 | size="icon-xs" |
| 441 | variant="ghost" |
| 442 | aria-label="Back to this thread's pull requests" |
| 443 | onClick={props.onBack} |
| 444 | > |
| 445 | <ArrowLeftIcon aria-hidden className="size-3.5" /> |
| 446 | </Button> |
| 447 | ) : null} |
| 448 | {props.onClose ? ( |
| 449 | <div className="ml-auto"> |
| 450 | <Button |
| 451 | size="icon-xs" |
| 452 | variant="ghost" |
| 453 | aria-label="Collapse pull request panel" |
| 454 | onClick={props.onClose} |
| 455 | > |
| 456 | <PanelRightIcon aria-hidden className="size-3.5" /> |
| 457 | </Button> |
| 458 | </div> |
| 459 | ) : null} |
| 460 | </div> |
| 461 | ) : null} |
| 462 | <Suspense fallback={<div role="status">Opening Mergebot…</div>}> |
| 463 | <MergebotBrowserPanel |
| 464 | key={`${props.environmentId}:${url}`} |
| 465 | threadRef={ |
| 466 | props.threadRef ?? |
| 467 | scopeThreadRef( |
| 468 | props.environmentId, |
| 469 | ThreadId.make(mergebotPullRequestBrowserThreadId(reference)), |
| 470 | ) |
| 471 | } |
| 472 | url={url} |
| 473 | {...(props.onMergebotReady ? { onReady: props.onMergebotReady } : {})} |
| 474 | /> |
| 475 | </Suspense> |
| 476 | </div> |
| 477 | ) : ( |
| 478 | <NativePullRequestDetailPanel {...props} /> |
| 479 | ); |
| 480 | } |
| 481 | |
| 482 | function NativePullRequestDetailPanel({ |
| 483 | environmentId, |
| 484 | shortcutsEnabled, |
| 485 | getShortcutContext, |
| 486 | threadRef = null, |
| 487 | reference: requestedReference, |
| 488 | listEntry = null, |
| 489 | refreshToken: forcedRefreshToken = 0, |
| 490 | onActed, |
| 491 | onClose, |
| 492 | context = "page", |
| 493 | composerDraftTarget, |
| 494 | onBack, |
| 495 | onSelectPullRequest, |
| 496 | onGuideShown, |
| 497 | }: { |
| 498 | environmentId: EnvironmentId; |
| 499 | shortcutsEnabled: boolean; |
| 500 | getShortcutContext: () => ShortcutMatchContext; |
| 501 | onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined; |
| 502 | onMergebotReady?: ((tabId: string) => boolean) | undefined; |
| 503 | /** |
| 504 | * The thread this panel sits beside, if any. Links that are not the pull |
| 505 | * request itself (check details, host permalinks) can open in that thread's |
| 506 | * in-app browser when the user has asked for it; the page has no thread, so |
| 507 | * there they always go to the system browser. |
| 508 | */ |
| 509 | threadRef?: ScopedThreadRef | null; |
| 510 | reference: PullRequestRef; |
| 511 | /** Row fields already loaded by the pull-request list, used while richer detail arrives. */ |
| 512 | listEntry?: PullRequestListEntry | null; |
| 513 | /** |
| 514 | * Bumped by whatever holds the panel when a reader asks for everything on screen to be read |
| 515 | * again. The panel owns its own reads, so the page cannot refresh them for it — it says when, |
| 516 | * and this says it. |
| 517 | */ |
| 518 | refreshToken?: number; |
| 519 | /** |
| 520 | * An action changed this pull request on the host, so a list showing it is now out of date. |
| 521 | * Told rather than assumed: only the page knows whether it is showing one. |
| 522 | */ |
| 523 | /** |
| 524 | * Each host action as it goes: "sent" the moment it leaves, so a list can answer before the |
| 525 | * host does; "done" or "failed" when the host has spoken. Undefined for one the caller cannot |
| 526 | * name, which is only ever "done". |
| 527 | */ |
| 528 | onActed?: (action?: PullRequestAction, phase?: "sent" | "done" | "failed") => void; |
| 529 | /** Page-owned detail columns use this to clear the selected pull request. */ |
| 530 | onClose?: () => void; |
| 531 | /** |
| 532 | * Beside a thread, the checkout affordance disappears: the panel is showing that thread's |
| 533 | * own pull request, so the branch is already under the reader's feet — and checking it out |
| 534 | * again is at best a no-op and at worst git refusing a branch two checkouts. |
| 535 | */ |
| 536 | context?: "page" | "thread"; |
| 537 | /** The open thread's composer. */ |
| 538 | composerDraftTarget?: ScopedThreadRef | DraftId; |
| 539 | /** |
| 540 | * Beside a thread, the way back to that thread's list of pull requests. The tab strip can |
| 541 | * close this surface, but closing is not going back: the reader came from the list and |
| 542 | * expects to land on it, with this one still open behind. |
| 543 | */ |
| 544 | onBack?: (() => void) | undefined; |
| 545 | /** Told when a written guide comes on screen. The page maximizes its panel for the guide. */ |
| 546 | onGuideShown?: (() => void) | undefined; |
| 547 | }) { |
| 548 | const environmentConfigs = useServerConfigs(); |
| 549 | const projects = useProjects(); |
| 550 | const project = projects.find( |
| 551 | (project) => |
| 552 | project.id === requestedReference.projectId && project.environmentId === environmentId, |
| 553 | ); |
| 554 | const repositoryIdentity = project?.repositoryIdentity; |
| 555 | const supportsThreadPullRequests = |
| 556 | environmentConfigs.get(environmentId)?.environment.capabilities.threadPullRequests === true; |
| 557 | const reference = useMemo( |
| 558 | () => |
| 559 | supportsThreadPullRequests |
| 560 | ? resolvePullRequestReferenceHost(requestedReference, repositoryIdentity) |
| 561 | : { |
| 562 | projectId: requestedReference.projectId, |
| 563 | repository: requestedReference.repository, |
| 564 | number: requestedReference.number, |
| 565 | }, |
| 566 | [requestedReference, repositoryIdentity, supportsThreadPullRequests], |
| 567 | ); |
| 568 | const pullRequestKey = `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`; |
| 569 | const matchingListEntry = |
| 570 | listEntry?.projectId === reference.projectId && |
| 571 | listEntry.repository.toLowerCase() === reference.repository.toLowerCase() && |
| 572 | (reference.host === undefined || |
| 573 | listEntry.host.toLowerCase() === reference.host.toLowerCase()) && |
| 574 | listEntry.number === reference.number |
| 575 | ? listEntry |
| 576 | : null; |
| 577 | const [threadPickerOpen, setThreadPickerOpen] = useState(false); |
| 578 | // Counted up by the guide's last stop to open the composer on the review. |
| 579 | const [openReviewRequest, setOpenReviewRequest] = useState(0); |
| 580 | const [tab, setTab] = useState<DetailTab>("summary"); |
| 581 | const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest"); |
| 582 | const [codeCommitScope, setCodeCommitScope] = useState<{ |
| 583 | readonly pullRequestKey: string; |
| 584 | readonly oid: string | null; |
| 585 | }>(() => ({ pullRequestKey, oid: null })); |
| 586 | const selectedCodeCommitOid = |
| 587 | codeCommitScope.pullRequestKey === pullRequestKey ? codeCommitScope.oid : null; |
| 588 | const selectCodeCommit = (oid: string | null) => { |
| 589 | setCodeCommitScope({ pullRequestKey, oid }); |
| 590 | }; |
| 591 | const openCommit = (oid: string) => { |
| 592 | selectCodeCommit(oid); |
| 593 | setTab("code"); |
| 594 | }; |
| 595 | // The guide names files; following one shows it in the whole diff, not a single commit's. |
| 596 | const [codeRevealRequest, setCodeRevealRequest] = useState<{ |
| 597 | readonly path: string; |
| 598 | readonly nonce: number; |
| 599 | } | null>(null); |
| 600 | const openFileInCode = (path: string) => { |
| 601 | selectCodeCommit(null); |
| 602 | setCodeRevealRequest((previous) => ({ path, nonce: (previous?.nonce ?? 0) + 1 })); |
| 603 | void loadCodeTab(); |
| 604 | setTab("code"); |
| 605 | }; |
| 606 | // A guide notice asks for its pull request's Guide tab once the page has opened it. |
| 607 | const guideOpenRequested = useGuideRunStore( |
| 608 | (state) => state.openRequestKey === guideRunKey(environmentId, reference), |
| 609 | ); |
| 610 | if (guideOpenRequested && tab !== "guide") setTab("guide"); |
| 611 | useEffect(() => { |
| 612 | if (guideOpenRequested) useGuideRunStore.getState().requestOpen(null); |
| 613 | }, [guideOpenRequested]); |
| 614 | // Every tab the reader has opened stays mounted behind the active one. The diff viewer |
| 615 | // always needed this (it virtualizes against its own scroll position); the trace showed the |
| 616 | // summary needs it too — a large description re-parses its whole markdown on every return |
| 617 | // to the tab. `visibility` keeps boxes, sizes and scroll offsets, and takes hidden content |
| 618 | // out of the tab order and the accessibility tree. |
| 619 | const tabScopeKey = `${environmentId}:${pullRequestKey}`; |
| 620 | const [tabMountState, setTabMountState] = useState(() => ({ |
| 621 | key: tabScopeKey, |
| 622 | tabs: new Set<DetailTab>(["summary"]), |
| 623 | })); |
| 624 | // A previously visited Code tab must not fetch diffs for every later PR while hidden. |
| 625 | const mountedTabs = |
| 626 | tabMountState.key === tabScopeKey ? tabMountState.tabs : new Set<DetailTab>([tab]); |
| 627 | useEffect(() => { |
| 628 | setTabMountState((previous) => { |
| 629 | if (previous.key !== tabScopeKey) return { key: tabScopeKey, tabs: new Set([tab]) }; |
| 630 | if (previous.tabs.has(tab)) return previous; |
| 631 | return { key: tabScopeKey, tabs: new Set(previous.tabs).add(tab) }; |
| 632 | }); |
| 633 | }, [tab, tabScopeKey]); |
| 634 | const [chromeCondensed, setChromeCondensed] = useState(false); |
| 635 | // Each mounted tab remembers its own scroll chrome; short tabs cannot scroll to reopen it. |
| 636 | const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({}); |
| 637 | useEffect(() => { |
| 638 | setChromeCondensed(chromeStateByTab.current[tab] ?? false); |
| 639 | }, [tab]); |
| 640 | const condensed = chromeCondensed; |
| 641 | const scrollerByTab = useRef<Partial<Record<DetailTab, HTMLDivElement | null>>>({}); |
| 642 | const scrollerRefs = useMemo( |
| 643 | () => ({ |
| 644 | summary: (node: HTMLDivElement | null) => { |
| 645 | scrollerByTab.current.summary = node; |
| 646 | }, |
| 647 | timeline: (node: HTMLDivElement | null) => { |
| 648 | scrollerByTab.current.timeline = node; |
| 649 | }, |
| 650 | code: (node: HTMLDivElement | null) => { |
| 651 | scrollerByTab.current.code = node; |
| 652 | }, |
| 653 | }), |
| 654 | [], |
| 655 | ); |
| 656 | const foldRef = useRef<HTMLDivElement | null>(null); |
| 657 | const condensedRowRef = useRef<HTMLDivElement | null>(null); |
| 658 | // Refund after the fold commits so the content under the reader does not jump with its height. |
| 659 | const compensationRef = useRef<{ |
| 660 | tab: DetailTab; |
| 661 | scroller: HTMLDivElement; |
| 662 | delta: number; |
| 663 | } | null>(null); |
| 664 | useLayoutEffect(() => { |
| 665 | const compensation = compensationRef.current; |
| 666 | if (compensation === null) return; |
| 667 | compensationRef.current = null; |
| 668 | if ( |
| 669 | !condensed || |
| 670 | compensation.tab !== tab || |
| 671 | scrollerByTab.current[tab] !== compensation.scroller |
| 672 | ) |
| 673 | return; |
| 674 | compensation.scroller.scrollTop = Math.max( |
| 675 | 0, |
| 676 | compensation.scroller.scrollTop + compensation.delta, |
| 677 | ); |
| 678 | }, [condensed, tab]); |
| 679 | const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod); |
| 680 | const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod); |
| 681 | const resolveProjectDefaultMergeMethod = usePullRequestDefaultMergeMethodResolver( |
| 682 | environmentId, |
| 683 | reference.projectId, |
| 684 | ); |
| 685 | const projectDefaultMergeMethod = useMemo( |
| 686 | () => resolveProjectDefaultMergeMethod(), |
| 687 | [resolveProjectDefaultMergeMethod], |
| 688 | ); |
| 689 | const [mergeMethodSelection, setMergeMethodSelection] = useState<{ |
| 690 | readonly pullRequestKey: string; |
| 691 | readonly method: PullRequestMergeMethod; |
| 692 | } | null>(null); |
| 693 | const setMergeMethod = (method: PullRequestMergeMethod) => { |
| 694 | setMergeMethodSelection({ pullRequestKey, method }); |
| 695 | }; |
| 696 | const inlineConfirm = useInlineActionConfirm(pullRequestKey); |
| 697 | // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself |
| 698 | // alone. One at a time whatever the key: they all check the same pull request out. |
| 699 | const [handoff, setHandoff] = useState<string | null>(null); |
| 700 | const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); |
| 701 | const detailQuery = useEnvironmentQuery( |
| 702 | pullRequestEnvironment.detail({ environmentId, input: reference }), |
| 703 | ); |
| 704 | const activityQuery = useEnvironmentQuery( |
| 705 | pullRequestEnvironment.activity({ environmentId, input: reference }), |
| 706 | ); |
| 707 | const turnRefresh = usePullRequestTurnRefresh(environmentId); |
| 708 | const [cachedDetail, setCachedDetail] = useState(() => |
| 709 | readPullRequestDetailSnapshot( |
| 710 | typeof window === "undefined" ? undefined : window.localStorage, |
| 711 | environmentId, |
| 712 | reference, |
| 713 | ), |
| 714 | ); |
| 715 | useEffect(() => { |
| 716 | setCachedDetail( |
| 717 | readPullRequestDetailSnapshot( |
| 718 | typeof window === "undefined" ? undefined : window.localStorage, |
| 719 | environmentId, |
| 720 | reference, |
| 721 | ), |
| 722 | ); |
| 723 | }, [environmentId, pullRequestKey, reference.projectId, reference.repository, reference.number]); |
| 724 | useEffect(() => { |
| 725 | if (detailQuery.data === null) return; |
| 726 | writePullRequestDetailSnapshot( |
| 727 | typeof window === "undefined" ? undefined : window.localStorage, |
| 728 | environmentId, |
| 729 | reference, |
| 730 | detailQuery.data, |
| 731 | ); |
| 732 | setCachedDetail(detailQuery.data); |
| 733 | }, [ |
| 734 | detailQuery.data, |
| 735 | environmentId, |
| 736 | pullRequestKey, |
| 737 | reference.projectId, |
| 738 | reference.repository, |
| 739 | reference.number, |
| 740 | ]); |
| 741 | const resolvedCoreDetail = resolveDisplayedPullRequestDetail({ |
| 742 | live: detailQuery.data, |
| 743 | cached: cachedDetail, |
| 744 | reference, |
| 745 | }); |
| 746 | const listSummary = useMemo( |
| 747 | () => (matchingListEntry === null ? null : pullRequestListEntryToSummary(matchingListEntry)), |
| 748 | [matchingListEntry], |
| 749 | ); |
| 750 | const detailSummary = useMemo( |
| 751 | () => |
| 752 | detailQuery.data === null |
| 753 | ? null |
| 754 | : { |
| 755 | ...detailQuery.data, |
| 756 | checksState: pullRequestChecksState(detailQuery.data.checks), |
| 757 | }, |
| 758 | [detailQuery.data], |
| 759 | ); |
| 760 | const observedSummary = useSharedPullRequestSummary( |
| 761 | environmentId, |
| 762 | reference, |
| 763 | detailSummary, |
| 764 | detailQuery.dataUpdatedAt, |
| 765 | ); |
| 766 | // The list row is also published to the shared cache, but only after this commit's layout |
| 767 | // effects run, so it is compared directly rather than trusted to be there already. |
| 768 | const sharedSummary = useMemo( |
| 769 | () => |
| 770 | newestPullRequestSummary( |
| 771 | resolvedCoreDetail, |
| 772 | newestPullRequestSummary(observedSummary, listSummary), |
| 773 | ), |
| 774 | [resolvedCoreDetail, observedSummary, listSummary], |
| 775 | ); |
| 776 | const coreDetail = useMemo( |
| 777 | () => |
| 778 | resolvedCoreDetail === null || sharedSummary === null || sharedSummary === resolvedCoreDetail |
| 779 | ? resolvedCoreDetail |
| 780 | : { |
| 781 | ...resolvedCoreDetail, |
| 782 | title: sharedSummary.title, |
| 783 | state: sharedSummary.state, |
| 784 | headBranch: sharedSummary.headBranch, |
| 785 | baseBranch: sharedSummary.baseBranch, |
| 786 | updatedAt: sharedSummary.updatedAt, |
| 787 | author: sharedSummary.author ?? resolvedCoreDetail.author, |
| 788 | additions: sharedSummary.additions ?? resolvedCoreDetail.additions, |
| 789 | deletions: sharedSummary.deletions ?? resolvedCoreDetail.deletions, |
| 790 | changedFiles: sharedSummary.changedFiles ?? resolvedCoreDetail.changedFiles, |
| 791 | mergeability: sharedSummary.mergeability ?? resolvedCoreDetail.mergeability, |
| 792 | closedAt: |
| 793 | sharedSummary.closedAt === undefined |
| 794 | ? resolvedCoreDetail.closedAt |
| 795 | : sharedSummary.closedAt, |
| 796 | mergedAt: |
| 797 | sharedSummary.mergedAt === undefined |
| 798 | ? resolvedCoreDetail.mergedAt |
| 799 | : sharedSummary.mergedAt, |
| 800 | // A summary may come from an older server that does not report draft state. Keep the |
| 801 | // detail's required value instead of making the complete detail shape partial. |
| 802 | isDraft: sharedSummary.isDraft ?? resolvedCoreDetail.isDraft, |
| 803 | }, |
| 804 | [resolvedCoreDetail, sharedSummary], |
| 805 | ); |
| 806 | const activity = activityQuery.data; |
| 807 | const detail = useMemo( |
| 808 | () => |
| 809 | coreDetail === null |
| 810 | ? null |
| 811 | : { |
| 812 | ...coreDetail, |
| 813 | capabilities: canWriteSourceControl |
| 814 | ? coreDetail.capabilities |
| 815 | : { |
| 816 | ...coreDetail.capabilities, |
| 817 | reactions: false, |
| 818 | edit: { changeRequest: false, comment: false }, |
| 819 | }, |
| 820 | viewerPermissions: canWriteSourceControl |
| 821 | ? coreDetail.viewerPermissions |
| 822 | : { |
| 823 | ...coreDetail.viewerPermissions, |
| 824 | actions: [], |
| 825 | comment: false, |
| 826 | resolve: false, |
| 827 | verdicts: [], |
| 828 | requestReviewers: false, |
| 829 | updateMethods: [], |
| 830 | labels: false, |
| 831 | }, |
| 832 | author: activity?.author ?? coreDetail.author, |
| 833 | reviewers: activity?.reviewers ?? coreDetail.reviewers, |
| 834 | comments: activity?.comments ?? [], |
| 835 | commentCount: activity?.commentCount ?? 0, |
| 836 | commentsTruncated: activity?.commentsTruncated ?? false, |
| 837 | reviewThreads: activity?.reviewThreads ?? [], |
| 838 | commits: activity?.commits ?? [], |
| 839 | reactions: activity?.reactions ?? [], |
| 840 | }, |
| 841 | [activity, canWriteSourceControl, coreDetail], |
| 842 | ); |
| 843 | const handoffSummary = detail ?? sharedSummary; |
| 844 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 845 | const { copyToClipboard: copyReference } = useCopyToClipboard<string>({ |
| 846 | target: "pull request reference", |
| 847 | onCopy: (label) => toastManager.add({ type: "success", title: `${label} copied` }), |
| 848 | onError: (error, label) => |
| 849 | toastManager.add({ |
| 850 | type: "error", |
| 851 | title: `Failed to copy ${label}`, |
| 852 | description: error.message, |
| 853 | }), |
| 854 | }); |
| 855 | const copyFromShortcut = useEffectEvent((event: KeyboardEvent) => { |
| 856 | if (!shortcutsEnabled || event.defaultPrevented || isCommandPaletteOpen()) return; |
| 857 | const command = resolveShortcutCommand(event, keybindings, { |
| 858 | context: getShortcutContext(), |
| 859 | }); |
| 860 | if (command !== "pullRequest.copyNumber") return; |
| 861 | event.preventDefault(); |
| 862 | event.stopPropagation(); |
| 863 | if (!event.repeat) copyReference(`#${reference.number}`, "PR number"); |
| 864 | }); |
| 865 | useEffect(() => { |
| 866 | const onKeyDown = (event: KeyboardEvent) => copyFromShortcut(event); |
| 867 | window.addEventListener("keydown", onKeyDown, true); |
| 868 | return () => window.removeEventListener("keydown", onKeyDown, true); |
| 869 | }, []); |
| 870 | useEffect(() => { |
| 871 | if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod); |
| 872 | }, [detail?.autoMergeMethod, pullRequestKey]); |
| 873 | const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url); |
| 874 | const hostClient = sourceControlClients.get(detail?.provider); |
| 875 | const markdownContext = useMemo( |
| 876 | () => ({ |
| 877 | repositoryUrl: |
| 878 | repositoryUrl === null ? null : hostClient.referenceAutolinkRepositoryUrl(repositoryUrl), |
| 879 | threadRef, |
| 880 | }), |
| 881 | [hostClient, repositoryUrl, threadRef], |
| 882 | ); |
| 883 | const authorProfileUrl = |
| 884 | detail?.author && repositoryUrl !== null |
| 885 | ? hostClient.authorProfileUrl(detail.author.login, repositoryUrl) |
| 886 | : null; |
| 887 | const checkoutCommand = handoffSummary |
| 888 | ? pullRequestCheckoutCommand( |
| 889 | handoffSummary.provider, |
| 890 | handoffSummary.number, |
| 891 | handoffSummary.headBranch, |
| 892 | detail?.headRepositoryNameWithOwner, |
| 893 | changeRequestRepositoryUrl(handoffSummary.url), |
| 894 | ) |
| 895 | : loadingPullRequestCheckoutCommand(reference, repositoryIdentity); |
| 896 | const onCheckoutCommandError = useCallback((error: Error) => { |
| 897 | toastManager.add({ |
| 898 | type: "error", |
| 899 | title: "Could not copy checkout command", |
| 900 | description: error.message, |
| 901 | }); |
| 902 | }, []); |
| 903 | const branchRefsQuery = useEnvironmentQuery( |
| 904 | detail === null |
| 905 | ? null |
| 906 | : vcsEnvironment.listRefs({ |
| 907 | environmentId, |
| 908 | input: { |
| 909 | cwd: detail.workspaceRoot, |
| 910 | includeMatchingRemoteRefs: true, |
| 911 | // listRefs keeps the current ref first and a known default second. |
| 912 | limit: 2, |
| 913 | }, |
| 914 | }), |
| 915 | ); |
| 916 | const isStackedPullRequest = |
| 917 | detail !== null && |
| 918 | isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []); |
| 919 | // The host's own stack, where it keeps one. Only asked for once the detail has landed so a |
| 920 | // pull request nobody can read costs one request rather than two. |
| 921 | const stackReference = useMemo( |
| 922 | () => |
| 923 | detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests |
| 924 | ? null |
| 925 | : { ...reference, host: reference.host ?? parseChangeRequestUrl(detail.url)?.host }, |
| 926 | [detail, reference, supportsThreadPullRequests], |
| 927 | ); |
| 928 | const nativeStackQuery = usePullRequestStack(environmentId, stackReference); |
| 929 | const nativeStack = nativeStackQuery.data; |
| 930 | const supportsStackActions = |
| 931 | supportsThreadPullRequests && |
| 932 | detail?.capabilities.stacks === true && |
| 933 | detail.capabilities.stackActions === true && |
| 934 | environmentConfigs.get(environmentId)?.environment.capabilities.pullRequestStackActions === |
| 935 | true; |
| 936 | const canMergeSinglePullRequest = allowsSinglePullRequestMerge({ |
| 937 | supportsStackActions, |
| 938 | hasStack: nativeStack !== null, |
| 939 | stackPending: !nativeStackQuery.isFresh, |
| 940 | stackError: nativeStackQuery.error, |
| 941 | }); |
| 942 | const activityPending = activityQuery.isPending && activity === null; |
| 943 | const activityError = activity === null ? activityQuery.error : null; |
| 944 | const refreshDetail = useCallback(() => { |
| 945 | detailQuery.refresh(); |
| 946 | activityQuery.refresh(); |
| 947 | nativeStackQuery.refresh(); |
| 948 | }, [activityQuery.refresh, detailQuery.refresh, nativeStackQuery.refresh]); |
| 949 | const [refreshToken, setRefreshToken] = useState(0); |
| 950 | const codeRefreshToken = refreshToken + (turnRefresh ?? 0); |
| 951 | const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>( |
| 952 | null, |
| 953 | ); |
| 954 | useEffect(() => { |
| 955 | if (!coreDetail) return; |
| 956 | const next = { key: tabScopeKey, updatedAt: coreDetail.updatedAt }; |
| 957 | if (shouldRefreshPullRequestActivity(activityRevision.current, next)) { |
| 958 | // Let an existing read settle before revalidating the new revision. Interrupting a |
| 959 | // mutation's activity refresh can leave SWR displaying its previous value. |
| 960 | if (activityQuery.isPending) return; |
| 961 | activityQuery.refresh(); |
| 962 | setRefreshToken((token) => token + 1); |
| 963 | } |
| 964 | activityRevision.current = next; |
| 965 | }, [activityQuery.isPending, activityQuery.refresh, coreDetail, tabScopeKey]); |
| 966 | // Reuse activity and diff until core detail reports a changed revision. Keyed by |
| 967 | // the pull request rather than by the panel, because this one panel shows a different pull |
| 968 | // request every time it is opened. |
| 969 | useLiveRefresh( |
| 970 | () => { |
| 971 | detailQuery.refresh(); |
| 972 | }, |
| 973 | { key: `pull-request:${environmentId}:${pullRequestKey}` }, |
| 974 | ); |
| 975 | // The button, on the other hand, goes around the server's cache rather than through it: it is |
| 976 | // the answer for a reader who can see that what they are looking at is behind. The |
| 977 | // invalidation goes first so the re-reads miss that cache; if it fails, the reads still run |
| 978 | // and at worst answer from it. |
| 979 | const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); |
| 980 | const [isInvalidating, setIsInvalidating] = useState(false); |
| 981 | // One word for "the host is being asked again", whichever of the two halves is in flight: |
| 982 | // the invalidation round trip, then the detail read it kicks off. |
| 983 | const refreshing = isInvalidating || detailQuery.isPending; |
| 984 | const refreshFromHost = useCallback(async () => { |
| 985 | setIsInvalidating(true); |
| 986 | try { |
| 987 | await invalidate({ environmentId, input: { reference } }); |
| 988 | refreshDetail(); |
| 989 | setRefreshToken((token) => token + 1); |
| 990 | } finally { |
| 991 | setIsInvalidating(false); |
| 992 | } |
| 993 | }, [environmentId, invalidate, reference, refreshDetail]); |
| 994 | // A refresh asked for by the page: the detail, and through the token below, the diff with it. |
| 995 | const appliedForcedToken = useRef(forcedRefreshToken); |
| 996 | useEffect(() => { |
| 997 | if (appliedForcedToken.current === forcedRefreshToken) return; |
| 998 | appliedForcedToken.current = forcedRefreshToken; |
| 999 | void refreshFromHost(); |
| 1000 | }, [forcedRefreshToken, refreshFromHost]); |
| 1001 | const runAction = useAtomCommand(pullRequestEnvironment.runAction, { |
| 1002 | reportFailure: false, |
| 1003 | }); |
| 1004 | const postComment = useAtomCommand(pullRequestEnvironment.comment, { |
| 1005 | reportFailure: false, |
| 1006 | }); |
| 1007 | // Which action is in flight, not merely that one is: every control here is disabled while any |
| 1008 | // of them runs, but only the button that was pressed may say what it is doing. |
| 1009 | const [pendingAction, setPendingAction] = useState<PullRequestAction | null>(null); |
| 1010 | const actionPending = pendingAction !== null; |
| 1011 | const update = useAtomCommand(pullRequestEnvironment.update, { reportFailure: false }); |
| 1012 | // Scoped to the pull request it was typed against, since this one panel shows a different one |
| 1013 | // every time it is opened and a half-written title must not follow it there. |
| 1014 | const [titleScope, setTitleScope] = useState<{ |
| 1015 | readonly pullRequestKey: string; |
| 1016 | readonly text: string; |
| 1017 | } | null>(null); |
| 1018 | const titleDraft = titleScope?.pullRequestKey === pullRequestKey ? titleScope.text : null; |
| 1019 | const [titleSaving, setTitleSaving] = useState(false); |
| 1020 | const newThread = useNewThreadHandler(); |
| 1021 | const { environments } = useEnvironments(); |
| 1022 | const unavailableGitHubUrl = useMemo(() => { |
| 1023 | const identity = projects.find( |
| 1024 | (project) => project.id === reference.projectId && project.environmentId === environmentId, |
| 1025 | )?.repositoryIdentity; |
| 1026 | return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number); |
| 1027 | }, [environmentId, projects, reference.number, reference.projectId, reference.repository]); |
| 1028 | // Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and |
| 1029 | // the thread is already on one server's copy of the branch. |
| 1030 | const pickableEnvironments = useMemo( |
| 1031 | () => |
| 1032 | context === "page" |
| 1033 | ? resolvePickableEnvironments( |
| 1034 | { environmentId, projectId: reference.projectId }, |
| 1035 | projects, |
| 1036 | environments.map((environment) => ({ |
| 1037 | environmentId: environment.environmentId, |
| 1038 | label: environment.label, |
| 1039 | machine: resolveEnvironmentMachineKind(environment.serverConfig), |
| 1040 | })), |
| 1041 | ) |
| 1042 | : [], |
| 1043 | [context, environmentId, environments, projects, reference.projectId], |
| 1044 | ); |
| 1045 | // Which server the reader chose, and only for the pull request they chose it on: this one panel |
| 1046 | // shows a different pull request every time it is opened, and the choice does not follow. |
| 1047 | const [actingScope, setActingScope] = useState<{ |
| 1048 | readonly pullRequestKey: string; |
| 1049 | readonly environmentId: EnvironmentId; |
| 1050 | } | null>(null); |
| 1051 | const chosenEnvironmentId = |
| 1052 | actingScope?.pullRequestKey === pullRequestKey ? actingScope.environmentId : environmentId; |
| 1053 | // Null wherever there is no choice on offer — one server, or a chosen one that has since gone — |
| 1054 | // and then the panel's own server and its own checkout are the answer, as they always were. |
| 1055 | const acting = |
| 1056 | pickableEnvironments.find((entry) => entry.environmentId === chosenEnvironmentId) ?? null; |
| 1057 | const actingEnvironmentId = acting?.environmentId ?? environmentId; |
| 1058 | const checkoutRoot = |
| 1059 | acting?.workspaceRoot ?? detail?.workspaceRoot ?? project?.workspaceRoot ?? null; |
| 1060 | const canOperateThread = useEnvironmentScope(actingEnvironmentId, AuthOrchestrationOperateScope); |
| 1061 | const prepareThread = usePreparePullRequestThreadAction({ |
| 1062 | environmentId: actingEnvironmentId, |
| 1063 | cwd: checkoutRoot, |
| 1064 | }); |
| 1065 | |
| 1066 | const finishAction = async ( |
| 1067 | action: PullRequestAction, |
| 1068 | method?: PullRequestMergeMethod, |
| 1069 | updateMethod?: PullRequestUpdateMethod, |
| 1070 | ) => { |
| 1071 | onActed?.(action, "sent"); |
| 1072 | const result = await runAction({ |
| 1073 | environmentId, |
| 1074 | input: { |
| 1075 | ...reference, |
| 1076 | action, |
| 1077 | ...(method ? { mergeMethod: method } : {}), |
| 1078 | ...(updateMethod ? { updateMethod } : {}), |
| 1079 | }, |
| 1080 | }); |
| 1081 | setPendingAction(null); |
| 1082 | if (result._tag === "Failure") { |
| 1083 | // The host's own sentence, because it is the only thing that says why. A merge strategy a |
| 1084 | // branch policy forbids is refused at completion and nowhere earlier — Azure DevOps |
| 1085 | // publishes no per-strategy availability to hide the control with — so "action failed" |
| 1086 | // would leave the reader pressing the same button again. |
| 1087 | const failure = squashAtomCommandFailure(result); |
| 1088 | // The hint stands for what was actually asked for: a reader who pressed Update branch is |
| 1089 | // told to check their access, not offered the merge commit they already chose. |
| 1090 | const hint = |
| 1091 | updateMethod === "rebase" |
| 1092 | ? UPDATE_BRANCH_REBASE_FAILURE_HINT |
| 1093 | : ACTION_FAILURE_HINTS[action]; |
| 1094 | toastManager.add({ |
| 1095 | type: "error", |
| 1096 | title: ACTION_FAILURE_LABELS[action], |
| 1097 | description: readableFailure(failure, hint), |
| 1098 | }); |
| 1099 | onActed?.(action, "failed"); |
| 1100 | return false; |
| 1101 | } |
| 1102 | // Reverting opens another PR; the current panel has no new state to show. |
| 1103 | if (action === "revert") { |
| 1104 | toastManager.add({ type: "success", title: "Revert pull request opened" }); |
| 1105 | } |
| 1106 | if (action === "delete-branch") { |
| 1107 | toastManager.add({ type: "success", title: "Branch deleted" }); |
| 1108 | } |
| 1109 | // A branch update moves the head commit, which leaves the diff atom pointed at a comparison |
| 1110 | // that no longer exists — the same staleness the manual refresh button fixes, so it goes |
| 1111 | // through that path rather than a second one. Every other action here only changes metadata; |
| 1112 | // a merge does move the branch too, but it also closes the pull request, where the diff is |
| 1113 | // no longer what anyone is looking at. |
| 1114 | if (pullRequestActionNeedsHostRefresh(action)) { |
| 1115 | void refreshFromHost(); |
| 1116 | } else { |
| 1117 | refreshDetail(); |
| 1118 | } |
| 1119 | onActed?.(action, "done"); |
| 1120 | return true; |
| 1121 | }; |
| 1122 | |
| 1123 | const perform = async ( |
| 1124 | action: PullRequestAction, |
| 1125 | method?: PullRequestMergeMethod, |
| 1126 | updateMethod?: PullRequestUpdateMethod, |
| 1127 | ) => { |
| 1128 | if (!canWriteSourceControl || pendingAction !== null) return false; |
| 1129 | setPendingAction(action); |
| 1130 | return finishAction(action, method, updateMethod); |
| 1131 | }; |
| 1132 | |
| 1133 | const performCommentAction = async (body: string, action: "close" | "reopen") => { |
| 1134 | if (!canWriteSourceControl || pendingAction !== null) return { commentPosted: false }; |
| 1135 | setPendingAction(action); |
| 1136 | const commentResult = await postComment({ |
| 1137 | environmentId, |
| 1138 | input: { ...reference, body }, |
| 1139 | }); |
| 1140 | if (commentResult._tag === "Failure") { |
| 1141 | setPendingAction(null); |
| 1142 | toastManager.add({ type: "error", title: "Could not post the comment" }); |
| 1143 | return { commentPosted: false }; |
| 1144 | } |
| 1145 | const actionSucceeded = await finishAction(action); |
| 1146 | // The comment is durable even if the state change was refused, so make it visible while the |
| 1147 | // shared action failure explains why the pull request stayed where it was. |
| 1148 | if (!actionSucceeded) refreshDetail(); |
| 1149 | return { commentPosted: true }; |
| 1150 | }; |
| 1151 | |
| 1152 | const saveTitle = async (next: string) => { |
| 1153 | const title = next.trim(); |
| 1154 | if (!canWriteSourceControl || detail === null || titleSaving) return; |
| 1155 | if (title.length === 0 || title === detail.title) { |
| 1156 | setTitleScope(null); |
| 1157 | return; |
| 1158 | } |
| 1159 | setTitleSaving(true); |
| 1160 | const result = await update({ environmentId, input: { ...reference, title } }); |
| 1161 | setTitleSaving(false); |
| 1162 | if (result._tag === "Failure") { |
| 1163 | // The draft stays open with the words still in it: retyping a title somebody has just |
| 1164 | // rewritten is the one thing a failed save must not cost them. |
| 1165 | toastManager.add({ |
| 1166 | type: "error", |
| 1167 | title: "The title could not be saved", |
| 1168 | description: readableFailure( |
| 1169 | squashAtomCommandFailure(result), |
| 1170 | "The host refused the new title.", |
| 1171 | ), |
| 1172 | }); |
| 1173 | return; |
| 1174 | } |
| 1175 | setTitleScope(null); |
| 1176 | refreshDetail(); |
| 1177 | }; |
| 1178 | |
| 1179 | type ThreadTask = { |
| 1180 | prompt: string; |
| 1181 | reviewComments?: ReadonlyArray<ReviewCommentContext>; |
| 1182 | }; |
| 1183 | |
| 1184 | const attachTarget = composerDraftTarget ?? null; |
| 1185 | const canPrepareWorktree = prepareThread.isAllowed && canOperateThread; |
| 1186 | const canFixFindings = attachTarget !== null || canPrepareWorktree; |
| 1187 | const handoffLabels = pullRequestHandoffLabels(attachTarget !== null); |
| 1188 | // The guide is written from the menu as well as its tab, so a reader can ask for a rewrite |
| 1189 | // without the tour carrying a control for it. |
| 1190 | const guideWriter = useReviewGuideWriter({ |
| 1191 | environmentId, |
| 1192 | reference, |
| 1193 | title: detail?.title ?? null, |
| 1194 | }); |
| 1195 | const writeGuide = () => { |
| 1196 | void guideWriter.write(); |
| 1197 | if (guideWriter.data?.guide == null) setTab("guide"); |
| 1198 | }; |
| 1199 | |
| 1200 | const writeTaskToComposer = (target: ScopedThreadRef | DraftId, task: ThreadTask) => { |
| 1201 | const store = useComposerDraftStore.getState(); |
| 1202 | const draft = store.getComposerDraft(target); |
| 1203 | const key = composerTargetKey(target); |
| 1204 | const previousCommentIds = new Set((draft?.reviewComments ?? []).map((comment) => comment.id)); |
| 1205 | const repeatedCommentIds = new Set( |
| 1206 | (task.reviewComments ?? []) |
| 1207 | .filter((comment) => previousCommentIds.has(comment.id)) |
| 1208 | .map((comment) => comment.id), |
| 1209 | ); |
| 1210 | const promptWithoutPreviousHandoff = stripPullRequestHandoffReferences( |
| 1211 | draft?.prompt ?? "", |
| 1212 | draft?.reviewComments ?? [], |
| 1213 | repeatedCommentIds, |
| 1214 | ); |
| 1215 | const prompt = handoffPrompt( |
| 1216 | { |
| 1217 | prompt: promptWithoutPreviousHandoff, |
| 1218 | lastHandoffPrompt: lastHandoffPromptByDraft.get(key), |
| 1219 | }, |
| 1220 | task.prompt, |
| 1221 | ); |
| 1222 | lastHandoffPromptByDraft.set(key, task.prompt); |
| 1223 | store.setPrompt(target, prompt); |
| 1224 | store.setReviewComments( |
| 1225 | target, |
| 1226 | handoffReviewComments(draft?.reviewComments ?? [], task.reviewComments ?? []), |
| 1227 | ); |
| 1228 | for (const comment of task.reviewComments ?? []) { |
| 1229 | if (!repeatedCommentIds.has(comment.id)) continue; |
| 1230 | store.addReviewComment(target, comment, { |
| 1231 | allowDuplicateReference: true, |
| 1232 | insertAtCaret: false, |
| 1233 | }); |
| 1234 | } |
| 1235 | }; |
| 1236 | |
| 1237 | /** |
| 1238 | * Opens a thread on this project and leaves the task in its composer for the reader to send. |
| 1239 | * |
| 1240 | * Nothing is checked out: asking a question is not a reason to move somebody's working tree or |
| 1241 | * to make a worktree they did not ask for. The two hand-offs that do need the code call this |
| 1242 | * after preparing it, so there is one path from "a task" to "a thread holding it". |
| 1243 | */ |
| 1244 | const openThreadWithTask = async ( |
| 1245 | projectRef: ReturnType<typeof scopeProjectRef>, |
| 1246 | task: ThreadTask | null, |
| 1247 | opened?: { draftId: DraftId }, |
| 1248 | ): Promise<{ draftId: DraftId } | null> => { |
| 1249 | const session = |
| 1250 | opened ?? |
| 1251 | (await newThread(projectRef).then( |
| 1252 | (result) => result, |
| 1253 | () => null, |
| 1254 | )); |
| 1255 | if (session === null) return null; |
| 1256 | if (task === null) return session; |
| 1257 | // The latest press is the ask: it takes over what an earlier hand-off left, prompt and chips |
| 1258 | // both, rather than stacking a second one under the first. What the reader typed themselves |
| 1259 | // survives — the composer they are handed is not always a fresh one, and a prompt they have |
| 1260 | // since edited is theirs rather than the hand-off's. |
| 1261 | writeTaskToComposer(session.draftId, task); |
| 1262 | return session; |
| 1263 | }; |
| 1264 | |
| 1265 | /** |
| 1266 | * A question about the change, which needs a thread and nothing else. `keepGuide` keeps the |
| 1267 | * reader's place: the new thread opens with this pull request beside it on the Guide tab, at the |
| 1268 | * stop they asked from, so the answer and the tour can be read together. |
| 1269 | */ |
| 1270 | const startAsk = async ( |
| 1271 | kind: string, |
| 1272 | task: ThreadTask, |
| 1273 | options?: { readonly keepGuide?: boolean }, |
| 1274 | ) => { |
| 1275 | if (!detail || handoff !== null) return; |
| 1276 | if (attachTarget !== null) { |
| 1277 | writeTaskToComposer(attachTarget, task); |
| 1278 | toastManager.add({ |
| 1279 | type: "success", |
| 1280 | title: "Added to the composer", |
| 1281 | description: |
| 1282 | task.prompt.length > 0 |
| 1283 | ? "The question is in the composer — read it over, then send." |
| 1284 | : "The pull request is in the composer — type your question, then send.", |
| 1285 | }); |
| 1286 | return; |
| 1287 | } |
| 1288 | setHandoff(kind); |
| 1289 | const projectRef = scopeProjectRef(actingEnvironmentId, acting?.projectId ?? detail.projectId); |
| 1290 | const opened = await openThreadWithTask(projectRef, task); |
| 1291 | setHandoff(null); |
| 1292 | if (opened !== null && options?.keepGuide === true) { |
| 1293 | const draft = useComposerDraftStore.getState().getDraftSession(opened.draftId); |
| 1294 | if (draft !== null) { |
| 1295 | useRightPanelStore |
| 1296 | .getState() |
| 1297 | .openPullRequest(scopeThreadRef(draft.environmentId, draft.threadId), { |
| 1298 | environmentId, |
| 1299 | projectId: reference.projectId, |
| 1300 | ...(reference.host === undefined ? {} : { host: reference.host }), |
| 1301 | repository: reference.repository, |
| 1302 | number: reference.number, |
| 1303 | url: detail.url, |
| 1304 | }); |
| 1305 | useGuideRunStore.getState().requestOpen(guideRunKey(environmentId, reference)); |
| 1306 | } |
| 1307 | } |
| 1308 | if (opened === null) { |
| 1309 | toastManager.add({ |
| 1310 | type: "error", |
| 1311 | title: "Could not open a thread", |
| 1312 | description: "Try again from the project, or open a thread first.", |
| 1313 | }); |
| 1314 | return; |
| 1315 | } |
| 1316 | toastManager.add({ |
| 1317 | type: "success", |
| 1318 | title: "Asked in a thread", |
| 1319 | // "Ask" leaves the composer empty on purpose, so saying the question is in it would send |
| 1320 | // the reader looking for something that is not there. The chips are what landed. |
| 1321 | description: |
| 1322 | task.prompt.length > 0 |
| 1323 | ? "The question is in the composer — read it over, then send." |
| 1324 | : "The pull request is in the composer — type your question, then send.", |
| 1325 | }); |
| 1326 | }; |
| 1327 | |
| 1328 | // Every handoff works the same way: check the pull request out into its own worktree, open a |
| 1329 | // thread there, and — when it carries a task — put that in the composer for the user to read |
| 1330 | // before sending. Checking out is the whole point of the ones that carry nothing. |
| 1331 | const startHandoff = async ( |
| 1332 | kind: string, |
| 1333 | task: { prompt: string; reviewComments?: ReadonlyArray<ReviewCommentContext> } | null, |
| 1334 | // A worktree leaves whatever is open alone, which is why it is the default. Checking out in |
| 1335 | // the repository itself is what you want when the point is to run the thing where you |
| 1336 | // already work — and it moves the branch under everything else that is open there. |
| 1337 | mode: "worktree" | "local" = "worktree", |
| 1338 | ) => { |
| 1339 | if (!handoffSummary || handoff !== null) return; |
| 1340 | if (attachTarget !== null && task !== null) { |
| 1341 | writeTaskToComposer(attachTarget, task); |
| 1342 | toastManager.add({ |
| 1343 | type: "success", |
| 1344 | title: "Added to the composer", |
| 1345 | description: "The task is in the composer — read it over, then send.", |
| 1346 | }); |
| 1347 | return; |
| 1348 | } |
| 1349 | if (checkoutRoot === null) return; |
| 1350 | if ( |
| 1351 | !prepareThread.isAllowed || |
| 1352 | !readEnvironmentScope(actingEnvironmentId, AuthSourceControlWriteScope) |
| 1353 | ) { |
| 1354 | return; |
| 1355 | } |
| 1356 | if ( |
| 1357 | mode === "worktree" && |
| 1358 | !readEnvironmentScope(actingEnvironmentId, AuthOrchestrationOperateScope) |
| 1359 | ) { |
| 1360 | return; |
| 1361 | } |
| 1362 | setHandoff(kind); |
| 1363 | // The menu closes on the press and takes its "Preparing..." label with it, so this is the |
| 1364 | // only thing answering for the checkout. It carries no timeout of its own: a loading toast |
| 1365 | // never expires, and an explicit one would survive the update and pin the result on screen. |
| 1366 | const toastId = toastManager.add({ |
| 1367 | type: "loading", |
| 1368 | title: "Preparing the pull request checkout...", |
| 1369 | }); |
| 1370 | // Wherever the reader chose to act: the thread, the checkout it is pointed at and the composer |
| 1371 | // the task lands in are all one server's, and picking another one moves all three. |
| 1372 | const projectRef = scopeProjectRef( |
| 1373 | actingEnvironmentId, |
| 1374 | acting?.projectId ?? handoffSummary.projectId, |
| 1375 | ); |
| 1376 | // The thread is opened before the checkout rather than after it, because the project's setup |
| 1377 | // script only runs for a checkout that knows which thread it is for — and a worktree with no |
| 1378 | // dependencies installed is not something anyone can test. |
| 1379 | const opened = await newThread(projectRef).then( |
| 1380 | (session) => session, |
| 1381 | () => null, |
| 1382 | ); |
| 1383 | if (opened === null) { |
| 1384 | setHandoff(null); |
| 1385 | // Without a thread there is nowhere for the checkout to belong: its setup script would not |
| 1386 | // run and its task would have no composer to land in. Better to stop before touching the |
| 1387 | // working tree than to prepare a worktree nobody asked for. |
| 1388 | toastManager.update(toastId, { |
| 1389 | type: "error", |
| 1390 | title: "Could not open a thread for the checkout", |
| 1391 | description: "Try again from the project, or open a thread first.", |
| 1392 | }); |
| 1393 | return; |
| 1394 | } |
| 1395 | const prepared = await prepareThread.run({ |
| 1396 | reference: handoffSummary.url, |
| 1397 | mode, |
| 1398 | threadId: opened.threadId, |
| 1399 | }); |
| 1400 | if (prepared._tag === "Failure") { |
| 1401 | setHandoff(null); |
| 1402 | // The server says what to do about it — that the branch is already checked out in the main |
| 1403 | // repository, say — and that sentence is the only way out of the failure. |
| 1404 | const detailMessage = |
| 1405 | prepareThread.error instanceof Error ? prepareThread.error.message : null; |
| 1406 | toastManager.update(toastId, { |
| 1407 | type: "error", |
| 1408 | title: "Could not prepare the pull request checkout", |
| 1409 | ...(detailMessage ? { description: detailMessage } : {}), |
| 1410 | }); |
| 1411 | return; |
| 1412 | } |
| 1413 | // The same thread again, now that there is somewhere to point it at. A local checkout has |
| 1414 | // no worktree of its own, so the thread runs where the repository already is. |
| 1415 | const pointed = await newThread(projectRef, { |
| 1416 | branch: prepared.value.branch, |
| 1417 | worktreePath: prepared.value.worktreePath, |
| 1418 | envMode: prepared.value.worktreePath === null ? "local" : "worktree", |
| 1419 | }).then( |
| 1420 | (session) => session !== null, |
| 1421 | () => false, |
| 1422 | ); |
| 1423 | if (!pointed) { |
| 1424 | setHandoff(null); |
| 1425 | // The checkout is on disk; only the thread failed to move onto it. Writing the task now |
| 1426 | // would send the agent at whatever the thread was already open on — which is the one |
| 1427 | // outcome worth stopping for, since it reads as success and is not. |
| 1428 | toastManager.update(toastId, { |
| 1429 | type: "error", |
| 1430 | title: "Checked out, but the thread stayed where it was", |
| 1431 | description: `The checkout is ready on \`${prepared.value.branch}\`. Point a thread at it from the branch picker, then ask again.`, |
| 1432 | }); |
| 1433 | return; |
| 1434 | } |
| 1435 | // Released here whatever happened next: a loading toast never expires on its own, so leaving |
| 1436 | // this set would spin forever and lock every handoff behind it until a reload. |
| 1437 | setHandoff(null); |
| 1438 | // A worktree that was already there and had been worked in keeps whatever it holds, so the |
| 1439 | // thread opens on older code than the pull request carries. Said once, in place of the |
| 1440 | // success, because everything else about the handoff did happen. |
| 1441 | const staleCheckoutToast = { |
| 1442 | type: "warning", |
| 1443 | title: "Checked out, but not on the latest commits", |
| 1444 | description: |
| 1445 | "The checkout could not be moved onto the pull request's latest commits, so the code there is older than the pull request. Uncommitted work or local commits keep it where it is.", |
| 1446 | } as const; |
| 1447 | if (task === null) { |
| 1448 | toastManager.update( |
| 1449 | toastId, |
| 1450 | prepared.value.isOnPullRequestHead |
| 1451 | ? { |
| 1452 | type: "success", |
| 1453 | title: mode === "local" ? "Checked out here" : "Checked out", |
| 1454 | description: |
| 1455 | mode === "local" |
| 1456 | ? "This repository is on the pull request's branch, with a thread open on it." |
| 1457 | : "The pull request is in its own worktree, with a thread open on it.", |
| 1458 | } |
| 1459 | : staleCheckoutToast, |
| 1460 | ); |
| 1461 | return; |
| 1462 | } |
| 1463 | await openThreadWithTask(projectRef, task, opened); |
| 1464 | toastManager.update( |
| 1465 | toastId, |
| 1466 | prepared.value.isOnPullRequestHead |
| 1467 | ? { |
| 1468 | type: "success", |
| 1469 | title: "Checkout ready", |
| 1470 | description: "The task is in the composer — read it over, then send.", |
| 1471 | } |
| 1472 | : staleCheckoutToast, |
| 1473 | ); |
| 1474 | }; |
| 1475 | |
| 1476 | const askAboutPullRequest = () => { |
| 1477 | if (!detail) return; |
| 1478 | void startAsk("ask", { |
| 1479 | ...buildAskAboutPullRequestHandoff({ |
| 1480 | number: detail.number, |
| 1481 | title: detail.title, |
| 1482 | url: detail.url, |
| 1483 | headBranch: detail.headBranch, |
| 1484 | baseBranch: detail.baseBranch, |
| 1485 | state: detail.state, |
| 1486 | isDraft: detail.isDraft, |
| 1487 | }), |
| 1488 | }); |
| 1489 | }; |
| 1490 | |
| 1491 | const explainPullRequest = () => { |
| 1492 | if (!detail) return; |
| 1493 | void startAsk("explain", { |
| 1494 | ...buildExplainPullRequestHandoff({ |
| 1495 | number: detail.number, |
| 1496 | title: detail.title, |
| 1497 | url: detail.url, |
| 1498 | headBranch: detail.headBranch, |
| 1499 | baseBranch: detail.baseBranch, |
| 1500 | state: detail.state, |
| 1501 | isDraft: detail.isDraft, |
| 1502 | }), |
| 1503 | }); |
| 1504 | }; |
| 1505 | |
| 1506 | const askAboutGuideStop = (stop: PullRequestReviewGuideStop, index: number, question: string) => { |
| 1507 | if (!detail) return; |
| 1508 | void startAsk( |
| 1509 | `guide-stop:${index}`, |
| 1510 | buildAskAboutGuideStopHandoff({ |
| 1511 | number: detail.number, |
| 1512 | title: detail.title, |
| 1513 | url: detail.url, |
| 1514 | headBranch: detail.headBranch, |
| 1515 | baseBranch: detail.baseBranch, |
| 1516 | state: detail.state, |
| 1517 | isDraft: detail.isDraft, |
| 1518 | stop: { |
| 1519 | index, |
| 1520 | title: stop.title, |
| 1521 | summary: stop.summary, |
| 1522 | attention: stop.attention?.note ?? null, |
| 1523 | excerpts: stop.excerpts, |
| 1524 | }, |
| 1525 | question, |
| 1526 | }), |
| 1527 | { keepGuide: true }, |
| 1528 | ); |
| 1529 | }; |
| 1530 | |
| 1531 | const addSelectionToAgent = ( |
| 1532 | selection: PullRequestAgentSelectionInput, |
| 1533 | options?: { readonly keepGuide?: boolean }, |
| 1534 | ) => { |
| 1535 | if (!detail) return; |
| 1536 | void startAsk( |
| 1537 | `selection:${selection.comment.id}`, |
| 1538 | buildAddSelectionToAgentHandoff({ |
| 1539 | number: detail.number, |
| 1540 | title: detail.title, |
| 1541 | url: detail.url, |
| 1542 | headBranch: detail.headBranch, |
| 1543 | baseBranch: detail.baseBranch, |
| 1544 | state: detail.state, |
| 1545 | isDraft: detail.isDraft, |
| 1546 | comment: selection.comment, |
| 1547 | request: selection.request, |
| 1548 | }), |
| 1549 | options, |
| 1550 | ); |
| 1551 | }; |
| 1552 | |
| 1553 | const startCheckout = (mode: "worktree" | "local") => { |
| 1554 | if (!handoffSummary) return; |
| 1555 | void startHandoff(`checkout:${mode}`, null, mode); |
| 1556 | }; |
| 1557 | |
| 1558 | /** One finding, handed over on its own — the surfaces that show findings call this. */ |
| 1559 | const startFixFinding = (finding: PullRequestFinding) => { |
| 1560 | if (!detail) return; |
| 1561 | void startHandoff( |
| 1562 | pullRequestFindingKey(finding), |
| 1563 | buildFixFindingHandoff({ |
| 1564 | number: detail.number, |
| 1565 | title: detail.title, |
| 1566 | url: detail.url, |
| 1567 | headBranch: detail.headBranch, |
| 1568 | baseBranch: detail.baseBranch, |
| 1569 | finding, |
| 1570 | }), |
| 1571 | ); |
| 1572 | }; |
| 1573 | |
| 1574 | const startFixFindings = () => { |
| 1575 | if (!detail) return; |
| 1576 | void startHandoff( |
| 1577 | "findings", |
| 1578 | buildFixFindingsHandoff({ |
| 1579 | number: detail.number, |
| 1580 | title: detail.title, |
| 1581 | url: detail.url, |
| 1582 | headBranch: detail.headBranch, |
| 1583 | baseBranch: detail.baseBranch, |
| 1584 | reviewThreads: detail.reviewThreads, |
| 1585 | comments: detail.comments, |
| 1586 | checks: checksStale ? [] : detail.checks, |
| 1587 | commentsTruncated: detail.commentsTruncated, |
| 1588 | }), |
| 1589 | ); |
| 1590 | }; |
| 1591 | |
| 1592 | const startResolveConflicts = () => { |
| 1593 | if (!handoffSummary) return; |
| 1594 | void startHandoff("conflicts", { |
| 1595 | prompt: buildResolveConflictsPrompt({ |
| 1596 | number: handoffSummary.number, |
| 1597 | url: handoffSummary.url, |
| 1598 | headBranch: handoffSummary.headBranch, |
| 1599 | baseBranch: handoffSummary.baseBranch, |
| 1600 | }), |
| 1601 | }); |
| 1602 | }; |
| 1603 | |
| 1604 | // The host says which strategies it offers at all; the repository narrows that to the ones |
| 1605 | // it actually allows. |
| 1606 | const allowedMergeMethods = detail |
| 1607 | ? detail.capabilities.mergeMethods.filter((method) => detail.mergeCapabilities[method]) |
| 1608 | : []; |
| 1609 | const currentMergeMethod = |
| 1610 | mergeMethodSelection?.pullRequestKey === pullRequestKey ? mergeMethodSelection.method : null; |
| 1611 | const selectedMergeMethod = resolvePullRequestMergeMethod( |
| 1612 | allowedMergeMethods, |
| 1613 | currentMergeMethod, |
| 1614 | projectDefaultMergeMethod, |
| 1615 | lastSelectedMergeMethod, |
| 1616 | ); |
| 1617 | const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod]; |
| 1618 | const pendingAutoMergeLabel = `Auto-merge (${selectedMergeMethodLabel.toLowerCase()})`; |
| 1619 | const conflicting = detail?.state === "open" && detail.mergeability === "conflicting"; |
| 1620 | // Only an outright yes arms it. A host that reports nothing has not said the merge is already |
| 1621 | // spoken for, and an off switch for something that may not be on says the wrong thing twice. |
| 1622 | const autoMergeArmed = detail?.state === "open" && detail.autoMergeEnabled === true; |
| 1623 | const armedMergeMethod = detail?.autoMergeMethod; |
| 1624 | const armedAutoMergeLabel = armedMergeMethod |
| 1625 | ? `Auto-merge (${PULL_REQUEST_MERGE_METHOD_LABELS[armedMergeMethod].toLowerCase()})` |
| 1626 | : "Auto-merge"; |
| 1627 | const workflowApprovalsRequired = |
| 1628 | detail?.state === "open" ? (detail.workflowApprovalsRequired ?? 0) : 0; |
| 1629 | // Out of date with the base, and still cleanly mergeable — the one pairing an update button |
| 1630 | // exists for. Null everywhere else, including hosts that cannot compare at all. |
| 1631 | const freshness = detail === null ? null : resolveBaseFreshness(detail); |
| 1632 | // A host that cannot produce a patch has no Code tab to open. While detail is loading the ghost |
| 1633 | // uses this optimistic tab set to reserve the same chrome; a host without a patch removes Code |
| 1634 | // when its capabilities arrive. |
| 1635 | // The Guide tab is a beta the pull request's server opts into in Settings → Source Control. |
| 1636 | const reviewGuidesEnabled = useEnvironmentSettings( |
| 1637 | environmentId, |
| 1638 | (settings) => settings.reviewGuidesEnabled, |
| 1639 | ); |
| 1640 | const visibleTabs = TABS.filter( |
| 1641 | (item) => |
| 1642 | (item.value !== "code" || detail === null || detail.capabilities.diff) && |
| 1643 | (item.value !== "guide" || reviewGuidesEnabled), |
| 1644 | ); |
| 1645 | // The Code tab can be opened while the detail is still on its way, and the detail may then say |
| 1646 | // this host has no patch to show. The tab goes, so whoever was standing on it is moved back to |
| 1647 | // the summary rather than left looking at a panel that is no longer reachable. |
| 1648 | useEffect(() => { |
| 1649 | if (!visibleTabs.some((item) => item.value === tab)) setTab("summary"); |
| 1650 | }, [tab, visibleTabs]); |
| 1651 | // Two questions, both of which have to say yes: whether this host can do it at all, and |
| 1652 | // whether this account may. A reader with read access on someone else's project sees the pull |
| 1653 | // request and none of the buttons that would only ever be refused. |
| 1654 | const can = (action: PullRequestAction) => |
| 1655 | detail?.capabilities.actions.includes(action) === true && |
| 1656 | detail.viewerPermissions.actions.includes(action); |
| 1657 | const detailChecksState = detail ? pullRequestChecksState(detail.checks) : null; |
| 1658 | const latestChecksState = |
| 1659 | sharedSummary?.checksState === undefined ? detailChecksState : sharedSummary.checksState; |
| 1660 | // List rollups can omit workflows awaiting approval. Only refreshed detail can clear those. |
| 1661 | const checksState = |
| 1662 | latestChecksState !== "failing" && |
| 1663 | detail?.checks.some((check) => check.status === "action-required") |
| 1664 | ? "pending" |
| 1665 | : latestChecksState; |
| 1666 | // A newer rollup cannot tell us which runs changed or how many passed. |
| 1667 | const checksStale = checksState !== detailChecksState; |
| 1668 | // The merge state remains in one stable slot from waiting through completion. Conflicts take |
| 1669 | // the slot while they need a person; the armed badge remains beside them so that state is not lost. |
| 1670 | const primaryAction = detail |
| 1671 | ? resolvePullRequestPrimaryControl({ |
| 1672 | state: detail.state, |
| 1673 | isDraft: detail.isDraft, |
| 1674 | mergeability: detail.mergeability, |
| 1675 | checksState, |
| 1676 | autoMergeEnabled: detail.autoMergeEnabled, |
| 1677 | hasMergeMethod: allowedMergeMethods.length > 0, |
| 1678 | canMerge: canMergeSinglePullRequest && can("merge"), |
| 1679 | canMarkReady: can("ready"), |
| 1680 | canEnableAutoMerge: canMergeSinglePullRequest && can("enable-auto-merge"), |
| 1681 | }) |
| 1682 | : null; |
| 1683 | // What the menu's action group holds. Named once so the separators around it are drawn from |
| 1684 | // the same answer as its contents, rather than on the assumption that it has any. |
| 1685 | const showsDraftToggle = |
| 1686 | detail?.state === "open" && |
| 1687 | can(detail.isDraft ? "ready" : "draft") && |
| 1688 | !(detail.isDraft && primaryAction === "ready"); |
| 1689 | const showsAutoMerge = |
| 1690 | canMergeSinglePullRequest && |
| 1691 | detail?.state === "open" && |
| 1692 | ((autoMergeArmed && can("disable-auto-merge")) || |
| 1693 | (!autoMergeArmed && |
| 1694 | primaryAction !== "enable-auto-merge" && |
| 1695 | !detail.isDraft && |
| 1696 | !conflicting && |
| 1697 | can("enable-auto-merge") && |
| 1698 | allowedMergeMethods.length > 0)); |
| 1699 | const showsMergeNow = |
| 1700 | canMergeSinglePullRequest && |
| 1701 | detail?.state === "open" && |
| 1702 | (primaryAction === "enable-auto-merge" || primaryAction === "auto-merge-armed") && |
| 1703 | can("merge") && |
| 1704 | !detail.isDraft && |
| 1705 | !conflicting && |
| 1706 | allowedMergeMethods.length > 0; |
| 1707 | const showsMergeMethods = |
| 1708 | detail?.state === "open" && |
| 1709 | can("merge") && |
| 1710 | !detail.isDraft && |
| 1711 | !conflicting && |
| 1712 | allowedMergeMethods.length > 1; |
| 1713 | // The pull request number carries this state in the overview and the right-panel tab mirrors |
| 1714 | // it. Conflicts take the action slot while they need a person, but do not change the PR state. |
| 1715 | const statePresentation = detail |
| 1716 | ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) |
| 1717 | : null; |
| 1718 | const showsDeleteBranch = detail !== null && canDeletePullRequestBranch(detail); |
| 1719 | const environmentConnected = environments.some( |
| 1720 | (environment) => |
| 1721 | environment.environmentId === environmentId && environment.connection.phase === "connected", |
| 1722 | ); |
| 1723 | const confirmDeleteBranch = async () => { |
| 1724 | if (detail === null) return; |
| 1725 | const confirmed = await readLocalApi()?.dialogs.confirm( |
| 1726 | [ |
| 1727 | `Delete branch ${detail.headBranch}?`, |
| 1728 | `This deletes ${detail.headBranch} from ${detail.headRepositoryNameWithOwner ?? detail.repository}. Local branches and worktrees are not changed.`, |
| 1729 | ].join("\n"), |
| 1730 | { variant: "destructive" }, |
| 1731 | ); |
| 1732 | if (confirmed) void perform("delete-branch"); |
| 1733 | }; |
| 1734 | const showsApproveWorkflows = |
| 1735 | workflowApprovalsRequired > 0 && !checksStale && can("approve-workflows"); |
| 1736 | const checksSummary = checksStale |
| 1737 | ? checksState === null |
| 1738 | ? "No checks reported" |
| 1739 | : pullRequestChecksStatePresentation(checksState).label |
| 1740 | : detail |
| 1741 | ? summarizePullRequestChecks(detail.checks) |
| 1742 | : null; |
| 1743 | // Approvals that still stand, and only those. A superseded one is dimmed beside the reviewer |
| 1744 | // who gave it, so counting it here would have the header assert in a number what the row next |
| 1745 | // to it has just qualified. |
| 1746 | // |
| 1747 | // Not counted at all from a conversation this page only holds the recent end of: an approval |
| 1748 | // older than the window would be missing, and "1" beside a tick is read as the whole answer. |
| 1749 | // The Summary tab's row can say it may be short; a bare number cannot, so it stays away. |
| 1750 | const approvalCount = |
| 1751 | detail && !detail.commentsTruncated |
| 1752 | ? latestPullRequestReviewOutcomes(detail.comments, detail.commits).filter( |
| 1753 | (entry) => entry.outcome === "approved" && !entry.stale, |
| 1754 | ).length |
| 1755 | : 0; |
| 1756 | |
| 1757 | const checkoutControl = |
| 1758 | context === "page" ? ( |
| 1759 | <Menu> |
| 1760 | <Tooltip> |
| 1761 | <TooltipTrigger |
| 1762 | render={ |
| 1763 | <MenuTrigger |
| 1764 | disabled={handoff !== null || checkoutRoot === null} |
| 1765 | render={ |
| 1766 | <Button |
| 1767 | size="xs" |
| 1768 | variant="outline" |
| 1769 | aria-label={handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} |
| 1770 | > |
| 1771 | <GitBranchIcon aria-hidden className="size-3.5" /> |
| 1772 | <span className="@max-[35rem]/pr-header:hidden"> |
| 1773 | {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} |
| 1774 | </span> |
| 1775 | <ChevronDownIcon aria-hidden className="size-3.5 text-muted-foreground" /> |
| 1776 | </Button> |
| 1777 | } |
| 1778 | /> |
| 1779 | } |
| 1780 | /> |
| 1781 | <TooltipPopup>Check out this pull request</TooltipPopup> |
| 1782 | </Tooltip> |
| 1783 | <MenuPopup align="end" side="bottom"> |
| 1784 | <MenuItem disabled={!canPrepareWorktree} onClick={() => startCheckout("worktree")}> |
| 1785 | <GitBranchIcon className="mt-1 size-3.5 shrink-0 self-start" /> |
| 1786 | <span className="flex min-w-0 flex-col"> |
| 1787 | <span>In a separate worktree</span> |
| 1788 | <span className="text-xs text-muted-foreground"> |
| 1789 | Its own folder and thread. Nothing you have open moves. |
| 1790 | </span> |
| 1791 | </span> |
| 1792 | </MenuItem> |
| 1793 | <MenuItem disabled={!prepareThread.isAllowed} onClick={() => startCheckout("local")}> |
| 1794 | <FolderGit2Icon className="mt-1 size-3.5 shrink-0 self-start" /> |
| 1795 | <span className="flex min-w-0 flex-col"> |
| 1796 | <span>In this repository</span> |
| 1797 | <span className="text-xs text-muted-foreground"> |
| 1798 | Switches the branch you are working in, like `gh pr checkout`. |
| 1799 | </span> |
| 1800 | </span> |
| 1801 | </MenuItem> |
| 1802 | {pickableEnvironments.length > 0 ? ( |
| 1803 | <ActOnEnvironmentPicker |
| 1804 | environments={pickableEnvironments} |
| 1805 | value={actingEnvironmentId} |
| 1806 | onChange={(next) => setActingScope({ pullRequestKey, environmentId: next })} |
| 1807 | disabled={handoff !== null} |
| 1808 | /> |
| 1809 | ) : null} |
| 1810 | </MenuPopup> |
| 1811 | </Menu> |
| 1812 | ) : null; |
| 1813 | |
| 1814 | const resolveConflictsControl = ( |
| 1815 | <Tooltip> |
| 1816 | <TooltipTrigger |
| 1817 | render={ |
| 1818 | <span className="inline-flex shrink-0"> |
| 1819 | <Button |
| 1820 | size="xs" |
| 1821 | variant="destructive-outline" |
| 1822 | disabled={handoff !== null || (attachTarget === null && checkoutRoot === null)} |
| 1823 | onClick={startResolveConflicts} |
| 1824 | aria-label={handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} |
| 1825 | > |
| 1826 | <PullRequestGlyph.conflicting aria-hidden className="size-3.5" /> |
| 1827 | <span className="@max-[30rem]/pr-header:hidden"> |
| 1828 | {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} |
| 1829 | </span> |
| 1830 | </Button> |
| 1831 | </span> |
| 1832 | } |
| 1833 | /> |
| 1834 | <TooltipPopup side="top"> |
| 1835 | {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} |
| 1836 | </TooltipPopup> |
| 1837 | </Tooltip> |
| 1838 | ); |
| 1839 | |
| 1840 | // The list already has the pull request's identity and summary. Keep them on screen |
| 1841 | // and let the richer detail read replace the remaining placeholders in place. |
| 1842 | if (detailQuery.isPending && !detail) { |
| 1843 | return ( |
| 1844 | <PullRequestDetailGhost |
| 1845 | seed={matchingListEntry} |
| 1846 | summary={sharedSummary} |
| 1847 | checkoutCommand={checkoutCommand} |
| 1848 | onCheckoutError={onCheckoutCommandError} |
| 1849 | number={reference.number} |
| 1850 | tabs={visibleTabs} |
| 1851 | activeTab={tab} |
| 1852 | {...(onBack ? { onBack } : {})} |
| 1853 | {...(onClose ? { onClose } : {})} |
| 1854 | actions={ |
| 1855 | handoffSummary ? ( |
| 1856 | <TooltipProvider delay={150} closeDelay={150} timeout={400}> |
| 1857 | {handoffSummary.state === "open" && handoffSummary.mergeability === "conflicting" |
| 1858 | ? resolveConflictsControl |
| 1859 | : null} |
| 1860 | {checkoutControl} |
| 1861 | </TooltipProvider> |
| 1862 | ) : undefined |
| 1863 | } |
| 1864 | /> |
| 1865 | ); |
| 1866 | } |
| 1867 | |
| 1868 | return ( |
| 1869 | <div className="relative flex h-full min-h-0 w-full flex-col bg-background"> |
| 1870 | {threadPickerOpen && detail ? ( |
| 1871 | <PullRequestThreadLinks |
| 1872 | key={`${environmentId}:${detail.url}`} |
| 1873 | display="picker" |
| 1874 | environmentId={environmentId} |
| 1875 | reference={reference} |
| 1876 | url={detail.url} |
| 1877 | threadRef={null} |
| 1878 | onPickerOpenChange={setThreadPickerOpen} |
| 1879 | /> |
| 1880 | ) : null} |
| 1881 | <div |
| 1882 | className={cn( |
| 1883 | "@container/pr-header grid min-w-0 shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2", |
| 1884 | detail && "border-b border-border/60", |
| 1885 | !detail && !onClose && "hidden", |
| 1886 | )} |
| 1887 | > |
| 1888 | <div className="pl-4 grid h-7 min-w-0 items-center overflow-hidden"> |
| 1889 | <div |
| 1890 | aria-hidden={condensed} |
| 1891 | inert={condensed} |
| 1892 | className={cn( |
| 1893 | "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-[opacity,transform] ease-out motion-reduce:transform-none motion-reduce:transition-none sm:text-xs", |
| 1894 | condensed |
| 1895 | ? "pointer-events-none -translate-y-1 opacity-0 duration-100" |
| 1896 | : "translate-y-0 opacity-100 delay-50 duration-150", |
| 1897 | )} |
| 1898 | > |
| 1899 | {detail && statePresentation ? ( |
| 1900 | <> |
| 1901 | {onBack ? ( |
| 1902 | <Tooltip> |
| 1903 | <TooltipTrigger |
| 1904 | render={ |
| 1905 | <Button |
| 1906 | size="icon-micro" |
| 1907 | variant="ghost-muted" |
| 1908 | onClick={onBack} |
| 1909 | className="-ml-1.5" |
| 1910 | aria-label="Back to this thread's pull requests" |
| 1911 | > |
| 1912 | <ArrowLeftIcon aria-hidden className="size-3.5" /> |
| 1913 | </Button> |
| 1914 | } |
| 1915 | /> |
| 1916 | <TooltipPopup side="top">Back to pull requests</TooltipPopup> |
| 1917 | </Tooltip> |
| 1918 | ) : null} |
| 1919 | <Tooltip> |
| 1920 | <TooltipTrigger |
| 1921 | render={ |
| 1922 | repositoryUrl ? ( |
| 1923 | <button |
| 1924 | type="button" |
| 1925 | onClick={() => void readLocalApi()?.shell.openExternal(repositoryUrl)} |
| 1926 | className="min-w-0 cursor-pointer truncate text-left font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" |
| 1927 | > |
| 1928 | {detail.repository} |
| 1929 | </button> |
| 1930 | ) : ( |
| 1931 | <span className="min-w-0 truncate font-medium text-muted-foreground"> |
| 1932 | {detail.repository} |
| 1933 | </span> |
| 1934 | ) |
| 1935 | } |
| 1936 | /> |
| 1937 | <TooltipPopup side="top"> |
| 1938 | {repositoryUrl ? `Open ${detail.repository} repository` : detail.repository} |
| 1939 | </TooltipPopup> |
| 1940 | </Tooltip> |
| 1941 | <Tooltip> |
| 1942 | <TooltipTrigger |
| 1943 | render={ |
| 1944 | <button |
| 1945 | type="button" |
| 1946 | onClick={() => void readLocalApi()?.shell.openExternal(detail.url)} |
| 1947 | onContextMenu={(event) => openNumberContextMenu(event, detail)} |
| 1948 | className={cn( |
| 1949 | "inline-flex shrink-0 cursor-pointer items-center gap-0.5 font-medium underline-offset-2 hover:underline", |
| 1950 | statePresentation.toneClassName, |
| 1951 | )} |
| 1952 | aria-label={`Open pull request #${detail.number} on host`} |
| 1953 | > |
| 1954 | #{detail.number} |
| 1955 | <ExternalLinkIcon aria-hidden className="size-2.5" /> |
| 1956 | </button> |
| 1957 | } |
| 1958 | /> |
| 1959 | <TooltipPopup side="top">{openOnHostLabel(hostClient)}</TooltipPopup> |
| 1960 | </Tooltip> |
| 1961 | </> |
| 1962 | ) : null} |
| 1963 | </div> |
| 1964 | <div |
| 1965 | aria-hidden={!condensed} |
| 1966 | inert={!condensed} |
| 1967 | className={cn( |
| 1968 | "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-[opacity,transform] ease-out motion-reduce:transform-none motion-reduce:transition-none sm:text-xs", |
| 1969 | condensed |
| 1970 | ? "translate-y-0 opacity-100 delay-50 duration-150" |
| 1971 | : "pointer-events-none translate-y-1 opacity-0 duration-100", |
| 1972 | )} |
| 1973 | > |
| 1974 | {detail && statePresentation ? ( |
| 1975 | <> |
| 1976 | {onBack ? ( |
| 1977 | <Tooltip> |
| 1978 | <TooltipTrigger |
| 1979 | render={ |
| 1980 | <Button |
| 1981 | size="icon-micro" |
| 1982 | variant="ghost-muted" |
| 1983 | tabIndex={condensed ? 0 : -1} |
| 1984 | onClick={onBack} |
| 1985 | className="-ml-1.5" |
| 1986 | aria-label="Back to this thread's pull requests" |
| 1987 | > |
| 1988 | <ArrowLeftIcon aria-hidden className="size-3.5" /> |
| 1989 | </Button> |
| 1990 | } |
| 1991 | /> |
| 1992 | <TooltipPopup side="top">Back to pull requests</TooltipPopup> |
| 1993 | </Tooltip> |
| 1994 | ) : null} |
| 1995 | <Tooltip> |
| 1996 | <TooltipTrigger |
| 1997 | render={ |
| 1998 | <button |
| 1999 | type="button" |
| 2000 | tabIndex={condensed ? 0 : -1} |
| 2001 | onClick={() => void readLocalApi()?.shell.openExternal(detail.url)} |
| 2002 | onContextMenu={(event) => openNumberContextMenu(event, detail)} |
| 2003 | className={cn( |
| 2004 | "inline-flex shrink-0 cursor-pointer items-center gap-0.5 font-medium underline-offset-2 hover:underline", |
| 2005 | statePresentation.toneClassName, |
| 2006 | )} |
| 2007 | aria-label={`Open pull request #${detail.number} on host`} |
| 2008 | > |
| 2009 | #{detail.number} |
| 2010 | <ExternalLinkIcon aria-hidden className="size-2.5" /> |
| 2011 | </button> |
| 2012 | } |
| 2013 | /> |
| 2014 | <TooltipPopup side="top">{openOnHostLabel(hostClient)}</TooltipPopup> |
| 2015 | </Tooltip> |
| 2016 | <Tooltip> |
| 2017 | <TooltipTrigger |
| 2018 | render={ |
| 2019 | <span className="min-w-0 truncate font-medium text-foreground"> |
| 2020 | {detail.title} |
| 2021 | </span> |
| 2022 | } |
| 2023 | /> |
| 2024 | <TooltipPopup side="top">{detail.title}</TooltipPopup> |
| 2025 | </Tooltip> |
| 2026 | </> |
| 2027 | ) : null} |
| 2028 | </div> |
| 2029 | </div> |
| 2030 | <div className="mr-4 flex h-7 shrink-0 items-center justify-end gap-1"> |
| 2031 | {detail ? ( |
| 2032 | <TooltipProvider delay={150} closeDelay={150} timeout={400}> |
| 2033 | {!nativeStack && supportsStackActions && nativeStackQuery.error ? ( |
| 2034 | <Button variant="ghost" size="xs" onClick={nativeStackQuery.refresh}> |
| 2035 | Retry stack lookup |
| 2036 | </Button> |
| 2037 | ) : null} |
| 2038 | {nativeStack ? ( |
| 2039 | <PullRequestStackMenu |
| 2040 | hostLabel={hostClient.label} |
| 2041 | stack={nativeStack} |
| 2042 | notice={nativeStackQuery.notice} |
| 2043 | onRetry={nativeStackQuery.error ? nativeStackQuery.refresh : undefined} |
| 2044 | reference={reference} |
| 2045 | environmentId={environmentId} |
| 2046 | onSelect={onSelectPullRequest} |
| 2047 | mergeMethod={selectedMergeMethod} |
| 2048 | canMerge={ |
| 2049 | nativeStackQuery.isFresh && |
| 2050 | supportsStackActions && |
| 2051 | can("merge") && |
| 2052 | allowedMergeMethods.length > 0 |
| 2053 | } |
| 2054 | canRebase={ |
| 2055 | nativeStackQuery.isFresh && |
| 2056 | supportsStackActions && |
| 2057 | detail.viewerPermissions.stackRebase === true |
| 2058 | } |
| 2059 | onActed={() => { |
| 2060 | refreshDetail(); |
| 2061 | onActed?.(); |
| 2062 | }} |
| 2063 | /> |
| 2064 | ) : null} |
| 2065 | {context === "page" ? ( |
| 2066 | <PullRequestThreadLinks |
| 2067 | display="count" |
| 2068 | environmentId={environmentId} |
| 2069 | reference={reference} |
| 2070 | url={detail.url} |
| 2071 | threadRef={null} |
| 2072 | /> |
| 2073 | ) : null} |
| 2074 | {/* Said where the Merge button is, because it is the answer to why nobody has |
| 2075 | pressed it: the merge is already asked for, and the host is holding it. */} |
| 2076 | {autoMergeArmed && primaryAction !== "auto-merge-armed" ? ( |
| 2077 | <Tooltip> |
| 2078 | <TooltipTrigger |
| 2079 | render={ |
| 2080 | <Badge |
| 2081 | size="control" |
| 2082 | variant="info" |
| 2083 | role="img" |
| 2084 | aria-label={armedAutoMergeLabel} |
| 2085 | > |
| 2086 | <PullRequestGlyph.merged aria-hidden className="size-3.5" /> |
| 2087 | <span className="@max-[30rem]/pr-header:hidden">{armedAutoMergeLabel}</span> |
| 2088 | </Badge> |
| 2089 | } |
| 2090 | /> |
| 2091 | <TooltipPopup side="top"> |
| 2092 | {armedAutoMergeLabel}: the host will merge this on its own once its requirements |
| 2093 | are met |
| 2094 | </TooltipPopup> |
| 2095 | </Tooltip> |
| 2096 | ) : null} |
| 2097 | {primaryAction === "resolve" ? ( |
| 2098 | resolveConflictsControl |
| 2099 | ) : primaryAction === "ready" ? ( |
| 2100 | <Tooltip> |
| 2101 | <TooltipTrigger |
| 2102 | render={ |
| 2103 | <span className="inline-flex shrink-0"> |
| 2104 | <Button |
| 2105 | size="xs" |
| 2106 | variant="default" |
| 2107 | disabled={actionPending} |
| 2108 | onClick={() => void perform("ready")} |
| 2109 | aria-label="Ready for review" |
| 2110 | > |
| 2111 | <PullRequestGlyph.pullRequest aria-hidden className="size-3.5" /> |
| 2112 | <span className="@max-[30rem]/pr-header:hidden">Ready for review</span> |
| 2113 | </Button> |
| 2114 | </span> |
| 2115 | } |
| 2116 | /> |
| 2117 | <TooltipPopup side="top">Ready for review</TooltipPopup> |
| 2118 | </Tooltip> |
| 2119 | ) : primaryAction === "enable-auto-merge" ? ( |
| 2120 | <Tooltip> |
| 2121 | <TooltipTrigger |
| 2122 | render={ |
| 2123 | <span className="inline-flex shrink-0"> |
| 2124 | <Button |
| 2125 | size="xs" |
| 2126 | variant="default" |
| 2127 | disabled={actionPending} |
| 2128 | {...inlineConfirm.props( |
| 2129 | "enable-auto-merge", |
| 2130 | () => void perform("enable-auto-merge", selectedMergeMethod), |
| 2131 | )} |
| 2132 | aria-label={ |
| 2133 | pendingAction === "enable-auto-merge" |
| 2134 | ? "Enabling..." |
| 2135 | : inlineConfirm.isArmed("enable-auto-merge") |
| 2136 | ? "Confirm auto-merge?" |
| 2137 | : pendingAutoMergeLabel |
| 2138 | } |
| 2139 | > |
| 2140 | {inlineConfirm.isArmed("enable-auto-merge") ? ( |
| 2141 | <CheckIcon aria-hidden className="size-3.5" /> |
| 2142 | ) : ( |
| 2143 | <PullRequestGlyph.merged aria-hidden className="size-3.5" /> |
| 2144 | )} |
| 2145 | <AnimatedWidth className="@max-[30rem]/pr-header:hidden"> |
| 2146 | <span className="grid"> |
| 2147 | <span aria-hidden className="invisible col-start-1 row-start-1"> |
| 2148 | {pendingAutoMergeLabel} |
| 2149 | </span> |
| 2150 | <span className="col-start-1 row-start-1"> |
| 2151 | {pendingAction === "enable-auto-merge" |
| 2152 | ? "Enabling..." |
| 2153 | : inlineConfirm.isArmed("enable-auto-merge") |
| 2154 | ? "Confirm auto-merge?" |
| 2155 | : pendingAutoMergeLabel} |
| 2156 | </span> |
| 2157 | </span> |
| 2158 | </AnimatedWidth> |
| 2159 | </Button> |
| 2160 | </span> |
| 2161 | } |
| 2162 | /> |
| 2163 | <TooltipPopup side="top"> |
| 2164 | {pendingAction === "enable-auto-merge" |
| 2165 | ? "Enabling..." |
| 2166 | : inlineConfirm.isArmed("enable-auto-merge") |
| 2167 | ? "Confirm auto-merge?" |
| 2168 | : pendingAutoMergeLabel} |
| 2169 | </TooltipPopup> |
| 2170 | </Tooltip> |
| 2171 | ) : primaryAction === "auto-merge-armed" ? ( |
| 2172 | <Tooltip> |
| 2173 | <TooltipTrigger |
| 2174 | render={ |
| 2175 | <Badge |
| 2176 | size="control" |
| 2177 | variant="info" |
| 2178 | role="img" |
| 2179 | aria-label={armedAutoMergeLabel} |
| 2180 | > |
| 2181 | <PullRequestGlyph.merged aria-hidden className="size-3.5" /> |
| 2182 | <span className="@max-[30rem]/pr-header:hidden">{armedAutoMergeLabel}</span> |
| 2183 | </Badge> |
| 2184 | } |
| 2185 | /> |
| 2186 | <TooltipPopup side="top"> |
| 2187 | {armedAutoMergeLabel}: the host will merge this on its own once its requirements |
| 2188 | are met |
| 2189 | </TooltipPopup> |
| 2190 | </Tooltip> |
| 2191 | ) : primaryAction === "merge" ? ( |
| 2192 | <Tooltip> |
| 2193 | <TooltipTrigger |
| 2194 | render={ |
| 2195 | <span className="inline-flex shrink-0"> |
| 2196 | <Button |
| 2197 | size="xs" |
| 2198 | variant="default" |
| 2199 | disabled={actionPending} |
| 2200 | {...inlineConfirm.props( |
| 2201 | "merge", |
| 2202 | () => void perform("merge", selectedMergeMethod), |
| 2203 | )} |
| 2204 | aria-label={ |
| 2205 | pendingAction === "merge" |
| 2206 | ? "Merging..." |
| 2207 | : inlineConfirm.isArmed("merge") |
| 2208 | ? "Confirm merge?" |
| 2209 | : selectedMergeMethodLabel |
| 2210 | } |
| 2211 | > |
| 2212 | {inlineConfirm.isArmed("merge") ? ( |
| 2213 | <CheckIcon aria-hidden className="size-3.5" /> |
| 2214 | ) : ( |
| 2215 | <PullRequestGlyph.merged aria-hidden className="size-3.5" /> |
| 2216 | )} |
| 2217 | <AnimatedWidth className="@max-[30rem]/pr-header:hidden"> |
| 2218 | <span className="grid"> |
| 2219 | <span aria-hidden className="invisible col-start-1 row-start-1"> |
| 2220 | {selectedMergeMethodLabel} |
| 2221 | </span> |
| 2222 | <span className="col-start-1 row-start-1"> |
| 2223 | {pendingAction === "merge" |
| 2224 | ? "Merging..." |
| 2225 | : inlineConfirm.isArmed("merge") |
| 2226 | ? "Confirm merge?" |
| 2227 | : selectedMergeMethodLabel} |
| 2228 | </span> |
| 2229 | </span> |
| 2230 | </AnimatedWidth> |
| 2231 | </Button> |
| 2232 | </span> |
| 2233 | } |
| 2234 | /> |
| 2235 | <TooltipPopup side="top"> |
| 2236 | {pendingAction === "merge" |
| 2237 | ? "Merging..." |
| 2238 | : inlineConfirm.isArmed("merge") |
| 2239 | ? "Confirm merge?" |
| 2240 | : selectedMergeMethodLabel} |
| 2241 | </TooltipPopup> |
| 2242 | </Tooltip> |
| 2243 | ) : (primaryAction === "merged" || primaryAction === "closed") && |
| 2244 | statePresentation !== null ? ( |
| 2245 | <Badge size="control" variant="outline"> |
| 2246 | <span className={cn("flex items-center gap-1", statePresentation.toneClassName)}> |
| 2247 | <statePresentation.Icon className="size-3.5" /> |
| 2248 | {statePresentation.label} |
| 2249 | </span> |
| 2250 | </Badge> |
| 2251 | ) : null} |
| 2252 | {/* Keep checkout beside the menu so host actions arriving on its left cannot |
| 2253 | move a different action under the reader's pointer. */} |
| 2254 | {checkoutControl} |
| 2255 | <Menu> |
| 2256 | <Tooltip> |
| 2257 | <TooltipTrigger |
| 2258 | render={ |
| 2259 | <MenuTrigger |
| 2260 | render={ |
| 2261 | <Button |
| 2262 | aria-label={ |
| 2263 | refreshing ? "Refreshing pull request" : "More pull request actions" |
| 2264 | } |
| 2265 | size="icon-xs" |
| 2266 | variant="ghost-muted" |
| 2267 | /> |
| 2268 | } |
| 2269 | > |
| 2270 | {/* The refresh lives in this menu, so while one runs the trigger wears |
| 2271 | the spinning glyph in place of the dots: the reader sees the panel |
| 2272 | is fetching without a control appearing or the row shifting. */} |
| 2273 | {refreshing ? ( |
| 2274 | <RefreshIcon refreshing size="md" /> |
| 2275 | ) : ( |
| 2276 | <MoreHorizontalIcon className="size-4" /> |
| 2277 | )} |
| 2278 | </MenuTrigger> |
| 2279 | } |
| 2280 | /> |
| 2281 | <TooltipPopup> |
| 2282 | {refreshing ? "Refreshing pull request" : "More pull request actions"} |
| 2283 | </TooltipPopup> |
| 2284 | </Tooltip> |
| 2285 | <MenuPopup align="end" side="bottom"> |
| 2286 | <PullRequestThreadLinks |
| 2287 | display="menu-item" |
| 2288 | environmentId={environmentId} |
| 2289 | reference={reference} |
| 2290 | url={detail.url} |
| 2291 | threadRef={ |
| 2292 | threadRef ?? |
| 2293 | (typeof composerDraftTarget === "object" ? composerDraftTarget : null) |
| 2294 | } |
| 2295 | onPickerOpenChange={setThreadPickerOpen} |
| 2296 | /> |
| 2297 | <MenuItem disabled={refreshing} onClick={() => void refreshFromHost()}> |
| 2298 | <RefreshIcon size="sm" refreshing={refreshing} /> |
| 2299 | Refresh |
| 2300 | </MenuItem> |
| 2301 | <MenuItem disabled={handoff !== null} onClick={askAboutPullRequest}> |
| 2302 | <MessageCircleQuestionIcon className="mt-1 size-3.5 shrink-0 self-start" /> |
| 2303 | <span className="flex min-w-0 flex-col"> |
| 2304 | <span>{handoff === "ask" ? "Opening..." : "Ask a question"}</span> |
| 2305 | <span className="text-xs text-muted-foreground"> |
| 2306 | {attachTarget !== null |
| 2307 | ? "Adds the pull request to this thread's composer." |
| 2308 | : "Opens a thread that knows which pull request you mean."} |
| 2309 | </span> |
| 2310 | </span> |
| 2311 | </MenuItem> |
| 2312 | <MenuItem disabled={handoff !== null} onClick={explainPullRequest}> |
| 2313 | <BookOpenIcon className="mt-1 size-3.5 shrink-0 self-start" /> |
| 2314 | <span className="flex min-w-0 flex-col"> |
| 2315 | <span>{handoff === "explain" ? "Opening..." : "Explain this PR"}</span> |
| 2316 | <span className="text-xs text-muted-foreground"> |
| 2317 | A walk through the diff and what to read closely. |
| 2318 | </span> |
| 2319 | </span> |
| 2320 | </MenuItem> |
| 2321 | <MenuItem |
| 2322 | disabled={handoff !== null || !canFixFindings} |
| 2323 | onClick={startFixFindings} |
| 2324 | > |
| 2325 | <HammerIcon className="size-3.5" /> |
| 2326 | {handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings} |
| 2327 | </MenuItem> |
| 2328 | {reviewGuidesEnabled && guideWriter.canWrite ? ( |
| 2329 | <MenuItem disabled={guideWriter.running} onClick={writeGuide}> |
| 2330 | <SparklesIcon className="size-3.5" /> |
| 2331 | {guideWriter.running |
| 2332 | ? "Writing guide..." |
| 2333 | : guideWriter.data?.guide == null |
| 2334 | ? "Write guide" |
| 2335 | : "Rewrite guide"} |
| 2336 | </MenuItem> |
| 2337 | ) : null} |
| 2338 | {pickableEnvironments.length > 0 ? ( |
| 2339 | <ActOnEnvironmentPicker |
| 2340 | environments={pickableEnvironments} |
| 2341 | value={actingEnvironmentId} |
| 2342 | onChange={(next) => setActingScope({ pullRequestKey, environmentId: next })} |
| 2343 | disabled={handoff !== null} |
| 2344 | /> |
| 2345 | ) : null} |
| 2346 | <MenuSeparator /> |
| 2347 | {detail.state === "open" ? ( |
| 2348 | <> |
| 2349 | {/* Only where the button row could not take it: "Ready for review" on a |
| 2350 | draft is the primary header button, so offering it here as well would |
| 2351 | show the same action twice. */} |
| 2352 | {showsDraftToggle ? ( |
| 2353 | <MenuItem |
| 2354 | disabled={actionPending} |
| 2355 | onClick={() => void perform(detail.isDraft ? "ready" : "draft")} |
| 2356 | > |
| 2357 | {detail.isDraft ? ( |
| 2358 | <PullRequestGlyph.pullRequest className="size-3.5" /> |
| 2359 | ) : ( |
| 2360 | <PullRequestGlyph.draft className="size-3.5" /> |
| 2361 | )} |
| 2362 | {detail.isDraft ? "Ready for review" : "Convert to draft"} |
| 2363 | </MenuItem> |
| 2364 | ) : null} |
| 2365 | {showsMergeNow ? ( |
| 2366 | <MenuItem |
| 2367 | closeOnClick={inlineConfirm.isArmed("merge")} |
| 2368 | aria-label={ |
| 2369 | inlineConfirm.isArmed("merge") ? "Confirm merge?" : "Merge now" |
| 2370 | } |
| 2371 | disabled={actionPending} |
| 2372 | {...inlineConfirm.props( |
| 2373 | "merge", |
| 2374 | () => void perform("merge", selectedMergeMethod), |
| 2375 | )} |
| 2376 | > |
| 2377 | <PullRequestGlyph.merged className="size-3.5" /> |
| 2378 | {inlineConfirm.isArmed("merge") ? "Confirm merge?" : "Merge now"} |
| 2379 | </MenuItem> |
| 2380 | ) : null} |
| 2381 | {/* The same merge, left with the host to carry out once its requirements |
| 2382 | pass. A conflicting branch cannot be armed because nothing the host |
| 2383 | waits for will clear the conflict. */} |
| 2384 | {autoMergeArmed && can("disable-auto-merge") ? ( |
| 2385 | <MenuItem |
| 2386 | disabled={actionPending} |
| 2387 | onClick={() => void perform("disable-auto-merge")} |
| 2388 | > |
| 2389 | <PullRequestGlyph.merged className="size-3.5" /> |
| 2390 | Disable auto-merge |
| 2391 | </MenuItem> |
| 2392 | ) : showsAutoMerge ? ( |
| 2393 | <MenuItem |
| 2394 | closeOnClick={inlineConfirm.isArmed("enable-auto-merge")} |
| 2395 | aria-label={ |
| 2396 | inlineConfirm.isArmed("enable-auto-merge") |
| 2397 | ? "Confirm auto-merge?" |
| 2398 | : "Enable auto-merge" |
| 2399 | } |
| 2400 | disabled={actionPending} |
| 2401 | {...inlineConfirm.props( |
| 2402 | "enable-auto-merge", |
| 2403 | () => void perform("enable-auto-merge", selectedMergeMethod), |
| 2404 | )} |
| 2405 | > |
| 2406 | <PullRequestGlyph.merged className="size-3.5" /> |
| 2407 | {inlineConfirm.isArmed("enable-auto-merge") |
| 2408 | ? "Confirm auto-merge?" |
| 2409 | : "Enable auto-merge"} |
| 2410 | </MenuItem> |
| 2411 | ) : null} |
| 2412 | {/* A preference for the merge action rather than a second action, so it |
| 2413 | is a radio group here instead of a chevron welded to the Merge pill. |
| 2414 | Hidden while conflicting: every method would fail. */} |
| 2415 | {/* Only where merging is on offer at all: a strategy to merge with is not |
| 2416 | a choice for someone who may not merge. */} |
| 2417 | {showsMergeMethods ? ( |
| 2418 | <> |
| 2419 | {/* Only below the draft control. A host with no draft of its own, or |
| 2420 | a draft whose control is already the header button, would leave |
| 2421 | this against the separator that opened the group. */} |
| 2422 | {showsDraftToggle || showsMergeNow || showsAutoMerge ? ( |
| 2423 | <MenuSeparator /> |
| 2424 | ) : null} |
| 2425 | <MenuRadioGroup |
| 2426 | value={selectedMergeMethod} |
| 2427 | onValueChange={(method) => { |
| 2428 | const selectedMethod = method as PullRequestMergeMethod; |
| 2429 | setMergeMethod(selectedMethod); |
| 2430 | setLastSelectedMergeMethod(selectedMethod); |
| 2431 | }} |
| 2432 | > |
| 2433 | {allowedMergeMethods.map((method) => ( |
| 2434 | <MenuRadioItem |
| 2435 | key={method} |
| 2436 | value={method} |
| 2437 | disabled={actionPending} |
| 2438 | closeOnClick |
| 2439 | > |
| 2440 | {/* The radio item lays its children out as one block, so the |
| 2441 | icon and the label need their own row to share a line. */} |
| 2442 | <span className="flex min-w-0 items-center gap-2"> |
| 2443 | <PullRequestGlyph.merged className="size-3.5" /> |
| 2444 | <span>{PULL_REQUEST_MERGE_METHOD_LABELS[method]}</span> |
| 2445 | </span> |
| 2446 | </MenuRadioItem> |
| 2447 | ))} |
| 2448 | </MenuRadioGroup> |
| 2449 | </> |
| 2450 | ) : null} |
| 2451 | {pullRequestActionMenuHasGroup( |
| 2452 | showsDraftToggle, |
| 2453 | showsAutoMerge || showsMergeNow, |
| 2454 | showsMergeMethods, |
| 2455 | ) ? ( |
| 2456 | <MenuSeparator /> |
| 2457 | ) : null} |
| 2458 | </> |
| 2459 | ) : null} |
| 2460 | <MenuItem onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}> |
| 2461 | <ArrowUpRightIcon className="size-3.5" /> |
| 2462 | {openOnHostLabel(hostClient)} |
| 2463 | </MenuItem> |
| 2464 | <MenuItem onClick={() => copyReference(detail.url, "PR link")}> |
| 2465 | <LinkIcon className="size-3.5" /> |
| 2466 | Copy link |
| 2467 | <MenuShortcut> |
| 2468 | {shortcutLabelForCommand(keybindings, "thread.copyReference")} |
| 2469 | </MenuShortcut> |
| 2470 | </MenuItem> |
| 2471 | <MenuItem onClick={() => copyReference(`#${reference.number}`, "PR number")}> |
| 2472 | <CopyIcon className="size-3.5" /> |
| 2473 | Copy PR number |
| 2474 | <MenuShortcut> |
| 2475 | {shortcutLabelForCommand(keybindings, "pullRequest.copyNumber")} |
| 2476 | </MenuShortcut> |
| 2477 | </MenuItem> |
| 2478 | {detail.state === "open" && can("close") ? ( |
| 2479 | <> |
| 2480 | <MenuSeparator /> |
| 2481 | <MenuItem |
| 2482 | closeOnClick={inlineConfirm.isArmed("close")} |
| 2483 | aria-label={ |
| 2484 | inlineConfirm.isArmed("close") ? "Confirm close?" : "Close pull request" |
| 2485 | } |
| 2486 | variant="destructive" |
| 2487 | disabled={actionPending} |
| 2488 | {...inlineConfirm.props("close", () => void perform("close"))} |
| 2489 | > |
| 2490 | <PullRequestGlyph.closed className="size-3.5" /> |
| 2491 | {inlineConfirm.isArmed("close") ? "Confirm close?" : "Close pull request"} |
| 2492 | </MenuItem> |
| 2493 | </> |
| 2494 | ) : detail.state === "closed" && can("reopen") ? ( |
| 2495 | <> |
| 2496 | <MenuSeparator /> |
| 2497 | <MenuItem disabled={actionPending} onClick={() => void perform("reopen")}> |
| 2498 | <PullRequestGlyph.reopen className="size-3.5" /> |
| 2499 | Reopen pull request |
| 2500 | </MenuItem> |
| 2501 | </> |
| 2502 | ) : detail.state === "merged" && can("revert") ? ( |
| 2503 | <> |
| 2504 | <MenuSeparator /> |
| 2505 | <MenuItem |
| 2506 | closeOnClick={inlineConfirm.isArmed("revert")} |
| 2507 | aria-label={ |
| 2508 | inlineConfirm.isArmed("revert") ? "Confirm revert?" : "Revert changes" |
| 2509 | } |
| 2510 | variant={inlineConfirm.isArmed("revert") ? "destructive" : "default"} |
| 2511 | disabled={actionPending} |
| 2512 | {...inlineConfirm.props("revert", () => void perform("revert"))} |
| 2513 | > |
| 2514 | <RotateCcwIcon className="size-3.5" /> |
| 2515 | {inlineConfirm.isArmed("revert") ? "Confirm revert?" : "Revert changes"} |
| 2516 | </MenuItem> |
| 2517 | </> |
| 2518 | ) : null} |
| 2519 | {showsDeleteBranch ? ( |
| 2520 | <> |
| 2521 | <MenuSeparator /> |
| 2522 | <MenuItem |
| 2523 | variant="destructive" |
| 2524 | disabled={actionPending || !environmentConnected} |
| 2525 | onClick={() => void confirmDeleteBranch()} |
| 2526 | > |
| 2527 | <Trash2Icon className="mt-1 size-3.5 shrink-0 self-start" /> |
| 2528 | <span className="flex min-w-0 flex-col"> |
| 2529 | <span> |
| 2530 | {pendingAction === "delete-branch" |
| 2531 | ? "Deleting branch..." |
| 2532 | : "Delete branch"} |
| 2533 | </span> |
| 2534 | <span className="truncate text-xs text-muted-foreground"> |
| 2535 | {environmentConnected |
| 2536 | ? detail.headBranch |
| 2537 | : "Reconnect to delete this branch"} |
| 2538 | </span> |
| 2539 | </span> |
| 2540 | </MenuItem> |
| 2541 | </> |
| 2542 | ) : null} |
| 2543 | </MenuPopup> |
| 2544 | </Menu> |
| 2545 | </TooltipProvider> |
| 2546 | ) : null} |
| 2547 | {onClose ? ( |
| 2548 | <Button |
| 2549 | size="icon-xs" |
| 2550 | variant="ghost" |
| 2551 | aria-label="Collapse pull request panel" |
| 2552 | onClick={onClose} |
| 2553 | > |
| 2554 | <PanelRightIcon className="size-3.5" /> |
| 2555 | </Button> |
| 2556 | ) : null} |
| 2557 | </div> |
| 2558 | |
| 2559 | <div |
| 2560 | className={cn( |
| 2561 | "col-span-2 grid", |
| 2562 | condensed |
| 2563 | ? "grid-rows-[1fr]" |
| 2564 | : "grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none", |
| 2565 | )} |
| 2566 | > |
| 2567 | <div |
| 2568 | ref={condensedRowRef} |
| 2569 | className={cn( |
| 2570 | "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none", |
| 2571 | condensed |
| 2572 | ? "translate-y-0 opacity-100 delay-50" |
| 2573 | : "translate-y-1 opacity-0 duration-100", |
| 2574 | )} |
| 2575 | inert={!condensed} |
| 2576 | > |
| 2577 | {detail ? ( |
| 2578 | <div className="col-span-2 min-w-0 px-4 pb-2 pt-1"> |
| 2579 | <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground"> |
| 2580 | <span className="flex min-w-0 shrink items-center gap-1.5 overflow-hidden text-xs text-muted-foreground"> |
| 2581 | <PullRequestActorLabel |
| 2582 | actor={detail.author} |
| 2583 | profileUrl={authorProfileUrl} |
| 2584 | variant="avatar" |
| 2585 | className="shrink-0" |
| 2586 | /> |
| 2587 | <span className="shrink-0">{formatRelativeTimeLabel(detail.updatedAt)}</span> |
| 2588 | </span> |
| 2589 | <span aria-hidden className="h-3 w-px shrink-0 bg-border/70" /> |
| 2590 | <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-2xs text-muted-foreground/65"> |
| 2591 | {/* An out-of-date base wears the warning on the branch name itself, so the |
| 2592 | name is amber and pointing at either the name or the mark opens the way |
| 2593 | out. Up to date, the name keeps its plain tooltip. */} |
| 2594 | {freshness ? ( |
| 2595 | <PullRequestBaseFreshnessWarning |
| 2596 | baseBranch={detail.baseBranch} |
| 2597 | freshness={freshness} |
| 2598 | pending={actionPending} |
| 2599 | onUpdate={(method) => void perform("update-branch", undefined, method)} |
| 2600 | iconClassName="size-3" |
| 2601 | className="max-w-[40%]" |
| 2602 | > |
| 2603 | {isStackedPullRequest ? ( |
| 2604 | <PullRequestGlyph.stack |
| 2605 | aria-label="Stacked pull request" |
| 2606 | className="size-3 shrink-0" |
| 2607 | /> |
| 2608 | ) : null} |
| 2609 | <code className="flex min-w-0"> |
| 2610 | <MiddleTruncate value={detail.baseBranch} showTitle={false} /> |
| 2611 | </code> |
| 2612 | </PullRequestBaseFreshnessWarning> |
| 2613 | ) : ( |
| 2614 | <Tooltip> |
| 2615 | <TooltipTrigger |
| 2616 | render={ |
| 2617 | <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1"> |
| 2618 | {isStackedPullRequest ? ( |
| 2619 | <PullRequestGlyph.stack |
| 2620 | aria-label="Stacked pull request" |
| 2621 | className="size-3 shrink-0" |
| 2622 | /> |
| 2623 | ) : null} |
| 2624 | <code className="flex min-w-0"> |
| 2625 | <MiddleTruncate value={detail.baseBranch} showTitle={false} /> |
| 2626 | </code> |
| 2627 | </span> |
| 2628 | } |
| 2629 | /> |
| 2630 | <TooltipPopup side="top"> |
| 2631 | {isStackedPullRequest |
| 2632 | ? `Stacked on ${detail.baseBranch}` |
| 2633 | : detail.baseBranch} |
| 2634 | </TooltipPopup> |
| 2635 | </Tooltip> |
| 2636 | )} |
| 2637 | <ArrowLeftIcon |
| 2638 | aria-label="receives changes from" |
| 2639 | className="size-3 shrink-0 opacity-60" |
| 2640 | /> |
| 2641 | <Tooltip> |
| 2642 | <TooltipTrigger |
| 2643 | render={ |
| 2644 | <code className="flex min-w-0 flex-1"> |
| 2645 | <MiddleTruncate value={detail.headBranch} showTitle={false} /> |
| 2646 | </code> |
| 2647 | } |
| 2648 | /> |
| 2649 | <TooltipPopup side="top">{detail.headBranch}</TooltipPopup> |
| 2650 | </Tooltip> |
| 2651 | </span> |
| 2652 | <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2 text-2xs"> |
| 2653 | <span |
| 2654 | className="inline-flex items-center gap-1 tabular-nums" |
| 2655 | aria-label={`${detail.changedFiles.toLocaleString()} changed ${ |
| 2656 | detail.changedFiles === 1 ? "file" : "files" |
| 2657 | }`} |
| 2658 | > |
| 2659 | <FileDiffIcon aria-hidden className="size-3" /> |
| 2660 | {detail.changedFiles.toLocaleString()} |
| 2661 | </span> |
| 2662 | <PullRequestDiffStat |
| 2663 | additions={detail.additions} |
| 2664 | deletions={detail.deletions} |
| 2665 | className="shrink-0 font-mono text-2xs" |
| 2666 | /> |
| 2667 | </span> |
| 2668 | </div> |
| 2669 | </div> |
| 2670 | ) : null} |
| 2671 | </div> |
| 2672 | </div> |
| 2673 | |
| 2674 | <div |
| 2675 | className={cn( |
| 2676 | "col-span-2 grid", |
| 2677 | // Collapse before the scroll refund paints; only reopening eases back in. Animating |
| 2678 | // both directions makes the shrinking track fight the scrollTop correction. |
| 2679 | condensed |
| 2680 | ? "grid-rows-[0fr]" |
| 2681 | : "grid-rows-[1fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none", |
| 2682 | )} |
| 2683 | > |
| 2684 | <div |
| 2685 | ref={foldRef} |
| 2686 | className={cn( |
| 2687 | "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none", |
| 2688 | condensed |
| 2689 | ? "-translate-y-1 opacity-0 duration-100" |
| 2690 | : "translate-y-0 opacity-100 delay-50", |
| 2691 | )} |
| 2692 | inert={condensed} |
| 2693 | > |
| 2694 | {detail ? ( |
| 2695 | <PullRequestDetailHeaderBody |
| 2696 | title={ |
| 2697 | titleDraft === null ? ( |
| 2698 | <PullRequestDetailTitleRow className="group gap-1"> |
| 2699 | <Tooltip> |
| 2700 | <TooltipTrigger |
| 2701 | render={ |
| 2702 | <h1 className="min-w-0 flex-1 truncate text-base font-semibold leading-snug"> |
| 2703 | {detail.title} |
| 2704 | </h1> |
| 2705 | } |
| 2706 | /> |
| 2707 | <TooltipPopup side="top">{detail.title}</TooltipPopup> |
| 2708 | </Tooltip> |
| 2709 | {canEditPullRequestChangeRequest(detail) ? ( |
| 2710 | <PullRequestEditButton |
| 2711 | aria-label="Edit title" |
| 2712 | onClick={() => setTitleScope({ pullRequestKey, text: detail.title })} |
| 2713 | /> |
| 2714 | ) : null} |
| 2715 | </PullRequestDetailTitleRow> |
| 2716 | ) : ( |
| 2717 | // A title is one line of text, not markdown, so it takes an input rather than |
| 2718 | // the editor the description and the remarks share. |
| 2719 | <div className="space-y-2"> |
| 2720 | <Input |
| 2721 | autoFocus |
| 2722 | size="sm" |
| 2723 | disabled={titleSaving} |
| 2724 | value={titleDraft} |
| 2725 | aria-label="Pull request title" |
| 2726 | onChange={(event) => |
| 2727 | setTitleScope({ pullRequestKey, text: event.target.value }) |
| 2728 | } |
| 2729 | onKeyDown={(event) => { |
| 2730 | if (event.key === "Enter") { |
| 2731 | event.preventDefault(); |
| 2732 | void saveTitle(titleDraft); |
| 2733 | } else if (event.key === "Escape") { |
| 2734 | event.preventDefault(); |
| 2735 | setTitleScope(null); |
| 2736 | } |
| 2737 | }} |
| 2738 | /> |
| 2739 | <div className="flex justify-end gap-2"> |
| 2740 | <Button |
| 2741 | size="xs" |
| 2742 | variant="ghost" |
| 2743 | disabled={titleSaving} |
| 2744 | onClick={() => setTitleScope(null)} |
| 2745 | > |
| 2746 | Cancel |
| 2747 | </Button> |
| 2748 | <Button |
| 2749 | size="xs" |
| 2750 | variant="outline" |
| 2751 | disabled={ |
| 2752 | !canWriteSourceControl || titleSaving || titleDraft.trim().length === 0 |
| 2753 | } |
| 2754 | onClick={() => void saveTitle(titleDraft)} |
| 2755 | > |
| 2756 | {titleSaving ? "Saving..." : "Save"} |
| 2757 | </Button> |
| 2758 | </div> |
| 2759 | </div> |
| 2760 | ) |
| 2761 | } |
| 2762 | author={ |
| 2763 | <PullRequestActorLabel actor={detail.author} profileUrl={authorProfileUrl} /> |
| 2764 | } |
| 2765 | updated={<span>updated {formatRelativeTimeLabel(detail.updatedAt)}</span>} |
| 2766 | checkout={ |
| 2767 | checkoutCommand ? ( |
| 2768 | <PullRequestCopyableCode |
| 2769 | key={checkoutCommand} |
| 2770 | value={checkoutCommand} |
| 2771 | target="pull request checkout command" |
| 2772 | copyLabel="Copy checkout command" |
| 2773 | copiedLabel="Checkout command copied" |
| 2774 | className="ml-auto font-mono" |
| 2775 | tooltipSide="bottom" |
| 2776 | onError={onCheckoutCommandError} |
| 2777 | /> |
| 2778 | ) : null |
| 2779 | } |
| 2780 | base={ |
| 2781 | /* An out-of-date base wears the warning on the branch name itself, so the |
| 2782 | name is amber and pointing at either the name or the mark opens the way |
| 2783 | out. Up to date, the name keeps its plain tooltip. */ |
| 2784 | freshness ? ( |
| 2785 | <PullRequestBaseFreshnessWarning |
| 2786 | baseBranch={detail.baseBranch} |
| 2787 | freshness={freshness} |
| 2788 | pending={actionPending} |
| 2789 | onUpdate={(method) => void perform("update-branch", undefined, method)} |
| 2790 | className="max-w-[40%]" |
| 2791 | > |
| 2792 | {isStackedPullRequest ? ( |
| 2793 | <PullRequestGlyph.stack |
| 2794 | aria-label="Stacked pull request" |
| 2795 | className="size-3 shrink-0" |
| 2796 | /> |
| 2797 | ) : null} |
| 2798 | <code className="flex min-w-0"> |
| 2799 | <MiddleTruncate value={detail.baseBranch} showTitle={false} /> |
| 2800 | </code> |
| 2801 | </PullRequestBaseFreshnessWarning> |
| 2802 | ) : ( |
| 2803 | <Tooltip> |
| 2804 | <TooltipTrigger |
| 2805 | render={ |
| 2806 | <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1"> |
| 2807 | {isStackedPullRequest ? ( |
| 2808 | <PullRequestGlyph.stack |
| 2809 | aria-label="Stacked pull request" |
| 2810 | className="size-3 shrink-0" |
| 2811 | /> |
| 2812 | ) : null} |
| 2813 | <code className="flex min-w-0"> |
| 2814 | <MiddleTruncate value={detail.baseBranch} showTitle={false} /> |
| 2815 | </code> |
| 2816 | </span> |
| 2817 | } |
| 2818 | /> |
| 2819 | <TooltipPopup side="top"> |
| 2820 | {isStackedPullRequest |
| 2821 | ? `Stacked on ${detail.baseBranch}` |
| 2822 | : detail.baseBranch} |
| 2823 | </TooltipPopup> |
| 2824 | </Tooltip> |
| 2825 | ) |
| 2826 | } |
| 2827 | head={ |
| 2828 | <PullRequestCopyableCode |
| 2829 | key={detail.headBranch} |
| 2830 | value={detail.headBranch} |
| 2831 | target="branch name" |
| 2832 | copyLabel="Copy pull request branch" |
| 2833 | copiedLabel="Branch name copied" |
| 2834 | /> |
| 2835 | } |
| 2836 | files={`${detail.changedFiles.toLocaleString()} ${detail.changedFiles === 1 ? "file" : "files"}`} |
| 2837 | diffStat={ |
| 2838 | <PullRequestDiffStat |
| 2839 | additions={detail.additions} |
| 2840 | deletions={detail.deletions} |
| 2841 | className="shrink-0 font-mono text-xs" |
| 2842 | /> |
| 2843 | } |
| 2844 | /> |
| 2845 | ) : null} |
| 2846 | {detail?.state === "open" && !condensed ? ( |
| 2847 | <div className="px-4 pb-3"> |
| 2848 | <MergebotQueueBadge |
| 2849 | environmentId={environmentId} |
| 2850 | repository={detail.repository} |
| 2851 | number={detail.number} |
| 2852 | headSha={detail.headSha} |
| 2853 | /> |
| 2854 | </div> |
| 2855 | ) : null} |
| 2856 | </div> |
| 2857 | </div> |
| 2858 | |
| 2859 | {detail ? ( |
| 2860 | <PullRequestDetailTabBar |
| 2861 | tabs={visibleTabs} |
| 2862 | value={tab} |
| 2863 | onValueChange={setTab} |
| 2864 | onTabIntent={(item) => { |
| 2865 | if (item === "code") void loadCodeTab(); |
| 2866 | }} |
| 2867 | > |
| 2868 | {tab === "summary" ? ( |
| 2869 | showsApproveWorkflows ? ( |
| 2870 | <Tooltip> |
| 2871 | <TooltipTrigger |
| 2872 | render={ |
| 2873 | <span className="ml-auto inline-flex shrink-0"> |
| 2874 | <Button |
| 2875 | size="xs" |
| 2876 | variant="warning-outline" |
| 2877 | disabled={actionPending} |
| 2878 | {...inlineConfirm.props( |
| 2879 | "approve-workflows", |
| 2880 | () => void perform("approve-workflows"), |
| 2881 | )} |
| 2882 | aria-label={ |
| 2883 | pendingAction === "approve-workflows" |
| 2884 | ? "Approving..." |
| 2885 | : inlineConfirm.isArmed("approve-workflows") |
| 2886 | ? "Confirm approve?" |
| 2887 | : "Approve workflows to run" |
| 2888 | } |
| 2889 | > |
| 2890 | <PlayIcon aria-hidden className="size-3.5" /> |
| 2891 | <span className="inline-grid"> |
| 2892 | <span aria-hidden className="invisible col-start-1 row-start-1"> |
| 2893 | Approve workflows to run |
| 2894 | </span> |
| 2895 | <span aria-hidden className="invisible col-start-1 row-start-1"> |
| 2896 | Confirm approve? |
| 2897 | </span> |
| 2898 | <span className="col-start-1 row-start-1"> |
| 2899 | {pendingAction === "approve-workflows" |
| 2900 | ? "Approving..." |
| 2901 | : inlineConfirm.isArmed("approve-workflows") |
| 2902 | ? "Confirm approve?" |
| 2903 | : "Approve workflows to run"} |
| 2904 | </span> |
| 2905 | </span> |
| 2906 | </Button> |
| 2907 | </span> |
| 2908 | } |
| 2909 | /> |
| 2910 | <TooltipPopup side="top"> |
| 2911 | {pendingAction === "approve-workflows" |
| 2912 | ? "Approving..." |
| 2913 | : inlineConfirm.isArmed("approve-workflows") |
| 2914 | ? "Confirm approve?" |
| 2915 | : "Approve workflows to run"} |
| 2916 | </TooltipPopup> |
| 2917 | </Tooltip> |
| 2918 | ) : ( |
| 2919 | <PullRequestChecksStatusLine |
| 2920 | className="text-muted-foreground" |
| 2921 | aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"} |
| 2922 | icon={ |
| 2923 | checksState !== null ? ( |
| 2924 | <PullRequestChecksPopover |
| 2925 | checks={detail.checks} |
| 2926 | stale={checksStale} |
| 2927 | checksState={checksState} |
| 2928 | threadRef={threadRef} |
| 2929 | /> |
| 2930 | ) : ( |
| 2931 | <CircleDotIcon aria-hidden className="size-3.5" /> |
| 2932 | ) |
| 2933 | } |
| 2934 | label={checksSummary} |
| 2935 | /> |
| 2936 | ) |
| 2937 | ) : tab === "timeline" ? ( |
| 2938 | <div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground"> |
| 2939 | <PullRequestMetaLine |
| 2940 | className={cn( |
| 2941 | "whitespace-nowrap text-2xs transition-opacity", |
| 2942 | (activityPending || activityError) && "opacity-35", |
| 2943 | )} |
| 2944 | > |
| 2945 | <span |
| 2946 | className="inline-flex items-center gap-1" |
| 2947 | aria-label={ |
| 2948 | activityError |
| 2949 | ? "Comments unavailable" |
| 2950 | : `${detail.commentCount.toLocaleString()} ${ |
| 2951 | detail.commentCount === 1 ? "comment" : "comments" |
| 2952 | }` |
| 2953 | } |
| 2954 | > |
| 2955 | <MessageSquareIcon aria-hidden className="size-3" /> |
| 2956 | {activityError |
| 2957 | ? "—" |
| 2958 | : activityPending |
| 2959 | ? "…" |
| 2960 | : detail.commentCount.toLocaleString()} |
| 2961 | </span> |
| 2962 | <span |
| 2963 | className="inline-flex items-center gap-1" |
| 2964 | aria-label={ |
| 2965 | activityError |
| 2966 | ? "Commits unavailable" |
| 2967 | : `${detail.commits.length.toLocaleString()} ${ |
| 2968 | detail.commits.length === 1 ? "commit" : "commits" |
| 2969 | }` |
| 2970 | } |
| 2971 | > |
| 2972 | <GitCommitHorizontalIcon aria-hidden className="size-3" /> |
| 2973 | {activityError |
| 2974 | ? "—" |
| 2975 | : activityPending |
| 2976 | ? "…" |
| 2977 | : detail.commits.length.toLocaleString()} |
| 2978 | </span> |
| 2979 | {approvalCount > 0 ? ( |
| 2980 | <span |
| 2981 | className={cn( |
| 2982 | "inline-flex items-center gap-1", |
| 2983 | pullRequestReviewOutcomeToneClassName("approved"), |
| 2984 | )} |
| 2985 | > |
| 2986 | <PullRequestReviewOutcomeIcon outcome="approved" className="size-3" /> |
| 2987 | {approvalCount.toLocaleString()} |
| 2988 | <span className="sr-only"> |
| 2989 | {approvalCount === 1 ? "approval" : "approvals"} |
| 2990 | </span> |
| 2991 | </span> |
| 2992 | ) : null} |
| 2993 | </PullRequestMetaLine> |
| 2994 | <Button |
| 2995 | size="xs" |
| 2996 | variant="ghost-muted" |
| 2997 | aria-label={ |
| 2998 | timelineOrder === "newest" |
| 2999 | ? "Show oldest activity first" |
| 3000 | : "Show newest activity first" |
| 3001 | } |
| 3002 | onClick={() => |
| 3003 | setTimelineOrder((value) => (value === "newest" ? "oldest" : "newest")) |
| 3004 | } |
| 3005 | > |
| 3006 | <ArrowDownUpIcon aria-hidden className="size-3" /> |
| 3007 | {timelineOrder === "newest" ? "Newest first" : "Oldest first"} |
| 3008 | </Button> |
| 3009 | </div> |
| 3010 | ) : null} |
| 3011 | </PullRequestDetailTabBar> |
| 3012 | ) : null} |
| 3013 | </div> |
| 3014 | |
| 3015 | <div |
| 3016 | className="relative flex min-h-0 flex-1 flex-col overflow-hidden" |
| 3017 | onScrollCapture={(event) => { |
| 3018 | const scroller = scrollerByTab.current[tab]; |
| 3019 | if (event.target !== scroller || scroller === null || scroller === undefined) return; |
| 3020 | const top = scroller.scrollTop; |
| 3021 | setChromeCondensed((previous) => { |
| 3022 | let next = previous; |
| 3023 | const foldHeight = foldRef.current?.scrollHeight ?? 0; |
| 3024 | // The condensed row remains mounted, so refund only the height that actually leaves. |
| 3025 | const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0); |
| 3026 | if (previous) { |
| 3027 | // The hard top reopens the chrome with no refund: the reader asked for the top, |
| 3028 | // and moving them a fold's height back down would snatch it away — the fold |
| 3029 | // slides in above while the content stays where they left it. |
| 3030 | if (top < 4 && foldHeight > 0) { |
| 3031 | next = false; |
| 3032 | } |
| 3033 | } else if (foldHeight > 0 && top > foldHeight + 32) { |
| 3034 | compensationRef.current = { tab, scroller, delta: -chromeDelta }; |
| 3035 | next = true; |
| 3036 | } |
| 3037 | chromeStateByTab.current[tab] = next; |
| 3038 | return next; |
| 3039 | }); |
| 3040 | }} |
| 3041 | > |
| 3042 | {detailQuery.error && !detail ? ( |
| 3043 | <PullRequestsUnavailableState |
| 3044 | {...(isPullRequestNotFound(detailQuery.failure) |
| 3045 | ? { |
| 3046 | title: `Pull request #${reference.number} not found`, |
| 3047 | error: |
| 3048 | "It may be an issue rather than a pull request, or this account can't see it.", |
| 3049 | } |
| 3050 | : { error: detailQuery.error })} |
| 3051 | refreshing={detailQuery.isPending} |
| 3052 | onRetry={refreshDetail} |
| 3053 | {...(unavailableGitHubUrl ? { gitHubUrl: unavailableGitHubUrl } : {})} |
| 3054 | /> |
| 3055 | ) : detail ? ( |
| 3056 | <PullRequestMarkdownContext value={markdownContext}> |
| 3057 | {mountedTabs.has("summary") ? ( |
| 3058 | <div className={cn("absolute inset-0", tab !== "summary" && "invisible")}> |
| 3059 | <PullRequestSummaryTab |
| 3060 | scrollerRef={scrollerRefs.summary} |
| 3061 | environmentId={environmentId} |
| 3062 | threadRef={threadRef} |
| 3063 | reference={reference} |
| 3064 | detail={detail} |
| 3065 | activityPending={activityPending} |
| 3066 | checksStale={checksStale} |
| 3067 | activityError={activityError} |
| 3068 | pendingFinding={handoff} |
| 3069 | fixFindingLabel={handoffLabels.fixFinding} |
| 3070 | fixCheckLabel={handoffLabels.fixCheck} |
| 3071 | {...(canFixFindings ? { onFixFinding: startFixFinding } : {})} |
| 3072 | onRefresh={refreshDetail} |
| 3073 | onRefreshChecks={refreshFromHost} |
| 3074 | /> |
| 3075 | </div> |
| 3076 | ) : null} |
| 3077 | {mountedTabs.has("guide") ? ( |
| 3078 | <div className={cn("absolute inset-0", tab !== "guide" && "invisible")}> |
| 3079 | <PullRequestGuideTab |
| 3080 | environmentId={environmentId} |
| 3081 | reference={reference} |
| 3082 | active={tab === "guide"} |
| 3083 | title={detail.title} |
| 3084 | workspaceRoot={detail.workspaceRoot} |
| 3085 | canComment={ |
| 3086 | detail.capabilities.review.inlineComment && detail.viewerPermissions.comment |
| 3087 | } |
| 3088 | onAskAboutLines={(comment, request) => |
| 3089 | addSelectionToAgent({ comment, request }, { keepGuide: true }) |
| 3090 | } |
| 3091 | onAskAboutStop={askAboutGuideStop} |
| 3092 | onFinishReview={ |
| 3093 | detail.capabilities.review.verdicts.some((verdict) => |
| 3094 | detail.viewerPermissions.verdicts.includes(verdict), |
| 3095 | ) |
| 3096 | ? () => setOpenReviewRequest((count) => count + 1) |
| 3097 | : null |
| 3098 | } |
| 3099 | onOpenFile={openFileInCode} |
| 3100 | onTourShown={onGuideShown} |
| 3101 | /> |
| 3102 | </div> |
| 3103 | ) : null} |
| 3104 | {mountedTabs.has("timeline") ? ( |
| 3105 | <div className={cn("absolute inset-0", tab !== "timeline" && "invisible")}> |
| 3106 | {activityPending ? ( |
| 3107 | <PullRequestTimelineGhost /> |
| 3108 | ) : activityError ? ( |
| 3109 | <PullRequestActivityUnavailableState |
| 3110 | error={activityError} |
| 3111 | onRetry={activityQuery.refresh} |
| 3112 | /> |
| 3113 | ) : ( |
| 3114 | <PullRequestTimelineTab |
| 3115 | scrollerRef={scrollerRefs.timeline} |
| 3116 | detail={detail} |
| 3117 | environmentId={environmentId} |
| 3118 | threadRef={threadRef} |
| 3119 | reference={reference} |
| 3120 | order={timelineOrder} |
| 3121 | onOpenCommit={openCommit} |
| 3122 | onRefresh={refreshDetail} |
| 3123 | /> |
| 3124 | )} |
| 3125 | </div> |
| 3126 | ) : null} |
| 3127 | {mountedTabs.has("code") ? ( |
| 3128 | <div className={cn("absolute inset-0", tab !== "code" && "invisible")}> |
| 3129 | <Suspense fallback={<DiffPanelLoadingState label="Loading pull request diff..." />}> |
| 3130 | <PullRequestCodeTab |
| 3131 | scrollerRef={scrollerRefs.code} |
| 3132 | onAddToAgentSelection={addSelectionToAgent} |
| 3133 | environmentId={environmentId} |
| 3134 | reference={reference} |
| 3135 | detail={detail} |
| 3136 | selectedCommitOid={selectedCodeCommitOid} |
| 3137 | onSelectedCommitChange={selectCodeCommit} |
| 3138 | pendingFinding={handoff} |
| 3139 | fixFindingLabel={handoffLabels.fixFinding} |
| 3140 | {...(canFixFindings ? { onFixFinding: startFixFinding } : {})} |
| 3141 | onRefresh={refreshDetail} |
| 3142 | refreshToken={codeRefreshToken} |
| 3143 | revealRequest={codeRevealRequest} |
| 3144 | /> |
| 3145 | </Suspense> |
| 3146 | </div> |
| 3147 | ) : null} |
| 3148 | </PullRequestMarkdownContext> |
| 3149 | ) : null} |
| 3150 | </div> |
| 3151 | |
| 3152 | {/* Float over the content; do not reserve a footer or padding in the PR tabs. */} |
| 3153 | {detail ? ( |
| 3154 | <div className="absolute right-4 bottom-3 z-20"> |
| 3155 | <PullRequestComposer |
| 3156 | key={JSON.stringify([ |
| 3157 | environmentId, |
| 3158 | reference.projectId, |
| 3159 | reference.host, |
| 3160 | reference.repository, |
| 3161 | reference.number, |
| 3162 | ])} |
| 3163 | environmentId={environmentId} |
| 3164 | reference={reference} |
| 3165 | detail={detail} |
| 3166 | actionPending={actionPending} |
| 3167 | onCommentAction={performCommentAction} |
| 3168 | onCommented={refreshDetail} |
| 3169 | onReviewSubmitted={refreshDetail} |
| 3170 | openReviewRequest={openReviewRequest} |
| 3171 | /> |
| 3172 | </div> |
| 3173 | ) : null} |
| 3174 | </div> |
| 3175 | ); |
| 3176 | } |