1import { parsePullRequestAnchor } from "@t3tools/shared/pullRequestAnchor";
2import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
3import { useAtomValue } from "@effect/atom-react";
4import { useNavigate } from "@tanstack/react-router";
5import { type MouseEvent } from "react";
6
7import {
8 resolveChangeRequestLinkTarget,
9 resolvePullRequestPreviewTarget,
10} from "@t3tools/client-runtime/change-request-links";
11
12import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
13
14import { canOpenLinksInApp } from "../browser/browserLinkTarget";
15import { useOpenLink } from "../browser/useOpenLink";
16import { useClientSettings } from "../hooks/useSettings";
17import { stackedThreadToast, toastManager } from "../components/ui/toast";
18import { useRightPanelStore } from "../rightPanelStore";
19import {
20 selectThreadBranchBaseRef,
21 selectThreadDiffPanelSelection,
22 useDiffPanelStore,
23} from "../diffPanelStore";
24
25import { appAtomRegistry } from "../rpc/atomRegistry";
26import { readProject, readProjects, readThreadShell, useProjects } from "../state/entities";
27import { environmentServerConfigsAtom, serverEnvironment } from "../state/server";
28import { usePrimaryEnvironmentId } from "../state/environments";
29import { reviewEnvironment } from "../state/review";
30import { useAtomQueryRunner } from "../state/use-atom-query-runner";
31
32export function usePullRequestPreviewTarget(environmentId: EnvironmentId | null, url: string) {
33 const projects = useProjects();
34 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
35 return resolvePullRequestPreviewTarget({
36 environmentId,
37 projects,
38 pullRequestsEnabled: serverConfig !== null,
39 url,
40 });
41}
42
43function shouldOpenPullRequestExternally(
44 event: Pick<MouseEvent<HTMLElement>, "metaKey" | "ctrlKey"> &
45 Partial<Pick<MouseEvent<HTMLElement>, "button">>,
46): boolean {
47 return event.metaKey || event.ctrlKey || event.button === 1;
48}
49
50export function useOpenChangeRequestLink(
51 threadRef?: ScopedThreadRef,
52 panelRef?: ScopedThreadRef,
53): (
54 event: Pick<
55 MouseEvent<HTMLElement>,
56 "preventDefault" | "stopPropagation" | "metaKey" | "ctrlKey"
57 > &
58 Partial<Pick<MouseEvent<HTMLElement>, "button" | "shiftKey" | "altKey">>,
59 targetUrl: string,
60 targetThreadRef?: ScopedThreadRef,
61 targetEnvironmentId?: EnvironmentId,
62 onCommitUnavailable?: () => void,
63) => boolean {
64 const navigate = useNavigate();
65 const primaryEnvironmentId = usePrimaryEnvironmentId();
66 const openInApp = useClientSettings((settings) => settings.pullRequestLinksOpenInApp);
67 const ignoreWhitespace = useClientSettings((settings) => settings.diffIgnoreWhitespace);
68 const browserLinkTarget = useClientSettings((settings) => settings.browserLinkTarget);
69 const readDiffPreview = useAtomQueryRunner(reviewEnvironment.diffPreview, {
70 reportFailure: false,
71 refresh: true,
72 });
73 const openLink = useOpenLink(threadRef);
74 return (event, targetUrl, targetThreadRef, targetEnvironmentId, onCommitUnavailable) => {
75 if (!openInApp || shouldOpenPullRequestExternally(event)) return false;
76 const resolvedThreadRef = targetThreadRef ?? threadRef;
77 const resolvedPanelRef = panelRef ?? resolvedThreadRef;
78 const openPullRequest = () => {
79 // Read at click time: every provider status push replaces the config map, and
80 // subscribing would hand every rendered link a new handler. Projects likewise, so a
81 // link can resolve against a snapshot that arrived after this handler was built.
82 const serverConfigs = appAtomRegistry.get(environmentServerConfigsAtom);
83 const target = resolveChangeRequestLinkTarget({
84 url: targetUrl,
85 projects: readProjects(),
86 configLoaded: (environmentId) => serverConfigs.has(environmentId),
87 environmentId: resolvedThreadRef?.environmentId ?? targetEnvironmentId,
88 primaryEnvironmentId,
89 lendHostProject: resolvedPanelRef !== undefined,
90 });
91 if (target === null) return false;
92 const { project, repository, number, host } = target;
93 const revealUrl = parsePullRequestAnchor(targetUrl) === null ? undefined : targetUrl;
94 const pullRequestsSearch = {
95 repository,
96 number,
97 ...(host === undefined ? {} : { selectedHost: host }),
98 selectedProjectId: project.id,
99 selectedEnvironmentId: project.environmentId,
100 };
101 if (resolvedPanelRef) {
102 useRightPanelStore.getState().openPullRequest(resolvedPanelRef, {
103 // The standalone PR panel has a synthetic ref; each tab keeps its real environment.
104 ...(resolvedPanelRef.environmentId === project.environmentId
105 ? {}
106 : { environmentId: project.environmentId }),
107 projectId: project.id,
108 ...(host === undefined ? {} : { host }),
109 repository,
110 url: targetUrl,
111 ...(revealUrl === undefined ? {} : { revealUrl }),
112 number,
113 });
114 if (!resolvedThreadRef) {
115 void navigate({
116 to: "/pull-requests",
117 search: (previous) => ({
118 ...previous,
119 involvement: previous.involvement ?? "all",
120 state: previous.state ?? "all",
121 ...pullRequestsSearch,
122 }),
123 replace: true,
124 });
125 }
126 return true;
127 }
128 void navigate({
129 to: "/pull-requests",
130 search: {
131 involvement: "all",
132 state: "all",
133 ...pullRequestsSearch,
134 ...(revealUrl === undefined ? {} : { selectedUrl: revealUrl }),
135 },
136 });
137 return true;
138 };
139 const commitThread = resolvedThreadRef ? readThreadShell(resolvedThreadRef) : null;
140 const commitProject =
141 commitThread && resolvedThreadRef
142 ? readProject({
143 environmentId: resolvedThreadRef.environmentId,
144 projectId: commitThread.projectId,
145 })
146 : null;
147 const identity = commitProject?.repositoryIdentity;
148 const parsedCommit = sourceControlClients.parseCommitUrl(targetUrl, identity?.provider);
149 // Shift and Alt keep their browser meaning (new window, download) on commit links.
150 if (parsedCommit !== null && (event.shiftKey || event.altKey)) return false;
151 // A plain commit link stays a workspace commit; one naming a file or line opens the PR's diff.
152 const anchor = parsePullRequestAnchor(targetUrl);
153 if (anchor !== null && anchor.kind !== "code" && openPullRequest()) {
154 event.preventDefault();
155 event.stopPropagation();
156 return true;
157 }
158
159 if (
160 parsedCommit !== null &&
161 commitThread &&
162 resolvedThreadRef &&
163 commitProject?.environmentId === resolvedThreadRef.environmentId &&
164 identity &&
165 identity.provider === parsedCommit.definition.kind &&
166 parsedCommit.definition.isChangeRequestInRepository(identity, parsedCommit.link)
167 ) {
168 const commit = parsedCommit.link;
169 event.preventDefault();
170 event.stopPropagation();
171 const userActionRevision = useRightPanelStore.getState().markUserAction(resolvedThreadRef);
172 const baseRef = selectThreadBranchBaseRef(useDiffPanelStore.getState(), resolvedThreadRef);
173 const selection = selectThreadDiffPanelSelection(
174 useDiffPanelStore.getState().byThreadKey,
175 resolvedThreadRef,
176 );
177 void readDiffPreview({
178 environmentId: resolvedThreadRef.environmentId,
179 input: {
180 cwd: commitThread.worktreePath ?? commitProject.workspaceRoot,
181 ...(baseRef ? { baseRef } : {}),
182 commitSha: commit.sha,
183 ignoreWhitespace,
184 },
185 })
186 .then((result) => {
187 if (
188 useRightPanelStore.getState().getUserActionRevision(resolvedThreadRef) !==
189 userActionRevision ||
190 selectThreadDiffPanelSelection(
191 useDiffPanelStore.getState().byThreadKey,
192 resolvedThreadRef,
193 ) !== selection
194 )
195 return;
196 if (
197 result._tag === "Success" &&
198 result.value.sources.some(
199 (source) => source.kind === "commit" && source.headRef === commit.sha,
200 )
201 ) {
202 useDiffPanelStore.getState().selectCommit(resolvedThreadRef, commit.sha);
203 useRightPanelStore.getState().open(resolvedThreadRef, "diff");
204 return;
205 }
206 if (openPullRequest()) return;
207 if (onCommitUnavailable) return onCommitUnavailable();
208 // A popup opened after the lookup has lost the click's activation, so browsers block it.
209 if (
210 window.desktopBridge === undefined &&
211 (browserLinkTarget !== "app" || !canOpenLinksInApp(resolvedThreadRef))
212 ) {
213 const toastId = toastManager.add(
214 stackedThreadToast({
215 type: "info",
216 title: "This commit is not in the thread's workspace",
217 actionProps: {
218 children: "Open link",
219 onClick: () => {
220 toastManager.close(toastId);
221 window.open(targetUrl, "_blank", "noopener,noreferrer");
222 },
223 },
224 }),
225 );
226 return;
227 }
228 return openLink(targetUrl, {
229 event: { metaKey: event.metaKey, ctrlKey: event.ctrlKey, button: event.button },
230 threadRef: resolvedThreadRef,
231 });
232 })
233 .catch((error: unknown) => console.error(error));
234 return true;
235 }
236 if (!openPullRequest()) return false;
237 event.preventDefault();
238 event.stopPropagation();
239 return true;
240 };
241}
242
243export function useOpenPrLink(threadRef?: ScopedThreadRef) {
244 const openChangeRequest = useOpenChangeRequestLink(threadRef);
245 const openLink = useOpenLink(threadRef);
246 return (event: MouseEvent<HTMLElement>, prUrl: string, targetThreadRef?: ScopedThreadRef) => {
247 if (event.button > 1) return false;
248 event.stopPropagation();
249 const openInBrowser = shouldOpenPullRequestExternally(event);
250 const isAnchor =
251 event.currentTarget instanceof HTMLAnchorElement && event.currentTarget.href.length > 0;
252 // Native links handle modified clicks; buttons still need openExternal.
253 if (openInBrowser && isAnchor) return false;
254
255 event.preventDefault();
256 if (!openInBrowser && openChangeRequest(event, prUrl, targetThreadRef)) return true;
257
258 void openLink(prUrl, { event, threadRef: targetThreadRef }).catch((error: unknown) => {
259 console.error(error);
260 toastManager.add(
261 stackedThreadToast({
262 type: "error",
263 title: "Unable to open pull request link",
264 description: error instanceof Error ? error.message : "An error occurred.",
265 }),
266 );
267 });
268 return false;
269 };
270}