| 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,8 +162,10 @@ import { |
| 162 | findProjectForChangeRequest, | 162 | findProjectForChangeRequest, |
| 163 | matchesLinkedPullRequestUrl, | 163 | matchesLinkedPullRequestUrl, |
| 164 | parseChangeRequestUrl, | 164 | parseChangeRequestUrl, |
| 165 | pullRequestCandidateUrlFromReferenceAutolink, | ||
| 165 | useOpenChangeRequestLink, | 166 | useOpenChangeRequestLink, |
| 166 | } from "~/lib/openPullRequestLink"; | 167 | } from "~/lib/openPullRequestLink"; |
| 168 | import { useOpenLink } from "../browser/useOpenLink"; | ||
| 167 | import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; | 169 | import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; |
| 168 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; | 170 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; |
| 169 | import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links"; | 171 | import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links"; |
| ... | @@ -174,6 +176,7 @@ import { | ... | @@ -174,6 +176,7 @@ import { |
| 174 | BrowserPreviewUnavailableError, | 176 | BrowserPreviewUnavailableError, |
| 175 | } from "../browser/openFileInPreview"; | 177 | } from "../browser/openFileInPreview"; |
| 176 | import { resolveLinkTarget } from "../browser/browserLinkTarget"; | 178 | import { resolveLinkTarget } from "../browser/browserLinkTarget"; |
| 179 | import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; | ||
| 177 | 180 | ||
| 178 | interface ChatMarkdownProps { | 181 | interface ChatMarkdownProps { |
| 179 | text: string; | 182 | text: string; |
| ... | @@ -2117,6 +2120,7 @@ function useChatMarkdownState({ | ... | @@ -2117,6 +2120,7 @@ function useChatMarkdownState({ |
| 2117 | event.clipboardData.setData("text/html", payload.html); | 2120 | event.clipboardData.setData("text/html", payload.html); |
| 2118 | }, []); | 2121 | }, []); |
| 2119 | const openChangeRequestLink = useOpenChangeRequestLink(threadRef); | 2122 | const openChangeRequestLink = useOpenChangeRequestLink(threadRef); |
| 2123 | const openDeferredMarkdownLink = useOpenLink(threadRef); | ||
| 2120 | // Subscribed rather than read at click time: the anchor has to decide | 2124 | // Subscribed rather than read at click time: the anchor has to decide |
| 2121 | // synchronously whether to intercept its `_blank`, and a subscription is what | 2125 | // synchronously whether to intercept its `_blank`, and a subscription is what |
| 2122 | // makes a persisted "app" apply once settings hydrate after launch. | 2126 | // makes a persisted "app" apply once settings hydrate after launch. |
| ... | @@ -2352,6 +2356,7 @@ function useChatMarkdownState({ | ... | @@ -2352,6 +2356,7 @@ function useChatMarkdownState({ |
| 2352 | () => ({ | 2356 | () => ({ |
| 2353 | cwd, | 2357 | cwd, |
| 2354 | diffThemeName, | 2358 | diffThemeName, |
| 2359 | environmentId, | ||
| 2355 | expandMedia, | 2360 | expandMedia, |
| 2356 | fileLinkChip, | 2361 | fileLinkChip, |
| 2357 | imageBaseDir, | 2362 | imageBaseDir, |
| ... | @@ -2362,10 +2367,13 @@ function useChatMarkdownState({ | ... | @@ -2362,10 +2367,13 @@ function useChatMarkdownState({ |
| 2362 | onTaskListChange, | 2367 | onTaskListChange, |
| 2363 | onUseArtifactTemplate, | 2368 | onUseArtifactTemplate, |
| 2364 | openChangeRequestLink, | 2369 | openChangeRequestLink, |
| 2370 | openDeferredMarkdownLink, | ||
| 2365 | openExternalLinkInPreview, | 2371 | openExternalLinkInPreview, |
| 2366 | openMarkdownMedia, | 2372 | openMarkdownMedia, |
| 2373 | projects, | ||
| 2367 | resolveThreadPullRequest, | 2374 | resolveThreadPullRequest, |
| 2368 | resolvedTheme, | 2375 | resolvedTheme, |
| 2376 | serverConfig, | ||
| 2369 | skills, | 2377 | skills, |
| 2370 | text, | 2378 | text, |
| 2371 | threadRef, | 2379 | threadRef, |
| ... | @@ -2374,6 +2382,7 @@ function useChatMarkdownState({ | ... | @@ -2374,6 +2382,7 @@ function useChatMarkdownState({ |
| 2374 | [ | 2382 | [ |
| 2375 | cwd, | 2383 | cwd, |
| 2376 | diffThemeName, | 2384 | diffThemeName, |
| 2385 | environmentId, | ||
| 2377 | expandMedia, | 2386 | expandMedia, |
| 2378 | fileLinkChip, | 2387 | fileLinkChip, |
| 2379 | imageBaseDir, | 2388 | imageBaseDir, |
| ... | @@ -2384,10 +2393,13 @@ function useChatMarkdownState({ | ... | @@ -2384,10 +2393,13 @@ function useChatMarkdownState({ |
| 2384 | onTaskListChange, | 2393 | onTaskListChange, |
| 2385 | onUseArtifactTemplate, | 2394 | onUseArtifactTemplate, |
| 2386 | openChangeRequestLink, | 2395 | openChangeRequestLink, |
| 2396 | openDeferredMarkdownLink, | ||
| 2387 | openExternalLinkInPreview, | 2397 | openExternalLinkInPreview, |
| 2388 | openMarkdownMedia, | 2398 | openMarkdownMedia, |
| 2399 | projects, | ||
| 2389 | resolveThreadPullRequest, | 2400 | resolveThreadPullRequest, |
| 2390 | resolvedTheme, | 2401 | resolvedTheme, |
| 2402 | serverConfig, | ||
| 2391 | skills, | 2403 | skills, |
| 2392 | text, | 2404 | text, |
| 2393 | threadRef, | 2405 | threadRef, |
| ... | @@ -2492,14 +2504,18 @@ const CHAT_MARKDOWN_COMPONENTS = { | ... | @@ -2492,14 +2504,18 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2492 | a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) { | 2504 | a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) { |
| 2493 | const { | 2505 | const { |
| 2494 | cwd, | 2506 | cwd, |
| 2507 | environmentId, | ||
| 2495 | imageBaseDir, | 2508 | imageBaseDir, |
| 2496 | markdownFileLinkMetaByHref, | 2509 | markdownFileLinkMetaByHref, |
| 2497 | threadRef, | 2510 | threadRef, |
| 2498 | openMarkdownMedia, | 2511 | openMarkdownMedia, |
| 2499 | openChangeRequestLink, | 2512 | openChangeRequestLink, |
| 2513 | openDeferredMarkdownLink, | ||
| 2500 | linkTargetPreference, | 2514 | linkTargetPreference, |
| 2501 | openExternalLinkInPreview, | 2515 | openExternalLinkInPreview, |
| 2516 | projects, | ||
| 2502 | resolveThreadPullRequest, | 2517 | resolveThreadPullRequest, |
| 2518 | serverConfig, | ||
| 2503 | updateThreadPullRequestLink, | 2519 | updateThreadPullRequestLink, |
| 2504 | fileLinkChip, | 2520 | fileLinkChip, |
| 2505 | } = use(ChatMarkdownRendererContext); | 2521 | } = use(ChatMarkdownRendererContext); |
| ... | @@ -2522,6 +2538,34 @@ const CHAT_MARKDOWN_COMPONENTS = { | ... | @@ -2522,6 +2538,34 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2522 | ? plainHastText(node) | 2538 | ? plainHastText(node) |
| 2523 | : undefined; | 2539 | : undefined; |
| 2524 | const isPullRequestAutolink = pullRequestCopy !== undefined; | 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 | const isSameDocumentLink = href?.startsWith("#") ?? false; | 2569 | const isSameDocumentLink = href?.startsWith("#") ?? false; |
| 2526 | const onClick = props.onClick; | 2570 | const onClick = props.onClick; |
| 2527 | const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); | 2571 | const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); |
| ... | @@ -2653,6 +2697,30 @@ const CHAT_MARKDOWN_COMPONENTS = { | ... | @@ -2653,6 +2697,30 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2653 | if (!faviconHost || !href) { | 2697 | if (!faviconHost || !href) { |
| 2654 | return link; | 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 | return ( | 2724 | return ( |
| 2657 | <Tooltip> | 2725 | <Tooltip> |
| 2658 | <TooltipTrigger render={link} /> | 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,6 +7,7 @@ import { |
| 7 | matchesLinkedPullRequestUrl, | 7 | matchesLinkedPullRequestUrl, |
| 8 | openPullRequestLink, | 8 | openPullRequestLink, |
| 9 | parseChangeRequestUrl, | 9 | parseChangeRequestUrl, |
| 10 | pullRequestCandidateUrlFromReferenceAutolink, | ||
| 10 | PullRequestLinkOpenError, | 11 | PullRequestLinkOpenError, |
| 11 | shouldOpenPullRequestExternally, | 12 | shouldOpenPullRequestExternally, |
| 12 | } from "./openPullRequestLink"; | 13 | } from "./openPullRequestLink"; |
| ... | @@ -130,6 +131,29 @@ describe("changeRequestRepositoryUrl", () => { | ... | @@ -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 | describe("matchesLinkedPullRequestUrl", () => { | 157 | describe("matchesLinkedPullRequestUrl", () => { |
| 134 | const linkedPullRequest = { | 158 | const linkedPullRequest = { |
| 135 | projectId: ProjectId.make("project-1"), | 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,6 +160,30 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu |
| 160 | return null; | 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 | /** Match a stored PR without requiring its project to remain available. */ | 187 | /** Match a stored PR without requiring its project to remain available. */ |
| 164 | export function matchesLinkedPullRequestUrl( | 188 | export function matchesLinkedPullRequestUrl( |
| 165 | linkedPullRequest: ThreadLinkedPullRequest, | 189 | linkedPullRequest: ThreadLinkedPullRequest, |
| ... | @@ -263,13 +287,14 @@ export function useOpenChangeRequestLink( | ... | @@ -263,13 +287,14 @@ export function useOpenChangeRequestLink( |
| 263 | >, | 287 | >, |
| 264 | targetUrl: string, | 288 | targetUrl: string, |
| 265 | targetThreadRef?: ScopedThreadRef, | 289 | targetThreadRef?: ScopedThreadRef, |
| 290 | targetEnvironmentId?: EnvironmentId, | ||
| 266 | ) => boolean { | 291 | ) => boolean { |
| 267 | const navigate = useNavigate(); | 292 | const navigate = useNavigate(); |
| 268 | const allProjects = useProjects(); | 293 | const allProjects = useProjects(); |
| 269 | const serverConfigs = useServerConfigs(); | 294 | const serverConfigs = useServerConfigs(); |
| 270 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 295 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 271 | return useCallback( | 296 | return useCallback( |
| 272 | (event, targetUrl, targetThreadRef) => { | 297 | (event, targetUrl, targetThreadRef, targetEnvironmentId) => { |
| 273 | if (shouldOpenPullRequestExternally(event)) return false; | 298 | if (shouldOpenPullRequestExternally(event)) return false; |
| 274 | const resolvedThreadRef = targetThreadRef ?? threadRef; | 299 | const resolvedThreadRef = targetThreadRef ?? threadRef; |
| 275 | const parsed = parseChangeRequestUrl(targetUrl); | 300 | const parsed = parseChangeRequestUrl(targetUrl); |
| ... | @@ -285,13 +310,15 @@ export function useOpenChangeRequestLink( | ... | @@ -285,13 +310,15 @@ export function useOpenChangeRequestLink( |
| 285 | // against all of them, the primary first where two hold the same repository. | 310 | // against all of them, the primary first where two hold the same repository. |
| 286 | const projects = resolvedThreadRef | 311 | const projects = resolvedThreadRef |
| 287 | ? allProjects.filter((project) => project.environmentId === resolvedThreadRef.environmentId) | 312 | ? allProjects.filter((project) => project.environmentId === resolvedThreadRef.environmentId) |
| 288 | : allProjects | 313 | : targetEnvironmentId |
| 289 | .filter((project) => reads(project.environmentId)) | 314 | ? allProjects.filter((project) => project.environmentId === targetEnvironmentId) |
| 290 | .toSorted( | 315 | : allProjects |
| 291 | (left, right) => | 316 | .filter((project) => reads(project.environmentId)) |
| 292 | Number(right.environmentId === primaryEnvironmentId) - | 317 | .toSorted( |
| 293 | Number(left.environmentId === primaryEnvironmentId), | 318 | (left, right) => |
| 294 | ); | 319 | Number(right.environmentId === primaryEnvironmentId) - |
| 320 | Number(left.environmentId === primaryEnvironmentId), | ||
| 321 | ); | ||
| 295 | const project = findProjectForChangeRequest(projects, parsed); | 322 | const project = findProjectForChangeRequest(projects, parsed); |
| 296 | if (project === undefined || !reads(project.environmentId)) return false; | 323 | if (project === undefined || !reads(project.environmentId)) return false; |
| 297 | event.preventDefault(); | 324 | event.preventDefault(); |