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<ScopedThreadRef | null>(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 ( <LinkPullRequestDialog open threadRef={threadRef} projectId={thread?.projectId ?? null} onOpenChange={(open) => { 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<HTMLInputElement>(null); const [reference, setReference] = useState(""); const [dirty, setDirty] = useState(false); const [submitError, setSubmitError] = useState<string | null>(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 ( <Dialog open={open} onOpenChange={(next) => (pending ? undefined : onOpenChange(next))}> <DialogPopup className="max-w-xl"> <DialogHeader> <DialogTitle>Link pull request</DialogTitle> <DialogDescription> Attach a pull request to this thread. A full URL can point at any repository on a host this environment has a project for. </DialogDescription> </DialogHeader> <DialogPanel className="space-y-3"> <Input ref={inputRef} placeholder="Pull request URL or #42" value={reference} onChange={(event) => { setDirty(true); setReference(event.target.value); }} onKeyDown={(event) => { if (event.key !== "Enter") return; event.preventDefault(); void submit(); }} /> {resolved !== null && "link" in resolved ? ( <p className="truncate text-muted-foreground text-xs"> {resolved.link.host}/{resolved.link.repository} #{resolved.link.number} </p> ) : null} {(validation ?? submitError) ? ( <p className="text-destructive text-xs">{validation ?? submitError}</p> ) : null} </DialogPanel> <DialogFooter> <Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)} disabled={pending} > Cancel </Button> <Button type="button" size="sm" onClick={() => void submit()} disabled={pending || resolved === null || "error" in resolved} > {pending ? "Linking..." : "Link"} </Button> </DialogFooter> </DialogPopup> </Dialog> );}
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<ScopedThreadRef | null>(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 (
<LinkPullRequestDialog
open
threadRef={threadRef}
projectId={thread?.projectId ?? null}
onOpenChange={(open) => {
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<HTMLInputElement>(null);
const [reference, setReference] = useState("");
const [dirty, setDirty] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={(next) => (pending ? undefined : onOpenChange(next))}>
<DialogPopup className="max-w-xl">
<DialogHeader>
<DialogTitle>Link pull request</DialogTitle>
<DialogDescription>
Attach a pull request to this thread. A full URL can point at any repository on a host
this environment has a project for.
</DialogDescription>
</DialogHeader>
<DialogPanel className="space-y-3">
<Input
ref={inputRef}
placeholder="Pull request URL or #42"
value={reference}
onChange={(event) => {
setDirty(true);
setReference(event.target.value);
}}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
void submit();
}}
/>
{resolved !== null && "link" in resolved ? (
<p className="truncate text-muted-foreground text-xs">
{resolved.link.host}/{resolved.link.repository} #{resolved.link.number}
</p>
) : null}
{(validation ?? submitError) ? (
<p className="text-destructive text-xs">{validation ?? submitError}</p>
) : null}
</DialogPanel>
<DialogFooter>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={pending}
>
Cancel
</Button>
<Button
type="button"
size="sm"
onClick={() => void submit()}
disabled={pending || resolved === null || "error" in resolved}
>
{pending ? "Linking..." : "Link"}
</Button>
</DialogFooter>
</DialogPopup>
</Dialog>
);
}