authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-09-20 15:51:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 19:51:32-03:00
log93349247008517663aa16a3ed145d5b0f3f044ff
treea14e016d045d85c23eb78ed3d896e2fa3b69864e
parentadcd90858c3bfece59095465d2f49860ba3dcea9
signature Signed by PGP key B5690EEEBB952194

fix(web): pull request chips share the link hover preview (#12719)


9 files changed, 146 insertions(+), 82 deletions(-)

apps/web/src/components/ChatMarkdown.test.tsx+1
...@@ -55,6 +55,7 @@ vi.mock("../editorPreferences", () => ({...@@ -55,6 +55,7 @@ vi.mock("../editorPreferences", () => ({
55vi.mock("~/lib/openPullRequestLink", () => ({55vi.mock("~/lib/openPullRequestLink", () => ({
56 findProjectOnChangeRequestHost: () => undefined,56 findProjectOnChangeRequestHost: () => undefined,
57 parseChangeRequestUrl: () => null,57 parseChangeRequestUrl: () => null,
58 resolvePullRequestPreviewTarget: () => null,
58 useOpenChangeRequestLink: () => vi.fn(),59 useOpenChangeRequestLink: () => vi.fn(),
59}));60}));
6061
apps/web/src/components/ChatMarkdown.tsx+8-27
...@@ -53,7 +53,6 @@ import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-li...@@ -53,7 +53,6 @@ import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-li
53import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";53import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";
54import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";54import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
55import * as Cause from "effect/Cause";55import * as Cause from "effect/Cause";
56import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
57import { AsyncResult } from "effect/unstable/reactivity";56import { AsyncResult } from "effect/unstable/reactivity";
58import React, {57import React, {
59 Children,58 Children,
...@@ -178,9 +177,9 @@ import {...@@ -178,9 +177,9 @@ import {
178 WORKSPACE_BASENAME_LOOKUP_LIMIT,177 WORKSPACE_BASENAME_LOOKUP_LIMIT,
179} from "../workspaceBasenameLookup";178} from "../workspaceBasenameLookup";
180import {179import {
181 findProjectForChangeRequest,
182 parseChangeRequestUrl,180 parseChangeRequestUrl,
183 pullRequestCandidateUrlFromReferenceAutolink,181 pullRequestCandidateUrlFromReferenceAutolink,
182 resolvePullRequestPreviewTarget,
184 useOpenChangeRequestLink,183 useOpenChangeRequestLink,
185} from "~/lib/openPullRequestLink";184} from "~/lib/openPullRequestLink";
186import { useOpenLink } from "../browser/useOpenLink";185import { useOpenLink } from "../browser/useOpenLink";
...@@ -2889,32 +2888,14 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -2889,32 +2888,14 @@ const CHAT_MARKDOWN_COMPONENTS = {
2889 const confirmBeforeOpen = pullRequestAutolink === "reference";2888 const confirmBeforeOpen = pullRequestAutolink === "reference";
2890 const pullRequestCandidateUrl =2889 const pullRequestCandidateUrl =
2891 confirmBeforeOpen && href ? pullRequestCandidateUrlFromReferenceAutolink(href) : href;2890 confirmBeforeOpen && href ? pullRequestCandidateUrlFromReferenceAutolink(href) : href;
2892 const pullRequestCandidate = pullRequestCandidateUrl2891 const pullRequestPreviewTarget = pullRequestCandidateUrl
2893 ? parseChangeRequestUrl(pullRequestCandidateUrl)2892 ? resolvePullRequestPreviewTarget({
2893 environmentId,
2894 projects,
2895 pullRequestsEnabled: serverConfig?.environment.capabilities.pullRequests === true,
2896 url: pullRequestCandidateUrl,
2897 })
2894 : null;2898 : null;
2895 const pullRequestProject =
2896 environmentId !== null &&
2897 serverConfig?.environment.capabilities.pullRequests === true &&
2898 pullRequestCandidate !== null
2899 ? findProjectForChangeRequest(
2900 projects.filter((project) => project.environmentId === environmentId),
2901 pullRequestCandidate,
2902 )
2903 : undefined;
2904 const pullRequestPreviewTarget =
2905 environmentId === null || pullRequestProject === undefined || pullRequestCandidate === null
2906 ? null
2907 : {
2908 environmentId,
2909 input: {
2910 projectId: pullRequestProject.id,
2911 host: pullRequestCandidate.authority ?? pullRequestCandidate.host,
2912 repository:
2913 sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ??
2914 pullRequestCandidate.repository,
2915 number: pullRequestCandidate.number,
2916 },
2917 };
2918 const isSameDocumentLink = href?.startsWith("#") ?? false;2899 const isSameDocumentLink = href?.startsWith("#") ?? false;
2919 const onClick = props.onClick;2900 const onClick = props.onClick;
2920 const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();2901 const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+1
...@@ -44,6 +44,7 @@ vi.mock("../editorPreferences", () => ({...@@ -44,6 +44,7 @@ vi.mock("../editorPreferences", () => ({
44vi.mock("~/lib/openPullRequestLink", () => ({44vi.mock("~/lib/openPullRequestLink", () => ({
45 findProjectOnChangeRequestHost: () => undefined,45 findProjectOnChangeRequestHost: () => undefined,
46 parseChangeRequestUrl: () => null,46 parseChangeRequestUrl: () => null,
47 resolvePullRequestPreviewTarget: () => null,
47 useOpenChangeRequestLink: () => vi.fn(),48 useOpenChangeRequestLink: () => vi.fn(),
48}));49}));
4950
apps/web/src/components/chat/ChatComposer.tsx+1
...@@ -1626,6 +1626,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1626,6 +1626,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1626 const previewFile = composerFiles.find((file) => file.id === previewFileId);1626 const previewFile = composerFiles.find((file) => file.id === previewFileId);
1627 const composerContextActions = useMemo(1627 const composerContextActions = useMemo(
1628 () => ({1628 () => ({
1629 environmentId,
1629 expandImage: (imageId: string) => {1630 expandImage: (imageId: string) => {
1630 const preview = buildExpandedImagePreview(composerImages, imageId);1631 const preview = buildExpandedImagePreview(composerImages, imageId);
1631 if (preview) onExpandImage(preview);1632 if (preview) onExpandImage(preview);
apps/web/src/components/chat/MessagesTimeline.tsx+2-1
...@@ -3508,12 +3508,13 @@ function UserMessagePullRequestContextChip(props: {...@@ -3508,12 +3508,13 @@ function UserMessagePullRequestContextChip(props: {
3508 copyMarkdown: string;3508 copyMarkdown: string;
3509 toneClassName: string;3509 toneClassName: string;
3510}) {3510}) {
3511 const { openPullRequest } = use(TimelineRowCtx);3511 const { activeThreadEnvironmentId, openPullRequest } = use(TimelineRowCtx);
3512 const metadata = props.record.pullRequest;3512 const metadata = props.record.pullRequest;
3513 if (metadata === undefined) return null;3513 if (metadata === undefined) return null;
3514 return (3514 return (
3515 <PullRequestChip3515 <PullRequestChip
3516 metadata={metadata}3516 metadata={metadata}
3517 environmentId={activeThreadEnvironmentId}
3517 label={reviewCommentContextLabel(props.record)}3518 label={reviewCommentContextLabel(props.record)}
3518 kindLabel={pullRequestContextKindLabel(props.record)}3519 kindLabel={pullRequestContextKindLabel(props.record)}
3519 className={cn(CHAT_INLINE_CHIP_CLASS_NAME, props.toneClassName)}3520 className={cn(CHAT_INLINE_CHIP_CLASS_NAME, props.toneClassName)}
apps/web/src/components/composerContextPresentation.tsx+4
...@@ -5,6 +5,7 @@ import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"...@@ -5,6 +5,7 @@ import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"
5import { videoMimeType } from "@t3tools/shared/video";5import { videoMimeType } from "@t3tools/shared/video";
6import { MessageCircleIcon, MousePointerClickIcon } from "lucide-react";6import { MessageCircleIcon, MousePointerClickIcon } from "lucide-react";
7import { createContext, type MouseEvent, type ReactElement, type ReactNode, use } from "react";7import { createContext, type MouseEvent, type ReactElement, type ReactNode, use } from "react";
8import type { EnvironmentId } from "@t3tools/contracts";
89
9import type { ComposerFileAttachment, ComposerImageAttachment } from "~/composerDraftStore";10import type { ComposerFileAttachment, ComposerImageAttachment } from "~/composerDraftStore";
10import { composerFileNeedsReattach } from "~/composerDraftStore";11import { composerFileNeedsReattach } from "~/composerDraftStore";
...@@ -66,6 +67,7 @@ export type ComposerDraftContextRecord =...@@ -66,6 +67,7 @@ export type ComposerDraftContextRecord =
6667
67/** What a chip can do beyond showing itself; the composer supplies the handlers. */68/** What a chip can do beyond showing itself; the composer supplies the handlers. */
68export interface ComposerContextActions {69export interface ComposerContextActions {
70 environmentId: EnvironmentId | null;
69 expandImage: (imageId: string) => void;71 expandImage: (imageId: string) => void;
70 expandVideo: (fileId: string) => void;72 expandVideo: (fileId: string) => void;
71 openFile: (fileId: string) => void;73 openFile: (fileId: string) => void;
...@@ -74,6 +76,7 @@ export interface ComposerContextActions {...@@ -74,6 +76,7 @@ export interface ComposerContextActions {
74}76}
7577
76export const ComposerContextActionsContext = createContext<ComposerContextActions>({78export const ComposerContextActionsContext = createContext<ComposerContextActions>({
79 environmentId: null,
77 expandImage: () => {},80 expandImage: () => {},
78 expandVideo: () => {},81 expandVideo: () => {},
79 openFile: () => {},82 openFile: () => {},
...@@ -247,6 +250,7 @@ function PullRequestContextChip(props: { record: ReviewCommentContext; toneClass...@@ -247,6 +250,7 @@ function PullRequestContextChip(props: { record: ReviewCommentContext; toneClass
247 return (250 return (
248 <PullRequestChip251 <PullRequestChip
249 metadata={metadata}252 metadata={metadata}
253 environmentId={actions.environmentId}
250 label={reviewCommentContextLabel(props.record)}254 label={reviewCommentContextLabel(props.record)}
251 kindLabel={pullRequestContextKindLabel(props.record)}255 kindLabel={pullRequestContextKindLabel(props.record)}
252 className={cn(COMPOSER_INLINE_CHIP_CLASS_NAME, props.toneClassName)}256 className={cn(COMPOSER_INLINE_CHIP_CLASS_NAME, props.toneClassName)}
apps/web/src/components/contextChipParts.tsx+36-22
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { PullRequestContextMetadata } from "@t3tools/contracts";1import type { EnvironmentId, PullRequestContextMetadata } from "@t3tools/contracts";
2import { CircleDashedIcon, FilmIcon, ImageIcon } from "lucide-react";2import { CircleDashedIcon, FilmIcon, ImageIcon } from "lucide-react";
3import {3import {
4 useState,4 useState,
...@@ -23,6 +23,8 @@ import { PullRequestContextDetails } from "./PullRequestContextDetails";...@@ -23,6 +23,8 @@ import { PullRequestContextDetails } from "./PullRequestContextDetails";
23import { Button } from "./ui/button";23import { Button } from "./ui/button";
24import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover";24import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover";
25import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";25import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
26import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview";
27import { usePullRequestPreviewTarget } from "~/lib/openPullRequestLink";
2628
27/** Shared visual slots; each surface keeps ownership of payload lookup and actions. */29/** Shared visual slots; each surface keeps ownership of payload lookup and actions. */
28export function ContextChipShell({30export function ContextChipShell({
...@@ -112,6 +114,7 @@ export function ContextChipPopover(props: {...@@ -112,6 +114,7 @@ export function ContextChipPopover(props: {
112114
113export function PullRequestChip(props: {115export function PullRequestChip(props: {
114 metadata: PullRequestContextMetadata;116 metadata: PullRequestContextMetadata;
117 environmentId: EnvironmentId | null;
115 label: string;118 label: string;
116 kindLabel: string;119 kindLabel: string;
117 className: string;120 className: string;
...@@ -119,29 +122,40 @@ export function PullRequestChip(props: {...@@ -119,29 +122,40 @@ export function PullRequestChip(props: {
119 copyMarkdown?: string;122 copyMarkdown?: string;
120 onOpen: (event: MouseEvent<HTMLElement>, url: string) => void;123 onOpen: (event: MouseEvent<HTMLElement>, url: string) => void;
121}) {124}) {
125 const previewTarget = usePullRequestPreviewTarget(props.environmentId, props.metadata.url);
126 const button = (
127 <Button
128 variant="chip"
129 className={cn(
130 props.className,
131 CONTEXT_INLINE_CHIP_FOCUS_CLASS_NAME,
132 CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME,
133 "cursor-pointer",
134 )}
135 aria-label={`Open ${props.kindLabel} ${props.label}: ${props.metadata.title}`}
136 data-markdown-copy={props.copyMarkdown}
137 onClick={(event) => props.onOpen(event, props.metadata.url)}
138 >
139 <PullRequestGlyph.pullRequest
140 className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")}
141 />
142 <span className={props.labelClassName}>{props.label}</span>
143 </Button>
144 );
145 if (previewTarget !== null) {
146 return (
147 <PullRequestLinkPreview
148 link={button}
149 originalUrl={props.metadata.url}
150 target={previewTarget}
151 confirmBeforeOpen={false}
152 fallback={<PullRequestContextDetails metadata={props.metadata} />}
153 />
154 );
155 }
122 return (156 return (
123 <Tooltip>157 <Tooltip>
124 <TooltipTrigger158 <TooltipTrigger render={button} />
125 render={
126 <Button
127 variant="chip"
128 className={cn(
129 props.className,
130 CONTEXT_INLINE_CHIP_FOCUS_CLASS_NAME,
131 CONTEXT_INLINE_CHIP_INTERACTIVE_CLASS_NAME,
132 "cursor-pointer",
133 )}
134 aria-label={`Open ${props.kindLabel} ${props.label}: ${props.metadata.title}`}
135 data-markdown-copy={props.copyMarkdown}
136 onClick={(event) => props.onOpen(event, props.metadata.url)}
137 >
138 <PullRequestGlyph.pullRequest
139 className={cn(COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, "size-3.5")}
140 />
141 <span className={props.labelClassName}>{props.label}</span>
142 </Button>
143 }
144 />
145 <TooltipPopup side="top">159 <TooltipPopup side="top">
146 <PullRequestContextDetails metadata={props.metadata} />160 <PullRequestContextDetails metadata={props.metadata} />
147 </TooltipPopup>161 </TooltipPopup>
apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx+44-30
...@@ -1,6 +1,13 @@...@@ -1,6 +1,13 @@
1import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";1import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
2import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts";2import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts";
3import { cloneElement, useState, type ComponentPropsWithoutRef, type ReactElement } from "react";3import {
4 cloneElement,
5 useState,
6 type ComponentPropsWithoutRef,
7 type MouseEvent,
8 type ReactElement,
9 type ReactNode,
10} from "react";
411
5import { formatRelativeTimeLabel } from "~/timestampFormat";12import { formatRelativeTimeLabel } from "~/timestampFormat";
6import { pullRequestEnvironment } from "~/state/pullRequests";13import { pullRequestEnvironment } from "~/state/pullRequests";
...@@ -15,7 +22,9 @@ interface PullRequestLinkPreviewTarget {...@@ -15,7 +22,9 @@ interface PullRequestLinkPreviewTarget {
15 readonly input: PullRequestRef;22 readonly input: PullRequestRef;
16}23}
1724
18type PullRequestLinkElement = ReactElement<ComponentPropsWithoutRef<"a">>;25type PullRequestLinkElement = ReactElement<
26 ComponentPropsWithoutRef<"a"> | ComponentPropsWithoutRef<"button">
27>;
1928
20export function PullRequestLinkPreview({29export function PullRequestLinkPreview({
21 link,30 link,
...@@ -24,13 +33,15 @@ export function PullRequestLinkPreview({...@@ -24,13 +33,15 @@ export function PullRequestLinkPreview({
24 confirmBeforeOpen,33 confirmBeforeOpen,
25 onOpenPullRequest,34 onOpenPullRequest,
26 onOpenFallback,35 onOpenFallback,
36 fallback,
27}: {37}: {
28 link: PullRequestLinkElement;38 link: PullRequestLinkElement;
29 originalUrl: string;39 originalUrl: string;
30 target: PullRequestLinkPreviewTarget;40 target: PullRequestLinkPreviewTarget;
31 confirmBeforeOpen: boolean;41 confirmBeforeOpen?: boolean;
32 onOpenPullRequest: (url: string) => boolean;42 onOpenPullRequest?: (url: string) => boolean;
33 onOpenFallback: (url: string) => Promise<void>;43 onOpenFallback?: (url: string) => Promise<void>;
44 fallback?: ReactNode;
34}) {45}) {
35 const [open, setOpen] = useState(false);46 const [open, setOpen] = useState(false);
36 const [resolvingClick, setResolvingClick] = useState(false);47 const [resolvingClick, setResolvingClick] = useState(false);
...@@ -46,28 +57,29 @@ export function PullRequestLinkPreview({...@@ -46,28 +57,29 @@ export function PullRequestLinkPreview({
46 reportFailure: false,57 reportFailure: false,
47 reportDefect: false,58 reportDefect: false,
48 });59 });
49 const trigger = confirmBeforeOpen60 const trigger =
50 ? cloneElement(link, {61 confirmBeforeOpen === true
51 onClick: (event) => {62 ? cloneElement(link, {
52 if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;63 onClick: (event: MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => {
53 event.preventDefault();64 if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
54 event.stopPropagation();65 event.preventDefault();
55 if (resolvingClick) return;66 event.stopPropagation();
56 setOpen(false);67 if (resolvingClick) return;
57 setResolvingClick(true);68 setOpen(false);
58 void readPreview(target)69 setResolvingClick(true);
59 .then(async (result) => {70 void readPreview(target)
60 if (isAtomCommandInterrupted(result)) return;71 .then(async (result) => {
61 if (result._tag === "Success" && onOpenPullRequest(result.value.url)) return;72 if (isAtomCommandInterrupted(result)) return;
62 await onOpenFallback(originalUrl);73 if (result._tag === "Success" && onOpenPullRequest?.(result.value.url)) return;
63 })74 await onOpenFallback?.(originalUrl);
64 .catch((error: unknown) => {75 })
65 console.error("[pull-request-link-preview] failed to open link", error);76 .catch((error: unknown) => {
66 })77 console.error("[pull-request-link-preview] failed to open link", error);
67 .finally(() => setResolvingClick(false));78 })
68 },79 .finally(() => setResolvingClick(false));
69 })80 },
70 : link;81 })
82 : link;
71 const detail = detailQuery.data;83 const detail = detailQuery.data;
72 const state =84 const state =
73 detail === null85 detail === null
...@@ -86,9 +98,11 @@ export function PullRequestLinkPreview({...@@ -86,9 +98,11 @@ export function PullRequestLinkPreview({
86 {detail !== null || detailQuery.error !== null ? (98 {detail !== null || detailQuery.error !== null ? (
87 <PreviewCardPopup align="center" className="w-80 max-w-[calc(100vw-2rem)] p-3">99 <PreviewCardPopup align="center" className="w-80 max-w-[calc(100vw-2rem)] p-3">
88 {detail === null ? (100 {detail === null ? (
89 <p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere">101 (fallback ?? (
90 {originalUrl}102 <p className="text-xs leading-relaxed text-muted-foreground wrap-anywhere">
91 </p>103 {originalUrl}
104 </p>
105 ))
92 ) : (106 ) : (
93 <div className="min-w-0">107 <div className="min-w-0">
94 <div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">108 <div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
apps/web/src/lib/openPullRequestLink.ts+49-2
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";1import type { EnvironmentId, PullRequestRef, ScopedThreadRef } from "@t3tools/contracts";
2import { useAtomValue } from "@effect/atom-react";
2import { useNavigate } from "@tanstack/react-router";3import { useNavigate } from "@tanstack/react-router";
3import { type MouseEvent, useCallback } from "react";4import { type MouseEvent, useCallback, useMemo } from "react";
45
5import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";6import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";
6import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";7import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";
...@@ -15,6 +16,7 @@ import { useRightPanelStore } from "../rightPanelStore";...@@ -15,6 +16,7 @@ import { useRightPanelStore } from "../rightPanelStore";
15import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";16import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
1617
17import { useProjects, useServerConfigs } from "../state/entities";18import { useProjects, useServerConfigs } from "../state/entities";
19import { serverEnvironment } from "../state/server";
18import { usePrimaryEnvironmentId } from "../state/environments";20import { usePrimaryEnvironmentId } from "../state/environments";
1921
20export {22export {
...@@ -93,6 +95,51 @@ export function findProjectForChangeRequest(...@@ -93,6 +95,51 @@ export function findProjectForChangeRequest(
93 });95 });
94}96}
9597
98export function resolvePullRequestPreviewTarget({
99 environmentId,
100 projects,
101 pullRequestsEnabled,
102 url,
103}: {
104 environmentId: EnvironmentId | null;
105 projects: ReadonlyArray<EnvironmentProject>;
106 pullRequestsEnabled: boolean;
107 url: string;
108}): { environmentId: EnvironmentId; input: PullRequestRef } | null {
109 if (!pullRequestsEnabled || environmentId === null) return null;
110 const parsed = parseChangeRequestUrl(url);
111 if (parsed === null) return null;
112 const project = findProjectForChangeRequest(
113 projects.filter((candidate) => candidate.environmentId === environmentId),
114 parsed,
115 );
116 if (project === undefined) return null;
117 return {
118 environmentId,
119 input: {
120 projectId: project.id,
121 host: parsed.authority ?? parsed.host,
122 repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository,
123 number: parsed.number,
124 },
125 };
126}
127
128export function usePullRequestPreviewTarget(environmentId: EnvironmentId | null, url: string) {
129 const projects = useProjects();
130 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
131 return useMemo(
132 () =>
133 resolvePullRequestPreviewTarget({
134 environmentId,
135 projects,
136 pullRequestsEnabled: serverConfig?.environment.capabilities.pullRequests === true,
137 url,
138 }),
139 [environmentId, projects, serverConfig, url],
140 );
141}
142
96/**143/**
97 * Any project checked out from the link's host. Thread links are host-level, so a pull request144 * Any project checked out from the link's host. Thread links are host-level, so a pull request
98 * from a repository nobody has checked out is still linkable as long as one project on that145 * from a repository nobody has checked out is still linkable as long as one project on that