1import { ExternalLinkIcon, PaperclipIcon } from "lucide-react";
2import { markdownImageSourceFragment } from "@t3tools/client-runtime/markdown-images";
3import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia";
4import type { AssetResource, EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
5import { createContext, useContext, useMemo } from "react";
6import type { Options as ReactMarkdownOptions } from "react-markdown";
7
8import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";
9import { cn } from "~/lib/utils";
10import { PULL_REQUESTS_PANEL_REF } from "~/rightPanelStore";
11
12import ChatMarkdown from "../ChatMarkdown";
13import { MediaVideoPlayer } from "../media/MediaVideoPlayer";
14import { remarkPullRequestAutolinks, splitPullRequestBody } from "./pullRequestMarkdown.logic";
15
16export const PullRequestMarkdownContext = createContext<{
17 repositoryUrl: string | null;
18 threadRef: ScopedThreadRef | null;
19} | null>(null);
20
21/**
22 * A video GitHub hosts for the repository. It plays through a signed asset URL the server
23 * fetches with the repository's GitHub credential, which is what a private repository's
24 * uploads need; the URL is re-signed on retry, so a stale one recovers without a reload.
25 */
26function PullRequestGitHubVideo({
27 environmentId,
28 cwd,
29 url,
30 fetchUrl,
31}: {
32 environmentId: EnvironmentId;
33 cwd: string;
34 /** What the body authored, which is what "Open original" should reach. */
35 url: string;
36 /** The canonical GitHub media URL: a `blob` link addresses the page, not the bytes. */
37 fetchUrl: string;
38}) {
39 const resource = useMemo<AssetResource>(
40 () => ({ _tag: "github-media", cwd, url: fetchUrl }),
41 [cwd, fetchUrl],
42 );
43 const assetUrl = useAssetUrlState(environmentId, resource);
44 const refreshAssetUrl = useAssetUrlRefresh(environmentId, resource);
45 // A server too old to sign this resource, or one with no route to GitHub, still leaves a
46 // public repository's video playing exactly as it did before.
47 const src =
48 assetUrl._tag === "Success" ? assetUrl.url : assetUrl._tag === "Failure" ? fetchUrl : null;
49 return (
50 <MediaVideoPlayer
51 src={src === null ? null : src + markdownImageSourceFragment(url)}
52 originalUrl={url}
53 label="Pull request video"
54 className="w-full"
55 videoClassName="rounded-lg border border-border/60"
56 onRetry={refreshAssetUrl}
57 />
58 );
59}
60
61/** Renders PR uploads inline, with retry and an original link when video playback fails. */
62export function PullRequestMarkdown({
63 text,
64 cwd,
65 environmentId,
66 threadRef,
67 className,
68}: {
69 text: string;
70 cwd: string;
71 environmentId: EnvironmentId;
72 /** Thread the body is shown beside, so its links can open in that thread's in-app browser. */
73 threadRef?: ScopedThreadRef | null;
74 className?: string;
75}) {
76 const segments = splitPullRequestBody(text);
77 const context = useContext(PullRequestMarkdownContext);
78 const repositoryUrl = context?.repositoryUrl;
79 const resolvedThreadRef = threadRef ?? context?.threadRef ?? undefined;
80 const extraRemarkPlugins = useMemo<NonNullable<ReactMarkdownOptions["remarkPlugins"]>>(
81 () => (repositoryUrl ? [[remarkPullRequestAutolinks, { repositoryUrl }]] : []),
82 [repositoryUrl],
83 );
84 return (
85 <div
86 className={cn(
87 "space-y-3 [&_[data-markdown-details]]:border-0 [&_[data-markdown-details-summary]]:text-foreground/80 [&_[data-markdown-details-summary]>svg]:text-muted-foreground/60",
88 className,
89 )}
90 data-image-gallery
91 >
92 {segments.map((segment) => {
93 if (segment.kind === "markdown" || segment.media === "image") {
94 return (
95 <ChatMarkdown
96 key={segment.id}
97 text={segment.kind === "markdown" ? segment.text : `![](<${segment.url}>)`}
98 cwd={cwd}
99 threadRef={resolvedThreadRef}
100 pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF}
101 environmentId={environmentId}
102 extraRemarkPlugins={extraRemarkPlugins}
103 githubMedia
104 />
105 );
106 }
107 const githubMediaUrl = segment.media === "video" ? githubMediaFetchUrl(segment.url) : null;
108 if (githubMediaUrl !== null) {
109 return (
110 <PullRequestGitHubVideo
111 key={`${segment.id}:${segment.url}`}
112 environmentId={environmentId}
113 cwd={cwd}
114 url={segment.url}
115 fetchUrl={githubMediaUrl}
116 />
117 );
118 }
119 if (segment.media === "video") {
120 return (
121 <MediaVideoPlayer
122 key={`${segment.id}:${segment.url}`}
123 src={segment.url}
124 originalUrl={segment.url}
125 label="Pull request video"
126 className="w-full"
127 videoClassName="rounded-lg border border-border/60"
128 />
129 );
130 }
131 return (
132 // A plain anchor rather than the page's openExternal button: the desktop window
133 // turns a blocked _blank into openExternal itself, and in a browser tab — where
134 // there is no shell to call — this is the only one of the two that goes anywhere.
135 <a
136 key={segment.id}
137 href={segment.url}
138 rel="noreferrer noopener"
139 target="_blank"
140 className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-sm hover:bg-muted/60"
141 >
142 <PaperclipIcon aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
143 <span className="min-w-0 flex-1 truncate">Open attachment on GitHub</span>
144 <ExternalLinkIcon aria-hidden className="size-3 shrink-0 text-muted-foreground" />
145 </a>
146 );
147 })}
148 </div>
149 );
150}