1import type { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card";
2import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
3import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts";
4import {
5 cloneElement,
6 useRef,
7 useState,
8 type ComponentPropsWithoutRef,
9 type MouseEvent,
10 type ReactElement,
11 type ReactNode,
12} from "react";
13
14import { formatRelativeTimeLabel } from "~/timestampFormat";
15import { pullRequestEnvironment } from "~/state/pullRequests";
16import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
17import { useEnvironmentQuery } from "~/state/query";
18
19import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "../ui/preview-card";
20import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
21import { PullRequestActorAvatar, resolvePullRequestState } from "./pullRequestPresentation";
22
23interface PullRequestLinkPreviewTarget {
24 readonly environmentId: EnvironmentId;
25 readonly input: PullRequestRef;
26}
27
28type PullRequestLinkElement = ReactElement<
29 ComponentPropsWithoutRef<"a"> | ComponentPropsWithoutRef<"button">
30>;
31
32export 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}