1import {
2 AuthOrchestrationOperateScope,
3 type EnvironmentId,
4 type ThreadId,
5} from "@t3tools/contracts";
6import {
7 isAtomCommandInterrupted,
8 squashAtomCommandFailure,
9} from "@t3tools/client-runtime/state/runtime";
10import { useDebouncedValue } from "@tanstack/react-pacer";
11import { useCallback, useEffect, useMemo, useRef, useState } from "react";
12
13import {
14 readCachedPullRequestResolution,
15 usePreparePullRequestThreadAction,
16 usePullRequestResolution,
17} from "~/lib/sourceControlActions";
18import { cn } from "~/lib/utils";
19import { parsePullRequestReference } from "~/pullRequestReference";
20import { getSourceControlPresentation } from "~/sourceControlPresentation";
21import { useEnvironmentQuery } from "~/state/query";
22import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
23import { vcsEnvironment } from "~/state/vcs";
24import { Button } from "./ui/button";
25import {
26 Dialog,
27 DialogDescription,
28 DialogFooter,
29 DialogHeader,
30 DialogPanel,
31 DialogPopup,
32 DialogTitle,
33} from "./ui/dialog";
34import { Input } from "./ui/input";
35import { Spinner } from "./ui/spinner";
36
37interface PullRequestThreadDialogProps {
38 open: boolean;
39 environmentId: EnvironmentId;
40 threadId: ThreadId;
41 cwd: string | null;
42 initialReference: string | null;
43 onOpenChange: (open: boolean) => void;
44 onPrepared: (input: { branch: string; worktreePath: string | null }) => Promise<void> | void;
45}
46
47export function PullRequestThreadDialog({
48 open,
49 environmentId,
50 threadId,
51 cwd,
52 initialReference,
53 onOpenChange,
54 onPrepared,
55}: PullRequestThreadDialogProps) {
56 const referenceInputRef = useRef<HTMLInputElement>(null);
57 const [reference, setReference] = useState(initialReference ?? "");
58 const [referenceDirty, setReferenceDirty] = useState(false);
59 const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null);
60 const [prepareErrorMessage, setPrepareErrorMessage] = useState<string | null>(null);
61 const [debouncedReference, referenceDebouncer] = useDebouncedValue(
62 reference,
63 { wait: 450 },
64 (debouncerState) => ({ isPending: debouncerState.isPending }),
65 );
66 const { data: gitStatus } = useEnvironmentQuery(
67 cwd === null
68 ? null
69 : vcsEnvironment.status({
70 environmentId,
71 input: { cwd },
72 }),
73 );
74 const sourceControlPresentation = useMemo(
75 () => getSourceControlPresentation(gitStatus?.sourceControlProvider),
76 [gitStatus?.sourceControlProvider],
77 );
78 const terminology = sourceControlPresentation.terminology;
79 const SourceControlIcon = sourceControlPresentation.Icon;
80
81 useEffect(() => {
82 if (!open) return;
83 const frame = window.requestAnimationFrame(() => {
84 referenceInputRef.current?.focus();
85 referenceInputRef.current?.select();
86 });
87 return () => {
88 window.cancelAnimationFrame(frame);
89 };
90 }, [open]);
91
92 const parsedReference = parsePullRequestReference(reference);
93 const parsedDebouncedReference = parsePullRequestReference(debouncedReference);
94 const sourceControlScope = useMemo(
95 () => ({
96 environmentId,
97 cwd,
98 }),
99 [cwd, environmentId],
100 );
101 const pullRequestResolution = usePullRequestResolution({
102 ...sourceControlScope,
103 reference: open ? parsedDebouncedReference : null,
104 });
105 const cachedPullRequest = useMemo(() => {
106 return (
107 readCachedPullRequestResolution({
108 ...sourceControlScope,
109 reference: parsedReference,
110 })?.pullRequest ?? null
111 );
112 }, [parsedReference, sourceControlScope]);
113 const preparePullRequestThreadAction = usePreparePullRequestThreadAction(sourceControlScope);
114 const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
115
116 const liveResolvedPullRequest =
117 parsedReference !== null && parsedReference === parsedDebouncedReference
118 ? (pullRequestResolution.data?.pullRequest ?? null)
119 : null;
120 const resolvedPullRequest = liveResolvedPullRequest ?? cachedPullRequest;
121 const isResolving =
122 open &&
123 parsedReference !== null &&
124 resolvedPullRequest === null &&
125 (referenceDebouncer.state.isPending ||
126 parsedReference !== parsedDebouncedReference ||
127 pullRequestResolution.isPending ||
128 pullRequestResolution.isFetching);
129 const statusTone = useMemo(() => {
130 switch (resolvedPullRequest?.state) {
131 case "merged":
132 return "text-violet-600 dark:text-violet-300/90";
133 case "closed":
134 return "text-zinc-500 dark:text-zinc-400/80";
135 case "open":
136 return "text-emerald-600 dark:text-emerald-300/90";
137 default:
138 return "text-muted-foreground";
139 }
140 }, [resolvedPullRequest?.state]);
141
142 const handleConfirm = useCallback(
143 async (mode: "local" | "worktree") => {
144 if (!preparePullRequestThreadAction.isAllowed) return;
145 if (
146 mode === "worktree" &&
147 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
148 ) {
149 return;
150 }
151 if (!parsedReference) {
152 setReferenceDirty(true);
153 return;
154 }
155 if (!parsedReference || !resolvedPullRequest || !cwd) {
156 return;
157 }
158 setPrepareErrorMessage(null);
159 setPreparingMode(mode);
160 const result = await preparePullRequestThreadAction.run({
161 reference: parsedReference,
162 mode,
163 ...(mode === "worktree" ? { threadId } : {}),
164 });
165 setPreparingMode(null);
166 if (result._tag === "Failure") {
167 if (isAtomCommandInterrupted(result)) {
168 preparePullRequestThreadAction.resetError();
169 } else {
170 const error = squashAtomCommandFailure(result);
171 setPrepareErrorMessage(
172 error instanceof Error
173 ? error.message
174 : `Failed to prepare ${terminology.singular} thread.`,
175 );
176 }
177 return;
178 }
179 await onPrepared({
180 branch: result.value.branch,
181 worktreePath: result.value.worktreePath,
182 });
183 onOpenChange(false);
184 },
185 [
186 cwd,
187 environmentId,
188 onOpenChange,
189 onPrepared,
190 parsedReference,
191 preparePullRequestThreadAction,
192 resolvedPullRequest,
193 terminology.singular,
194 threadId,
195 ],
196 );
197
198 const validationMessage = !referenceDirty
199 ? null
200 : reference.trim().length === 0
201 ? `Paste a ${terminology.singular} URL, checkout command, or enter 123 / #123.`
202 : parsedReference === null
203 ? `Use a ${terminology.singular} URL, checkout command, 123, or #123.`
204 : null;
205 const errorMessage =
206 validationMessage ??
207 prepareErrorMessage ??
208 (resolvedPullRequest === null && pullRequestResolution.error
209 ? pullRequestResolution.error
210 : preparePullRequestThreadAction.error instanceof Error
211 ? preparePullRequestThreadAction.error.message
212 : preparePullRequestThreadAction.error
213 ? `Failed to prepare ${terminology.singular} thread.`
214 : null);
215
216 return (
217 <Dialog
218 open={open}
219 onOpenChange={(nextOpen) => {
220 if (!preparePullRequestThreadAction.isPending) {
221 onOpenChange(nextOpen);
222 }
223 }}
224 >
225 <DialogPopup className="max-w-xl">
226 <DialogHeader>
227 <DialogTitle className="flex items-center">
228 <SourceControlIcon className="me-2 size-4" />
229 Checkout {terminology.singular}
230 </DialogTitle>
231 <DialogDescription>
232 Resolve a {sourceControlPresentation.providerName} {terminology.singular}, then create
233 the draft thread in the main repo or in a dedicated worktree.
234 </DialogDescription>
235 </DialogHeader>
236 <DialogPanel>
237 <label className="grid gap-1.5">
238 <span className="text-xs font-medium text-foreground capitalize">
239 {terminology.singular}
240 </span>
241 <Input
242 ref={referenceInputRef}
243 placeholder={`${terminology.shortLabel} URL, checkout command, or #42`}
244 value={reference}
245 onChange={(event) => {
246 setReferenceDirty(true);
247 setReference(event.target.value);
248 }}
249 onKeyDown={(event) => {
250 if (event.key !== "Enter") {
251 return;
252 }
253 if (event.nativeEvent.isComposing || event.keyCode === 229) {
254 return;
255 }
256 event.preventDefault();
257 if (!isResolving && !preparePullRequestThreadAction.isPending) {
258 void handleConfirm("worktree");
259 }
260 }}
261 />
262 </label>
263
264 {resolvedPullRequest ? (
265 <div className="rounded-xl border border-border/70 bg-muted/24 p-3">
266 <div className="flex items-start justify-between gap-3">
267 <div className="min-w-0">
268 <p className="truncate font-medium text-sm">{resolvedPullRequest.title}</p>
269 <p className="truncate text-muted-foreground text-xs">
270 #{resolvedPullRequest.number} · {resolvedPullRequest.headBranch} to{" "}
271 {resolvedPullRequest.baseBranch}
272 </p>
273 </div>
274 <span className={cn("shrink-0 text-xs capitalize", statusTone)}>
275 {resolvedPullRequest.state}
276 </span>
277 </div>
278 </div>
279 ) : null}
280
281 {isResolving ? (
282 <div className="flex items-center gap-2 text-muted-foreground text-xs">
283 <Spinner size="sm" />
284 Resolving {terminology.singular}...
285 </div>
286 ) : null}
287
288 {errorMessage ? <p className="text-destructive text-xs">{errorMessage}</p> : null}
289 </DialogPanel>
290 <DialogFooter>
291 <Button
292 type="button"
293 variant="outline"
294 size="sm"
295 onClick={() => onOpenChange(false)}
296 disabled={preparePullRequestThreadAction.isPending}
297 >
298 Cancel
299 </Button>
300 <Button
301 type="button"
302 size="sm"
303 variant="outline"
304 onClick={() => handleConfirm("local")}
305 disabled={
306 !preparePullRequestThreadAction.isAllowed ||
307 !cwd ||
308 !resolvedPullRequest ||
309 isResolving ||
310 preparePullRequestThreadAction.isPending
311 }
312 >
313 {preparingMode === "local" ? "Preparing local..." : "Local"}
314 </Button>
315 <Button
316 type="button"
317 size="sm"
318 onClick={() => handleConfirm("worktree")}
319 disabled={
320 !preparePullRequestThreadAction.isAllowed ||
321 !canOperateThread ||
322 !cwd ||
323 !resolvedPullRequest ||
324 isResolving ||
325 preparePullRequestThreadAction.isPending
326 }
327 >
328 {preparingMode === "worktree" ? "Preparing worktree..." : "Worktree"}
329 </Button>
330 </DialogFooter>
331 </DialogPopup>
332 </Dialog>
333 );
334}