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