| author | |
| committer | |
| log | 95103905f5f045523994213bf76bfb14acc31b27 |
| tree | 80fa738f808cac8443790109b15631faeb594001 |
| parent | da7e46d08e85bcb07ecd78721a40f7b612fac2f2 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>5 files changed, 290 insertions(+), 8 deletions(-)
apps/web/src/components/ChatMarkdown.tsx+68| ... | ... | @@ -162,8 +162,10 @@ import { |
| 162 | 162 | findProjectForChangeRequest, |
| 163 | 163 | matchesLinkedPullRequestUrl, |
| 164 | 164 | parseChangeRequestUrl, |
| 165 | pullRequestCandidateUrlFromReferenceAutolink, | |
| 165 | 166 | useOpenChangeRequestLink, |
| 166 | 167 | } from "~/lib/openPullRequestLink"; |
| 168 | import { useOpenLink } from "../browser/useOpenLink"; | |
| 167 | 169 | import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; |
| 168 | 170 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; |
| 169 | 171 | import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links"; |
| ... | ... | @@ -174,6 +176,7 @@ import { |
| 174 | 176 | BrowserPreviewUnavailableError, |
| 175 | 177 | } from "../browser/openFileInPreview"; |
| 176 | 178 | import { resolveLinkTarget } from "../browser/browserLinkTarget"; |
| 179 | import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; | |
| 177 | 180 | |
| 178 | 181 | interface ChatMarkdownProps { |
| 179 | 182 | text: string; |
| ... | ... | @@ -2117,6 +2120,7 @@ function useChatMarkdownState({ |
| 2117 | 2120 | event.clipboardData.setData("text/html", payload.html); |
| 2118 | 2121 | }, []); |
| 2119 | 2122 | const openChangeRequestLink = useOpenChangeRequestLink(threadRef); |
| 2123 | const openDeferredMarkdownLink = useOpenLink(threadRef); | |
| 2120 | 2124 | // Subscribed rather than read at click time: the anchor has to decide |
| 2121 | 2125 | // synchronously whether to intercept its `_blank`, and a subscription is what |
| 2122 | 2126 | // makes a persisted "app" apply once settings hydrate after launch. |
| ... | ... | @@ -2352,6 +2356,7 @@ function useChatMarkdownState({ |
| 2352 | 2356 | () => ({ |
| 2353 | 2357 | cwd, |
| 2354 | 2358 | diffThemeName, |
| 2359 | environmentId, | |
| 2355 | 2360 | expandMedia, |
| 2356 | 2361 | fileLinkChip, |
| 2357 | 2362 | imageBaseDir, |
| ... | ... | @@ -2362,10 +2367,13 @@ function useChatMarkdownState({ |
| 2362 | 2367 | onTaskListChange, |
| 2363 | 2368 | onUseArtifactTemplate, |
| 2364 | 2369 | openChangeRequestLink, |
| 2370 | openDeferredMarkdownLink, | |
| 2365 | 2371 | openExternalLinkInPreview, |
| 2366 | 2372 | openMarkdownMedia, |
| 2373 | projects, | |
| 2367 | 2374 | resolveThreadPullRequest, |
| 2368 | 2375 | resolvedTheme, |
| 2376 | serverConfig, | |
| 2369 | 2377 | skills, |
| 2370 | 2378 | text, |
| 2371 | 2379 | threadRef, |
| ... | ... | @@ -2374,6 +2382,7 @@ function useChatMarkdownState({ |
| 2374 | 2382 | [ |
| 2375 | 2383 | cwd, |
| 2376 | 2384 | diffThemeName, |
| 2385 | environmentId, | |
| 2377 | 2386 | expandMedia, |
| 2378 | 2387 | fileLinkChip, |
| 2379 | 2388 | imageBaseDir, |
| ... | ... | @@ -2384,10 +2393,13 @@ function useChatMarkdownState({ |
| 2384 | 2393 | onTaskListChange, |
| 2385 | 2394 | onUseArtifactTemplate, |
| 2386 | 2395 | openChangeRequestLink, |
| 2396 | openDeferredMarkdownLink, | |
| 2387 | 2397 | openExternalLinkInPreview, |
| 2388 | 2398 | openMarkdownMedia, |
| 2399 | projects, | |
| 2389 | 2400 | resolveThreadPullRequest, |
| 2390 | 2401 | resolvedTheme, |
| 2402 | serverConfig, | |
| 2391 | 2403 | skills, |
| 2392 | 2404 | text, |
| 2393 | 2405 | threadRef, |
| ... | ... | @@ -2492,14 +2504,18 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2492 | 2504 | a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) { |
| 2493 | 2505 | const { |
| 2494 | 2506 | cwd, |
| 2507 | environmentId, | |
| 2495 | 2508 | imageBaseDir, |
| 2496 | 2509 | markdownFileLinkMetaByHref, |
| 2497 | 2510 | threadRef, |
| 2498 | 2511 | openMarkdownMedia, |
| 2499 | 2512 | openChangeRequestLink, |
| 2513 | openDeferredMarkdownLink, | |
| 2500 | 2514 | linkTargetPreference, |
| 2501 | 2515 | openExternalLinkInPreview, |
| 2516 | projects, | |
| 2502 | 2517 | resolveThreadPullRequest, |
| 2518 | serverConfig, | |
| 2503 | 2519 | updateThreadPullRequestLink, |
| 2504 | 2520 | fileLinkChip, |
| 2505 | 2521 | } = use(ChatMarkdownRendererContext); |
| ... | ... | @@ -2522,6 +2538,34 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2522 | 2538 | ? plainHastText(node) |
| 2523 | 2539 | : undefined; |
| 2524 | 2540 | const isPullRequestAutolink = pullRequestCopy !== undefined; |
| 2541 | const confirmBeforeOpen = pullRequestAutolink === "reference"; | |
| 2542 | const pullRequestCandidateUrl = | |
| 2543 | confirmBeforeOpen && href ? pullRequestCandidateUrlFromReferenceAutolink(href) : href; | |
| 2544 | const pullRequestCandidate = pullRequestCandidateUrl | |
| 2545 | ? parseChangeRequestUrl(pullRequestCandidateUrl) | |
| 2546 | : null; | |
| 2547 | const pullRequestProject = | |
| 2548 | environmentId !== null && | |
| 2549 | serverConfig?.environment.capabilities.pullRequests === true && | |
| 2550 | pullRequestCandidate !== null | |
| 2551 | ? findProjectForChangeRequest( | |
| 2552 | projects.filter((project) => project.environmentId === environmentId), | |
| 2553 | pullRequestCandidate, | |
| 2554 | ) | |
| 2555 | : undefined; | |
| 2556 | const pullRequestPreviewTarget = | |
| 2557 | environmentId === null || pullRequestProject === undefined || pullRequestCandidate === null | |
| 2558 | ? null | |
| 2559 | : { | |
| 2560 | environmentId, | |
| 2561 | input: { | |
| 2562 | projectId: pullRequestProject.id, | |
| 2563 | repository: | |
| 2564 | pullRequestProject.repositoryIdentity?.displayName ?? | |
| 2565 | pullRequestCandidate.repository, | |
| 2566 | number: pullRequestCandidate.number, | |
| 2567 | }, | |
| 2568 | }; | |
| 2525 | 2569 | const isSameDocumentLink = href?.startsWith("#") ?? false; |
| 2526 | 2570 | const onClick = props.onClick; |
| 2527 | 2571 | const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); |
| ... | ... | @@ -2653,6 +2697,30 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2653 | 2697 | if (!faviconHost || !href) { |
| 2654 | 2698 | return link; |
| 2655 | 2699 | } |
| 2700 | if (pullRequestPreviewTarget !== null) { | |
| 2701 | return ( | |
| 2702 | <PullRequestLinkPreview | |
| 2703 | link={link} | |
| 2704 | originalUrl={href} | |
| 2705 | target={pullRequestPreviewTarget} | |
| 2706 | confirmBeforeOpen={confirmBeforeOpen} | |
| 2707 | onOpenPullRequest={(targetUrl) => | |
| 2708 | openChangeRequestLink( | |
| 2709 | { | |
| 2710 | metaKey: false, | |
| 2711 | ctrlKey: false, | |
| 2712 | preventDefault: () => undefined, | |
| 2713 | stopPropagation: () => undefined, | |
| 2714 | }, | |
| 2715 | targetUrl, | |
| 2716 | undefined, | |
| 2717 | environmentId ?? undefined, | |
| 2718 | ) | |
| 2719 | } | |
| 2720 | onOpenFallback={openDeferredMarkdownLink} | |
| 2721 | /> | |
| 2722 | ); | |
| 2723 | } | |
| 2656 | 2724 | return ( |
| 2657 | 2725 | <Tooltip> |
| 2658 | 2726 | <TooltipTrigger render={link} /> |
apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx created+119| ... | ... | @@ -0,0 +1,119 @@ |
| 1 | import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; | |
| 2 | import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; | |
| 3 | import { cloneElement, useState, type ComponentPropsWithoutRef, type ReactElement } from "react"; | |
| 4 | ||
| 5 | import { formatRelativeTimeLabel } from "~/timestampFormat"; | |
| 6 | import { pullRequestEnvironment } from "~/state/pullRequests"; | |
| 7 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; | |
| 8 | import { useEnvironmentQuery } from "~/state/query"; | |
| 9 | ||
| 10 | import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "../ui/preview-card"; | |
| 11 | import { PullRequestActorAvatar, resolvePullRequestState } from "./pullRequestPresentation"; | |
| 12 | ||
| 13 | interface PullRequestLinkPreviewTarget { | |
| 14 | readonly environmentId: EnvironmentId; | |
| 15 | readonly input: PullRequestRef; | |
| 16 | } | |
| 17 | ||
| 18 | type PullRequestLinkElement = ReactElement<ComponentPropsWithoutRef<"a">>; | |
| 19 | ||
| 20 | export function PullRequestLinkPreview({ | |
| 21 | link, | |
| 22 | originalUrl, | |
| 23 | target, | |
| 24 | confirmBeforeOpen, | |
| 25 | onOpenPullRequest, | |
| 26 | onOpenFallback, | |
| 27 | }: { | |
| 28 | link: PullRequestLinkElement; | |
| 29 | originalUrl: string; | |
| 30 | target: PullRequestLinkPreviewTarget; | |
| 31 | confirmBeforeOpen: boolean; | |
| 32 | onOpenPullRequest: (url: string) => boolean; | |
| 33 | onOpenFallback: (url: string) => Promise<void>; | |
| 34 | }) { | |
| 35 | const [open, setOpen] = useState(false); | |
| 36 | const [resolvingClick, setResolvingClick] = useState(false); | |
| 37 | const detailQuery = useEnvironmentQuery( | |
| 38 | open | |
| 39 | ? pullRequestEnvironment.detail({ | |
| 40 | environmentId: target.environmentId, | |
| 41 | input: target.input, | |
| 42 | }) | |
| 43 | : null, | |
| 44 | ); | |
| 45 | const readDetail = useAtomQueryRunner(pullRequestEnvironment.detail, { | |
| 46 | reportFailure: false, | |
| 47 | reportDefect: false, | |
| 48 | }); | |
| 49 | ||
| 50 | const trigger = confirmBeforeOpen | |
| 51 | ? cloneElement(link, { | |
| 52 | onClick: (event) => { | |
| 53 | if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; | |
| 54 | event.preventDefault(); | |
| 55 | event.stopPropagation(); | |
| 56 | if (resolvingClick) return; | |
| 57 | setOpen(false); | |
| 58 | setResolvingClick(true); | |
| 59 | void readDetail({ environmentId: target.environmentId, input: target.input }) | |
| 60 | .then(async (result) => { | |
| 61 | if (isAtomCommandInterrupted(result)) return; | |
| 62 | if (result._tag === "Success" && onOpenPullRequest(result.value.url)) return; | |
| 63 | await onOpenFallback(originalUrl); | |
| 64 | }) | |
| 65 | .catch((error: unknown) => { | |
| 66 | console.error("[pull-request-link-preview] failed to open link", error); | |
| 67 | }) | |
| 68 | .finally(() => setResolvingClick(false)); | |
| 69 | }, | |
| 70 | }) | |
| 71 | : link; | |
| 72 | const detail = detailQuery.data; | |
| 73 | const state = | |
| 74 | detail === null | |
| 75 | ? null | |
| 76 | : resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }); | |
| 77 | const authorLabel = | |
| 78 | detail?.author === null | |
| 79 | ? "ghost" | |
| 80 | : detail?.author.name && detail.author.name !== detail.author.login | |
| 81 | ? `${detail.author.name} (@${detail.author.login})` | |
| 82 | : (detail?.author.login ?? null); | |
| 83 | ||
| 84 | return ( | |
| 85 | <PreviewCard open={open} onOpenChange={setOpen}> | |
| 86 | <PreviewCardTrigger render={trigger} delay={350} closeDelay={120} /> | |
| 87 | <PreviewCardPopup className="w-80 max-w-[calc(100vw-2rem)] p-3"> | |
| 88 | {detail === null ? ( | |
| 89 | <p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere"> | |
| 90 | {detailQuery.isPending ? "Loading pull request details…" : originalUrl} | |
| 91 | </p> | |
| 92 | ) : ( | |
| 93 | <div className="min-w-0"> | |
| 94 | <div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground"> | |
| 95 | <span className="min-w-0 truncate">{detail.repository}</span> | |
| 96 | <span className="shrink-0">#{detail.number}</span> | |
| 97 | <span aria-hidden>·</span> | |
| 98 | {state === null ? null : ( | |
| 99 | <span className="inline-flex shrink-0 items-center gap-1"> | |
| 100 | <state.Icon aria-hidden className={`size-3 ${state.toneClassName}`} /> | |
| 101 | {state.label} | |
| 102 | </span> | |
| 103 | )} | |
| 104 | </div> | |
| 105 | <p className="mt-1 text-sm font-medium leading-snug text-foreground text-pretty"> | |
| 106 | {detail.title} | |
| 107 | </p> | |
| 108 | <div className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"> | |
| 109 | <PullRequestActorAvatar actor={detail.author} className="size-4" /> | |
| 110 | <span className="min-w-0 truncate">{authorLabel}</span> | |
| 111 | <span aria-hidden>·</span> | |
| 112 | <span className="shrink-0">opened {formatRelativeTimeLabel(detail.createdAt)}</span> | |
| 113 | </div> | |
| 114 | </div> | |
| 115 | )} | |
| 116 | </PreviewCardPopup> | |
| 117 | </PreviewCard> | |
| 118 | ); | |
| 119 | } |
apps/web/src/components/ui/preview-card.tsx created+44| ... | ... | @@ -0,0 +1,44 @@ |
| 1 | import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"; | |
| 2 | ||
| 3 | import { cn } from "~/lib/utils"; | |
| 4 | ||
| 5 | const PreviewCard = PreviewCardPrimitive.Root; | |
| 6 | ||
| 7 | function PreviewCardTrigger(props: PreviewCardPrimitive.Trigger.Props) { | |
| 8 | return <PreviewCardPrimitive.Trigger data-slot="preview-card-trigger" {...props} />; | |
| 9 | } | |
| 10 | ||
| 11 | function PreviewCardPopup({ | |
| 12 | className, | |
| 13 | align = "start", | |
| 14 | side = "top", | |
| 15 | sideOffset = 6, | |
| 16 | ...props | |
| 17 | }: PreviewCardPrimitive.Popup.Props & { | |
| 18 | align?: PreviewCardPrimitive.Positioner.Props["align"]; | |
| 19 | side?: PreviewCardPrimitive.Positioner.Props["side"]; | |
| 20 | sideOffset?: PreviewCardPrimitive.Positioner.Props["sideOffset"]; | |
| 21 | }) { | |
| 22 | return ( | |
| 23 | <PreviewCardPrimitive.Portal> | |
| 24 | <PreviewCardPrimitive.Positioner | |
| 25 | align={align} | |
| 26 | className="z-[140] max-w-(--available-width)" | |
| 27 | data-slot="preview-card-positioner" | |
| 28 | side={side} | |
| 29 | sideOffset={sideOffset} | |
| 30 | > | |
| 31 | <PreviewCardPrimitive.Popup | |
| 32 | className={cn( | |
| 33 | "origin-(--transform-origin) rounded-lg border bg-popover text-popover-foreground shadow-lg outline-none transition-[scale,opacity] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0", | |
| 34 | className, | |
| 35 | )} | |
| 36 | data-slot="preview-card-popup" | |
| 37 | {...props} | |
| 38 | /> | |
| 39 | </PreviewCardPrimitive.Positioner> | |
| 40 | </PreviewCardPrimitive.Portal> | |
| 41 | ); | |
| 42 | } | |
| 43 | ||
| 44 | export { PreviewCard, PreviewCardPopup, PreviewCardTrigger }; |
apps/web/src/lib/openPullRequestLink.test.ts+24| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | matchesLinkedPullRequestUrl, |
| 8 | 8 | openPullRequestLink, |
| 9 | 9 | parseChangeRequestUrl, |
| 10 | pullRequestCandidateUrlFromReferenceAutolink, | |
| 10 | 11 | PullRequestLinkOpenError, |
| 11 | 12 | shouldOpenPullRequestExternally, |
| 12 | 13 | } from "./openPullRequestLink"; |
| ... | ... | @@ -130,6 +131,29 @@ describe("changeRequestRepositoryUrl", () => { |
| 130 | 131 | }); |
| 131 | 132 | }); |
| 132 | 133 | |
| 134 | describe("pullRequestCandidateUrlFromReferenceAutolink", () => { | |
| 135 | it("turns GitHub's shared issue route into a pull request candidate", () => { | |
| 136 | expect( | |
| 137 | pullRequestCandidateUrlFromReferenceAutolink( | |
| 138 | "https://github.com/pingdotgg/t3code/issues/8600#issuecomment-1", | |
| 139 | ), | |
| 140 | ).toBe("https://github.com/pingdotgg/t3code/pull/8600#issuecomment-1"); | |
| 141 | }); | |
| 142 | ||
| 143 | it("does not reinterpret other issue hosts or malformed references", () => { | |
| 144 | expect( | |
| 145 | pullRequestCandidateUrlFromReferenceAutolink( | |
| 146 | "https://gitlab.com/pingdotgg/t3code/-/issues/8600", | |
| 147 | ), | |
| 148 | ).toBeNull(); | |
| 149 | expect( | |
| 150 | pullRequestCandidateUrlFromReferenceAutolink( | |
| 151 | "https://github.com/pingdotgg/t3code/issues/not-a-number", | |
| 152 | ), | |
| 153 | ).toBeNull(); | |
| 154 | }); | |
| 155 | }); | |
| 156 | ||
| 133 | 157 | describe("matchesLinkedPullRequestUrl", () => { |
| 134 | 158 | const linkedPullRequest = { |
| 135 | 159 | projectId: ProjectId.make("project-1"), |
apps/web/src/lib/openPullRequestLink.ts+35-8| ... | ... | @@ -160,6 +160,30 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu |
| 160 | 160 | return null; |
| 161 | 161 | } |
| 162 | 162 | |
| 163 | /** | |
| 164 | * The pull-request URL a GitHub-style `#123` autolink might name. GitHub writes every bare | |
| 165 | * reference through `/issues/`, including pull requests, so this only builds a candidate: the | |
| 166 | * caller must successfully read it as a pull request before treating it as one. | |
| 167 | */ | |
| 168 | export function pullRequestCandidateUrlFromReferenceAutolink(targetUrl: string): string | null { | |
| 169 | let url: URL; | |
| 170 | try { | |
| 171 | url = new URL(targetUrl); | |
| 172 | } catch { | |
| 173 | return null; | |
| 174 | } | |
| 175 | if ( | |
| 176 | (url.protocol !== "https:" && url.protocol !== "http:") || | |
| 177 | !isHostOf(url.hostname.toLowerCase(), "github.com", "github") | |
| 178 | ) { | |
| 179 | return null; | |
| 180 | } | |
| 181 | const match = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u.exec(url.pathname); | |
| 182 | if (match?.[1] === undefined || match[2] === undefined) return null; | |
| 183 | url.pathname = `/${match[1]}/pull/${match[2]}`; | |
| 184 | return url.toString(); | |
| 185 | } | |
| 186 | ||
| 163 | 187 | /** Match a stored PR without requiring its project to remain available. */ |
| 164 | 188 | export function matchesLinkedPullRequestUrl( |
| 165 | 189 | linkedPullRequest: ThreadLinkedPullRequest, |
| ... | ... | @@ -263,13 +287,14 @@ export function useOpenChangeRequestLink( |
| 263 | 287 | >, |
| 264 | 288 | targetUrl: string, |
| 265 | 289 | targetThreadRef?: ScopedThreadRef, |
| 290 | targetEnvironmentId?: EnvironmentId, | |
| 266 | 291 | ) => boolean { |
| 267 | 292 | const navigate = useNavigate(); |
| 268 | 293 | const allProjects = useProjects(); |
| 269 | 294 | const serverConfigs = useServerConfigs(); |
| 270 | 295 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 271 | 296 | return useCallback( |
| 272 | (event, targetUrl, targetThreadRef) => { | |
| 297 | (event, targetUrl, targetThreadRef, targetEnvironmentId) => { | |
| 273 | 298 | if (shouldOpenPullRequestExternally(event)) return false; |
| 274 | 299 | const resolvedThreadRef = targetThreadRef ?? threadRef; |
| 275 | 300 | const parsed = parseChangeRequestUrl(targetUrl); |
| ... | ... | @@ -285,13 +310,15 @@ export function useOpenChangeRequestLink( |
| 285 | 310 | // against all of them, the primary first where two hold the same repository. |
| 286 | 311 | const projects = resolvedThreadRef |
| 287 | 312 | ? allProjects.filter((project) => project.environmentId === resolvedThreadRef.environmentId) |
| 288 | : allProjects | |
| 289 | .filter((project) => reads(project.environmentId)) | |
| 290 | .toSorted( | |
| 291 | (left, right) => | |
| 292 | Number(right.environmentId === primaryEnvironmentId) - | |
| 293 | Number(left.environmentId === primaryEnvironmentId), | |
| 294 | ); | |
| 313 | : targetEnvironmentId | |
| 314 | ? allProjects.filter((project) => project.environmentId === targetEnvironmentId) | |
| 315 | : allProjects | |
| 316 | .filter((project) => reads(project.environmentId)) | |
| 317 | .toSorted( | |
| 318 | (left, right) => | |
| 319 | Number(right.environmentId === primaryEnvironmentId) - | |
| 320 | Number(left.environmentId === primaryEnvironmentId), | |
| 321 | ); | |
| 295 | 322 | const project = findProjectForChangeRequest(projects, parsed); |
| 296 | 323 | if (project === undefined || !reads(project.environmentId)) return false; |
| 297 | 324 | event.preventDefault(); |