1import type { EnvironmentId, PullRequestContextMetadata } from "@t3tools/contracts";
2import { CircleDashedIcon, FilmIcon, ImageIcon } from "lucide-react";
3import {
4 useEffect,
5 useState,
6 type ComponentProps,
7 type CSSProperties,
8 type MouseEvent,
9 type ReactNode,
10} from "react";
11import { useAssetUrlState } from "~/assets/assetUrls";
12import { documentChipKindForFileName } from "~/pierre-icons";
13
14import { PULL_REQUEST_STATE_PRESENTATION } from "~/components/pullRequest/pullRequestIcons";
15import type { PullRequestContextDisplayState } from "~/lib/composerContextRecords";
16import { PierreEntryIcon } from "./chat/PierreEntryIcon";
17import { middleTruncateAttachmentName } from "./composerInlineChip";
18import { PullRequestContextDetails } from "./PullRequestContextDetails";
19import { ContextChip, ContextChipLabel, type ContextChipKind } from "./ContextChip";
20import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover";
21import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
22import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview";
23import { usePullRequestPreviewTarget } from "~/lib/openPullRequestLink";
24
25export const PULL_REQUEST_CHIP_KINDS = {
26 open: "pr-open",
27 draft: "pr-draft",
28 merged: "pr-merged",
29 closed: "pr-closed",
30 unknown: "pull-request",
31} as const satisfies Record<PullRequestContextDisplayState | "unknown", ContextChipKind>;
32
33export function ContextChipShell({
34 icon,
35 label,
36 tooltip,
37 ...props
38}: Omit<ComponentProps<typeof ContextChip>, "className" | "render"> & {
39 icon: ReactNode;
40 label: string;
41 tooltip?: ReactNode;
42}) {
43 const chip = (
44 <ContextChip
45 data-context-unresolved={props.state === "unresolved" ? "true" : undefined}
46 tabIndex={tooltip ? 0 : undefined}
47 {...props}
48 >
49 {icon}
50 <ContextChipLabel>{label}</ContextChipLabel>
51 </ContextChip>
52 );
53 if (!tooltip) return chip;
54 return (
55 <Tooltip>
56 <TooltipTrigger render={chip} />
57 <TooltipPopup side="top" className="whitespace-pre-wrap">
58 {tooltip}
59 </TooltipPopup>
60 </Tooltip>
61 );
62}
63
64export function ContextChipPopover(props: {
65 kind: ContextChipKind;
66 icon: ReactNode;
67 label: string;
68 copyMarkdown?: string | undefined;
69 accessibleLabel: string;
70 /** Overrides the kind's accent color. */
71 accent?: string | undefined;
72 children: ReactNode;
73}) {
74 return (
75 <Popover>
76 <PopoverTrigger
77 render={
78 <ContextChip
79 kind={props.kind}
80 style={
81 props.accent
82 ? ({ "--context-chip-accent": props.accent } as CSSProperties)
83 : undefined
84 }
85 render={<button type="button" />}
86 aria-label={`${props.accessibleLabel}. Show details`}
87 data-markdown-copy={props.copyMarkdown}
88 />
89 }
90 >
91 {props.icon}
92 <ContextChipLabel>{props.label}</ContextChipLabel>
93 </PopoverTrigger>
94 <PopoverPopup side="top" width="lg" padding="compact">
95 <PopoverTitle className="sr-only">{props.accessibleLabel}</PopoverTitle>
96 {props.children}
97 </PopoverPopup>
98 </Popover>
99 );
100}
101
102export function PullRequestChip(props: {
103 metadata: PullRequestContextMetadata;
104 environmentId: EnvironmentId | null;
105 label: string;
106 kindLabel: string;
107 kind: ContextChipKind;
108 copyMarkdown?: string;
109 onOpen: (event: MouseEvent<HTMLElement>, url: string) => void;
110}) {
111 const previewTarget = usePullRequestPreviewTarget(props.environmentId, props.metadata.url);
112 const displayState =
113 props.metadata.state === "open" && props.metadata.isDraft ? "draft" : props.metadata.state;
114 const StateIcon = PULL_REQUEST_STATE_PRESENTATION[displayState].Icon;
115 const button = (
116 <ContextChip
117 kind={props.kind}
118 render={<button type="button" />}
119 aria-label={`Open ${props.kindLabel} ${props.label}: ${props.metadata.title}`}
120 data-markdown-copy={props.copyMarkdown}
121 onClick={(event) => props.onOpen(event, props.metadata.url)}
122 onAuxClick={(event) => props.onOpen(event, props.metadata.url)}
123 >
124 <StateIcon />
125 <ContextChipLabel>{props.label}</ContextChipLabel>
126 </ContextChip>
127 );
128 if (previewTarget !== null) {
129 return (
130 <PullRequestLinkPreview
131 link={button}
132 originalUrl={props.metadata.url}
133 target={previewTarget}
134 confirmBeforeOpen={false}
135 fallback={<PullRequestContextDetails metadata={props.metadata} />}
136 />
137 );
138 }
139 return (
140 <Tooltip>
141 <TooltipTrigger render={button} />
142 <TooltipPopup side="top">
143 <PullRequestContextDetails metadata={props.metadata} />
144 </TooltipPopup>
145 </Tooltip>
146 );
147}
148
149export function ImageChipButton({
150 name,
151 previewUrl,
152 size,
153 suffix,
154 disabled,
155 onPreviewError,
156 ...props
157}: Omit<ComponentProps<typeof ContextChip>, "kind" | "render"> & {
158 name: string;
159 previewUrl: string | undefined;
160 size: string;
161 suffix?: string | null | undefined;
162 disabled?: boolean | undefined;
163 onPreviewError?: (() => void) | undefined;
164}) {
165 return (
166 <ContextChip
167 kind="image"
168 render={<button type="button" disabled={disabled} />}
169 aria-label={`Image attachment, ${name}, ${size}`}
170 {...props}
171 >
172 {previewUrl ? (
173 <img
174 key={previewUrl}
175 src={previewUrl}
176 alt=""
177 className="size-[1em] shrink-0 self-center rounded-sm object-cover"
178 onError={onPreviewError}
179 />
180 ) : (
181 <ImageIcon />
182 )}
183 <ContextChipLabel className="flex items-baseline gap-[0.33em]">
184 <span className="min-w-0 max-w-72 overflow-x-clip text-ellipsis whitespace-nowrap">
185 {middleTruncateAttachmentName(name)}
186 </span>
187 <span className="shrink-0 text-3xs text-current">{size}</span>
188 {suffix ? <span className="text-3xs text-current">{suffix}</span> : null}
189 </ContextChipLabel>
190 </ContextChip>
191 );
192}
193
194export function FileChip(props: {
195 name: string;
196 size: string;
197 isVideo: boolean;
198 theme: "light" | "dark";
199 accessibleLabel: string;
200 tooltip: string;
201 suffix?: string | null;
202 copyMarkdown?: string;
203 disabled?: boolean;
204 error?: boolean;
205 unresolved?: boolean;
206 onOpen?: (() => void) | undefined;
207 svgPreview?:
208 | {
209 file?: Blob | null | undefined;
210 environmentId?: EnvironmentId | undefined;
211 attachmentId?: string | undefined;
212 }
213 | undefined;
214}) {
215 if (props.svgPreview && !props.unresolved) return <SvgFileChip {...props} />;
216 const content = <FileChipContent {...props} />;
217 const state = props.unresolved
218 ? ("unresolved" as const)
219 : props.error
220 ? ("invalid" as const)
221 : null;
222 const attributes = {
223 kind: props.isVideo ? "video" : (documentChipKindForFileName(props.name) ?? "file"),
224 ...(state ? { state } : {}),
225 "aria-label": [props.accessibleLabel, props.suffix].filter(Boolean).join(", "),
226 "data-markdown-copy": props.copyMarkdown,
227 "data-context-unresolved": props.unresolved ? "true" : undefined,
228 } as const;
229 return (
230 <Tooltip>
231 <TooltipTrigger
232 render={
233 props.onOpen ? (
234 <ContextChip
235 render={<button type="button" disabled={props.disabled} />}
236 onClick={props.onOpen}
237 {...attributes}
238 >
239 {content}
240 </ContextChip>
241 ) : (
242 <ContextChip tabIndex={0} {...attributes}>
243 {content}
244 </ContextChip>
245 )
246 }
247 />
248 <TooltipPopup side="top" className="whitespace-pre-wrap">
249 {props.tooltip}
250 </TooltipPopup>
251 </Tooltip>
252 );
253}
254
255function SvgFileChip(props: ComponentProps<typeof FileChip>) {
256 const preview = props.svgPreview;
257 const [local, setLocal] = useState<{ file: Blob; url: string }>();
258 const [failedPreviewUrl, setFailedPreviewUrl] = useState<string>();
259 const asset = useAssetUrlState(
260 preview?.file ? null : (preview?.environmentId ?? null),
261 preview?.attachmentId
262 ? {
263 _tag: "attachment",
264 attachmentId: preview.attachmentId,
265 fileName: props.name,
266 mimeType: "image/svg+xml",
267 disposition: "inline",
268 }
269 : null,
270 );
271 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- creates an object URL for the local SVG file and revokes it on cleanup
272 useEffect(() => {
273 const file = preview?.file;
274 if (!file) return;
275 const url = URL.createObjectURL(file);
276 // oxlint-disable-next-line react/set-state-in-effect -- Publish a Blob URL with its cleanup registered.
277 setLocal({ file, url });
278 return () => URL.revokeObjectURL(url);
279 }, [preview?.file]);
280 const url = preview?.file
281 ? local?.file === preview.file
282 ? local.url
283 : undefined
284 : asset._tag === "Success"
285 ? asset.url
286 : undefined;
287 if (url !== undefined && failedPreviewUrl === url) {
288 return <FileChip {...props} svgPreview={undefined} />;
289 }
290 return (
291 <Tooltip>
292 <TooltipTrigger
293 render={
294 <ImageChipButton
295 name={props.name}
296 size={props.size}
297 previewUrl={url}
298 suffix={props.suffix}
299 disabled={props.disabled}
300 onClick={props.onOpen}
301 onPreviewError={() => setFailedPreviewUrl(url)}
302 data-markdown-copy={props.copyMarkdown}
303 aria-label={props.accessibleLabel}
304 {...(props.error ? { state: "invalid" as const } : {})}
305 />
306 }
307 />
308 <TooltipPopup side="top" className="whitespace-pre-wrap">
309 {props.tooltip}
310 </TooltipPopup>
311 </Tooltip>
312 );
313}
314
315function FileChipContent(props: {
316 name: string;
317 size: string;
318 isVideo: boolean;
319 theme: "light" | "dark";
320 suffix?: string | null;
321 unresolved?: boolean;
322}) {
323 return (
324 <>
325 {props.unresolved ? (
326 <CircleDashedIcon />
327 ) : props.isVideo ? (
328 <FilmIcon />
329 ) : (
330 <PierreEntryIcon pathValue={props.name} kind="file" theme={props.theme} />
331 )}
332 <ContextChipLabel className="flex items-baseline gap-[0.33em]">
333 <span className="min-w-0 max-w-72 overflow-x-clip text-ellipsis whitespace-nowrap">
334 {middleTruncateAttachmentName(props.name)}
335 </span>
336 <span className="shrink-0 text-3xs text-current">{props.size}</span>
337 {props.suffix ? <span className="text-3xs text-current">{props.suffix}</span> : null}
338 </ContextChipLabel>
339 </>
340 );
341}
342
343export function UnresolvedChip(props: { label: string; tooltip: string; copyMarkdown?: string }) {
344 return (
345 <ContextChipShell
346 icon={<CircleDashedIcon />}
347 label={props.label}
348 state="unresolved"
349 aria-label={`Unavailable context, ${props.label}`}
350 data-markdown-copy={props.copyMarkdown}
351 tooltip={props.tooltip}
352 />
353 );
354}