| 1 | import { |
| 2 | AuthOrchestrationOperateScope, |
| 3 | type EnvironmentId, |
| 4 | type ThreadId, |
| 5 | } from "@t3tools/contracts"; |
| 6 | import { |
| 7 | isAtomCommandInterrupted, |
| 8 | squashAtomCommandFailure, |
| 9 | } from "@t3tools/client-runtime/state/runtime"; |
| 10 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 11 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 12 | |
| 13 | import { |
| 14 | readCachedPullRequestResolution, |
| 15 | usePreparePullRequestThreadAction, |
| 16 | usePullRequestResolution, |
| 17 | } from "~/lib/sourceControlActions"; |
| 18 | import { cn } from "~/lib/utils"; |
| 19 | import { parsePullRequestReference } from "~/pullRequestReference"; |
| 20 | import { getSourceControlPresentation } from "~/sourceControlPresentation"; |
| 21 | import { useEnvironmentQuery } from "~/state/query"; |
| 22 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; |
| 23 | import { vcsEnvironment } from "~/state/vcs"; |
| 24 | import { Button } from "./ui/button"; |
| 25 | import { |
| 26 | Dialog, |
| 27 | DialogDescription, |
| 28 | DialogFooter, |
| 29 | DialogHeader, |
| 30 | DialogPanel, |
| 31 | DialogPopup, |
| 32 | DialogTitle, |
| 33 | } from "./ui/dialog"; |
| 34 | import { Input } from "./ui/input"; |
| 35 | import { Spinner } from "./ui/spinner"; |
| 36 | |
| 37 | interface 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 | |
| 47 | export 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 | } |