authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-09-20 13:25:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 13:25:46-07:00
log6a699f0f2fbd8847d7ec2df8d9245ce8c2eb8707
tree25c91eccd8e82b167a684b3f9e96df0d59a16a92
parent9f0c9f725d4553d34e3c91c13d67ebcc58be5b0f
signature Signed by PGP key B5690EEEBB952194

fix(web): stabilize pull request loading layout (#12721)

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>

6 files changed, 419 insertions(+), 214 deletions(-)

apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx created+66
...@@ -0,0 +1,66 @@
1import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
2import { cn } from "~/lib/utils";
3
4import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
5
6export function PullRequestCopyableCode({
7 value,
8 target,
9 copyLabel,
10 copiedLabel,
11 className,
12 tooltipSide = "top",
13 onError,
14}: {
15 readonly value: string;
16 readonly target: string;
17 readonly copyLabel: string;
18 readonly copiedLabel: string;
19 readonly className?: string;
20 readonly tooltipSide?: "top" | "bottom";
21 readonly onError?: (error: Error) => void;
22}) {
23 const { copyToClipboard, isCopied } = useCopyToClipboard({
24 target,
25 timeout: 1600,
26 ...(onError ? { onError } : {}),
27 });
28 return (
29 <Tooltip>
30 <TooltipTrigger
31 render={
32 <button
33 type="button"
34 className={cn(
35 "relative grid w-fit min-w-0 max-w-full shrink cursor-pointer rounded px-1 py-0.5 text-left outline-none transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent/45 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
36 className,
37 )}
38 aria-label={isCopied ? copiedLabel : copyLabel}
39 onClick={() => copyToClipboard(value)}
40 />
41 }
42 >
43 <code
44 className={cn(
45 "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none",
46 isCopied ? "opacity-0" : "opacity-100",
47 )}
48 >
49 {value}
50 </code>
51 <span
52 aria-hidden="true"
53 className={cn(
54 "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none",
55 isCopied ? "opacity-100" : "opacity-0",
56 )}
57 >
58 Copied
59 </span>
60 </TooltipTrigger>
61 <TooltipPopup className="max-w-96 wrap-anywhere font-mono" side={tooltipSide}>
62 {`${isCopied ? "Copied" : copyLabel}: ${value}`}
63 </TooltipPopup>
64 </Tooltip>
65 );
66}
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+31-82
...@@ -121,6 +121,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";...@@ -121,6 +121,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
121import { toastManager } from "../ui/toast";121import { toastManager } from "../ui/toast";
122import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";122import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
123import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";123import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";
124import { PullRequestCopyableCode } from "./PullRequestCopyableCode";
124import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";125import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";
125import { DiffPanelLoadingState } from "../DiffPanelShell";126import { DiffPanelLoadingState } from "../DiffPanelShell";
126import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";127import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";
...@@ -140,6 +141,7 @@ import {...@@ -140,6 +141,7 @@ import {
140 handoffPrompt,141 handoffPrompt,
141 handoffReviewComments,142 handoffReviewComments,
142 latestPullRequestReviewOutcomes,143 latestPullRequestReviewOutcomes,
144 loadingPullRequestCheckoutCommand,
143 isStackedPullRequestBase,145 isStackedPullRequestBase,
144 pullRequestActionMenuHasGroup,146 pullRequestActionMenuHasGroup,
145 pullRequestActionNeedsHostRefresh,147 pullRequestActionNeedsHostRefresh,
...@@ -326,68 +328,6 @@ const openNumberContextMenu = (...@@ -326,68 +328,6 @@ const openNumberContextMenu = (
326 });328 });
327};329};
328330
329function PullRequestCopyableCode({
330 value,
331 target,
332 copyLabel,
333 copiedLabel,
334 className,
335 tooltipSide = "top",
336 onError,
337}: {
338 readonly value: string;
339 readonly target: string;
340 readonly copyLabel: string;
341 readonly copiedLabel: string;
342 readonly className?: string;
343 readonly tooltipSide?: "top" | "bottom";
344 readonly onError?: (error: Error) => void;
345}) {
346 const { copyToClipboard, isCopied } = useCopyToClipboard({
347 target,
348 timeout: 1600,
349 ...(onError ? { onError } : {}),
350 });
351 return (
352 <Tooltip>
353 <TooltipTrigger
354 render={
355 <button
356 type="button"
357 className={cn(
358 "relative grid w-fit min-w-0 max-w-full shrink cursor-pointer rounded px-1 py-0.5 text-left outline-none transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent/45 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
359 className,
360 )}
361 aria-label={isCopied ? copiedLabel : copyLabel}
362 onClick={() => copyToClipboard(value)}
363 />
364 }
365 >
366 <code
367 className={cn(
368 "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none",
369 isCopied ? "opacity-0" : "opacity-100",
370 )}
371 >
372 {value}
373 </code>
374 <span
375 aria-hidden="true"
376 className={cn(
377 "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none",
378 isCopied ? "opacity-100" : "opacity-0",
379 )}
380 >
381 Copied
382 </span>
383 </TooltipTrigger>
384 <TooltipPopup className="max-w-96 wrap-anywhere font-mono" side={tooltipSide}>
385 {`${isCopied ? "Copied" : copyLabel}: ${value}`}
386 </TooltipPopup>
387 </Tooltip>
388 );
389}
390
391/**331/**
392 * The stale-branch warning, said beside the branch it is about rather than as a bar of its own.332 * The stale-branch warning, said beside the branch it is about rather than as a bar of its own.
393 * The banner this replaces held a row of chrome open across the top of every pull request that333 * The banner this replaces held a row of chrome open across the top of every pull request that
...@@ -804,15 +744,22 @@ export function PullRequestDetailPanel({...@@ -804,15 +744,22 @@ export function PullRequestDetailPanel({
804 repositoryUrl !== null744 repositoryUrl !== null
805 ? new URL(`/${encodeURIComponent(detail.author.login)}`, repositoryUrl).toString()745 ? new URL(`/${encodeURIComponent(detail.author.login)}`, repositoryUrl).toString()
806 : null;746 : null;
807 const checkoutCommand = detail747 const checkoutCommand = handoffSummary
808 ? pullRequestCheckoutCommand(748 ? pullRequestCheckoutCommand(
809 detail.provider,749 handoffSummary.provider,
810 detail.number,750 handoffSummary.number,
811 detail.headBranch,751 handoffSummary.headBranch,
812 detail.headRepositoryNameWithOwner,752 detail?.headRepositoryNameWithOwner,
813 repositoryUrl,753 changeRequestRepositoryUrl(handoffSummary.url),
814 )754 )
815 : null;755 : loadingPullRequestCheckoutCommand(reference, repositoryIdentity);
756 const onCheckoutCommandError = useCallback((error: Error) => {
757 toastManager.add({
758 type: "error",
759 title: "Could not copy checkout command",
760 description: error.message,
761 });
762 }, []);
816 const branchRefsQuery = useEnvironmentQuery(763 const branchRefsQuery = useEnvironmentQuery(
817 detail === null764 detail === null
818 ? null765 ? null
...@@ -1475,8 +1422,9 @@ export function PullRequestDetailPanel({...@@ -1475,8 +1422,9 @@ export function PullRequestDetailPanel({
1475 // Out of date with the base, and still cleanly mergeable — the one pairing an update button1422 // Out of date with the base, and still cleanly mergeable — the one pairing an update button
1476 // exists for. Null everywhere else, including hosts that cannot compare at all.1423 // exists for. Null everywhere else, including hosts that cannot compare at all.
1477 const freshness = detail === null ? null : resolveBaseFreshness(detail);1424 const freshness = detail === null ? null : resolveBaseFreshness(detail);
1478 // A host that cannot produce a patch has no Code tab to open. The tabs themselves stay hidden1425 // A host that cannot produce a patch has no Code tab to open. While detail is loading the ghost
1479 // until the detail arrives, so the loading ghost is the panel's only unfinished UI.1426 // uses this optimistic tab set to reserve the same chrome; a host without a patch removes Code
1427 // when its capabilities arrive.
1480 const visibleTabs = TABS.filter(1428 const visibleTabs = TABS.filter(
1481 (item) => item.value !== "code" || detail === null || detail.capabilities.diff,1429 (item) => item.value !== "code" || detail === null || detail.capabilities.diff,
1482 );1430 );
...@@ -1664,6 +1612,13 @@ export function PullRequestDetailPanel({...@@ -1664,6 +1612,13 @@ export function PullRequestDetailPanel({
1664 <PullRequestDetailGhost1612 <PullRequestDetailGhost
1665 seed={matchingListEntry}1613 seed={matchingListEntry}
1666 summary={sharedSummary}1614 summary={sharedSummary}
1615 checkoutCommand={checkoutCommand}
1616 onCheckoutError={onCheckoutCommandError}
1617 number={reference.number}
1618 tabs={visibleTabs}
1619 activeTab={tab}
1620 {...(onBack ? { onBack } : {})}
1621 {...(onClose ? { onClose } : {})}
1667 actions={1622 actions={
1668 handoffSummary ? (1623 handoffSummary ? (
1669 <TooltipProvider delay={150} closeDelay={150} timeout={400}>1624 <TooltipProvider delay={150} closeDelay={150} timeout={400}>
...@@ -2371,7 +2326,7 @@ export function PullRequestDetailPanel({...@@ -2371,7 +2326,7 @@ export function PullRequestDetailPanel({
2371 {detail ? (2326 {detail ? (
2372 <div className="col-span-2 mt-1 min-w-0 px-4 pb-4">2327 <div className="col-span-2 mt-1 min-w-0 px-4 pb-4">
2373 {titleDraft === null ? (2328 {titleDraft === null ? (
2374 <div className="group flex min-w-0 items-center gap-1">2329 <div className="group flex min-h-7 min-w-0 items-center gap-1 sm:min-h-6">
2375 <Tooltip>2330 <Tooltip>
2376 <TooltipTrigger2331 <TooltipTrigger
2377 render={2332 render={
...@@ -2432,7 +2387,7 @@ export function PullRequestDetailPanel({...@@ -2432,7 +2387,7 @@ export function PullRequestDetailPanel({
2432 </div>2387 </div>
2433 </div>2388 </div>
2434 )}2389 )}
2435 <div className="mt-2 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">2390 <div className="mt-2 flex min-h-5 min-w-0 items-center gap-2 text-xs text-muted-foreground">
2436 <PullRequestMetaLine className="min-w-0 whitespace-nowrap">2391 <PullRequestMetaLine className="min-w-0 whitespace-nowrap">
2437 <PullRequestActorLabel2392 <PullRequestActorLabel
2438 actor={detail.author}2393 actor={detail.author}
...@@ -2450,18 +2405,12 @@ export function PullRequestDetailPanel({...@@ -2450,18 +2405,12 @@ export function PullRequestDetailPanel({
2450 copiedLabel="Checkout command copied"2405 copiedLabel="Checkout command copied"
2451 className="ml-auto font-mono"2406 className="ml-auto font-mono"
2452 tooltipSide="bottom"2407 tooltipSide="bottom"
2453 onError={(error) =>2408 onError={onCheckoutCommandError}
2454 toastManager.add({
2455 type: "error",
2456 title: "Could not copy checkout command",
2457 description: error.message,
2458 })
2459 }
2460 />2409 />
2461 ) : null}2410 ) : null}
2462 </div>2411 </div>
24632412
2464 <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">2413 <div className="mt-4 flex min-h-5 min-w-0 items-center gap-2 text-xs text-muted-foreground">
2465 <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70">2414 <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70">
2466 <Tooltip>2415 <Tooltip>
2467 <TooltipTrigger2416 <TooltipTrigger
...@@ -2504,7 +2453,7 @@ export function PullRequestDetailPanel({...@@ -2504,7 +2453,7 @@ export function PullRequestDetailPanel({
2504 />2453 />
2505 </span>2454 </span>
2506 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">2455 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">
2507 <span className="inline-flex items-center gap-1.5 tabular-nums">2456 <span className="inline-flex min-w-16 items-center justify-end gap-1.5 tabular-nums">
2508 <FileDiffIcon className="size-3.5" />2457 <FileDiffIcon className="size-3.5" />
2509 {detail.changedFiles.toLocaleString()}{" "}2458 {detail.changedFiles.toLocaleString()}{" "}
2510 {detail.changedFiles === 1 ? "file" : "files"}2459 {detail.changedFiles === 1 ? "file" : "files"}
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+259-131
...@@ -8,16 +8,31 @@...@@ -8,16 +8,31 @@
8 * number of bars costs one opacity animation.8 * number of bars costs one opacity animation.
9 */9 */
10import type { PullRequestListEntry, PullRequestSummary } from "@t3tools/contracts";10import type { PullRequestListEntry, PullRequestSummary } from "@t3tools/contracts";
11import { ArrowLeftIcon } from "lucide-react";11import {
12 ArrowLeftIcon,
13 ChevronRightIcon,
14 EllipsisIcon,
15 ExternalLinkIcon,
16 FileDiffIcon,
17 PanelRightIcon,
18 TagIcon,
19 UserPlusIcon,
20 UsersIcon,
21} from "lucide-react";
12import type { ReactNode } from "react";22import type { ReactNode } from "react";
1323
24import { readLocalApi } from "~/localApi";
14import { cn } from "~/lib/utils";25import { cn } from "~/lib/utils";
15import { formatRelativeTimeLabel } from "~/timestampFormat";26import { formatRelativeTimeLabel } from "~/timestampFormat";
1627
28import { Button, InlineButton } from "../ui/button";
29import { Toggle, ToggleGroup } from "../ui/toggle-group";
30import { PullRequestCopyableCode } from "./PullRequestCopyableCode";
17import { pullRequestLabelColor } from "./pullRequestList.logic";31import { pullRequestLabelColor } from "./pullRequestList.logic";
18import {32import {
19 PullRequestActorLabel,33 PullRequestActorLabel,
20 PullRequestDiffStat,34 PullRequestDiffStat,
35 PullRequestMetaLine,
21 pullRequestChecksStatePresentation,36 pullRequestChecksStatePresentation,
22 resolvePullRequestState,37 resolvePullRequestState,
23} from "./pullRequestPresentation";38} from "./pullRequestPresentation";
...@@ -29,6 +44,11 @@ function GhostBar({ className }: { className?: string | undefined }) {...@@ -29,6 +44,11 @@ function GhostBar({ className }: { className?: string | undefined }) {
29/** Widths cycle rather than randomize, so the ghost renders the same on every pass. */44/** Widths cycle rather than randomize, so the ghost renders the same on every pass. */
30const TITLE_WIDTHS = ["w-3/5", "w-2/5", "w-1/2", "w-2/3", "w-2/5", "w-3/5", "w-1/2"];45const TITLE_WIDTHS = ["w-3/5", "w-2/5", "w-1/2", "w-2/3", "w-2/5", "w-3/5", "w-1/2"];
31const META_WIDTHS = ["w-2/5", "w-1/3", "w-2/5", "w-1/4", "w-1/3", "w-2/5", "w-1/3"];46const META_WIDTHS = ["w-2/5", "w-1/3", "w-2/5", "w-1/4", "w-1/3", "w-2/5", "w-1/3"];
47const DEFAULT_DETAIL_TABS = [
48 { value: "summary", label: "Summary" },
49 { value: "timeline", label: "Timeline" },
50 { value: "code", label: "Code" },
51] as const;
3252
33/** Rows in the list's own grid — glyph, title over meta, time over diffstat. */53/** Rows in the list's own grid — glyph, title over meta, time over diffstat. */
34export function PullRequestListGhost({54export function PullRequestListGhost({
...@@ -77,10 +97,25 @@ export function PullRequestDetailGhost({...@@ -77,10 +97,25 @@ export function PullRequestDetailGhost({
77 seed: entry,97 seed: entry,
78 summary,98 summary,
79 actions,99 actions,
100 checkoutCommand,
101 tabs = DEFAULT_DETAIL_TABS,
102 activeTab,
103 number,
104 onBack,
105 onClose,
106 onCheckoutError,
80}: {107}: {
81 seed?: PullRequestListEntry | null;108 seed?: PullRequestListEntry | null;
82 summary?: PullRequestSummary | null;109 summary?: PullRequestSummary | null;
83 actions?: ReactNode;110 actions?: ReactNode;
111 checkoutCommand?: string | null;
112 tabs?: ReadonlyArray<{ value: string; label: string }>;
113 /** The panel's current tab, so the highlight does not jump when the detail arrives. */
114 activeTab?: string;
115 number?: number;
116 onBack?: (() => void) | undefined;
117 onClose?: (() => void) | undefined;
118 onCheckoutError?: ((error: Error) => void) | undefined;
84}) {119}) {
85 const seed = summary120 const seed = summary
86 ? {121 ? {
...@@ -100,6 +135,10 @@ export function PullRequestDetailGhost({...@@ -100,6 +135,10 @@ export function PullRequestDetailGhost({
100 seed?.checksState === "failing" || seed?.checksState === "pending"135 seed?.checksState === "failing" || seed?.checksState === "pending"
101 ? pullRequestChecksStatePresentation(seed.checksState)136 ? pullRequestChecksStatePresentation(seed.checksState)
102 : null;137 : null;
138 const checkout = checkoutCommand ?? null;
139 const changedFiles = summary?.changedFiles ?? null;
140 const selectedTab =
141 tabs.find((item) => item.value === activeTab)?.value ?? tabs[0]?.value ?? "summary";
103142
104 return (143 return (
105 <div144 <div
...@@ -110,100 +149,178 @@ export function PullRequestDetailGhost({...@@ -110,100 +149,178 @@ export function PullRequestDetailGhost({
110 !seed && "motion-safe:animate-skeleton",149 !seed && "motion-safe:animate-skeleton",
111 )}150 )}
112 >151 >
113 <div className="@container/pr-header shrink-0 border-b border-border/60">152 <div className="@container/pr-header grid min-w-0 shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60">
114 <div className="flex h-7 items-center justify-between gap-3 px-4">153 <div className="pl-4 grid h-7 min-w-0 items-center overflow-hidden">
115 <div className="flex min-w-0 flex-1 items-center gap-1.5">154 <div className="flex min-w-0 items-center gap-1 text-sm text-muted-foreground sm:text-xs">
116 {seed && statePresentation ? (155 {onBack ? (
156 <Button
157 size="icon-micro"
158 variant="ghost-muted"
159 className="-ml-1.5"
160 onClick={onBack}
161 aria-label="Back to this thread's pull requests"
162 >
163 <ArrowLeftIcon aria-hidden className="size-3.5" />
164 </Button>
165 ) : null}
166 {seed ? (
117 <>167 <>
118 <span className="min-w-0 truncate text-xs font-medium text-muted-foreground">168 <span className="min-w-0 truncate font-medium">{seed.repository}</span>
119 {seed.repository}169 <InlineButton
120 </span>170 onClick={() => void readLocalApi()?.shell.openExternal(seed.url)}
121 <span171 className={cn(
122 className={cn("shrink-0 text-xs font-medium", statePresentation.toneClassName)}172 "font-medium underline-offset-2 hover:underline",
173 statePresentation?.toneClassName,
174 )}
175 aria-label={`Open pull request #${seed.number} on host`}
123 >176 >
124 #{seed.number}177 #{seed.number}
125 </span>178 <ExternalLinkIcon aria-hidden className="size-2.5" />
179 </InlineButton>
126 </>180 </>
127 ) : (181 ) : (
128 <>182 <>
129 <GhostBar className="w-24" />183 <GhostBar className="w-24" />
130 <GhostBar className="w-9" />184 <span className="shrink-0 text-xs text-muted-foreground">#{number ?? "…"}</span>
131 </>185 </>
132 )}186 )}
133 </div>187 </div>
134 <div className="flex shrink-0 items-center gap-1">188 </div>
135 {actions ?? <GhostBar className="h-5 w-16 rounded-md" />}189 <div className="mr-4 flex h-7 shrink-0 items-center justify-end gap-1">
136 <GhostBar className="size-6 rounded-md" />190 {actions ?? <GhostBar className="h-6 w-16 rounded-md" />}
137 </div>191 <Button size="icon-xs" variant="ghost" disabled aria-label="Pull request actions loading">
192 <EllipsisIcon aria-hidden className="size-4" />
193 </Button>
194 {onClose ? (
195 <Button
196 size="icon-xs"
197 variant="ghost"
198 aria-label="Collapse pull request panel"
199 onClick={onClose}
200 >
201 <PanelRightIcon aria-hidden className="size-3.5" />
202 </Button>
203 ) : null}
138 </div>204 </div>
139205
140 <div className="px-4 pb-4 pt-1">206 <div className="col-span-2 grid grid-rows-[1fr]">
141 {seed ? (207 <div className="min-h-0 overflow-hidden">
142 <h1 className="truncate text-base font-semibold leading-snug">{seed.title}</h1>208 <div className="col-span-2 mt-1 min-w-0 px-4 pb-4">
143 ) : (
144 <GhostBar className="h-5 w-4/5 max-w-md" />
145 )}
146 <div className="mt-2 flex items-center gap-1.5">
147 {seed ? (
148 <>
149 <PullRequestActorLabel
150 actor={seed.author ?? null}
151 className="font-medium"
152 tooltip={false}
153 />
154 <span className="text-xs text-muted-foreground">
155 updated {formatRelativeTimeLabel(seed.updatedAt)}
156 </span>
157 </>
158 ) : (
159 <>
160 <GhostBar className="size-4 rounded-full" />
161 <GhostBar className="w-24" />
162 </>
163 )}
164 </div>
165 <div className="mt-4 flex min-w-0 items-center gap-2">
166 {seed ? (
167 <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70">
168 <code className="min-w-0 max-w-[40%] shrink-0 truncate">{seed.baseBranch}</code>
169 <ArrowLeftIcon
170 aria-label="receives changes from"
171 className="size-3.5 shrink-0 opacity-60"
172 />
173 <code className="min-w-0 flex-1 truncate">{seed.headBranch}</code>
174 </span>
175 ) : (
176 <>
177 <GhostBar className="h-6 w-24 rounded-md" />
178 <GhostBar className="size-3 rounded-full" />
179 <GhostBar className="h-6 w-32 rounded-md" />
180 </>
181 )}
182 <div className="ml-auto flex shrink-0 items-center gap-2">
183 <GhostBar className="w-10" />
184 {seed ? (209 {seed ? (
185 <PullRequestDiffStat210 <div className="flex min-h-7 min-w-0 items-center sm:min-h-6">
186 additions={seed.additions ?? 0}211 <h1 className="truncate text-base font-semibold leading-snug">{seed.title}</h1>
187 deletions={seed.deletions ?? 0}212 </div>
188 className="font-mono text-xs"
189 />
190 ) : (213 ) : (
191 <GhostBar className="w-20" />214 <div className="flex min-h-7 min-w-0 items-center sm:min-h-6">
215 <GhostBar className="h-5 w-4/5 max-w-md" />
216 </div>
192 )}217 )}
218 <div className="mt-2 flex min-h-5 min-w-0 items-center gap-2 text-xs text-muted-foreground">
219 {seed ? (
220 <PullRequestMetaLine className="min-w-0 whitespace-nowrap">
221 <PullRequestActorLabel
222 actor={seed.author ?? null}
223 className="font-medium"
224 tooltip={false}
225 />
226 <span>updated {formatRelativeTimeLabel(seed.updatedAt)}</span>
227 </PullRequestMetaLine>
228 ) : (
229 <PullRequestMetaLine className="min-w-0 whitespace-nowrap">
230 <span className="flex items-center gap-1.5">
231 <GhostBar className="size-4 rounded-full" />
232 <GhostBar className="h-3 w-14" />
233 </span>
234 <GhostBar className="h-3 w-16" />
235 </PullRequestMetaLine>
236 )}
237 {checkout ? (
238 <PullRequestCopyableCode
239 key={checkout}
240 value={checkout}
241 target="pull request checkout command"
242 copyLabel="Copy checkout command"
243 copiedLabel="Checkout command copied"
244 className="ml-auto font-mono"
245 tooltipSide="bottom"
246 {...(onCheckoutError ? { onError: onCheckoutError } : {})}
247 />
248 ) : null}
249 </div>
250
251 <div className="mt-4 flex min-h-5 min-w-0 items-center gap-2 text-xs text-muted-foreground">
252 <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70">
253 {seed ? (
254 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
255 <code className="min-w-0 truncate">{seed.baseBranch}</code>
256 </span>
257 ) : (
258 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
259 <GhostBar className="h-3 w-12" />
260 </span>
261 )}
262 <ArrowLeftIcon
263 aria-label="receives changes from"
264 className="size-3.5 shrink-0 opacity-60"
265 />
266 {seed ? (
267 <PullRequestCopyableCode
268 key={seed.headBranch}
269 value={seed.headBranch}
270 target="branch name"
271 copyLabel="Copy pull request branch"
272 copiedLabel="Branch name copied"
273 className="min-w-0 font-mono"
274 />
275 ) : (
276 <GhostBar className="h-3 w-32 flex-1" />
277 )}
278 </span>
279 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">
280 <span className="inline-flex min-w-16 items-center justify-end gap-1.5 tabular-nums">
281 <FileDiffIcon aria-hidden className="size-3.5" />
282 {changedFiles === null ? (
283 <GhostBar className="h-3 w-10" />
284 ) : (
285 `${changedFiles.toLocaleString()} ${changedFiles === 1 ? "file" : "files"}`
286 )}
287 </span>
288 {seed ? (
289 <PullRequestDiffStat
290 additions={seed.additions ?? 0}
291 deletions={seed.deletions ?? 0}
292 className="shrink-0 font-mono text-xs"
293 />
294 ) : (
295 <GhostBar className="h-3 w-20" />
296 )}
297 </span>
298 </div>
193 </div>299 </div>
194 </div>300 </div>
195 </div>301 </div>
196302
197 <div className="flex min-h-10 items-center justify-between gap-3 border-t border-border/60 px-4 py-2">303 <nav
198 <div className="flex items-center gap-1 p-0.5">304 className="col-span-2 flex min-w-0 flex-wrap items-center gap-2 border-t border-border/60 px-4 py-2"
199 <GhostBar className="h-6 w-16 rounded-md" />305 aria-label="Pull request tabs"
200 <GhostBar className="h-6 w-16 rounded-md" />306 inert
201 <GhostBar className="h-6 w-12 rounded-md" />307 >
202 </div>308 <ToggleGroup
309 className="shrink-0"
310 size="segmented"
311 variant="segmented"
312 value={[selectedTab]}
313 >
314 {tabs.map((tab) => (
315 <Toggle key={tab.value} value={tab.value} tabIndex={-1}>
316 {tab.label}
317 </Toggle>
318 ))}
319 </ToggleGroup>
203 {checksPresentation ? (320 {checksPresentation ? (
204 <span321 <span
205 className={cn(322 className={cn(
206 "inline-flex items-center gap-1.5 text-xs",323 "ml-auto inline-flex items-center gap-1.5 text-xs",
207 checksPresentation.toneClassName,324 checksPresentation.toneClassName,
208 )}325 )}
209 >326 >
...@@ -211,74 +328,85 @@ export function PullRequestDetailGhost({...@@ -211,74 +328,85 @@ export function PullRequestDetailGhost({
211 {checksPresentation.label}328 {checksPresentation.label}
212 </span>329 </span>
213 ) : (330 ) : (
214 <GhostBar className="w-20" />331 <GhostBar className="ml-auto h-3 w-32" />
215 )}332 )}
216 </div>333 </nav>
217 </div>334 </div>
218335
219 <div className="min-h-0 flex-1 overflow-hidden">336 <div className="min-h-0 flex-1 overflow-hidden">
220 <section className="px-4 py-3">337 <section className="px-4 pt-2.5 pb-1">
221 <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2">338 <div className="space-y-2">
222 <div className="flex items-center gap-1.5">339 <div className="grid min-h-7 min-w-0 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs sm:min-h-6">
223 <GhostBar className="size-3.5 rounded-full" />340 <span className="flex items-center gap-1.5 text-muted-foreground">
224 <GhostBar className="w-14" />341 <UsersIcon aria-hidden className="size-3.5" />
225 </div>342 Reviewers
226 <div className="flex items-center gap-1">343 </span>
227 <GhostBar className="size-4 rounded-full" />344 <span className="flex min-w-0 items-center gap-1.5 text-foreground">
228 <GhostBar className="size-4 rounded-full" />345 <GhostBar className="h-3 w-10" />
229 <GhostBar className="ml-1 size-5 rounded-md" />346 <Button size="icon-xs" variant="ghost" disabled aria-label="Reviewers loading">
230 </div>347 <UserPlusIcon aria-hidden className="size-3.5" />
231 </div>348 </Button>
232 <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2">349 </span>
233 <div className="flex items-center gap-1.5">
234 <GhostBar className="size-3.5 rounded-full" />
235 <GhostBar className="w-10" />
236 </div>
237 <div className="flex items-center gap-1">
238 {seed?.labels ? (
239 seed.labels.slice(0, 3).map((label) => {
240 const color = pullRequestLabelColor(label.color);
241 return (
242 <span
243 key={label.name}
244 className="inline-flex h-5 max-w-32 items-center gap-1 truncate rounded-full border border-border/70 bg-muted/40 px-2 text-[10px] text-muted-foreground"
245 >
246 <span
247 aria-hidden
248 className="size-2 shrink-0 rounded-full bg-muted-foreground"
249 {...(color ? { style: { backgroundColor: color } } : {})}
250 />
251 <span className="truncate">{label.name}</span>
252 </span>
253 );
254 })
255 ) : (
256 <>
257 <GhostBar className="h-5 w-24 rounded-full" />
258 <GhostBar className="h-5 w-20 rounded-full" />
259 </>
260 )}
261 </div>350 </div>
262 </div>351 <div className="grid min-h-7 min-w-0 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs sm:min-h-6">
263 <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2">352 <span className="flex items-center gap-1.5 text-muted-foreground">
264 <div className="flex items-center gap-1.5">353 <TagIcon aria-hidden className="size-3.5" />
265 <GhostBar className="size-3.5 rounded-full" />354 Labels
266 <GhostBar className="w-14" />355 </span>
356 <span className="flex min-w-0 flex-wrap items-center gap-1 text-foreground">
357 {entry?.labels ? (
358 entry.labels.length > 0 ? (
359 entry.labels.map((label) => {
360 const color = pullRequestLabelColor(label.color);
361 return (
362 <span
363 key={label.name}
364 className="inline-flex max-w-48 items-center gap-1.5 truncate rounded-full bg-muted/40 py-0.5 pl-1.5 pr-2 text-xs"
365 >
366 <span
367 aria-hidden
368 className="size-2 shrink-0 rounded-full bg-muted-foreground"
369 {...(color ? { style: { backgroundColor: color } } : {})}
370 />
371 <span className="truncate">{label.name}</span>
372 </span>
373 );
374 })
375 ) : (
376 <span className="text-muted-foreground">None</span>
377 )
378 ) : (
379 <>
380 <GhostBar className="h-4 w-24 rounded-full" />
381 <GhostBar className="h-4 w-20 rounded-full" />
382 </>
383 )}
384 <Button size="icon-xs" variant="ghost" disabled aria-label="Labels loading">
385 <TagIcon aria-hidden className="size-3.5" />
386 </Button>
387 </span>
267 </div>388 </div>
268 <GhostBar className="w-20" />
269 </div>389 </div>
270 </section>390 </section>
271391
272 <section className="border-t border-border/60">392 <section>
273 <div className="flex min-h-11 items-center gap-1.5 px-4 py-3">393 <div className="sticky top-0 z-10 flex w-full items-center bg-background pr-4">
274 <GhostBar className="h-4 w-24" />394 <div className="flex min-w-0 flex-1 items-center gap-1.5 px-4 py-3 text-left text-xs font-medium text-muted-foreground">
275 <GhostBar className="size-3.5 rounded-full" />395 <span>Description</span>
396 <ChevronRightIcon
397 aria-hidden
398 className="size-3.5 rotate-90 text-muted-foreground/60"
399 />
400 </div>
276 </div>401 </div>
277 <div className="space-y-2 px-4 pb-4">402 <div className="space-y-3 px-4 pb-4">
278 <GhostBar className="w-full" />403 <GhostBar className="h-5 w-40" />
279 <GhostBar className="w-11/12" />404 <div className="space-y-2">
280 <GhostBar className="w-4/5" />405 <GhostBar className="h-4 w-full" />
281 <GhostBar className="w-2/3" />406 <GhostBar className="h-4 w-11/12" />
407 <GhostBar className="h-4 w-4/5" />
408 <GhostBar className="h-4 w-2/3" />
409 </div>
282 </div>410 </div>
283 </section>411 </section>
284 </div>412 </div>
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+1-1
...@@ -275,7 +275,7 @@ function MetaRow({...@@ -275,7 +275,7 @@ function MetaRow({
275 children: ReactNode;275 children: ReactNode;
276}) {276}) {
277 return (277 return (
278 <div className="grid min-w-0 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs">278 <div className="grid min-h-7 min-w-0 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs sm:min-h-6">
279 <span className="flex items-center gap-1.5 text-muted-foreground">279 <span className="flex items-center gap-1.5 text-muted-foreground">
280 {icon}280 {icon}
281 {label}281 {label}
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+46
...@@ -7,7 +7,9 @@ import {...@@ -7,7 +7,9 @@ import {
7 type PullRequestComment,7 type PullRequestComment,
8 type PullRequestDetail,8 type PullRequestDetail,
9 type PullRequestDetailView,9 type PullRequestDetailView,
10 type PullRequestRef,
10 type PullRequestReviewThread,11 type PullRequestReviewThread,
12 type RepositoryIdentity,
11 type ThreadPullRequestLink,13 type ThreadPullRequestLink,
12} from "@t3tools/contracts";14} from "@t3tools/contracts";
13import { describe, expect, it } from "vite-plus/test";15import { describe, expect, it } from "vite-plus/test";
...@@ -27,6 +29,7 @@ import {...@@ -27,6 +29,7 @@ import {
27 stripPullRequestHandoffReferences,29 stripPullRequestHandoffReferences,
28 isPullRequestVerdictStale,30 isPullRequestVerdictStale,
29 isStackedPullRequestBase,31 isStackedPullRequestBase,
32 loadingPullRequestCheckoutCommand,
30 pullRequestPanelContext,33 pullRequestPanelContext,
31 latestPullRequestReviewOutcomes,34 latestPullRequestReviewOutcomes,
32 newestPullRequestCommitAt,35 newestPullRequestCommitAt,
...@@ -94,6 +97,49 @@ describe("pull request checkout commands", () => {...@@ -94,6 +97,49 @@ describe("pull request checkout commands", () => {
94 "git fetch 'https://forgejo.local/maria/repo'\\''$(echo nope)' refs/pull/42/head && git checkout -B pulls/42 FETCH_HEAD",97 "git fetch 'https://forgejo.local/maria/repo'\\''$(echo nope)' refs/pull/42/head && git checkout -B pulls/42 FETCH_HEAD",
95 );98 );
96 });99 });
100
101 const reference = (host?: string): PullRequestRef => ({
102 projectId: ProjectId.make("project-1"),
103 ...(host === undefined ? {} : { host }),
104 repository: "acme/web",
105 number: 42,
106 });
107 const identity = (provider: string, canonicalKey: string): RepositoryIdentity => ({
108 canonicalKey,
109 locator: {
110 source: "git-remote",
111 remoteName: "origin",
112 remoteUrl: "git@github.com:acme/web.git",
113 },
114 provider,
115 });
116
117 it("uses a public host when no repository identity is available", () => {
118 expect(loadingPullRequestCheckoutCommand(reference("github.com"), undefined)).toBe(
119 "gh pr checkout 42",
120 );
121 expect(loadingPullRequestCheckoutCommand(reference("gitlab.com"), null)).toBe(
122 "glab mr checkout 42",
123 );
124 });
125
126 it("uses a matching enterprise identity and rejects an explicit host mismatch", () => {
127 const enterprise = identity("github", "github.example.test/acme/web");
128 expect(loadingPullRequestCheckoutCommand(reference("github.example.test"), enterprise)).toBe(
129 "gh pr checkout 42",
130 );
131 expect(loadingPullRequestCheckoutCommand(reference("github.com"), enterprise)).toBeNull();
132 });
133
134 it("does not infer a number-only command without a trusted provider", () => {
135 expect(loadingPullRequestCheckoutCommand(reference(), undefined)).toBeNull();
136 expect(
137 loadingPullRequestCheckoutCommand(
138 reference("github.com"),
139 identity("gitlab", "gitlab.com/acme/web"),
140 ),
141 ).toBeNull();
142 });
97});143});
98144
99const TIMELINE_SOURCE: Pick<145const TIMELINE_SOURCE: Pick<
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+16
...@@ -140,6 +140,22 @@ export function pullRequestCheckoutCommand(...@@ -140,6 +140,22 @@ export function pullRequestCheckoutCommand(
140 }140 }
141}141}
142142
143/** Build a checkout command from identity metadata while the detail request is still pending. */
144export function loadingPullRequestCheckoutCommand(
145 reference: PullRequestRef,
146 identity: RepositoryIdentity | null | undefined,
147): string | null {
148 const host = reference.host?.trim().toLowerCase();
149 const provider =
150 identity?.provider ??
151 (host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : null);
152 if (provider !== "github" && provider !== "gitlab" && provider !== "azure-devops") return null;
153 if (identity?.provider !== undefined && host && pullRequestHostOf(identity, provider) !== host) {
154 return null;
155 }
156 return pullRequestCheckoutCommand(provider, reference.number, "");
157}
158
143/** Activity changes only when the same host resource reports a newer revision. */159/** Activity changes only when the same host resource reports a newer revision. */
144export function shouldRefreshPullRequestActivity(160export function shouldRefreshPullRequestActivity(
145 previous: { readonly key: string; readonly updatedAt: string } | null,161 previous: { readonly key: string; readonly updatedAt: string } | null,