import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { readCachedPullRequestResolution, usePreparePullRequestThreadAction, usePullRequestResolution, } from "~/lib/sourceControlActions"; import { useVcsStatus } from "~/lib/vcsStatusState"; import { cn } from "~/lib/utils"; import { parsePullRequestReference } from "~/pullRequestReference"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; 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; environmentId: EnvironmentId; threadId: ThreadId; cwd: string | null; initialReference: string | null; onOpenChange: (open: boolean) => void; onPrepared: (input: { branch: string; worktreePath: string | null }) => Promise | void; } export function PullRequestThreadDialog({ open, environmentId, threadId, 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 [debouncedReference, referenceDebouncer] = useDebouncedValue( reference, { wait: 450 }, (debouncerState) => ({ isPending: debouncerState.isPending }), ); const { data: gitStatus } = useVcsStatus({ environmentId, cwd }); const sourceControlPresentation = useMemo( () => getSourceControlPresentation(gitStatus?.sourceControlProvider), [gitStatus?.sourceControlProvider], ); const terminology = sourceControlPresentation.terminology; const SourceControlIcon = sourceControlPresentation.Icon; useEffect(() => { if (!open) return; setReference(initialReference ?? ""); setReferenceDirty(false); setPreparingMode(null); }, [initialReference, open]); 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 sourceControlScope = useMemo( () => ({ environmentId, cwd, }), [cwd, environmentId], ); const pullRequestResolution = usePullRequestResolution({ ...sourceControlScope, reference: open ? parsedDebouncedReference : null, }); const cachedPullRequest = useMemo(() => { return ( readCachedPullRequestResolution({ ...sourceControlScope, reference: parsedReference, })?.pullRequest ?? null ); }, [parsedReference, sourceControlScope]); const preparePullRequestThreadAction = usePreparePullRequestThreadAction(sourceControlScope); const liveResolvedPullRequest = parsedReference !== null && parsedReference === parsedDebouncedReference ? (pullRequestResolution.data?.pullRequest ?? null) : null; const resolvedPullRequest = liveResolvedPullRequest ?? cachedPullRequest; const isResolving = open && parsedReference !== null && resolvedPullRequest === null && (referenceDebouncer.state.isPending || parsedReference !== parsedDebouncedReference || pullRequestResolution.isPending || pullRequestResolution.isFetching); 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 handleConfirm = useCallback( async (mode: "local" | "worktree") => { if (!parsedReference) { setReferenceDirty(true); return; } if (!parsedReference || !resolvedPullRequest || !cwd) { return; } setPreparingMode(mode); try { const result = await preparePullRequestThreadAction.run({ reference: parsedReference, mode, ...(mode === "worktree" ? { threadId } : {}), }); await onPrepared({ branch: result.branch, worktreePath: result.worktreePath, }); onOpenChange(false); } finally { setPreparingMode(null); } }, [ cwd, onOpenChange, onPrepared, parsedReference, preparePullRequestThreadAction, resolvedPullRequest, threadId, ], ); const validationMessage = !referenceDirty ? null : reference.trim().length === 0 ? `Paste a ${terminology.singular} URL, checkout command, or enter 123 / #123.` : parsedReference === null ? `Use a ${terminology.singular} URL, checkout command, 123, or #123.` : null; const errorMessage = validationMessage ?? (resolvedPullRequest === null && pullRequestResolution.error ? pullRequestResolution.error instanceof Error ? pullRequestResolution.error.message : `Failed to resolve ${terminology.singular}.` : preparePullRequestThreadAction.error instanceof Error ? preparePullRequestThreadAction.error.message : preparePullRequestThreadAction.error ? `Failed to prepare ${terminology.singular} thread.` : null); return ( { if (!preparePullRequestThreadAction.isPending) { onOpenChange(nextOpen); } }} > Checkout {terminology.singular} Resolve a {sourceControlPresentation.providerName} {terminology.singular}, 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 {terminology.singular}...
) : null} {errorMessage ?

{errorMessage}

: null}
); }