237 lines · 8.6 KB · TypeScript
RawDownload
import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";import { scopeThreadRef } from "@t3tools/client-runtime/environment";import type { EnvironmentId, PullRequestRef, ScopedThreadRef, ThreadId } from "@t3tools/contracts";import { CheckIcon, LinkIcon, MessageSquareIcon, UnlinkIcon } from "lucide-react";import { useState } from "react";import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility";import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { parseChangeRequestUrl } from "~/lib/openPullRequestLink";import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities";import { pullRequestEnvironment } from "~/state/pullRequests";import { useEnvironmentQuery } from "~/state/query";import { appAtomRegistry } from "~/rpc/atomRegistry";import { openCommandPalette } from "~/commandPaletteBus";import { Button } from "../ui/button";import { Command, CommandInput, CommandItem, CommandList } from "../ui/command";import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";import { MenuItem } from "../ui/menu";import { toastManager } from "../ui/toast"; interface PullRequestThreadLinksProps {  environmentId: EnvironmentId;  reference: PullRequestRef;  url: string;  threadRef: ScopedThreadRef | null;  display: "count" | "menu-item" | "picker";  onPickerOpenChange?: (open: boolean) => void;} /** Thread relations belong to the detail environment, including when another environment is active. */export function PullRequestThreadLinks(props: PullRequestThreadLinksProps) {  const configs = useServerConfigs();  if (    threadPullRequestLinkMode(configs.get(props.environmentId)?.environment.capabilities) ===    "unsupported"  ) {    return null;  }  return <EnabledPullRequestThreadLinks key={`${props.environmentId}:${props.url}`} {...props} />;} function EnabledPullRequestThreadLinks({  environmentId,  reference,  url,  threadRef,  display,  onPickerOpenChange,}: PullRequestThreadLinksProps) {  const parsed = parseChangeRequestUrl(url);  const currentThreadRef = threadRef?.environmentId === environmentId ? threadRef : null;  const thread = useThreadShell(currentThreadRef);  const linking = usePullRequestLinking(environmentId);  const linkedHere = linking.isLinked(thread, url);  const relations = useEnvironmentQuery(    linking.mode === "multiple" && display !== "menu-item"      ? pullRequestEnvironment.linkedThreads({          environmentId,          input: parsed === null ? reference : { ...reference, ...parsed },        })      : null,  );  // Refreshes can briefly clear the query value. Keep the last response so polling  // does not hide the linked-thread count between responses.  const [lastRelations, setLastRelations] = useState(relations.data);  if (relations.data !== null && relations.data !== lastRelations) {    setLastRelations(relations.data);  }  const [pending, setPending] = useState(false);   const changeLink = async (threadId: ThreadId, remove: boolean) => {    if (parsed === null || pending) return;    setPending(true);    try {      await linking.changeLink(scopeThreadRef(environmentId, threadId), url, !remove);    } catch (error) {      toastManager.add({        type: "error",        title: remove ? "Could not unlink the pull request" : "Could not link the pull request",        description: error instanceof Error ? error.message : String(error),      });      return;    } finally {      setPending(false);    }    if (linking.mode === "multiple") {      appAtomRegistry.refresh(        pullRequestEnvironment.linkedThreads({ environmentId, input: { ...reference, ...parsed } }),      );    }    onPickerOpenChange?.(false);  };   if (parsed === null || (!linkedHere && !linking.canLink(url))) return null;  const linkedThreads =    linking.mode === "multiple" ? ((relations.data ?? lastRelations)?.threads ?? []) : [];  const linkedThreadsLabel =    linkedThreads.length > 0      ? `Linked from ${linkedThreads.length} ${linkedThreads.length === 1 ? "thread" : "threads"}`      : "Linked threads";  return (    <>      {display === "count" && (linkedThreads.length > 0 || relations.error !== null) ? (        <Tooltip>          <TooltipTrigger            render={              <Button                size="xs"                variant="ghost"                aria-label={linkedThreadsLabel}                onClick={() =>                  openCommandPalette({                    query: url,                    ...((relations.data ?? lastRelations) === null                      ? {}                      : { linkedThreads: { environmentId, threads: linkedThreads } }),                  })                }              >                <MessageSquareIcon aria-hidden className="size-3.5" />                <span aria-hidden>{linkedThreads.length || "?"}</span>              </Button>            }          />          <TooltipPopup>{linkedThreadsLabel}. Search in the command palette.</TooltipPopup>        </Tooltip>      ) : null}      {display === "menu-item" ? (        <MenuItem          disabled={pending}          onClick={() => {            if (currentThreadRef !== null) {              void changeLink(currentThreadRef.threadId, linkedHere);            } else {              onPickerOpenChange?.(true);            }          }}        >          {linkedHere ? (            <UnlinkIcon aria-hidden className="size-3.5" />          ) : (            <LinkIcon aria-hidden className="size-3.5" />          )}          {linkedHere            ? "Unlink from this thread"            : currentThreadRef              ? "Link to this thread"              : "Link to thread"}        </MenuItem>      ) : null}      {display === "picker" ? (        <Dialog open onOpenChange={onPickerOpenChange}>          <DialogPopup className="max-w-md" showCloseButton={false}>            <DialogTitle className="sr-only">Link pull request to a thread</DialogTitle>            <ThreadPicker              environmentId={environmentId}              url={url}              pending={pending}              onSelect={(threadId) => void changeLink(threadId, false)}            />          </DialogPopup>        </Dialog>      ) : null}    </>  );} function ThreadPicker({  environmentId,  url,  pending,  onSelect,}: {  environmentId: EnvironmentId;  url: string;  pending: boolean;  onSelect: (threadId: ThreadId) => void;}) {  const threads = useThreadShells();  const linking = usePullRequestLinking(environmentId);  const projects = useProjects();  const [query, setQuery] = useState("");  const projectNames = new Map(    projects      .filter((project) => project.environmentId === environmentId)      .map((project) => [project.id, project.title]),  );  const search = query.trim().toLocaleLowerCase();  const candidates = threads    .filter(      (thread) =>        thread.environmentId === environmentId &&        thread.archivedAt === null &&        `${thread.title} ${projectNames.get(thread.projectId) ?? ""}`          .toLocaleLowerCase()          .includes(search),    )    .toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt));  return (    <Command mode="none" value={query} onValueChange={setQuery} aria-label="Choose a thread">      <CommandInput placeholder="Search threads or projects..." disabled={pending} />      <CommandList className="max-h-80 overflow-y-auto">        {candidates.length === 0 ? (          <div className="px-3 py-6 text-center text-sm text-muted-foreground">            No active threads found.          </div>        ) : (          candidates.map((thread) => {            const linked = linking.isLinked(thread, url);            return (              <CommandItem                key={thread.id}                value={thread.id}                disabled={pending || linked}                onClick={() => onSelect(thread.id)}              >                <MessageSquareIcon aria-hidden className="size-4 shrink-0" />                <span className="flex min-w-0 flex-1 flex-col">                  <span className="truncate">{thread.title || "Untitled thread"}</span>                  <span className="truncate text-xs text-muted-foreground">                    {projectNames.get(thread.projectId)}                  </span>                </span>                {linked ? (                  <>                    <CheckIcon aria-hidden className="size-3.5" />                    <span className="text-xs text-muted-foreground">Linked</span>                  </>                ) : null}              </CommandItem>            );          })        )}      </CommandList>    </Command>  );}