| ... | ... | @@ -25,6 +25,7 @@ import { |
| 25 | 25 | GitPullRequestDraftIcon, |
| 26 | 26 | GitPullRequestIcon, |
| 27 | 27 | HammerIcon, |
| 28 | LayersIcon, |
| 28 | 29 | MessageCircleQuestionIcon, |
| 29 | 30 | MessageSquareIcon, |
| 30 | 31 | LinkIcon, |
| ... | ... | @@ -50,6 +51,7 @@ import { |
| 50 | 51 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 51 | 52 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; |
| 52 | 53 | import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 54 | import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink"; |
| 53 | 55 | import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; |
| 54 | 56 | import { cn } from "~/lib/utils"; |
| 55 | 57 | import { readLocalApi } from "~/localApi"; |
| ... | ... | @@ -60,6 +62,7 @@ import { useEnvironmentQuery } from "~/state/query"; |
| 60 | 62 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| 61 | 63 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 62 | 64 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 65 | import { vcsEnvironment } from "~/state/vcs"; |
| 63 | 66 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 64 | 67 | |
| 65 | 68 | import { |
| ... | ... | @@ -74,6 +77,7 @@ import { |
| 74 | 77 | import { Badge } from "../ui/badge"; |
| 75 | 78 | import { Button } from "../ui/button"; |
| 76 | 79 | import { Input } from "../ui/input"; |
| 80 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; |
| 77 | 81 | import { |
| 78 | 82 | Menu, |
| 79 | 83 | MenuItem, |
| ... | ... | @@ -104,6 +108,7 @@ import { |
| 104 | 108 | handoffPrompt, |
| 105 | 109 | handoffReviewComments, |
| 106 | 110 | latestPullRequestReviewOutcomes, |
| 111 | isStackedPullRequestBase, |
| 107 | 112 | pullRequestActionMenuHasGroup, |
| 108 | 113 | pullRequestActionNeedsHostRefresh, |
| 109 | 114 | pullRequestComposerTarget, |
| ... | ... | @@ -121,6 +126,7 @@ import { |
| 121 | 126 | } from "./pullRequestProjectAssignment.logic"; |
| 122 | 127 | import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; |
| 123 | 128 | import { |
| 129 | PullRequestActorAvatar, |
| 124 | 130 | PullRequestActorLabel, |
| 125 | 131 | PullRequestDiffStat, |
| 126 | 132 | PullRequestMetaLine, |
| ... | ... | @@ -362,7 +368,6 @@ export function PullRequestDetailPanel({ |
| 362 | 368 | onClose, |
| 363 | 369 | onStateChange, |
| 364 | 370 | context = "page", |
| 365 | | chromeVariant = "full", |
| 366 | 371 | composerDraftTarget, |
| 367 | 372 | }: { |
| 368 | 373 | environmentId: EnvironmentId; |
| ... | ... | @@ -394,12 +399,6 @@ export function PullRequestDetailPanel({ |
| 394 | 399 | * again is at best a no-op and at worst git refusing a branch two checkouts. |
| 395 | 400 | */ |
| 396 | 401 | context?: "page" | "thread"; |
| 397 | | /** |
| 398 | | * How the metadata above the content behaves: `full` keeps every row pinned; `collapse` |
| 399 | | * folds the whole of it into the top row once the active tab scrolls, and unfolds at the |
| 400 | | * top — the chrome spends its height on what is being read. |
| 401 | | */ |
| 402 | | chromeVariant?: "full" | "collapse"; |
| 403 | 402 | /** |
| 404 | 403 | * The open thread's composer. Beside the thread whose own pull request this is, hand-offs |
| 405 | 404 | * land here instead of opening a new thread — the branch is already under the reader's feet. |
| ... | ... | @@ -436,27 +435,16 @@ export function PullRequestDetailPanel({ |
| 436 | 435 | ); |
| 437 | 436 | }, [tab]); |
| 438 | 437 | const [chromeCondensed, setChromeCondensed] = useState(false); |
| 439 | | // Each tab remembers whether its chrome was condensed. Only the active tab can emit scroll |
| 440 | | // events, so the capture handler always writes the active tab's entry — and a tab switch |
| 441 | | // reads the destination's memory instead of inheriting the tab being left. A tab too short |
| 442 | | // to scroll remembers "expanded", which is what keeps it from being stranded under a chrome |
| 443 | | // it has no scrollbar to reopen. |
| 438 | // Each mounted tab remembers its own scroll chrome; short tabs cannot scroll to reopen it. |
| 444 | 439 | const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({}); |
| 445 | 440 | useEffect(() => { |
| 446 | 441 | setChromeCondensed(chromeStateByTab.current[tab] ?? false); |
| 447 | 442 | }, [tab]); |
| 448 | | const condensed = chromeVariant === "collapse" && chromeCondensed; |
| 449 | | // Collapsing removes the fold's height from the chrome, which would otherwise hand that |
| 450 | | // height to the scrollport and leap the content up by it mid-scroll. The cure is exact |
| 451 | | // compensation: collapse only once the reader has scrolled at least the fold's height, |
| 452 | | // then give that height back to `scrollTop` before the next paint — the content under |
| 453 | | // their eyes does not move, and the collapse itself is the only thing that changes. |
| 443 | const condensed = chromeCondensed; |
| 454 | 444 | const scrollerRef = useRef<HTMLElement | null>(null); |
| 455 | 445 | const foldRef = useRef<HTMLDivElement | null>(null); |
| 456 | | // The condensed chrome's second row opens as the fold closes, so the height the scrollport |
| 457 | | // gains is the fold's minus this row's. Measured the same way the fold is: `scrollHeight` |
| 458 | | // through a zero track reads its natural height in either state. |
| 459 | 446 | const condensedRowRef = useRef<HTMLDivElement | null>(null); |
| 447 | // Refund after the fold commits so the content under the reader does not jump with its height. |
| 460 | 448 | const compensationRef = useRef<number | null>(null); |
| 461 | 449 | useLayoutEffect(() => { |
| 462 | 450 | if (compensationRef.current === null) return; |
| ... | ... | @@ -478,7 +466,6 @@ export function PullRequestDetailPanel({ |
| 478 | 466 | target: "branch name", |
| 479 | 467 | timeout: 1600, |
| 480 | 468 | }); |
| 481 | | |
| 482 | 469 | // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be |
| 483 | 470 | // clicked, so a reader who does click it lands on a chunk already in the module cache. |
| 484 | 471 | useEffect(() => { |
| ... | ... | @@ -517,6 +504,23 @@ export function PullRequestDetailPanel({ |
| 517 | 504 | }, |
| 518 | 505 | [activity, coreDetail], |
| 519 | 506 | ); |
| 507 | const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url); |
| 508 | const branchRefsQuery = useEnvironmentQuery( |
| 509 | detail === null |
| 510 | ? null |
| 511 | : vcsEnvironment.listRefs({ |
| 512 | environmentId, |
| 513 | input: { |
| 514 | cwd: detail.workspaceRoot, |
| 515 | includeMatchingRemoteRefs: true, |
| 516 | // listRefs keeps the current ref first and a known default second. |
| 517 | limit: 2, |
| 518 | }, |
| 519 | }), |
| 520 | ); |
| 521 | const isStackedPullRequest = |
| 522 | detail !== null && |
| 523 | isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []); |
| 520 | 524 | const activityPending = activityQuery.isPending && activity === null; |
| 521 | 525 | const activityError = activity === null ? activityQuery.error : null; |
| 522 | 526 | const refreshDetail = useCallback(() => { |
| ... | ... | @@ -1046,17 +1050,17 @@ export function PullRequestDetailPanel({ |
| 1046 | 1050 | const can = (action: PullRequestAction) => |
| 1047 | 1051 | detail?.capabilities.actions.includes(action) === true && |
| 1048 | 1052 | detail.viewerPermissions.actions.includes(action); |
| 1049 | | // One live action holds the slot. A conflicting change cannot be merged now, so the slot goes |
| 1050 | | // to the thing that would help instead of a Merge button that only ever says no. |
| 1053 | // One live action holds the slot. Conflicts take priority because every other completion action |
| 1054 | // depends on resolving them first, even for a reader who cannot merge on the host themselves. |
| 1051 | 1055 | const primaryAction = |
| 1052 | 1056 | detail === null || detail.state !== "open" |
| 1053 | 1057 | ? null |
| 1054 | | : detail.isDraft && can("ready") |
| 1055 | | ? "ready" |
| 1056 | | : !can("merge") |
| 1057 | | ? null |
| 1058 | | : conflicting |
| 1059 | | ? "resolve" |
| 1058 | : conflicting |
| 1059 | ? "resolve" |
| 1060 | : detail.isDraft && can("ready") |
| 1061 | ? "ready" |
| 1062 | : !can("merge") |
| 1063 | ? null |
| 1060 | 1064 | : allowedMergeMethods.length > 0 |
| 1061 | 1065 | ? "merge" |
| 1062 | 1066 | : null; |
| ... | ... | @@ -1081,7 +1085,7 @@ export function PullRequestDetailPanel({ |
| 1081 | 1085 | !conflicting && |
| 1082 | 1086 | allowedMergeMethods.length > 1; |
| 1083 | 1087 | // The pull request number carries this state in the overview and the right-panel tab mirrors |
| 1084 | | // it. Conflicts keep their own row below: an open pull request remains green there. |
| 1088 | // it. The conflict action is separate from this state: an open pull request remains green. |
| 1085 | 1089 | const statePresentation = detail |
| 1086 | 1090 | ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) |
| 1087 | 1091 | : null; |
| ... | ... | @@ -1101,34 +1105,47 @@ export function PullRequestDetailPanel({ |
| 1101 | 1105 | ).length |
| 1102 | 1106 | : 0; |
| 1103 | 1107 | |
| 1108 | if (detailQuery.isPending && !detail) { |
| 1109 | return <PullRequestDetailGhost />; |
| 1110 | } |
| 1111 | |
| 1104 | 1112 | return ( |
| 1105 | 1113 | <div className="flex h-full min-h-0 w-full flex-col bg-background"> |
| 1106 | | {/* The top row's geometry never changes: both of its states occupy the same stacked |
| 1107 | | cell and crossfade, so the actions on the right have one home whatever the chrome |
| 1108 | | is doing below. The fold and this fade share one 200ms clock. */} |
| 1109 | 1114 | <div className="grid shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60"> |
| 1110 | | {/* The fixed height lives on the two top-row cells — not the grid, whose later rows |
| 1111 | | are the fold — so the actions have one immovable home in both states. */} |
| 1112 | | <div className="ml-4 grid h-11 min-w-0 items-center"> |
| 1115 | <div className="ml-4 grid h-7 min-w-0 items-center"> |
| 1113 | 1116 | <div |
| 1114 | 1117 | aria-hidden={condensed} |
| 1115 | 1118 | inert={condensed} |
| 1116 | 1119 | className={cn( |
| 1117 | | "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-opacity sm:text-xs motion-reduce:transition-none", |
| 1118 | | // Sequenced, not simultaneous: the leaving layer clears quickly before the |
| 1119 | | // arriving one lands, so no frame shows both texts superimposed at half opacity. |
| 1120 | "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", |
| 1120 | 1121 | condensed |
| 1121 | | ? "pointer-events-none opacity-0 duration-100" |
| 1122 | | : "opacity-100 delay-75 duration-150", |
| 1122 | ? "pointer-events-none -translate-y-1 opacity-0 duration-100" |
| 1123 | : "translate-y-0 opacity-100 delay-50 duration-150", |
| 1123 | 1124 | )} |
| 1124 | 1125 | > |
| 1125 | 1126 | {detail && statePresentation ? ( |
| 1126 | 1127 | <> |
| 1127 | 1128 | <Tooltip> |
| 1128 | 1129 | <TooltipTrigger |
| 1129 | | render={<span className="min-w-0 truncate">{detail.repository}</span>} |
| 1130 | render={ |
| 1131 | repositoryUrl ? ( |
| 1132 | <button |
| 1133 | type="button" |
| 1134 | onClick={() => void readLocalApi()?.shell.openExternal(repositoryUrl)} |
| 1135 | className="min-w-0 cursor-pointer truncate text-left font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" |
| 1136 | > |
| 1137 | {detail.repository} |
| 1138 | </button> |
| 1139 | ) : ( |
| 1140 | <span className="min-w-0 truncate font-medium text-muted-foreground"> |
| 1141 | {detail.repository} |
| 1142 | </span> |
| 1143 | ) |
| 1144 | } |
| 1130 | 1145 | /> |
| 1131 | | <TooltipPopup side="top">{detail.repository}</TooltipPopup> |
| 1146 | <TooltipPopup side="top"> |
| 1147 | {repositoryUrl ? `Open ${detail.repository} repository` : detail.repository} |
| 1148 | </TooltipPopup> |
| 1132 | 1149 | </Tooltip> |
| 1133 | 1150 | <Tooltip> |
| 1134 | 1151 | <TooltipTrigger |
| ... | ... | @@ -1156,10 +1173,10 @@ export function PullRequestDetailPanel({ |
| 1156 | 1173 | aria-hidden={!condensed} |
| 1157 | 1174 | inert={!condensed} |
| 1158 | 1175 | className={cn( |
| 1159 | | "col-start-1 row-start-1 flex min-w-0 items-center gap-1.5 text-sm transition-opacity sm:text-xs motion-reduce:transition-none", |
| 1176 | "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", |
| 1160 | 1177 | condensed |
| 1161 | | ? "opacity-100 delay-75 duration-150" |
| 1162 | | : "pointer-events-none opacity-0 duration-100", |
| 1178 | ? "translate-y-0 opacity-100 delay-50 duration-150" |
| 1179 | : "pointer-events-none translate-y-1 opacity-0 duration-100", |
| 1163 | 1180 | )} |
| 1164 | 1181 | > |
| 1165 | 1182 | {detail && statePresentation ? ( |
| ... | ... | @@ -1194,29 +1211,103 @@ export function PullRequestDetailPanel({ |
| 1194 | 1211 | /> |
| 1195 | 1212 | <TooltipPopup side="top">{detail.title}</TooltipPopup> |
| 1196 | 1213 | </Tooltip> |
| 1197 | | {conflicting ? ( |
| 1198 | | <Badge |
| 1199 | | variant="error" |
| 1200 | | className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px] text-destructive" |
| 1201 | | > |
| 1202 | | <TriangleAlertIcon className="size-3" /> |
| 1203 | | Conflicts |
| 1204 | | </Badge> |
| 1205 | | ) : checksSummary ? ( |
| 1206 | | <span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground"> |
| 1207 | | {detail && checksState !== null ? ( |
| 1208 | | <PullRequestChecksPopover checks={detail.checks} checksState={checksState} /> |
| 1209 | | ) : null} |
| 1210 | | {checksSummary} |
| 1211 | | </span> |
| 1212 | | ) : null} |
| 1213 | 1214 | </> |
| 1214 | 1215 | ) : null} |
| 1215 | 1216 | </div> |
| 1216 | 1217 | </div> |
| 1217 | | <div className="mr-4 flex h-11 min-w-0 flex-nowrap items-center justify-end gap-1"> |
| 1218 | <div className="mr-4 flex h-7 min-w-0 flex-nowrap items-center justify-end gap-1"> |
| 1218 | 1219 | {detail ? ( |
| 1219 | 1220 | <> |
| 1221 | {/* Checking a pull request out is the reason to open one here at all, so it is a |
| 1222 | button of its own rather than a side effect of asking an agent for something. |
| 1223 | It asks where, because the two answers are not interchangeable: one leaves your |
| 1224 | work where it is, the other moves the repository you are standing in. Only on |
| 1225 | the page: beside a thread the branch is already checked out right there. */} |
| 1226 | {context === "page" ? ( |
| 1227 | <Menu> |
| 1228 | <MenuTrigger |
| 1229 | disabled={handoff !== null} |
| 1230 | render={ |
| 1231 | <Button size="xs" variant="outline"> |
| 1232 | <GitBranchIcon aria-hidden className="size-3.5" /> |
| 1233 | {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} |
| 1234 | <ChevronDownIcon aria-hidden className="size-3.5 text-muted-foreground" /> |
| 1235 | </Button> |
| 1236 | } |
| 1237 | /> |
| 1238 | <MenuPopup align="end" side="bottom" className="min-w-72"> |
| 1239 | <MenuItem onClick={() => startCheckout("worktree")}> |
| 1240 | <GitBranchIcon className="mt-0.5 size-3.5 shrink-0 self-start" /> |
| 1241 | <span className="flex min-w-0 flex-col"> |
| 1242 | <span>In a separate worktree</span> |
| 1243 | <span className="text-xs text-muted-foreground"> |
| 1244 | Its own folder and thread. Nothing you have open moves. |
| 1245 | </span> |
| 1246 | </span> |
| 1247 | </MenuItem> |
| 1248 | <MenuItem onClick={() => startCheckout("local")}> |
| 1249 | <FolderGit2Icon className="mt-0.5 size-3.5 shrink-0 self-start" /> |
| 1250 | <span className="flex min-w-0 flex-col"> |
| 1251 | <span>In this repository</span> |
| 1252 | <span className="text-xs text-muted-foreground"> |
| 1253 | Switches the branch you are working in, like `gh pr checkout`. |
| 1254 | </span> |
| 1255 | </span> |
| 1256 | </MenuItem> |
| 1257 | {pickableEnvironments.length > 0 ? ( |
| 1258 | <ActOnEnvironmentPicker |
| 1259 | environments={pickableEnvironments} |
| 1260 | value={actingEnvironmentId} |
| 1261 | onChange={(next) => setActingScope({ pullRequestKey, environmentId: next })} |
| 1262 | disabled={handoff !== null} |
| 1263 | /> |
| 1264 | ) : null} |
| 1265 | </MenuPopup> |
| 1266 | </Menu> |
| 1267 | ) : null} |
| 1268 | {/* Said where the Merge button is, because it is the answer to why nobody has |
| 1269 | pressed it: the merge is already asked for, and the host is holding it. */} |
| 1270 | {autoMergeArmed ? ( |
| 1271 | <Tooltip> |
| 1272 | <TooltipTrigger |
| 1273 | render={ |
| 1274 | <Badge |
| 1275 | variant="info" |
| 1276 | className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px]" |
| 1277 | > |
| 1278 | <GitMergeIcon aria-hidden className="size-3" /> |
| 1279 | Auto-merge |
| 1280 | </Badge> |
| 1281 | } |
| 1282 | /> |
| 1283 | <TooltipPopup side="top"> |
| 1284 | The host will merge this on its own once its requirements are met |
| 1285 | </TooltipPopup> |
| 1286 | </Tooltip> |
| 1287 | ) : null} |
| 1288 | {primaryAction === "resolve" ? ( |
| 1289 | <Button |
| 1290 | size="xs" |
| 1291 | variant="destructive-outline" |
| 1292 | disabled={handoff !== null} |
| 1293 | onClick={startResolveConflicts} |
| 1294 | > |
| 1295 | <TriangleAlertIcon className="size-3.5" /> |
| 1296 | {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} |
| 1297 | </Button> |
| 1298 | ) : primaryAction === "ready" ? ( |
| 1299 | <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}> |
| 1300 | Ready for review |
| 1301 | </Button> |
| 1302 | ) : primaryAction === "merge" ? ( |
| 1303 | <Button |
| 1304 | size="xs" |
| 1305 | disabled={actionPending} |
| 1306 | onClick={() => setConfirmation({ open: true, action: "merge" })} |
| 1307 | > |
| 1308 | {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel} |
| 1309 | </Button> |
| 1310 | ) : null} |
| 1220 | 1311 | <Menu> |
| 1221 | 1312 | <MenuTrigger |
| 1222 | 1313 | render={ |
| ... | ... | @@ -1361,13 +1452,6 @@ export function PullRequestDetailPanel({ |
| 1361 | 1452 | <LinkIcon className="size-3.5" /> |
| 1362 | 1453 | Copy link |
| 1363 | 1454 | </MenuItem> |
| 1364 | | {/* Only where the button row could not take it, so it is never offered twice. */} |
| 1365 | | {conflicting && primaryAction !== "resolve" ? ( |
| 1366 | | <MenuItem disabled={handoff !== null} onClick={startResolveConflicts}> |
| 1367 | | <GitMergeIcon className="size-3.5" /> |
| 1368 | | {handoff === "conflicts" ? "Preparing..." : handoffLabels.resolveConflicts} |
| 1369 | | </MenuItem> |
| 1370 | | ) : null} |
| 1371 | 1455 | {detail.state === "open" && can("close") ? ( |
| 1372 | 1456 | <> |
| 1373 | 1457 | <MenuSeparator /> |
| ... | ... | @@ -1391,92 +1475,6 @@ export function PullRequestDetailPanel({ |
| 1391 | 1475 | ) : null} |
| 1392 | 1476 | </MenuPopup> |
| 1393 | 1477 | </Menu> |
| 1394 | | {/* Checking a pull request out is the reason to open one here at all, so it is a |
| 1395 | | button of its own rather than a side effect of asking an agent for something. |
| 1396 | | It asks where, because the two answers are not interchangeable: one leaves your |
| 1397 | | work where it is, the other moves the repository you are standing in. Only on |
| 1398 | | the page: beside a thread the branch is already checked out right there. */} |
| 1399 | | {context === "page" ? ( |
| 1400 | | <Menu> |
| 1401 | | <MenuTrigger |
| 1402 | | disabled={handoff !== null} |
| 1403 | | render={ |
| 1404 | | <Button size="xs" variant="outline"> |
| 1405 | | {handoff?.startsWith("checkout") ? ( |
| 1406 | | "Checking out..." |
| 1407 | | ) : ( |
| 1408 | | <> |
| 1409 | | <GitBranchIcon className="size-3" /> |
| 1410 | | Check out |
| 1411 | | <ChevronDownIcon className="size-3 text-muted-foreground" /> |
| 1412 | | </> |
| 1413 | | )} |
| 1414 | | </Button> |
| 1415 | | } |
| 1416 | | /> |
| 1417 | | <MenuPopup align="end" side="bottom" className="min-w-72"> |
| 1418 | | <MenuItem onClick={() => startCheckout("worktree")}> |
| 1419 | | <GitBranchIcon className="mt-0.5 size-3.5 shrink-0 self-start" /> |
| 1420 | | <span className="flex min-w-0 flex-col"> |
| 1421 | | <span>In a separate worktree</span> |
| 1422 | | <span className="text-xs text-muted-foreground"> |
| 1423 | | Its own folder and thread. Nothing you have open moves. |
| 1424 | | </span> |
| 1425 | | </span> |
| 1426 | | </MenuItem> |
| 1427 | | <MenuItem onClick={() => startCheckout("local")}> |
| 1428 | | <FolderGit2Icon className="mt-0.5 size-3.5 shrink-0 self-start" /> |
| 1429 | | <span className="flex min-w-0 flex-col"> |
| 1430 | | <span>In this repository</span> |
| 1431 | | <span className="text-xs text-muted-foreground"> |
| 1432 | | Switches the branch you are working in, like `gh pr checkout`. |
| 1433 | | </span> |
| 1434 | | </span> |
| 1435 | | </MenuItem> |
| 1436 | | {pickableEnvironments.length > 0 ? ( |
| 1437 | | <ActOnEnvironmentPicker |
| 1438 | | environments={pickableEnvironments} |
| 1439 | | value={actingEnvironmentId} |
| 1440 | | onChange={(next) => setActingScope({ pullRequestKey, environmentId: next })} |
| 1441 | | disabled={handoff !== null} |
| 1442 | | /> |
| 1443 | | ) : null} |
| 1444 | | </MenuPopup> |
| 1445 | | </Menu> |
| 1446 | | ) : null} |
| 1447 | | {/* Said where the Merge button is, because it is the answer to why nobody has |
| 1448 | | pressed it: the merge is already asked for, and the host is holding it. */} |
| 1449 | | {autoMergeArmed ? ( |
| 1450 | | <Tooltip> |
| 1451 | | <TooltipTrigger |
| 1452 | | render={ |
| 1453 | | <Badge |
| 1454 | | variant="info" |
| 1455 | | className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px]" |
| 1456 | | > |
| 1457 | | <GitMergeIcon aria-hidden className="size-3" /> |
| 1458 | | Auto-merge |
| 1459 | | </Badge> |
| 1460 | | } |
| 1461 | | /> |
| 1462 | | <TooltipPopup side="top"> |
| 1463 | | The host will merge this on its own once its requirements are met |
| 1464 | | </TooltipPopup> |
| 1465 | | </Tooltip> |
| 1466 | | ) : null} |
| 1467 | | {primaryAction === "ready" ? ( |
| 1468 | | <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}> |
| 1469 | | Ready for review |
| 1470 | | </Button> |
| 1471 | | ) : primaryAction === "merge" ? ( |
| 1472 | | <Button |
| 1473 | | size="xs" |
| 1474 | | disabled={actionPending} |
| 1475 | | onClick={() => setConfirmation({ open: true, action: "merge" })} |
| 1476 | | > |
| 1477 | | {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel} |
| 1478 | | </Button> |
| 1479 | | ) : null} |
| 1480 | 1478 | </> |
| 1481 | 1479 | ) : null} |
| 1482 | 1480 | {onClose ? ( |
| ... | ... | @@ -1491,14 +1489,9 @@ export function PullRequestDetailPanel({ |
| 1491 | 1489 | ) : null} |
| 1492 | 1490 | </div> |
| 1493 | 1491 | |
| 1494 | | {/* The condensed chrome's second row: the tabs that the closing fold takes with it, |
| 1495 | | and compact copies of the branch pair and diff stat so they stay in sight while |
| 1496 | | the full rows are folded away. Same zero-track mechanism as the fold, inverted. */} |
| 1497 | 1492 | <div |
| 1498 | 1493 | className={cn( |
| 1499 | 1494 | "col-span-2 grid", |
| 1500 | | // Inverse of the fold's track: closing eases while the fold above eases open, |
| 1501 | | // so the chrome trades rows in one motion instead of two jumps. |
| 1502 | 1495 | condensed |
| 1503 | 1496 | ? "grid-rows-[1fr]" |
| 1504 | 1497 | : "grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none", |
| ... | ... | @@ -1507,84 +1500,103 @@ export function PullRequestDetailPanel({ |
| 1507 | 1500 | <div |
| 1508 | 1501 | ref={condensedRowRef} |
| 1509 | 1502 | className={cn( |
| 1510 | | "min-h-0 overflow-hidden", |
| 1503 | "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none", |
| 1511 | 1504 | condensed |
| 1512 | | ? "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none" |
| 1513 | | : "opacity-0", |
| 1505 | ? "translate-y-0 opacity-100 delay-50" |
| 1506 | : "translate-y-1 opacity-0 duration-100", |
| 1514 | 1507 | )} |
| 1515 | 1508 | inert={!condensed} |
| 1516 | 1509 | > |
| 1517 | 1510 | {detail ? ( |
| 1518 | | <div className="flex min-w-0 items-center gap-1 px-4 pb-2"> |
| 1519 | | <nav aria-label="Pull request tabs" className="flex shrink-0 items-center gap-0.5"> |
| 1520 | | {visibleTabs.map((item) => ( |
| 1521 | | <button |
| 1522 | | key={item.value} |
| 1523 | | type="button" |
| 1524 | | tabIndex={condensed ? 0 : -1} |
| 1525 | | aria-pressed={tab === item.value} |
| 1526 | | onClick={() => setTab(item.value)} |
| 1527 | | className={cn( |
| 1528 | | "rounded-md px-2 py-1 text-[11px] transition-colors", |
| 1529 | | tab === item.value |
| 1530 | | ? "bg-accent text-foreground" |
| 1531 | | : "text-muted-foreground hover:text-foreground", |
| 1532 | | )} |
| 1511 | <div className="col-span-2 min-w-0 px-4 pb-2 pt-1"> |
| 1512 | <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground"> |
| 1513 | <span className="flex min-w-0 shrink items-center gap-1.5 overflow-hidden text-xs text-muted-foreground"> |
| 1514 | <Tooltip> |
| 1515 | <TooltipTrigger |
| 1516 | render={ |
| 1517 | <span |
| 1518 | className="shrink-0 rounded-full" |
| 1519 | aria-label={detail.author?.login ?? "ghost"} |
| 1520 | /> |
| 1521 | } |
| 1522 | > |
| 1523 | <PullRequestActorAvatar actor={detail.author} /> |
| 1524 | </TooltipTrigger> |
| 1525 | <TooltipPopup side="top">{detail.author?.login ?? "ghost"}</TooltipPopup> |
| 1526 | </Tooltip> |
| 1527 | <span className="shrink-0">{formatRelativeTimeLabel(detail.updatedAt)}</span> |
| 1528 | </span> |
| 1529 | <span aria-hidden className="h-3 w-px shrink-0 bg-border/70" /> |
| 1530 | <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-[11px] text-muted-foreground/65"> |
| 1531 | <Tooltip> |
| 1532 | <TooltipTrigger |
| 1533 | render={ |
| 1534 | <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1"> |
| 1535 | {isStackedPullRequest ? ( |
| 1536 | <LayersIcon |
| 1537 | aria-label="Stacked pull request" |
| 1538 | className="size-3 shrink-0" |
| 1539 | /> |
| 1540 | ) : null} |
| 1541 | <code className="min-w-0 truncate">{detail.baseBranch}</code> |
| 1542 | </span> |
| 1543 | } |
| 1544 | /> |
| 1545 | <TooltipPopup side="top"> |
| 1546 | {isStackedPullRequest |
| 1547 | ? `Stacked on ${detail.baseBranch}` |
| 1548 | : detail.baseBranch} |
| 1549 | </TooltipPopup> |
| 1550 | </Tooltip> |
| 1551 | {freshness ? ( |
| 1552 | <PullRequestBaseFreshnessWarning |
| 1553 | baseBranch={detail.baseBranch} |
| 1554 | freshness={freshness} |
| 1555 | pending={actionPending} |
| 1556 | onUpdate={(method) => void perform("update-branch", undefined, method)} |
| 1557 | iconClassName="size-3" |
| 1558 | /> |
| 1559 | ) : null} |
| 1560 | <ArrowLeftIcon |
| 1561 | aria-label="receives changes from" |
| 1562 | className="size-3 shrink-0 opacity-60" |
| 1563 | /> |
| 1564 | <Tooltip> |
| 1565 | <TooltipTrigger |
| 1566 | render={ |
| 1567 | <code className="min-w-0 flex-1 truncate">{detail.headBranch}</code> |
| 1568 | } |
| 1569 | /> |
| 1570 | <TooltipPopup side="top">{detail.headBranch}</TooltipPopup> |
| 1571 | </Tooltip> |
| 1572 | </span> |
| 1573 | <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2 text-[11px]"> |
| 1574 | <span |
| 1575 | className="inline-flex items-center gap-1 tabular-nums" |
| 1576 | aria-label={`${detail.changedFiles.toLocaleString()} changed ${ |
| 1577 | detail.changedFiles === 1 ? "file" : "files" |
| 1578 | }`} |
| 1533 | 1579 | > |
| 1534 | | {item.label} |
| 1535 | | </button> |
| 1536 | | ))} |
| 1537 | | </nav> |
| 1538 | | <span className="ml-auto inline-flex min-w-0 shrink items-center gap-1 font-mono text-[11px] text-muted-foreground"> |
| 1539 | | <Tooltip> |
| 1540 | | <TooltipTrigger render={<span className="truncate" />}> |
| 1541 | | {detail.baseBranch} |
| 1542 | | </TooltipTrigger> |
| 1543 | | <TooltipPopup side="top">{`${detail.baseBranch} ← ${detail.headBranch}`}</TooltipPopup> |
| 1544 | | </Tooltip> |
| 1545 | | {freshness ? ( |
| 1546 | | <PullRequestBaseFreshnessWarning |
| 1547 | | baseBranch={detail.baseBranch} |
| 1548 | | freshness={freshness} |
| 1549 | | pending={actionPending} |
| 1550 | | onUpdate={(method) => void perform("update-branch", undefined, method)} |
| 1551 | | iconClassName="size-3" |
| 1580 | <FileDiffIcon aria-hidden className="size-3" /> |
| 1581 | {detail.changedFiles.toLocaleString()} |
| 1582 | </span> |
| 1583 | <PullRequestDiffStat |
| 1584 | additions={detail.additions} |
| 1585 | deletions={detail.deletions} |
| 1586 | className="shrink-0 font-mono text-[11px]" |
| 1552 | 1587 | /> |
| 1553 | | ) : null} |
| 1554 | | <ArrowLeftIcon aria-label="receives changes from" className="size-3 shrink-0" /> |
| 1555 | | <Tooltip> |
| 1556 | | <TooltipTrigger render={<span className="truncate" />}> |
| 1557 | | {detail.headBranch} |
| 1558 | | </TooltipTrigger> |
| 1559 | | <TooltipPopup side="top">{`${detail.baseBranch} ← ${detail.headBranch}`}</TooltipPopup> |
| 1560 | | </Tooltip> |
| 1561 | | </span> |
| 1562 | | <span className="ml-2 inline-flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground"> |
| 1563 | | <span className="inline-flex items-center gap-1 tabular-nums"> |
| 1564 | | <FileDiffIcon className="size-3" /> |
| 1565 | | {detail.changedFiles.toLocaleString()} |
| 1566 | 1588 | </span> |
| 1567 | | <PullRequestDiffStat |
| 1568 | | additions={detail.additions} |
| 1569 | | deletions={detail.deletions} |
| 1570 | | className="shrink-0 font-mono text-[11px]" |
| 1571 | | /> |
| 1572 | | </span> |
| 1589 | </div> |
| 1573 | 1590 | </div> |
| 1574 | 1591 | ) : null} |
| 1575 | 1592 | </div> |
| 1576 | 1593 | </div> |
| 1577 | 1594 | |
| 1578 | | {/* Folding is a grid track going to zero: the rows below stay mounted, the track |
| 1579 | | animates closed over them, and `inert` takes the hidden controls out of the tab |
| 1580 | | order for as long as the chrome is condensed. */} |
| 1581 | 1595 | <div |
| 1582 | 1596 | className={cn( |
| 1583 | 1597 | "col-span-2 grid", |
| 1584 | | // Collapse is instant: it happens mid-scroll, and the compensation that keeps the |
| 1585 | | // content pinned would fight an animated track frame by frame. Reopening happens |
| 1586 | | // at the top with no compensation, so the fold can ease back in instead of |
| 1587 | | // snapping the content down. |
| 1598 | // Collapse before the scroll refund paints; only reopening eases back in. Animating |
| 1599 | // both directions makes the shrinking track fight the scrollTop correction. |
| 1588 | 1600 | condensed |
| 1589 | 1601 | ? "grid-rows-[0fr]" |
| 1590 | 1602 | : "grid-rows-[1fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none", |
| ... | ... | @@ -1592,19 +1604,16 @@ export function PullRequestDetailPanel({ |
| 1592 | 1604 | > |
| 1593 | 1605 | <div |
| 1594 | 1606 | ref={foldRef} |
| 1595 | | // One-way on purpose: appearing content eases in over ground the instant track |
| 1596 | | // already reserved; departing content cuts, because its ground is gone in the |
| 1597 | | // same frame and the scroll compensation reads it as scrolled past. |
| 1598 | 1607 | className={cn( |
| 1599 | | "min-h-0 overflow-hidden", |
| 1608 | "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none", |
| 1600 | 1609 | condensed |
| 1601 | | ? "opacity-0" |
| 1602 | | : "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none", |
| 1610 | ? "-translate-y-1 opacity-0 duration-100" |
| 1611 | : "translate-y-0 opacity-100 delay-50", |
| 1603 | 1612 | )} |
| 1604 | 1613 | inert={condensed} |
| 1605 | 1614 | > |
| 1606 | 1615 | {detail ? ( |
| 1607 | | <div className="col-span-2 mt-3 min-w-0 px-4 pb-4"> |
| 1616 | <div className="col-span-2 mt-1 min-w-0 px-4 pb-4"> |
| 1608 | 1617 | {titleDraft === null ? ( |
| 1609 | 1618 | <div className="group flex min-w-0 items-start gap-1"> |
| 1610 | 1619 | <h1 className="min-w-0 flex-1 text-base font-semibold leading-snug"> |
| ... | ... | @@ -1671,60 +1680,75 @@ export function PullRequestDetailPanel({ |
| 1671 | 1680 | </PullRequestMetaLine> |
| 1672 | 1681 | |
| 1673 | 1682 | <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground"> |
| 1674 | | <Tooltip> |
| 1675 | | <TooltipTrigger |
| 1676 | | render={ |
| 1677 | | <code className="min-w-0 max-w-48 shrink truncate rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground"> |
| 1678 | | {detail.baseBranch} |
| 1679 | | </code> |
| 1680 | | } |
| 1681 | | /> |
| 1682 | | <TooltipPopup side="top">{detail.baseBranch}</TooltipPopup> |
| 1683 | | </Tooltip> |
| 1684 | | {freshness ? ( |
| 1685 | | <PullRequestBaseFreshnessWarning |
| 1686 | | baseBranch={detail.baseBranch} |
| 1687 | | freshness={freshness} |
| 1688 | | pending={actionPending} |
| 1689 | | onUpdate={(method) => void perform("update-branch", undefined, method)} |
| 1683 | <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70"> |
| 1684 | <Tooltip> |
| 1685 | <TooltipTrigger |
| 1686 | render={ |
| 1687 | <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1"> |
| 1688 | {isStackedPullRequest ? ( |
| 1689 | <LayersIcon |
| 1690 | aria-label="Stacked pull request" |
| 1691 | className="size-3 shrink-0" |
| 1692 | /> |
| 1693 | ) : null} |
| 1694 | <code className="min-w-0 truncate">{detail.baseBranch}</code> |
| 1695 | </span> |
| 1696 | } |
| 1697 | /> |
| 1698 | <TooltipPopup side="top"> |
| 1699 | {isStackedPullRequest |
| 1700 | ? `Stacked on ${detail.baseBranch}` |
| 1701 | : detail.baseBranch} |
| 1702 | </TooltipPopup> |
| 1703 | </Tooltip> |
| 1704 | {freshness ? ( |
| 1705 | <PullRequestBaseFreshnessWarning |
| 1706 | baseBranch={detail.baseBranch} |
| 1707 | freshness={freshness} |
| 1708 | pending={actionPending} |
| 1709 | onUpdate={(method) => void perform("update-branch", undefined, method)} |
| 1710 | /> |
| 1711 | ) : null} |
| 1712 | <ArrowLeftIcon |
| 1713 | aria-label="receives changes from" |
| 1714 | className="size-3.5 shrink-0 opacity-60" |
| 1690 | 1715 | /> |
| 1691 | | ) : null} |
| 1692 | | <ArrowLeftIcon aria-label="receives changes from" className="size-4 shrink-0" /> |
| 1693 | | <Tooltip> |
| 1694 | | <TooltipTrigger |
| 1695 | | render={ |
| 1696 | | <button |
| 1697 | | type="button" |
| 1698 | | className="grid min-w-0 max-w-64 shrink cursor-pointer rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" |
| 1699 | | aria-label={ |
| 1700 | | isBranchCopied ? "Branch name copied" : "Copy pull request branch" |
| 1701 | | } |
| 1702 | | onClick={() => copyBranchToClipboard(detail.headBranch)} |
| 1703 | | /> |
| 1704 | | } |
| 1705 | | > |
| 1706 | | <code |
| 1707 | | className={cn( |
| 1708 | | "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none", |
| 1709 | | isBranchCopied ? "opacity-0" : "opacity-100", |
| 1710 | | )} |
| 1711 | | > |
| 1712 | | {detail.headBranch} |
| 1713 | | </code> |
| 1714 | | <span |
| 1715 | | aria-hidden="true" |
| 1716 | | className={cn( |
| 1717 | | "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none", |
| 1718 | | isBranchCopied ? "opacity-100" : "opacity-0", |
| 1719 | | )} |
| 1716 | <Tooltip> |
| 1717 | <TooltipTrigger |
| 1718 | render={ |
| 1719 | <button |
| 1720 | type="button" |
| 1721 | className="grid w-fit min-w-0 max-w-full shrink cursor-pointer rounded px-1 py-0.5 text-left outline-none transition-colors hover:bg-accent/45 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" |
| 1722 | aria-label={ |
| 1723 | isBranchCopied ? "Branch name copied" : "Copy pull request branch" |
| 1724 | } |
| 1725 | onClick={() => copyBranchToClipboard(detail.headBranch)} |
| 1726 | /> |
| 1727 | } |
| 1720 | 1728 | > |
| 1721 | | Copied |
| 1722 | | </span> |
| 1723 | | </TooltipTrigger> |
| 1724 | | <TooltipPopup side="top"> |
| 1725 | | {`${isBranchCopied ? "Copied" : "Copy pull request branch"}: ${detail.headBranch}`} |
| 1726 | | </TooltipPopup> |
| 1727 | | </Tooltip> |
| 1729 | <code |
| 1730 | className={cn( |
| 1731 | "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none", |
| 1732 | isBranchCopied ? "opacity-0" : "opacity-100", |
| 1733 | )} |
| 1734 | > |
| 1735 | {detail.headBranch} |
| 1736 | </code> |
| 1737 | <span |
| 1738 | aria-hidden="true" |
| 1739 | className={cn( |
| 1740 | "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none", |
| 1741 | isBranchCopied ? "opacity-100" : "opacity-0", |
| 1742 | )} |
| 1743 | > |
| 1744 | Copied |
| 1745 | </span> |
| 1746 | </TooltipTrigger> |
| 1747 | <TooltipPopup side="top"> |
| 1748 | {`${isBranchCopied ? "Copied" : "Copy pull request branch"}: ${detail.headBranch}`} |
| 1749 | </TooltipPopup> |
| 1750 | </Tooltip> |
| 1751 | </span> |
| 1728 | 1752 | <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2"> |
| 1729 | 1753 | <span className="inline-flex items-center gap-1.5 tabular-nums"> |
| 1730 | 1754 | <FileDiffIcon className="size-3.5" /> |
| ... | ... | @@ -1740,165 +1764,130 @@ export function PullRequestDetailPanel({ |
| 1740 | 1764 | </div> |
| 1741 | 1765 | </div> |
| 1742 | 1766 | ) : null} |
| 1767 | </div> |
| 1768 | </div> |
| 1743 | 1769 | |
| 1744 | | {detail && conflicting ? ( |
| 1745 | | <div className="col-span-2 flex items-center gap-1 px-4 pb-3"> |
| 1746 | | <Badge |
| 1747 | | variant="error" |
| 1748 | | className="h-auto gap-1.5 rounded-md px-3 py-1.5 text-xs text-destructive" |
| 1749 | | > |
| 1750 | | <TriangleAlertIcon className="size-3.5" /> |
| 1751 | | Merge conflicts |
| 1752 | | </Badge> |
| 1753 | | <Button |
| 1754 | | size="xs" |
| 1755 | | variant="ghost" |
| 1756 | | className="ml-auto text-destructive hover:bg-destructive/8 hover:text-destructive" |
| 1757 | | disabled={handoff !== null} |
| 1758 | | onClick={startResolveConflicts} |
| 1759 | | > |
| 1760 | | {handoff === "conflicts" ? "Preparing..." : handoffLabels.resolve} |
| 1761 | | <ArrowUpRightIcon className="size-3.5 text-destructive" /> |
| 1762 | | </Button> |
| 1763 | | </div> |
| 1764 | | ) : null} |
| 1765 | | |
| 1766 | | {detail ? ( |
| 1767 | | <nav |
| 1768 | | className="col-span-2 flex min-w-0 items-center gap-1 overflow-x-auto border-t border-border/60 px-4 py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" |
| 1769 | | aria-label="Pull request tabs" |
| 1770 | {detail ? ( |
| 1771 | <nav |
| 1772 | className="col-span-2 flex min-w-0 items-center gap-1 overflow-x-auto border-t border-border/60 px-4 py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" |
| 1773 | aria-label="Pull request tabs" |
| 1774 | > |
| 1775 | <ToggleGroup |
| 1776 | size="segmented" |
| 1777 | variant="segmented" |
| 1778 | value={[tab]} |
| 1779 | onValueChange={(next) => { |
| 1780 | const nextTab = visibleTabs.find((item) => item.value === next[0])?.value; |
| 1781 | if (nextTab) setTab(nextTab); |
| 1782 | }} |
| 1783 | > |
| 1784 | {visibleTabs.map((item) => ( |
| 1785 | <Toggle key={item.value} value={item.value}> |
| 1786 | {item.label} |
| 1787 | </Toggle> |
| 1788 | ))} |
| 1789 | </ToggleGroup> |
| 1790 | {tab === "summary" ? ( |
| 1791 | <span |
| 1792 | className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground" |
| 1793 | aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"} |
| 1770 | 1794 | > |
| 1771 | | {visibleTabs.map((item) => ( |
| 1772 | | <button |
| 1773 | | key={item.value} |
| 1774 | | type="button" |
| 1775 | | aria-pressed={tab === item.value} |
| 1776 | | onClick={() => setTab(item.value)} |
| 1777 | | className={cn( |
| 1778 | | "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs transition-colors", |
| 1779 | | tab === item.value |
| 1780 | | ? "bg-accent text-foreground" |
| 1781 | | : "text-muted-foreground hover:text-foreground", |
| 1782 | | )} |
| 1795 | {checksState !== null ? ( |
| 1796 | <PullRequestChecksPopover checks={detail.checks} checksState={checksState} /> |
| 1797 | ) : ( |
| 1798 | <CircleDotIcon aria-hidden className="size-3.5" /> |
| 1799 | )} |
| 1800 | {checksSummary} |
| 1801 | </span> |
| 1802 | ) : tab === "timeline" ? ( |
| 1803 | <div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground"> |
| 1804 | <PullRequestMetaLine |
| 1805 | className={cn( |
| 1806 | "whitespace-nowrap text-[11px] transition-opacity", |
| 1807 | (activityPending || activityError) && "opacity-35", |
| 1808 | )} |
| 1809 | > |
| 1810 | <span |
| 1811 | className="inline-flex items-center gap-1" |
| 1812 | aria-label={ |
| 1813 | activityError |
| 1814 | ? "Comments unavailable" |
| 1815 | : `${detail.commentCount.toLocaleString()} ${ |
| 1816 | detail.commentCount === 1 ? "comment" : "comments" |
| 1817 | }` |
| 1818 | } |
| 1783 | 1819 | > |
| 1784 | | {item.label} |
| 1785 | | </button> |
| 1786 | | ))} |
| 1787 | | {tab === "summary" ? ( |
| 1820 | <MessageSquareIcon aria-hidden className="size-3" /> |
| 1821 | {activityError |
| 1822 | ? "—" |
| 1823 | : activityPending |
| 1824 | ? "…" |
| 1825 | : detail.commentCount.toLocaleString()} |
| 1826 | </span> |
| 1788 | 1827 | <span |
| 1789 | | className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground" |
| 1790 | | aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"} |
| 1828 | className="inline-flex items-center gap-1" |
| 1829 | aria-label={ |
| 1830 | activityError |
| 1831 | ? "Commits unavailable" |
| 1832 | : `${detail.commits.length.toLocaleString()} ${ |
| 1833 | detail.commits.length === 1 ? "commit" : "commits" |
| 1834 | }` |
| 1835 | } |
| 1791 | 1836 | > |
| 1792 | | {/* The rollup icon opens the checks behind the summary; with none reported |
| 1793 | | there is nothing to open, so the plain glyph stays. */} |
| 1794 | | {detail && checksState !== null ? ( |
| 1795 | | <PullRequestChecksPopover checks={detail.checks} checksState={checksState} /> |
| 1796 | | ) : ( |
| 1797 | | <CircleDotIcon aria-hidden className="size-3.5" /> |
| 1798 | | )} |
| 1799 | | {checksSummary} |
| 1837 | <GitCommitHorizontalIcon aria-hidden className="size-3" /> |
| 1838 | {activityError |
| 1839 | ? "—" |
| 1840 | : activityPending |
| 1841 | ? "…" |
| 1842 | : detail.commits.length.toLocaleString()} |
| 1800 | 1843 | </span> |
| 1801 | | ) : tab === "timeline" ? ( |
| 1802 | | <div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground"> |
| 1803 | | <PullRequestMetaLine |
| 1844 | {approvalCount > 0 ? ( |
| 1845 | <span |
| 1804 | 1846 | className={cn( |
| 1805 | | "whitespace-nowrap text-[11px] transition-opacity", |
| 1806 | | (activityPending || activityError) && "opacity-35", |
| 1847 | "inline-flex items-center gap-1", |
| 1848 | pullRequestReviewOutcomeToneClassName("approved"), |
| 1807 | 1849 | )} |
| 1808 | 1850 | > |
| 1809 | | <span |
| 1810 | | className="inline-flex items-center gap-1" |
| 1811 | | aria-label={ |
| 1812 | | activityError |
| 1813 | | ? "Comments unavailable" |
| 1814 | | : `${detail.commentCount.toLocaleString()} ${ |
| 1815 | | detail.commentCount === 1 ? "comment" : "comments" |
| 1816 | | }` |
| 1817 | | } |
| 1818 | | > |
| 1819 | | <MessageSquareIcon aria-hidden className="size-3" /> |
| 1820 | | {activityError |
| 1821 | | ? "—" |
| 1822 | | : activityPending |
| 1823 | | ? "…" |
| 1824 | | : detail.commentCount.toLocaleString()} |
| 1825 | | </span> |
| 1826 | | <span |
| 1827 | | className="inline-flex items-center gap-1" |
| 1828 | | aria-label={ |
| 1829 | | activityError |
| 1830 | | ? "Commits unavailable" |
| 1831 | | : `${detail.commits.length.toLocaleString()} ${ |
| 1832 | | detail.commits.length === 1 ? "commit" : "commits" |
| 1833 | | }` |
| 1834 | | } |
| 1835 | | > |
| 1836 | | <GitCommitHorizontalIcon aria-hidden className="size-3" /> |
| 1837 | | {activityError |
| 1838 | | ? "—" |
| 1839 | | : activityPending |
| 1840 | | ? "…" |
| 1841 | | : detail.commits.length.toLocaleString()} |
| 1851 | <PullRequestReviewOutcomeIcon outcome="approved" className="size-3" /> |
| 1852 | {approvalCount.toLocaleString()} |
| 1853 | <span className="sr-only"> |
| 1854 | {approvalCount === 1 ? "approval" : "approvals"} |
| 1842 | 1855 | </span> |
| 1843 | | {/* Only once somebody has approved: a nought beside a tick would read as a |
| 1844 | | verdict of its own on a change nobody has looked at yet. */} |
| 1845 | | {approvalCount > 0 ? ( |
| 1846 | | <span |
| 1847 | | className={cn( |
| 1848 | | "inline-flex items-center gap-1", |
| 1849 | | pullRequestReviewOutcomeToneClassName("approved"), |
| 1850 | | )} |
| 1851 | | > |
| 1852 | | <PullRequestReviewOutcomeIcon outcome="approved" className="size-3" /> |
| 1853 | | {approvalCount.toLocaleString()} |
| 1854 | | {/* The icon is decorative and a name written onto a generic span is |
| 1855 | | not announced, so the bare number says what it counts in words. */} |
| 1856 | | <span className="sr-only"> |
| 1857 | | {approvalCount === 1 ? "approval" : "approvals"} |
| 1858 | | </span> |
| 1859 | | </span> |
| 1860 | | ) : null} |
| 1861 | | </PullRequestMetaLine> |
| 1862 | | <Button |
| 1863 | | size="xs" |
| 1864 | | variant="ghost" |
| 1865 | | className="h-7 px-2 text-[10px] text-muted-foreground" |
| 1866 | | aria-label={ |
| 1867 | | timelineOrder === "newest" |
| 1868 | | ? "Show oldest activity first" |
| 1869 | | : "Show newest activity first" |
| 1870 | | } |
| 1871 | | onClick={() => |
| 1872 | | setTimelineOrder((value) => (value === "newest" ? "oldest" : "newest")) |
| 1873 | | } |
| 1874 | | > |
| 1875 | | <ArrowDownUpIcon aria-hidden className="size-3" /> |
| 1876 | | {timelineOrder === "newest" ? "Newest first" : "Oldest first"} |
| 1877 | | </Button> |
| 1878 | | </div> |
| 1879 | | ) : null} |
| 1880 | | </nav> |
| 1856 | </span> |
| 1857 | ) : null} |
| 1858 | </PullRequestMetaLine> |
| 1859 | <Button |
| 1860 | size="xs" |
| 1861 | variant="ghost" |
| 1862 | className="h-7 px-2 text-[10px] text-muted-foreground" |
| 1863 | aria-label={ |
| 1864 | timelineOrder === "newest" |
| 1865 | ? "Show oldest activity first" |
| 1866 | : "Show newest activity first" |
| 1867 | } |
| 1868 | onClick={() => |
| 1869 | setTimelineOrder((value) => (value === "newest" ? "oldest" : "newest")) |
| 1870 | } |
| 1871 | > |
| 1872 | <ArrowDownUpIcon aria-hidden className="size-3" /> |
| 1873 | {timelineOrder === "newest" ? "Newest first" : "Oldest first"} |
| 1874 | </Button> |
| 1875 | </div> |
| 1881 | 1876 | ) : null} |
| 1882 | | </div> |
| 1883 | | </div> |
| 1877 | </nav> |
| 1878 | ) : null} |
| 1884 | 1879 | </div> |
| 1885 | 1880 | |
| 1886 | 1881 | <div |
| 1887 | 1882 | className="relative min-h-0 flex-1 overflow-hidden" |
| 1888 | | // Scroll does not bubble, but it captures: one listener hears every tab's own scroll |
| 1889 | | // container. Collapse past two line-heights, expand only back at the very top, so the |
| 1890 | | // boundary row cannot flap the chrome open and shut. |
| 1891 | 1883 | onScrollCapture={(event) => { |
| 1892 | | if (chromeVariant !== "collapse") return; |
| 1893 | 1884 | const scroller = event.target as HTMLElement; |
| 1894 | 1885 | scrollerRef.current = scroller; |
| 1895 | 1886 | const top = scroller.scrollTop; |
| 1896 | 1887 | setChromeCondensed((previous) => { |
| 1897 | 1888 | let next = previous; |
| 1898 | | // `scrollHeight` reads the fold's natural height whichever state the track is in. |
| 1899 | 1889 | const foldHeight = foldRef.current?.scrollHeight ?? 0; |
| 1900 | | // The chrome trades the fold for the condensed second row, so the height the |
| 1901 | | // scrollport actually gains is the difference between the two. |
| 1890 | // The condensed row remains mounted, so refund only the height that actually leaves. |
| 1902 | 1891 | const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0); |
| 1903 | 1892 | if (previous) { |
| 1904 | 1893 | // The hard top reopens the chrome with no refund: the reader asked for the top, |
| ... | ... | @@ -1916,17 +1905,7 @@ export function PullRequestDetailPanel({ |
| 1916 | 1905 | }); |
| 1917 | 1906 | }} |
| 1918 | 1907 | > |
| 1919 | | {detailQuery.isPending && !detail ? ( |
| 1920 | | // The ghost wears the shape of the tab being waited on, so switching tabs mid-load |
| 1921 | | // does not flash a summary outline under a timeline heading. |
| 1922 | | tab === "timeline" ? ( |
| 1923 | | <PullRequestTimelineGhost /> |
| 1924 | | ) : tab === "code" ? ( |
| 1925 | | <DiffPanelLoadingState label="Loading pull request diff..." /> |
| 1926 | | ) : ( |
| 1927 | | <PullRequestDetailGhost /> |
| 1928 | | ) |
| 1929 | | ) : detailQuery.error && !detail ? ( |
| 1908 | {detailQuery.error && !detail ? ( |
| 1930 | 1909 | <PullRequestsUnavailableState error={detailQuery.error} onRetry={refreshDetail} /> |
| 1931 | 1910 | ) : detail ? ( |
| 1932 | 1911 | <> |