authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-07 21:18:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 01:18:29-03:00
log843c593ab2dfe432abfba7b928292f3f8e9c7d44
tree2597de99ee9b35dffdb1e4b1102499d39e5ce1d8
parent6f83358ae6d85b5dee7d3280e645e0e9a0aeff62
signature Signed by PGP key B5690EEEBB952194

refactor(web): PR loading skeleton shares the detail panel's layout (#15583)


3 files changed, 412 insertions(+), 326 deletions(-)

apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx created+130
...@@ -0,0 +1,130 @@
1import { ArrowLeftIcon, FileDiffIcon } from "lucide-react";
2import type { ComponentProps, ReactNode } from "react";
3
4import { cn } from "~/lib/utils";
5
6import { Toggle, ToggleGroup } from "../ui/toggle-group";
7import { PullRequestMetaLine } from "./pullRequestPresentation";
8
9export function PullRequestDetailHeaderBody({
10 title,
11 author,
12 updated,
13 checkout,
14 base,
15 head,
16 files,
17 diffStat,
18}: {
19 title: ReactNode;
20 author: ReactNode;
21 updated: ReactNode;
22 checkout?: ReactNode;
23 base: ReactNode;
24 head: ReactNode;
25 files: ReactNode;
26 diffStat: ReactNode;
27}) {
28 return (
29 <div className="col-span-2 mt-1 min-w-0 px-4 pb-4">
30 {title}
31 <div className="mt-2 flex min-h-5 min-w-0 items-center gap-2 text-xs text-muted-foreground">
32 <PullRequestMetaLine className="min-w-0 whitespace-nowrap">
33 {author}
34 {updated}
35 </PullRequestMetaLine>
36 {checkout}
37 </div>
38 <div className="mt-4 flex min-h-5 min-w-0 items-center gap-2 text-xs text-muted-foreground">
39 <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70">
40 {base}
41 <ArrowLeftIcon
42 aria-label="receives changes from"
43 className="size-3.5 shrink-0 opacity-60"
44 />
45 {head}
46 </span>
47 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">
48 <span className="inline-flex min-w-16 items-center justify-end gap-1.5 tabular-nums">
49 <FileDiffIcon aria-hidden className="size-3.5" />
50 {files}
51 </span>
52 {diffStat}
53 </span>
54 </div>
55 </div>
56 );
57}
58
59export function PullRequestDetailTitleRow({ className, ...props }: ComponentProps<"div">) {
60 return (
61 <div className={cn("flex min-h-7 min-w-0 items-center sm:min-h-6", className)} {...props} />
62 );
63}
64
65export function PullRequestDetailTabBar<Tab extends string>({
66 tabs,
67 value,
68 onValueChange,
69 onTabIntent,
70 inert,
71 children,
72}: {
73 tabs: ReadonlyArray<{ readonly value: Tab; readonly label: string }>;
74 value: Tab;
75 onValueChange?: (tab: Tab) => void;
76 onTabIntent?: (tab: Tab) => void;
77 inert?: boolean;
78 children?: ReactNode;
79}) {
80 return (
81 <nav
82 className="col-span-2 flex min-w-0 flex-wrap items-center gap-2 border-t border-border/60 px-4 py-2"
83 aria-label="Pull request tabs"
84 inert={inert}
85 >
86 <ToggleGroup
87 className="shrink-0"
88 size="segmented"
89 variant="segmented"
90 value={[value]}
91 onValueChange={(next) => {
92 const nextTab = tabs.find((item) => item.value === next[0])?.value;
93 if (nextTab) onValueChange?.(nextTab);
94 }}
95 >
96 {tabs.map((tab) => (
97 <Toggle
98 key={tab.value}
99 value={tab.value}
100 onPointerEnter={onTabIntent ? () => onTabIntent(tab.value) : undefined}
101 onFocus={onTabIntent ? () => onTabIntent(tab.value) : undefined}
102 >
103 {tab.label}
104 </Toggle>
105 ))}
106 </ToggleGroup>
107 {children}
108 </nav>
109 );
110}
111
112export function PullRequestChecksStatusLine({
113 icon,
114 label,
115 className,
116 ...props
117}: Omit<ComponentProps<"span">, "children"> & { icon: ReactNode; label: ReactNode }) {
118 return (
119 <span
120 className={cn(
121 "ml-auto flex h-4 min-w-0 flex-1 flex-wrap content-start items-center justify-end gap-x-1.5 overflow-hidden text-xs",
122 className,
123 )}
124 {...props}
125 >
126 {icon}
127 <span className="whitespace-nowrap">{label}</span>
128 </span>
129 );
130}
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+194-219
...@@ -102,7 +102,6 @@ import { Badge } from "../ui/badge";...@@ -102,7 +102,6 @@ import { Badge } from "../ui/badge";
102import { Button } from "../ui/button";102import { Button } from "../ui/button";
103import { PullRequestEditButton } from "./PullRequestEditButton";103import { PullRequestEditButton } from "./PullRequestEditButton";
104import { Input } from "../ui/input";104import { Input } from "../ui/input";
105import { Toggle, ToggleGroup } from "../ui/toggle-group";
106import {105import {
107 Menu,106 Menu,
108 MenuItem,107 MenuItem,
...@@ -117,6 +116,12 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";...@@ -117,6 +116,12 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
117import { toastManager } from "../ui/toast";116import { toastManager } from "../ui/toast";
118import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";117import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
119import { MiddleTruncate } from "../ui/middle-truncate";118import { MiddleTruncate } from "../ui/middle-truncate";
119import {
120 PullRequestChecksStatusLine,
121 PullRequestDetailHeaderBody,
122 PullRequestDetailTabBar,
123 PullRequestDetailTitleRow,
124} from "./PullRequestDetailLayout";
120import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";125import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";
121import { PullRequestCopyableCode } from "./PullRequestCopyableCode";126import { PullRequestCopyableCode } from "./PullRequestCopyableCode";
122import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";127import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";
...@@ -2373,77 +2378,79 @@ export function PullRequestDetailPanel({...@@ -2373,77 +2378,79 @@ export function PullRequestDetailPanel({
2373 inert={condensed}2378 inert={condensed}
2374 >2379 >
2375 {detail ? (2380 {detail ? (
2376 <div className="col-span-2 mt-1 min-w-0 px-4 pb-4">2381 <PullRequestDetailHeaderBody
2377 {titleDraft === null ? (2382 title={
2378 <div className="group flex min-h-7 min-w-0 items-center gap-1 sm:min-h-6">2383 titleDraft === null ? (
2379 <Tooltip>2384 <PullRequestDetailTitleRow className="group gap-1">
2380 <TooltipTrigger2385 <Tooltip>
2381 render={2386 <TooltipTrigger
2382 <h1 className="min-w-0 flex-1 truncate text-base font-semibold leading-snug">2387 render={
2383 {detail.title}2388 <h1 className="min-w-0 flex-1 truncate text-base font-semibold leading-snug">
2384 </h1>2389 {detail.title}
2385 }2390 </h1>
2386 />2391 }
2387 <TooltipPopup side="top">{detail.title}</TooltipPopup>2392 />
2388 </Tooltip>2393 <TooltipPopup side="top">{detail.title}</TooltipPopup>
2389 {canEditPullRequestChangeRequest(detail) ? (2394 </Tooltip>
2390 <PullRequestEditButton2395 {canEditPullRequestChangeRequest(detail) ? (
2391 aria-label="Edit title"2396 <PullRequestEditButton
2392 onClick={() => setTitleScope({ pullRequestKey, text: detail.title })}2397 aria-label="Edit title"
2393 />2398 onClick={() => setTitleScope({ pullRequestKey, text: detail.title })}
2394 ) : null}2399 />
2395 </div>2400 ) : null}
2396 ) : (2401 </PullRequestDetailTitleRow>
2397 // A title is one line of text, not markdown, so it takes an input rather than2402 ) : (
2398 // the editor the description and the remarks share.2403 // A title is one line of text, not markdown, so it takes an input rather than
2399 <div className="space-y-2">2404 // the editor the description and the remarks share.
2400 <Input2405 <div className="space-y-2">
2401 autoFocus2406 <Input
2402 size="sm"2407 autoFocus
2403 disabled={titleSaving}2408 size="sm"
2404 value={titleDraft}
2405 aria-label="Pull request title"
2406 onChange={(event) =>
2407 setTitleScope({ pullRequestKey, text: event.target.value })
2408 }
2409 onKeyDown={(event) => {
2410 if (event.key === "Enter") {
2411 event.preventDefault();
2412 void saveTitle(titleDraft);
2413 } else if (event.key === "Escape") {
2414 event.preventDefault();
2415 setTitleScope(null);
2416 }
2417 }}
2418 />
2419 <div className="flex justify-end gap-2">
2420 <Button
2421 size="xs"
2422 variant="ghost"
2423 disabled={titleSaving}2409 disabled={titleSaving}
2424 onClick={() => setTitleScope(null)}2410 value={titleDraft}
2425 >2411 aria-label="Pull request title"
2426 Cancel2412 onChange={(event) =>
2427 </Button>2413 setTitleScope({ pullRequestKey, text: event.target.value })
2428 <Button
2429 size="xs"
2430 variant="outline"
2431 disabled={
2432 !canWriteSourceControl || titleSaving || titleDraft.trim().length === 0
2433 }2414 }
2434 onClick={() => void saveTitle(titleDraft)}2415 onKeyDown={(event) => {
2435 >2416 if (event.key === "Enter") {
2436 {titleSaving ? "Saving..." : "Save"}2417 event.preventDefault();
2437 </Button>2418 void saveTitle(titleDraft);
2419 } else if (event.key === "Escape") {
2420 event.preventDefault();
2421 setTitleScope(null);
2422 }
2423 }}
2424 />
2425 <div className="flex justify-end gap-2">
2426 <Button
2427 size="xs"
2428 variant="ghost"
2429 disabled={titleSaving}
2430 onClick={() => setTitleScope(null)}
2431 >
2432 Cancel
2433 </Button>
2434 <Button
2435 size="xs"
2436 variant="outline"
2437 disabled={
2438 !canWriteSourceControl || titleSaving || titleDraft.trim().length === 0
2439 }
2440 onClick={() => void saveTitle(titleDraft)}
2441 >
2442 {titleSaving ? "Saving..." : "Save"}
2443 </Button>
2444 </div>
2438 </div>2445 </div>
2439 </div>2446 )
2440 )}2447 }
2441 <div className="mt-2 flex min-h-5 min-w-0 items-center gap-2 text-xs text-muted-foreground">2448 author={
2442 <PullRequestMetaLine className="min-w-0 whitespace-nowrap">2449 <PullRequestActorLabel actor={detail.author} profileUrl={authorProfileUrl} />
2443 <PullRequestActorLabel actor={detail.author} profileUrl={authorProfileUrl} />2450 }
2444 <span>updated {formatRelativeTimeLabel(detail.updatedAt)}</span>2451 updated={<span>updated {formatRelativeTimeLabel(detail.updatedAt)}</span>}
2445 </PullRequestMetaLine>2452 checkout={
2446 {checkoutCommand ? (2453 checkoutCommand ? (
2447 <PullRequestCopyableCode2454 <PullRequestCopyableCode
2448 key={checkoutCommand}2455 key={checkoutCommand}
2449 value={checkoutCommand}2456 value={checkoutCommand}
...@@ -2454,159 +2461,127 @@ export function PullRequestDetailPanel({...@@ -2454,159 +2461,127 @@ export function PullRequestDetailPanel({
2454 tooltipSide="bottom"2461 tooltipSide="bottom"
2455 onError={onCheckoutCommandError}2462 onError={onCheckoutCommandError}
2456 />2463 />
2457 ) : null}2464 ) : null
2458 </div>2465 }
24592466 base={
2460 <div className="mt-4 flex min-h-5 min-w-0 items-center gap-2 text-xs text-muted-foreground">2467 /* An out-of-date base wears the warning on the branch name itself, so the
2461 <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70">
2462 {/* An out-of-date base wears the warning on the branch name itself, so the
2463 name is amber and pointing at either the name or the mark opens the way2468 name is amber and pointing at either the name or the mark opens the way
2464 out. Up to date, the name keeps its plain tooltip. */}2469 out. Up to date, the name keeps its plain tooltip. */
2465 {freshness ? (2470 freshness ? (
2466 <PullRequestBaseFreshnessWarning2471 <PullRequestBaseFreshnessWarning
2467 baseBranch={detail.baseBranch}2472 baseBranch={detail.baseBranch}
2468 freshness={freshness}2473 freshness={freshness}
2469 pending={actionPending}2474 pending={actionPending}
2470 onUpdate={(method) => void perform("update-branch", undefined, method)}2475 onUpdate={(method) => void perform("update-branch", undefined, method)}
2471 className="max-w-[40%]"2476 className="max-w-[40%]"
2472 >2477 >
2473 {isStackedPullRequest ? (2478 {isStackedPullRequest ? (
2474 <PullRequestGlyph.stack2479 <PullRequestGlyph.stack
2475 aria-label="Stacked pull request"2480 aria-label="Stacked pull request"
2476 className="size-3 shrink-0"2481 className="size-3 shrink-0"
2477 />
2478 ) : null}
2479 <code className="flex min-w-0">
2480 <MiddleTruncate value={detail.baseBranch} showTitle={false} />
2481 </code>
2482 </PullRequestBaseFreshnessWarning>
2483 ) : (
2484 <Tooltip>
2485 <TooltipTrigger
2486 render={
2487 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
2488 {isStackedPullRequest ? (
2489 <PullRequestGlyph.stack
2490 aria-label="Stacked pull request"
2491 className="size-3 shrink-0"
2492 />
2493 ) : null}
2494 <code className="flex min-w-0">
2495 <MiddleTruncate value={detail.baseBranch} showTitle={false} />
2496 </code>
2497 </span>
2498 }
2499 />2482 />
2500 <TooltipPopup side="top">2483 ) : null}
2501 {isStackedPullRequest2484 <code className="flex min-w-0">
2502 ? `Stacked on ${detail.baseBranch}`2485 <MiddleTruncate value={detail.baseBranch} showTitle={false} />
2503 : detail.baseBranch}2486 </code>
2504 </TooltipPopup>2487 </PullRequestBaseFreshnessWarning>
2505 </Tooltip>2488 ) : (
2506 )}2489 <Tooltip>
2507 <ArrowLeftIcon2490 <TooltipTrigger
2508 aria-label="receives changes from"2491 render={
2509 className="size-3.5 shrink-0 opacity-60"2492 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
2510 />2493 {isStackedPullRequest ? (
2511 <PullRequestCopyableCode2494 <PullRequestGlyph.stack
2512 key={detail.headBranch}2495 aria-label="Stacked pull request"
2513 value={detail.headBranch}2496 className="size-3 shrink-0"
2514 target="branch name"2497 />
2515 copyLabel="Copy pull request branch"2498 ) : null}
2516 copiedLabel="Branch name copied"2499 <code className="flex min-w-0">
2517 />2500 <MiddleTruncate value={detail.baseBranch} showTitle={false} />
2518 </span>2501 </code>
2519 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">2502 </span>
2520 <span className="inline-flex min-w-16 items-center justify-end gap-1.5 tabular-nums">2503 }
2521 <FileDiffIcon className="size-3.5" />2504 />
2522 {detail.changedFiles.toLocaleString()}{" "}2505 <TooltipPopup side="top">
2523 {detail.changedFiles === 1 ? "file" : "files"}2506 {isStackedPullRequest
2524 </span>2507 ? `Stacked on ${detail.baseBranch}`
2525 <PullRequestDiffStat2508 : detail.baseBranch}
2526 additions={detail.additions}2509 </TooltipPopup>
2527 deletions={detail.deletions}2510 </Tooltip>
2528 className="shrink-0 font-mono text-xs"2511 )
2529 />2512 }
2530 </span>2513 head={
2531 </div>2514 <PullRequestCopyableCode
2532 </div>2515 key={detail.headBranch}
2516 value={detail.headBranch}
2517 target="branch name"
2518 copyLabel="Copy pull request branch"
2519 copiedLabel="Branch name copied"
2520 />
2521 }
2522 files={`${detail.changedFiles.toLocaleString()} ${detail.changedFiles === 1 ? "file" : "files"}`}
2523 diffStat={
2524 <PullRequestDiffStat
2525 additions={detail.additions}
2526 deletions={detail.deletions}
2527 className="shrink-0 font-mono text-xs"
2528 />
2529 }
2530 />
2533 ) : null}2531 ) : null}
2534 </div>2532 </div>
2535 </div>2533 </div>
25362534
2537 {detail ? (2535 {detail ? (
2538 <nav2536 <PullRequestDetailTabBar
2539 className="col-span-2 flex min-w-0 flex-wrap items-center gap-2 border-t border-border/60 px-4 py-2"2537 tabs={visibleTabs}
2540 aria-label="Pull request tabs"2538 value={tab}
2539 onValueChange={setTab}
2540 onTabIntent={(item) => {
2541 if (item === "code") void loadCodeTab();
2542 }}
2541 >2543 >
2542 <ToggleGroup
2543 className="shrink-0"
2544 size="segmented"
2545 variant="segmented"
2546 value={[tab]}
2547 onValueChange={(next) => {
2548 const nextTab = visibleTabs.find((item) => item.value === next[0])?.value;
2549 if (nextTab) setTab(nextTab);
2550 }}
2551 >
2552 {visibleTabs.map((item) => (
2553 <Toggle
2554 key={item.value}
2555 value={item.value}
2556 onPointerEnter={item.value === "code" ? () => void loadCodeTab() : undefined}
2557 onFocus={item.value === "code" ? () => void loadCodeTab() : undefined}
2558 >
2559 {item.label}
2560 </Toggle>
2561 ))}
2562 </ToggleGroup>
2563 {tab === "summary" ? (2544 {tab === "summary" ? (
2564 <span2545 showsApproveWorkflows ? (
2565 className={cn(2546 <Tooltip>
2566 "ml-auto flex items-center justify-end",2547 <TooltipTrigger
2567 showsApproveWorkflows ? "shrink-0" : "min-w-0 flex-1",2548 render={
2568 )}2549 <span className="ml-auto inline-flex shrink-0">
2569 >2550 <Button
2570 {showsApproveWorkflows ? (2551 size="xs"
2571 <Tooltip>2552 variant="warning-outline"
2572 <TooltipTrigger2553 disabled={actionPending}
2573 render={2554 onClick={() =>
2574 <span className="inline-flex shrink-0">2555 setConfirmation({ open: true, action: "approve-workflows" })
2575 <Button2556 }
2576 size="xs"2557 aria-label={
2577 variant="warning-outline"2558 pendingAction === "approve-workflows"
2578 disabled={actionPending}2559 ? "Approving..."
2579 onClick={() =>2560 : "Approve workflows to run"
2580 setConfirmation({ open: true, action: "approve-workflows" })2561 }
2581 }2562 >
2582 aria-label={2563 <PlayIcon aria-hidden className="size-3.5" />
2583 pendingAction === "approve-workflows"2564 <span>
2584 ? "Approving..."2565 {pendingAction === "approve-workflows"
2585 : "Approve workflows to run"2566 ? "Approving..."
2586 }2567 : "Approve workflows to run"}
2587 >2568 </span>
2588 <PlayIcon aria-hidden className="size-3.5" />2569 </Button>
2589 <span>2570 </span>
2590 {pendingAction === "approve-workflows"2571 }
2591 ? "Approving..."2572 />
2592 : "Approve workflows to run"}2573 <TooltipPopup side="top">
2593 </span>2574 {pendingAction === "approve-workflows"
2594 </Button>2575 ? "Approving..."
2595 </span>2576 : "Approve workflows to run"}
2596 }2577 </TooltipPopup>
2597 />2578 </Tooltip>
2598 <TooltipPopup side="top">2579 ) : (
2599 {pendingAction === "approve-workflows"2580 <PullRequestChecksStatusLine
2600 ? "Approving..."2581 className="text-muted-foreground"
2601 : "Approve workflows to run"}2582 aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"}
2602 </TooltipPopup>2583 icon={
2603 </Tooltip>2584 checksState !== null ? (
2604 ) : (
2605 <span
2606 className="flex h-4 min-w-0 flex-wrap content-start items-center justify-end gap-x-1.5 overflow-hidden text-xs text-muted-foreground"
2607 aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"}
2608 >
2609 {checksState !== null ? (
2610 <PullRequestChecksPopover2585 <PullRequestChecksPopover
2611 checks={detail.checks}2586 checks={detail.checks}
2612 stale={checksStale}2587 stale={checksStale}
...@@ -2615,11 +2590,11 @@ export function PullRequestDetailPanel({...@@ -2615,11 +2590,11 @@ export function PullRequestDetailPanel({
2615 />2590 />
2616 ) : (2591 ) : (
2617 <CircleDotIcon aria-hidden className="size-3.5" />2592 <CircleDotIcon aria-hidden className="size-3.5" />
2618 )}2593 )
2619 <span className="whitespace-nowrap">{checksSummary}</span>2594 }
2620 </span>2595 label={checksSummary}
2621 )}2596 />
2622 </span>2597 )
2623 ) : tab === "timeline" ? (2598 ) : tab === "timeline" ? (
2624 <div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground">2599 <div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
2625 <PullRequestMetaLine2600 <PullRequestMetaLine
...@@ -2694,7 +2669,7 @@ export function PullRequestDetailPanel({...@@ -2694,7 +2669,7 @@ export function PullRequestDetailPanel({
2694 </Button>2669 </Button>
2695 </div>2670 </div>
2696 ) : null}2671 ) : null}
2697 </nav>2672 </PullRequestDetailTabBar>
2698 ) : null}2673 ) : null}
2699 </div>2674 </div>
27002675
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+88-107
...@@ -13,7 +13,6 @@ import {...@@ -13,7 +13,6 @@ import {
13 ChevronRightIcon,13 ChevronRightIcon,
14 EllipsisIcon,14 EllipsisIcon,
15 ExternalLinkIcon,15 ExternalLinkIcon,
16 FileDiffIcon,
17 PanelRightIcon,16 PanelRightIcon,
18 TagIcon,17 TagIcon,
19 UserPlusIcon,18 UserPlusIcon,
...@@ -26,20 +25,24 @@ import { cn } from "~/lib/utils";...@@ -26,20 +25,24 @@ import { cn } from "~/lib/utils";
26import { formatRelativeTimeLabel } from "~/timestampFormat";25import { formatRelativeTimeLabel } from "~/timestampFormat";
2726
28import { Button, InlineButton } from "../ui/button";27import { Button, InlineButton } from "../ui/button";
29import { Toggle, ToggleGroup } from "../ui/toggle-group";
30import { MiddleTruncate } from "../ui/middle-truncate";28import { MiddleTruncate } from "../ui/middle-truncate";
31import { PullRequestCopyableCode } from "./PullRequestCopyableCode";29import { PullRequestCopyableCode } from "./PullRequestCopyableCode";
30import {
31 PullRequestChecksStatusLine,
32 PullRequestDetailHeaderBody,
33 PullRequestDetailTabBar,
34 PullRequestDetailTitleRow,
35} from "./PullRequestDetailLayout";
32import {36import {
33 PullRequestActorLabel,37 PullRequestActorLabel,
34 PullRequestDiffStat,38 PullRequestDiffStat,
35 PullRequestLabelChip,39 PullRequestLabelChip,
36 PullRequestMetaLine,
37 pullRequestChecksStatePresentation,40 pullRequestChecksStatePresentation,
38 resolvePullRequestState,41 resolvePullRequestState,
39} from "./pullRequestPresentation";42} from "./pullRequestPresentation";
4043
41function GhostBar({ className }: { className?: string | undefined }) {44function GhostBar({ className }: { className?: string | undefined }) {
42 return <div aria-hidden className={cn("h-3 rounded bg-muted-foreground/15", className)} />;45 return <span aria-hidden className={cn("block h-3 rounded bg-muted-foreground/15", className)} />;
43}46}
4447
45/** Widths cycle rather than randomize, so the ghost renders the same on every pass. */48/** Widths cycle rather than randomize, so the ghost renders the same on every pass. */
...@@ -202,32 +205,35 @@ export function PullRequestDetailGhost({...@@ -202,32 +205,35 @@ export function PullRequestDetailGhost({
202205
203 <div className="col-span-2 grid grid-rows-[1fr]">206 <div className="col-span-2 grid grid-rows-[1fr]">
204 <div className="min-h-0 overflow-hidden">207 <div className="min-h-0 overflow-hidden">
205 <div className="col-span-2 mt-1 min-w-0 px-4 pb-4">208 <PullRequestDetailHeaderBody
206 {seed ? (209 title={
207 <div className="flex min-h-7 min-w-0 items-center sm:min-h-6">210 <PullRequestDetailTitleRow>
208 <h1 className="truncate text-base font-semibold leading-snug">{seed.title}</h1>211 {seed ? (
209 </div>212 <h1 className="truncate text-base font-semibold leading-snug">{seed.title}</h1>
210 ) : (213 ) : (
211 <div className="flex min-h-7 min-w-0 items-center sm:min-h-6">214 <GhostBar className="h-5 w-4/5 max-w-md" />
212 <GhostBar className="h-5 w-4/5 max-w-md" />215 )}
213 </div>216 </PullRequestDetailTitleRow>
214 )}217 }
215 <div className="mt-2 flex min-h-5 min-w-0 items-center gap-2 text-xs text-muted-foreground">218 author={
216 {seed ? (219 seed ? (
217 <PullRequestMetaLine className="min-w-0 whitespace-nowrap">220 <PullRequestActorLabel actor={seed.author ?? null} tooltip={false} />
218 <PullRequestActorLabel actor={seed.author ?? null} tooltip={false} />
219 <span>updated {formatRelativeTimeLabel(seed.updatedAt)}</span>
220 </PullRequestMetaLine>
221 ) : (221 ) : (
222 <PullRequestMetaLine className="min-w-0 whitespace-nowrap">222 <span className="flex items-center gap-1.5">
223 <span className="flex items-center gap-1.5">223 <GhostBar className="size-4 rounded-full" />
224 <GhostBar className="size-4 rounded-full" />224 <GhostBar className="h-3 w-14" />
225 <GhostBar className="h-3 w-14" />225 </span>
226 </span>226 )
227 <GhostBar className="h-3 w-16" />227 }
228 </PullRequestMetaLine>228 updated={
229 )}229 seed ? (
230 {checkout ? (230 <span>updated {formatRelativeTimeLabel(seed.updatedAt)}</span>
231 ) : (
232 <GhostBar className="h-3 w-16" />
233 )
234 }
235 checkout={
236 checkout ? (
231 <PullRequestCopyableCode237 <PullRequestCopyableCode
232 key={checkout}238 key={checkout}
233 value={checkout}239 value={checkout}
...@@ -238,94 +244,69 @@ export function PullRequestDetailGhost({...@@ -238,94 +244,69 @@ export function PullRequestDetailGhost({
238 tooltipSide="bottom"244 tooltipSide="bottom"
239 {...(onCheckoutError ? { onError: onCheckoutError } : {})}245 {...(onCheckoutError ? { onError: onCheckoutError } : {})}
240 />246 />
241 ) : null}247 ) : null
242 </div>248 }
243249 base={
244 <div className="mt-4 flex min-h-5 min-w-0 items-center gap-2 text-xs text-muted-foreground">250 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
245 <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70">
246 {seed ? (
247 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
248 <code className="flex min-w-0">
249 <MiddleTruncate value={seed.baseBranch} />
250 </code>
251 </span>
252 ) : (
253 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
254 <GhostBar className="h-3 w-12" />
255 </span>
256 )}
257 <ArrowLeftIcon
258 aria-label="receives changes from"
259 className="size-3.5 shrink-0 opacity-60"
260 />
261 {seed ? (
262 <PullRequestCopyableCode
263 key={seed.headBranch}
264 value={seed.headBranch}
265 target="branch name"
266 copyLabel="Copy pull request branch"
267 copiedLabel="Branch name copied"
268 className="min-w-0 font-mono"
269 />
270 ) : (
271 <GhostBar className="h-3 w-32 flex-1" />
272 )}
273 </span>
274 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">
275 <span className="inline-flex min-w-16 items-center justify-end gap-1.5 tabular-nums">
276 <FileDiffIcon aria-hidden className="size-3.5" />
277 {changedFiles === null ? (
278 <GhostBar className="h-3 w-10" />
279 ) : (
280 `${changedFiles.toLocaleString()} ${changedFiles === 1 ? "file" : "files"}`
281 )}
282 </span>
283 {seed ? (251 {seed ? (
284 <PullRequestDiffStat252 <code className="flex min-w-0">
285 additions={seed.additions ?? 0}253 <MiddleTruncate value={seed.baseBranch} />
286 deletions={seed.deletions ?? 0}254 </code>
287 className="shrink-0 font-mono text-xs"
288 />
289 ) : (255 ) : (
290 <GhostBar className="h-3 w-20" />256 <GhostBar className="h-3 w-12" />
291 )}257 )}
292 </span>258 </span>
293 </div>259 }
294 </div>260 head={
261 seed ? (
262 <PullRequestCopyableCode
263 key={seed.headBranch}
264 value={seed.headBranch}
265 target="branch name"
266 copyLabel="Copy pull request branch"
267 copiedLabel="Branch name copied"
268 className="min-w-0 font-mono"
269 />
270 ) : (
271 <GhostBar className="h-3 w-32 flex-1" />
272 )
273 }
274 files={
275 changedFiles === null ? (
276 <GhostBar className="h-3 w-10" />
277 ) : (
278 `${changedFiles.toLocaleString()} ${changedFiles === 1 ? "file" : "files"}`
279 )
280 }
281 diffStat={
282 seed ? (
283 <PullRequestDiffStat
284 additions={seed.additions ?? 0}
285 deletions={seed.deletions ?? 0}
286 className="shrink-0 font-mono text-xs"
287 />
288 ) : (
289 <GhostBar className="h-3 w-20" />
290 )
291 }
292 />
295 </div>293 </div>
296 </div>294 </div>
297295
298 <nav296 <PullRequestDetailTabBar tabs={tabs} value={selectedTab} inert>
299 className="col-span-2 flex min-w-0 flex-wrap items-center gap-2 border-t border-border/60 px-4 py-2"
300 aria-label="Pull request tabs"
301 inert
302 >
303 <ToggleGroup
304 className="shrink-0"
305 size="segmented"
306 variant="segmented"
307 value={[selectedTab]}
308 >
309 {tabs.map((tab) => (
310 <Toggle key={tab.value} value={tab.value} tabIndex={-1}>
311 {tab.label}
312 </Toggle>
313 ))}
314 </ToggleGroup>
315 {checksPresentation ? (297 {checksPresentation ? (
316 <span298 <PullRequestChecksStatusLine
317 className={cn(299 className={checksPresentation.toneClassName}
318 "ml-auto inline-flex items-center gap-1.5 text-xs",300 icon={<checksPresentation.Icon aria-hidden className="size-3.5" />}
319 checksPresentation.toneClassName,301 label={checksPresentation.label}
320 )}302 />
321 >
322 <checksPresentation.Icon aria-hidden className="size-3.5" />
323 {checksPresentation.label}
324 </span>
325 ) : (303 ) : (
326 <GhostBar className="ml-auto h-3 w-32" />304 <PullRequestChecksStatusLine
305 icon={<GhostBar className="my-px size-3.5 rounded-full" />}
306 label={<GhostBar className="w-28" />}
307 />
327 )}308 )}
328 </nav>309 </PullRequestDetailTabBar>
329 </div>310 </div>
330311
331 <div className="min-h-0 flex-1 overflow-hidden">312 <div className="min-h-0 flex-1 overflow-hidden">