import { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl"; export { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl"; import { pullRequestHostOf, type ScopedThreadRef, type SourceControlProviderKind, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; import { parsePullRequestReference } from "~/pullRequestReference"; import { useProjects, useThreadShell } from "~/state/entities"; import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { Atom } from "effect/unstable/reactivity"; import { Button } from "../ui/button"; import { Dialog, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "../ui/dialog"; import { Input } from "../ui/input"; /** * Which thread has the link dialog open, set by whichever entry point asked (command palette, * pull-requests surface, detail panel) and rendered once by the chat view so the dialog outlives * a palette that closes the moment its command runs. */ const linkPullRequestDialogThreadAtom = Atom.make(null).pipe( Atom.keepAlive, Atom.withLabel("pull-requests:link-dialog-thread"), ); export function openLinkPullRequestDialog(threadRef: ScopedThreadRef): void { appAtomRegistry.set(linkPullRequestDialogThreadAtom, threadRef); } interface LinkPullRequestDialogProps { open: boolean; threadRef: ScopedThreadRef; /** The thread's own project: bare numbers resolve against its repository. */ projectId: string | null; onOpenChange: (open: boolean) => void; } /** Mounted once per chat view; shows the dialog for whichever thread asked for it. */ export function LinkPullRequestDialogHost() { const threadRef = useAtomValue(linkPullRequestDialogThreadAtom); const thread = useThreadShell(threadRef); const linking = usePullRequestLinking(threadRef?.environmentId); if (threadRef === null || linking.mode === "unsupported") return null; return ( { if (!open) appAtomRegistry.set(linkPullRequestDialogThreadAtom, null); }} /> ); } interface ResolvedLink { readonly host: string; readonly repository: string; readonly number: number; readonly url: string; } /** * Which pull request an input names, or why it cannot. A URL carries its own host and * repository and may point at any repository on a host this environment has a project for; a * bare `#123` can only mean the thread's own repository. */ export function resolveLinkPullRequestInput(input: { readonly reference: string; readonly project: { readonly host: string; readonly repository: string; readonly webUrl: (number: number) => string | null; } | null; readonly hasProject: (reference: ResolvedLink) => boolean; }): { link: ResolvedLink } | { error: string } | null { const parsed = parseChangeRequestUrl(input.reference.trim()) !== null ? input.reference.trim() : parsePullRequestReference(input.reference); if (parsed === null) return null; const url = parseChangeRequestUrl(parsed); if (url !== null) { if (!input.hasProject({ ...url, url: parsed })) { return { error: `No project in this environment can read ${url.host}/${url.repository}.` }; } return { link: { host: url.host, repository: url.repository, number: url.number, url: parsed }, }; } const number = Number(parsed); if (!Number.isSafeInteger(number) || number < 1) return null; if (input.project === null) { return { error: "Paste a full URL to link a pull request from another repository." }; } const webUrl = input.project.webUrl(number); const webReference = webUrl === null ? null : parseChangeRequestUrl(webUrl); if (webUrl === null || webReference === null) { return { error: "Paste a full URL; this project's host has no known pull request URL." }; } return { link: { ...webReference, url: webUrl }, }; } function LinkPullRequestDialog({ open, threadRef, projectId, onOpenChange, }: LinkPullRequestDialogProps) { const inputRef = useRef(null); const [reference, setReference] = useState(""); const [dirty, setDirty] = useState(false); const [submitError, setSubmitError] = useState(null); const projects = useProjects(); const environmentProjects = useMemo( () => projects.filter((project) => project.environmentId === threadRef.environmentId), [projects, threadRef.environmentId], ); const ownProject = useMemo(() => { const project = environmentProjects.find((candidate) => candidate.id === projectId); const identity = project?.repositoryIdentity; if (!project || !identity) return null; const repository = identity.displayName ?? (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null); if (repository === null) return null; const kind = identity.provider as SourceControlProviderKind; const host = pullRequestHostOf(identity, kind); return { host, repository, webUrl: (number: number) => changeRequestWebUrl(kind, host, repository, number), }; }, [environmentProjects, projectId]); const linking = usePullRequestLinking(threadRef.environmentId); const [pending, setPending] = useState(false); useEffect(() => { if (!open) return; setReference(""); setDirty(false); setSubmitError(null); const frame = window.requestAnimationFrame(() => inputRef.current?.focus()); return () => window.cancelAnimationFrame(frame); }, [open]); const resolved = useMemo( () => resolveLinkPullRequestInput({ reference, project: ownProject, hasProject: (reference) => linking.canLink(reference.url), }), [linking, ownProject, reference], ); const submit = useCallback(async () => { setDirty(true); if (resolved === null || "error" in resolved) return; setSubmitError(null); setPending(true); try { await linking.changeLink(threadRef, resolved.link.url, true); } catch (error) { setSubmitError(error instanceof Error ? error.message : "Could not link the pull request."); return; } finally { setPending(false); } onOpenChange(false); }, [linking, onOpenChange, resolved, threadRef]); const validation = !dirty ? null : reference.trim().length === 0 ? "Paste a pull request URL or enter 123 / #123." : resolved === null ? "Use a pull request URL, 123, or #123." : "error" in resolved ? resolved.error : null; return ( (pending ? undefined : onOpenChange(next))}> Link pull request Attach a pull request to this thread. A full URL can point at any repository on a host this environment has a project for. { setDirty(true); setReference(event.target.value); }} onKeyDown={(event) => { if (event.key !== "Enter") return; event.preventDefault(); void submit(); }} /> {resolved !== null && "link" in resolved ? (

{resolved.link.host}/{resolved.link.repository} #{resolved.link.number}

) : null} {(validation ?? submitError) ? (

{validation ?? submitError}

) : null}
); }