| 1 | import type { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"; |
| 2 | import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; |
| 3 | import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; |
| 4 | import { |
| 5 | cloneElement, |
| 6 | useRef, |
| 7 | useState, |
| 8 | type ComponentPropsWithoutRef, |
| 9 | type MouseEvent, |
| 10 | type ReactElement, |
| 11 | type ReactNode, |
| 12 | } from "react"; |
| 13 | |
| 14 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 15 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 16 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; |
| 17 | import { useEnvironmentQuery } from "~/state/query"; |
| 18 | |
| 19 | import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "../ui/preview-card"; |
| 20 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 21 | import { PullRequestActorAvatar, resolvePullRequestState } from "./pullRequestPresentation"; |
| 22 | |
| 23 | interface PullRequestLinkPreviewTarget { |
| 24 | readonly environmentId: EnvironmentId; |
| 25 | readonly input: PullRequestRef; |
| 26 | } |
| 27 | |
| 28 | type PullRequestLinkElement = ReactElement< |
| 29 | ComponentPropsWithoutRef<"a"> | ComponentPropsWithoutRef<"button"> |
| 30 | >; |
| 31 | |
| 32 | export function PullRequestLinkPreview({ |
| 33 | link, |
| 34 | originalUrl, |
| 35 | target, |
| 36 | confirmBeforeOpen, |
| 37 | onOpenPullRequest, |
| 38 | onOpenFallback, |
| 39 | fallback, |
| 40 | }: { |
| 41 | link: PullRequestLinkElement; |
| 42 | originalUrl: string; |
| 43 | target: PullRequestLinkPreviewTarget; |
| 44 | confirmBeforeOpen?: boolean; |
| 45 | onOpenPullRequest?: (url: string) => boolean; |
| 46 | onOpenFallback?: (url: string) => Promise<void>; |
| 47 | fallback?: ReactNode; |
| 48 | }) { |
| 49 | const [open, setOpen] = useState(false); |
| 50 | const previewActionsRef = useRef<PreviewCardPrimitive.Root.Actions | null>(null); |
| 51 | const [resolvingClick, setResolvingClick] = useState(false); |
| 52 | const detailQuery = useEnvironmentQuery( |
| 53 | open |
| 54 | ? pullRequestEnvironment.detail({ |
| 55 | environmentId: target.environmentId, |
| 56 | input: target.input, |
| 57 | }) |
| 58 | : null, |
| 59 | ); |
| 60 | const readPreview = useAtomQueryRunner(pullRequestEnvironment.preview, { |
| 61 | reportFailure: false, |
| 62 | reportDefect: false, |
| 63 | }); |
| 64 | const trigger = |
| 65 | confirmBeforeOpen === true |
| 66 | ? cloneElement(link, { |
| 67 | onClick: (event: MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => { |
| 68 | if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; |
| 69 | event.preventDefault(); |
| 70 | event.stopPropagation(); |
| 71 | if (resolvingClick) return; |
| 72 | setOpen(false); |
| 73 | setResolvingClick(true); |
| 74 | void readPreview(target) |
| 75 | .then(async (result) => { |
| 76 | if (isAtomCommandInterrupted(result)) return; |
| 77 | if (result._tag === "Success" && onOpenPullRequest?.(result.value.url)) return; |
| 78 | await onOpenFallback?.(originalUrl); |
| 79 | }) |
| 80 | .catch((error: unknown) => { |
| 81 | console.error("[pull-request-link-preview] failed to open link", error); |
| 82 | }) |
| 83 | .finally(() => setResolvingClick(false)); |
| 84 | }, |
| 85 | }) |
| 86 | : link; |
| 87 | const detail = detailQuery.data; |
| 88 | const showCard = detail !== null || (detailQuery.error !== null && fallback !== undefined); |
| 89 | const showUrlTooltip = open && detailQuery.error !== null && !showCard; |
| 90 | const state = |
| 91 | detail === null |
| 92 | ? null |
| 93 | : resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }); |
| 94 | const authorLabel = |
| 95 | detail?.author === null |
| 96 | ? "ghost" |
| 97 | : detail?.author.name && detail.author.name !== detail.author.login |
| 98 | ? `${detail.author.name} (@${detail.author.login})` |
| 99 | : (detail?.author.login ?? null); |
| 100 | |
| 101 | return ( |
| 102 | <PreviewCard open={open} onOpenChange={setOpen} actionsRef={previewActionsRef}> |
| 103 | <Tooltip |
| 104 | open={showUrlTooltip} |
| 105 | onOpenChange={(nextOpen) => { |
| 106 | // Cancel the card's delayed hover too, without changing its content preview. |
| 107 | if (!nextOpen && !showCard) previewActionsRef.current?.close(); |
| 108 | }} |
| 109 | > |
| 110 | <PreviewCardTrigger |
| 111 | render={<TooltipTrigger render={trigger} />} |
| 112 | delay={350} |
| 113 | closeDelay={120} |
| 114 | /> |
| 115 | <TooltipPopup side="top">{originalUrl}</TooltipPopup> |
| 116 | </Tooltip> |
| 117 | {showCard ? ( |
| 118 | <PreviewCardPopup align="center" className="w-80 max-w-[calc(100vw-2rem)]"> |
| 119 | <div className="p-3"> |
| 120 | {detail === null ? ( |
| 121 | fallback |
| 122 | ) : ( |
| 123 | <div className="min-w-0"> |
| 124 | <div className="flex min-w-0 items-center gap-1.5 text-2xs text-muted-foreground"> |
| 125 | <span className="min-w-0 truncate">{detail.repository}</span> |
| 126 | <span className="shrink-0">#{detail.number}</span> |
| 127 | <span aria-hidden>·</span> |
| 128 | {state === null ? null : ( |
| 129 | <span className="inline-flex shrink-0 items-center gap-1"> |
| 130 | <state.Icon aria-hidden className={`size-3 ${state.toneClassName}`} /> |
| 131 | {state.label} |
| 132 | </span> |
| 133 | )} |
| 134 | </div> |
| 135 | <p className="mt-1 text-sm font-medium leading-snug text-foreground text-pretty"> |
| 136 | {detail.title} |
| 137 | </p> |
| 138 | <div className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"> |
| 139 | <PullRequestActorAvatar actor={detail.author} className="size-4" /> |
| 140 | <span className="min-w-0 truncate">{authorLabel}</span> |
| 141 | <span aria-hidden>·</span> |
| 142 | <span className="shrink-0"> |
| 143 | opened {formatRelativeTimeLabel(detail.createdAt)} |
| 144 | </span> |
| 145 | </div> |
| 146 | </div> |
| 147 | )} |
| 148 | </div> |
| 149 | </PreviewCardPopup> |
| 150 | ) : null} |
| 151 | </PreviewCard> |
| 152 | ); |
| 153 | } |