1import { pullRequestHostOf, type ScopedThreadRef } from "@t3tools/contracts";
2import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
3import { useAtomValue } from "@effect/atom-react";
4import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
6import { parseChangeRequestUrl } from "~/lib/openPullRequestLink";
7import { parsePullRequestReference } from "~/pullRequestReference";
8import { useProjects, useThreadShell } from "~/state/entities";
9import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
10import { appAtomRegistry } from "~/rpc/atomRegistry";
11import { Atom } from "effect/reactivity";
12import { Button } from "../ui/button";
13import {
14 Dialog,
15 DialogDescription,
16 DialogFooter,
17 DialogHeader,
18 DialogPanel,
19 DialogPopup,
20 DialogTitle,
21} from "../ui/dialog";
22import { Input } from "../ui/input";
23
24/**
25 * Which thread has the link dialog open, set by whichever entry point asked (command palette,
26 * pull-requests surface, detail panel) and rendered once by the chat view so the dialog outlives
27 * a palette that closes the moment its command runs.
28 */
29const linkPullRequestDialogThreadAtom = Atom.make<ScopedThreadRef | null>(null).pipe(
30 Atom.keepAlive,
31 Atom.withLabel("pull-requests:link-dialog-thread"),
32);
33
34export function openLinkPullRequestDialog(threadRef: ScopedThreadRef): void {
35 appAtomRegistry.set(linkPullRequestDialogThreadAtom, threadRef);
36}
37
38interface LinkPullRequestDialogProps {
39 open: boolean;
40 threadRef: ScopedThreadRef;
41 /** The thread's own project: bare numbers resolve against its repository. */
42 projectId: string | null;
43 onOpenChange: (open: boolean) => void;
44}
45
46/** Mounted once per chat view; shows the dialog for whichever thread asked for it. */
47export function LinkPullRequestDialogHost() {
48 const threadRef = useAtomValue(linkPullRequestDialogThreadAtom);
49 const thread = useThreadShell(threadRef);
50 const linking = usePullRequestLinking(threadRef?.environmentId);
51 if (threadRef === null || linking.mode === "unsupported") return null;
52 return (
53 <LinkPullRequestDialog
54 open
55 threadRef={threadRef}
56 projectId={thread?.projectId ?? null}
57 onOpenChange={(open) => {
58 if (!open) appAtomRegistry.set(linkPullRequestDialogThreadAtom, null);
59 }}
60 />
61 );
62}
63
64interface ResolvedLink {
65 readonly host: string;
66 readonly repository: string;
67 readonly number: number;
68 readonly url: string;
69}
70
71/**
72 * Which pull request an input names, or why it cannot. A URL carries its own host and
73 * repository; a bare `#123` can only mean the thread's own repository.
74 */
75export function resolveLinkPullRequestInput(input: {
76 readonly reference: string;
77 readonly project: {
78 readonly host: string;
79 readonly repository: string;
80 readonly webUrl: (number: number) => string | null;
81 } | null;
82 readonly hasProject: (reference: ResolvedLink) => boolean;
83}): { link: ResolvedLink } | { error: string } | null {
84 const parsed =
85 parseChangeRequestUrl(input.reference.trim()) !== null
86 ? input.reference.trim()
87 : parsePullRequestReference(input.reference);
88 if (parsed === null) return null;
89 const url = parseChangeRequestUrl(parsed);
90 if (url !== null) {
91 if (!input.hasProject({ ...url, url: parsed })) {
92 return { error: `No project in this environment can read ${url.host}/${url.repository}.` };
93 }
94 return {
95 link: { host: url.host, repository: url.repository, number: url.number, url: parsed },
96 };
97 }
98 const number = Number(parsed);
99 if (!Number.isSafeInteger(number) || number < 1) return null;
100 if (input.project === null) {
101 return { error: "Paste a full URL to link a pull request from another repository." };
102 }
103 const webUrl = input.project.webUrl(number);
104 const webReference = webUrl === null ? null : parseChangeRequestUrl(webUrl);
105 if (webUrl === null || webReference === null) {
106 return { error: "Paste a full URL; this project's host has no known pull request URL." };
107 }
108 return {
109 link: { ...webReference, url: webUrl },
110 };
111}
112
113function LinkPullRequestDialog({
114 open,
115 threadRef,
116 projectId,
117 onOpenChange,
118}: LinkPullRequestDialogProps) {
119 const inputRef = useRef<HTMLInputElement>(null);
120 const [reference, setReference] = useState("");
121 const [dirty, setDirty] = useState(false);
122 const [submitError, setSubmitError] = useState<string | null>(null);
123 const projects = useProjects();
124 const environmentProjects = useMemo(
125 () => projects.filter((project) => project.environmentId === threadRef.environmentId),
126 [projects, threadRef.environmentId],
127 );
128 const ownProject = useMemo(() => {
129 const project = environmentProjects.find((candidate) => candidate.id === projectId);
130 const identity = project?.repositoryIdentity;
131 if (!project || !identity) return null;
132 const repository =
133 identity.displayName ??
134 (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null);
135 if (repository === null) return null;
136 const definition = sourceControlClients.get(identity.provider ?? "unknown");
137 const host = pullRequestHostOf(identity, definition.kind);
138 return {
139 host,
140 repository,
141 webUrl: (number: number) =>
142 definition.changeRequestUrl({
143 host,
144 repository,
145 number,
146 remoteUrl: identity.locator.remoteUrl,
147 webUrl: identity.webUrl,
148 }),
149 };
150 }, [environmentProjects, projectId]);
151 const linking = usePullRequestLinking(threadRef.environmentId);
152 const [pending, setPending] = useState(false);
153
154 useEffect(() => {
155 if (!open) return;
156 setReference("");
157 setDirty(false);
158 setSubmitError(null);
159 const frame = window.requestAnimationFrame(() => inputRef.current?.focus());
160 return () => window.cancelAnimationFrame(frame);
161 }, [open]);
162
163 const resolved = useMemo(
164 () =>
165 resolveLinkPullRequestInput({
166 reference,
167 project: ownProject,
168 hasProject: (reference) => linking.canLink(reference.url),
169 }),
170 [linking, ownProject, reference],
171 );
172
173 const submit = useCallback(async () => {
174 setDirty(true);
175 if (resolved === null || "error" in resolved) return;
176 setSubmitError(null);
177 setPending(true);
178 try {
179 await linking.changeLink(threadRef, resolved.link.url, true);
180 } catch (error) {
181 setSubmitError(error instanceof Error ? error.message : "Could not link the pull request.");
182 return;
183 } finally {
184 setPending(false);
185 }
186 onOpenChange(false);
187 }, [linking, onOpenChange, resolved, threadRef]);
188
189 const validation = !dirty
190 ? null
191 : reference.trim().length === 0
192 ? "Paste a pull request URL or enter 123 / #123."
193 : resolved === null
194 ? "Use a pull request URL, 123, or #123."
195 : "error" in resolved
196 ? resolved.error
197 : null;
198
199 return (
200 <Dialog open={open} onOpenChange={(next) => (pending ? undefined : onOpenChange(next))}>
201 <DialogPopup className="max-w-xl">
202 <DialogHeader>
203 <DialogTitle>Link pull request</DialogTitle>
204 <DialogDescription>
205 Attach a pull request to this thread by its URL, or by its number for this thread's
206 repository.
207 </DialogDescription>
208 </DialogHeader>
209 <DialogPanel>
210 <Input
211 ref={inputRef}
212 placeholder="Pull request URL or #42"
213 value={reference}
214 onChange={(event) => {
215 setDirty(true);
216 setReference(event.target.value);
217 }}
218 onKeyDown={(event) => {
219 if (event.key !== "Enter") return;
220 event.preventDefault();
221 void submit();
222 }}
223 />
224 {resolved !== null && "link" in resolved ? (
225 <p className="truncate text-muted-foreground text-xs">
226 {resolved.link.host}/{resolved.link.repository} #{resolved.link.number}
227 </p>
228 ) : null}
229 {(validation ?? submitError) ? (
230 <p className="text-destructive text-xs">{validation ?? submitError}</p>
231 ) : null}
232 </DialogPanel>
233 <DialogFooter>
234 <Button
235 type="button"
236 variant="outline"
237 size="sm"
238 onClick={() => onOpenChange(false)}
239 disabled={pending}
240 >
241 Cancel
242 </Button>
243 <Button
244 type="button"
245 size="sm"
246 onClick={() => void submit()}
247 disabled={pending || resolved === null || "error" in resolved}
248 >
249 {pending ? "Linking..." : "Link"}
250 </Button>
251 </DialogFooter>
252 </DialogPopup>
253 </Dialog>
254 );
255}