import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { Cause, Option } from "effect"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "~/lib/utils"; import { parsePullRequestReference } from "~/pullRequestReference"; import { gitResolvePullRequestAtom, gitPreparePullRequestThreadMutationAtom } from "~/rpc/gitAtoms"; import { REACTIVITY_KEYS } from "~/rpc/client"; import { Button } from "./ui/button"; import { Dialog, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "./ui/dialog"; import { Input } from "./ui/input"; import { Spinner } from "./ui/spinner"; interface PullRequestThreadDialogProps { open: boolean; cwd: string | null; initialReference: string | null; onOpenChange: (open: boolean) => void; onPrepared: (input: { branch: string; worktreePath: string | null }) => Promise | void; } function toErrorMessage(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback; } export function PullRequestThreadDialog({ open, cwd, initialReference, onOpenChange, onPrepared, }: PullRequestThreadDialogProps) { const referenceInputRef = useRef(null); const [reference, setReference] = useState(initialReference ?? ""); const [referenceDirty, setReferenceDirty] = useState(false); const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null); const [prepareFollowupErrorMessage, setPrepareFollowupErrorMessage] = useState( null, ); const [debouncedReference, referenceDebouncer] = useDebouncedValue( reference, { wait: 450 }, (debouncerState) => ({ isPending: debouncerState.isPending }), ); const resetPreparePullRequestThread = useAtomSet(gitPreparePullRequestThreadMutationAtom); useEffect(() => { if (!open) return; setReference(initialReference ?? ""); setReferenceDirty(false); setPreparingMode(null); setPrepareFollowupErrorMessage(null); resetPreparePullRequestThread(Atom.Reset); }, [initialReference, open, resetPreparePullRequestThread]); useEffect(() => { if (!open) return; const frame = window.requestAnimationFrame(() => { referenceInputRef.current?.focus(); referenceInputRef.current?.select(); }); return () => { window.cancelAnimationFrame(frame); }; }, [open]); const parsedReference = parsePullRequestReference(reference); const parsedDebouncedReference = parsePullRequestReference(debouncedReference); const resolvePullRequestResult = useAtomValue( gitResolvePullRequestAtom({ cwd: open ? cwd : null, reference: open ? parsedDebouncedReference : null, }), ); const preparePullRequestThread = useAtomSet(gitPreparePullRequestThreadMutationAtom, { mode: "promise", }); const preparePullRequestThreadResult = useAtomValue(gitPreparePullRequestThreadMutationAtom); const resolvePullRequestData = Option.getOrUndefined(AsyncResult.value(resolvePullRequestResult)); const resolvePullRequestError = Option.getOrNull( Option.map(AsyncResult.cause(resolvePullRequestResult), (cause) => Cause.squash(cause)), ); const isPreparingPullRequestThread = preparingMode !== null; const liveResolvedPullRequest = parsedReference !== null && parsedReference === parsedDebouncedReference ? (resolvePullRequestData?.pullRequest ?? null) : null; const resolvedPullRequest = liveResolvedPullRequest; const isResolving = open && parsedReference !== null && resolvedPullRequest === null && (referenceDebouncer.state.isPending || parsedReference !== parsedDebouncedReference || AsyncResult.isInitial(resolvePullRequestResult) || resolvePullRequestResult.waiting); const statusTone = useMemo(() => { switch (resolvedPullRequest?.state) { case "merged": return "text-violet-600 dark:text-violet-300/90"; case "closed": return "text-zinc-500 dark:text-zinc-400/80"; case "open": return "text-emerald-600 dark:text-emerald-300/90"; default: return "text-muted-foreground"; } }, [resolvedPullRequest?.state]); const preparePullRequestThreadErrorMessage = AsyncResult.match(preparePullRequestThreadResult, { onInitial: () => null, onSuccess: () => null, onFailure: (error) => Cause.pretty(error.cause), }); const handleConfirm = useCallback( async (mode: "local" | "worktree") => { if (!parsedReference) { setReferenceDirty(true); return; } if (!parsedReference || !resolvedPullRequest || !cwd) { return; } setPrepareFollowupErrorMessage(null); setPreparingMode(mode); try { const result = await preparePullRequestThread({ payload: { cwd, reference: parsedReference, mode, }, reactivityKeys: [REACTIVITY_KEYS.git(cwd)], }); try { await onPrepared({ branch: result.branch, worktreePath: result.worktreePath, }); onOpenChange(false); } catch (error) { setPrepareFollowupErrorMessage( toErrorMessage(error, "Failed to prepare pull request thread."), ); } } catch { return; } finally { setPreparingMode(null); } }, [cwd, onOpenChange, onPrepared, parsedReference, preparePullRequestThread, resolvedPullRequest], ); const validationMessage = !referenceDirty ? null : reference.trim().length === 0 ? "Paste a GitHub pull request URL, `gh pr checkout 123`, or enter 123 / #123." : parsedReference === null ? "Use a GitHub pull request URL, `gh pr checkout 123`, 123, or #123." : null; const errorMessage = validationMessage ?? (resolvedPullRequest === null && AsyncResult.isFailure(resolvePullRequestResult) ? resolvePullRequestError instanceof Error ? resolvePullRequestError.message : "Failed to resolve pull request." : (prepareFollowupErrorMessage ?? preparePullRequestThreadErrorMessage)); return ( { if (!isPreparingPullRequestThread) { onOpenChange(nextOpen); } }} > Checkout Pull Request Resolve a GitHub pull request, then create the draft thread in the main repo or in a dedicated worktree. {resolvedPullRequest ? (

{resolvedPullRequest.title}

#{resolvedPullRequest.number} ยท {resolvedPullRequest.headBranch} to{" "} {resolvedPullRequest.baseBranch}

{resolvedPullRequest.state}
) : null} {isResolving ? (
Resolving pull request...
) : null} {errorMessage ?

{errorMessage}

: null}
); }