1/**
2 * Loading states specific to the pull request surface — the first list, a search under way,
3 * and a detail panel opening — use bars in the geometry of the content they stand for, pulsing
4 * on one composited layer. Diff loading uses the shared diff-panel skeleton instead.
5 *
6 * The bars share the app-wide `Skeleton` tone (`muted-foreground` at low alpha, which reads on
7 * both themes) and the single `animate-skeleton` pulse, applied once on the container so any
8 * number of bars costs one opacity animation.
9 */
10import type { PullRequestListEntry, PullRequestSummary } from "@t3tools/contracts";
11import {
12 ArrowLeftIcon,
13 ChevronRightIcon,
14 EllipsisIcon,
15 ExternalLinkIcon,
16 PanelRightIcon,
17 TagIcon,
18 UserPlusIcon,
19 UsersIcon,
20} from "lucide-react";
21import type { ReactNode } from "react";
22
23import { readLocalApi } from "~/localApi";
24import { cn } from "~/lib/utils";
25import { formatRelativeTimeLabel } from "~/timestampFormat";
26
27import { Button, InlineButton } from "../ui/button";
28import { MiddleTruncate } from "../ui/middle-truncate";
29import { PullRequestCopyableCode } from "./PullRequestCopyableCode";
30import {
31 PullRequestChecksStatusLine,
32 PullRequestDetailHeaderBody,
33 PullRequestDetailTabBar,
34 PullRequestDetailTitleRow,
35} from "./PullRequestDetailLayout";
36import {
37 PullRequestActorLabel,
38 PullRequestDiffStat,
39 PullRequestLabelChip,
40 pullRequestChecksStatePresentation,
41 resolvePullRequestState,
42} from "./pullRequestPresentation";
43
44function GhostBar({ className }: { className?: string | undefined }) {
45 return <span aria-hidden className={cn("block h-3 rounded bg-muted-foreground/15", className)} />;
46}
47
48/** Widths cycle rather than randomize, so the ghost renders the same on every pass. */
49const TITLE_WIDTHS = ["w-3/5", "w-2/5", "w-1/2", "w-2/3", "w-2/5", "w-3/5", "w-1/2"];
50const META_WIDTHS = ["w-2/5", "w-1/3", "w-2/5", "w-1/4", "w-1/3", "w-2/5", "w-1/3"];
51const DEFAULT_DETAIL_TABS = [
52 { value: "summary", label: "Summary" },
53 { value: "timeline", label: "Timeline" },
54 { value: "code", label: "Code" },
55] as const;
56
57/** Rows in the list's own grid — glyph, title over meta, time over diffstat. */
58export function PullRequestListGhost({
59 rows = 7,
60 caption,
61}: {
62 rows?: number;
63 /** Said where the group headers speak, for the states with something to say — a search. */
64 caption?: string;
65}) {
66 return (
67 <div
68 role="status"
69 aria-label={caption ?? "Loading pull requests"}
70 className="motion-safe:animate-skeleton space-y-0.5"
71 >
72 {caption ? (
73 <p className="px-3 pb-1 text-xs font-medium text-muted-foreground/70">{caption}</p>
74 ) : null}
75 {Array.from({ length: rows }, (_, index) => (
76 <div
77 key={index}
78 className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-md px-3 py-2.5"
79 >
80 <GhostBar className="size-4 rounded-full" />
81 <div className="min-w-0 space-y-1.5">
82 <GhostBar className={cn("h-4", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} />
83 <GhostBar className={cn("h-3.5", META_WIDTHS[index % META_WIDTHS.length])} />
84 </div>
85 <div className="flex flex-col items-end gap-1.5">
86 <GhostBar className="w-12" />
87 <GhostBar className="w-16" />
88 </div>
89 </div>
90 ))}
91 </div>
92 );
93}
94
95/**
96 * The detail panel's current expanded shape. Keeping the chrome, summary facts, and description
97 * boundaries in the ghost prevents the loaded pull request from replacing one layout with
98 * another a moment later.
99 */
100export function PullRequestDetailGhost({
101 seed: entry,
102 summary,
103 actions,
104 checkoutCommand,
105 tabs = DEFAULT_DETAIL_TABS,
106 activeTab,
107 number,
108 onBack,
109 onClose,
110 onCheckoutError,
111}: {
112 seed?: PullRequestListEntry | null;
113 summary?: PullRequestSummary | null;
114 actions?: ReactNode;
115 checkoutCommand?: string | null;
116 tabs?: ReadonlyArray<{ value: string; label: string }>;
117 /** The panel's current tab, so the highlight does not jump when the detail arrives. */
118 activeTab?: string;
119 number?: number;
120 onBack?: (() => void) | undefined;
121 onClose?: (() => void) | undefined;
122 onCheckoutError?: ((error: Error) => void) | undefined;
123}) {
124 const seed = summary
125 ? {
126 ...entry,
127 ...summary,
128 isDraft: summary.isDraft ?? entry?.isDraft,
129 }
130 : entry;
131 const statePresentation = seed
132 ? resolvePullRequestState({
133 state: seed.state,
134 isDraft: seed.isDraft ?? false,
135 })
136 : null;
137 // Passing list rollups can omit workflows awaiting approval; wait for detail to claim success.
138 const checksPresentation =
139 seed?.checksState === "failing" || seed?.checksState === "pending"
140 ? pullRequestChecksStatePresentation(seed.checksState)
141 : null;
142 const checkout = checkoutCommand ?? null;
143 const changedFiles = summary?.changedFiles ?? null;
144 const selectedTab =
145 tabs.find((item) => item.value === activeTab)?.value ?? tabs[0]?.value ?? "summary";
146
147 return (
148 <div
149 role="status"
150 aria-label="Loading pull request"
151 className={cn(
152 "flex h-full min-h-0 flex-col overflow-hidden bg-background",
153 !seed && "motion-safe:animate-skeleton",
154 )}
155 >
156 <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">
157 <div className="pl-4 grid h-7 min-w-0 items-center overflow-hidden">
158 <div className="flex min-w-0 items-center gap-1 text-sm text-muted-foreground sm:text-xs">
159 {onBack ? (
160 <Button
161 size="icon-micro"
162 variant="ghost-muted"
163 className="-ml-1.5"
164 onClick={onBack}
165 aria-label="Back to this thread's pull requests"
166 >
167 <ArrowLeftIcon aria-hidden className="size-3.5" />
168 </Button>
169 ) : null}
170 {seed ? (
171 <>
172 <span className="min-w-0 truncate font-medium">{seed.repository}</span>
173 <InlineButton
174 onClick={() => void readLocalApi()?.shell.openExternal(seed.url)}
175 aria-label={`Open pull request #${seed.number} on host`}
176 >
177 <span className={statePresentation?.toneClassName}>#{seed.number}</span>
178 <ExternalLinkIcon aria-hidden className="size-2.5" />
179 </InlineButton>
180 </>
181 ) : (
182 <>
183 <GhostBar className="w-24" />
184 <span className="shrink-0 text-xs text-muted-foreground">#{number ?? "…"}</span>
185 </>
186 )}
187 </div>
188 </div>
189 <div className="mr-4 flex h-7 shrink-0 items-center justify-end gap-1">
190 {actions ?? <GhostBar className="h-6 w-16 rounded-md" />}
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}
204 </div>
205
206 <div className="col-span-2 grid grid-rows-[1fr]">
207 <div className="min-h-0 overflow-hidden">
208 <PullRequestDetailHeaderBody
209 title={
210 <PullRequestDetailTitleRow>
211 {seed ? (
212 <h1 className="truncate text-base font-semibold leading-snug">{seed.title}</h1>
213 ) : (
214 <GhostBar className="h-5 w-4/5 max-w-md" />
215 )}
216 </PullRequestDetailTitleRow>
217 }
218 author={
219 seed ? (
220 <PullRequestActorLabel actor={seed.author ?? null} tooltip={false} />
221 ) : (
222 <span className="flex items-center gap-1.5">
223 <GhostBar className="size-4 rounded-full" />
224 <GhostBar className="h-3 w-14" />
225 </span>
226 )
227 }
228 updated={
229 seed ? (
230 <span>updated {formatRelativeTimeLabel(seed.updatedAt)}</span>
231 ) : (
232 <GhostBar className="h-3 w-16" />
233 )
234 }
235 checkout={
236 checkout ? (
237 <PullRequestCopyableCode
238 key={checkout}
239 value={checkout}
240 target="pull request checkout command"
241 copyLabel="Copy checkout command"
242 copiedLabel="Checkout command copied"
243 className="ml-auto font-mono"
244 tooltipSide="bottom"
245 {...(onCheckoutError ? { onError: onCheckoutError } : {})}
246 />
247 ) : null
248 }
249 base={
250 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
251 {seed ? (
252 <code className="flex min-w-0">
253 <MiddleTruncate value={seed.baseBranch} />
254 </code>
255 ) : (
256 <GhostBar className="h-3 w-12" />
257 )}
258 </span>
259 }
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 />
293 </div>
294 </div>
295
296 <PullRequestDetailTabBar tabs={tabs} value={selectedTab} inert>
297 {checksPresentation ? (
298 <PullRequestChecksStatusLine
299 className={checksPresentation.toneClassName}
300 icon={<checksPresentation.Icon aria-hidden className="size-3.5" />}
301 label={checksPresentation.label}
302 />
303 ) : (
304 <PullRequestChecksStatusLine
305 icon={<GhostBar className="my-px size-3.5 rounded-full" />}
306 label={<GhostBar className="w-28" />}
307 />
308 )}
309 </PullRequestDetailTabBar>
310 </div>
311
312 <div className="min-h-0 flex-1 overflow-hidden">
313 <section className="px-4 pt-2.5 pb-1">
314 <div className="space-y-2">
315 <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">
316 <span className="flex items-center gap-1.5 text-muted-foreground">
317 <UsersIcon aria-hidden className="size-3.5" />
318 Reviewers
319 </span>
320 <span className="flex min-w-0 items-center gap-1.5 text-foreground">
321 <GhostBar className="h-3 w-10" />
322 <Button size="icon-xs" variant="ghost" disabled aria-label="Reviewers loading">
323 <UserPlusIcon aria-hidden className="size-3.5" />
324 </Button>
325 </span>
326 </div>
327 <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">
328 <span className="flex items-center gap-1.5 text-muted-foreground">
329 <TagIcon aria-hidden className="size-3.5" />
330 Labels
331 </span>
332 <span className="flex min-w-0 flex-wrap items-center gap-1 text-foreground">
333 {entry?.labels ? (
334 entry.labels.length > 0 ? (
335 entry.labels.map((label) => (
336 <PullRequestLabelChip
337 key={label.name}
338 label={label}
339 size="default"
340 className="max-w-48"
341 />
342 ))
343 ) : (
344 <span className="text-muted-foreground">None</span>
345 )
346 ) : (
347 <>
348 <GhostBar className="h-4.5 w-24" />
349 <GhostBar className="h-4.5 w-20" />
350 </>
351 )}
352 <Button size="icon-xs" variant="ghost" disabled aria-label="Labels loading">
353 <TagIcon aria-hidden className="size-3.5" />
354 </Button>
355 </span>
356 </div>
357 </div>
358 </section>
359
360 <section>
361 <div className="sticky top-0 z-10 flex w-full items-center bg-background pr-4">
362 <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">
363 <span>Description</span>
364 <ChevronRightIcon
365 aria-hidden
366 className="size-3.5 rotate-90 text-muted-foreground/60"
367 />
368 </div>
369 </div>
370 <div className="space-y-3 px-4 pb-4">
371 <GhostBar className="h-5 w-40" />
372 <div className="space-y-2">
373 <GhostBar className="h-4 w-full" />
374 <GhostBar className="h-4 w-11/12" />
375 <GhostBar className="h-4 w-4/5" />
376 <GhostBar className="h-4 w-2/3" />
377 </div>
378 </div>
379 </section>
380 </div>
381 </div>
382 );
383}
384
385/** People-shaped: an avatar and a name, in the reviewer picker's own row height. */
386export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) {
387 return (
388 <div
389 role="status"
390 aria-label="Loading people"
391 className="motion-safe:animate-skeleton space-y-1 p-1"
392 >
393 {Array.from({ length: rows }, (_, index) => (
394 <div key={index} className="flex h-7 items-center gap-2 rounded-md px-2">
395 <GhostBar className="size-4 rounded-full" />
396 <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />
397 </div>
398 ))}
399 </div>
400 );
401}
402
403/** The timeline's own shape: dots on the rail, a line and a date to each. */
404export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {
405 return (
406 <div
407 role="status"
408 aria-label="Loading timeline"
409 className="motion-safe:animate-skeleton px-4 py-5"
410 >
411 <div className="relative ml-2 border-l border-border/70 pl-5">
412 {Array.from({ length: rows }, (_, index) => (
413 <div key={index} className="relative pb-5">
414 <GhostBar className="absolute -left-[1.55rem] top-1 size-2 rounded-full" />
415 <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} />
416 <GhostBar className="mt-1.5 w-16" />
417 </div>
418 ))}
419 </div>
420 </div>
421 );
422}
423
424/** A compact placeholder for the conversation while the core detail is already readable. */
425export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {
426 return (
427 <div
428 role="status"
429 aria-label="Loading pull request conversation"
430 className="motion-safe:animate-skeleton space-y-4 py-2"
431 >
432 {Array.from({ length: rows }, (_, index) => (
433 <div key={index} className="flex items-start gap-2">
434 <GhostBar className="size-5 shrink-0 rounded-full" />
435 <div className="flex-1 space-y-1.5">
436 <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />
437 <GhostBar className="w-full" />
438 <GhostBar className="w-3/4" />
439 </div>
440 </div>
441 ))}
442 </div>
443 );
444}