254 lines · 8.7 KB · TypeScript
RawDownload
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>  );}