| 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 | */ |
| 10 | import type { PullRequestListEntry, PullRequestSummary } from "@t3tools/contracts"; |
| 11 | import { |
| 12 | ArrowLeftIcon, |
| 13 | ChevronRightIcon, |
| 14 | EllipsisIcon, |
| 15 | ExternalLinkIcon, |
| 16 | PanelRightIcon, |
| 17 | TagIcon, |
| 18 | UserPlusIcon, |
| 19 | UsersIcon, |
| 20 | } from "lucide-react"; |
| 21 | import type { ReactNode } from "react"; |
| 22 | |
| 23 | import { readLocalApi } from "~/localApi"; |
| 24 | import { cn } from "~/lib/utils"; |
| 25 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 26 | |
| 27 | import { Button, InlineButton } from "../ui/button"; |
| 28 | import { MiddleTruncate } from "../ui/middle-truncate"; |
| 29 | import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; |
| 30 | import { |
| 31 | PullRequestChecksStatusLine, |
| 32 | PullRequestDetailHeaderBody, |
| 33 | PullRequestDetailTabBar, |
| 34 | PullRequestDetailTitleRow, |
| 35 | } from "./PullRequestDetailLayout"; |
| 36 | import { |
| 37 | PullRequestActorLabel, |
| 38 | PullRequestDiffStat, |
| 39 | PullRequestLabelChip, |
| 40 | pullRequestChecksStatePresentation, |
| 41 | resolvePullRequestState, |
| 42 | } from "./pullRequestPresentation"; |
| 43 | |
| 44 | function 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. */ |
| 49 | const TITLE_WIDTHS = ["w-3/5", "w-2/5", "w-1/2", "w-2/3", "w-2/5", "w-3/5", "w-1/2"]; |
| 50 | const META_WIDTHS = ["w-2/5", "w-1/3", "w-2/5", "w-1/4", "w-1/3", "w-2/5", "w-1/3"]; |
| 51 | const 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. */ |
| 58 | export 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 | */ |
| 100 | export 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. */ |
| 386 | export 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. */ |
| 404 | export 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. */ |
| 425 | export 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 | } |