| 1 | import { ExternalLinkIcon, PaperclipIcon } from "lucide-react"; |
| 2 | import { markdownImageSourceFragment } from "@t3tools/client-runtime/markdown-images"; |
| 3 | import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; |
| 4 | import type { AssetResource, EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; |
| 5 | import { createContext, useContext, useMemo } from "react"; |
| 6 | import type { Options as ReactMarkdownOptions } from "react-markdown"; |
| 7 | |
| 8 | import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; |
| 9 | import { cn } from "~/lib/utils"; |
| 10 | import { PULL_REQUESTS_PANEL_REF } from "~/rightPanelStore"; |
| 11 | |
| 12 | import ChatMarkdown from "../ChatMarkdown"; |
| 13 | import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; |
| 14 | import { remarkPullRequestAutolinks, splitPullRequestBody } from "./pullRequestMarkdown.logic"; |
| 15 | |
| 16 | export 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 | */ |
| 26 | function 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. */ |
| 62 | export 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 : ``} |
| 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 | } |