458 lines · 14.1 KB · TypeScript
RawDownload
"use client"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";import type { ProjectId } from "@t3tools/contracts";import { useNavigate, useParams } from "@tanstack/react-router";import {  ArrowDownIcon,  ArrowLeftIcon,  ArrowUpIcon,  MessageSquareIcon,  SettingsIcon,  SquarePenIcon,} from "lucide-react";import {  useDeferredValue,  useEffect,  useMemo,  useRef,  useState,  type KeyboardEvent,  type ReactNode,} from "react";import { useShallow } from "zustand/react/shallow";import { useCommandPaletteStore } from "../commandPaletteStore";import { useHandleNewThread } from "../hooks/useHandleNewThread";import { useSettings } from "../hooks/useSettings";import {  startNewThreadInProjectFromContext,  startNewThreadFromContext,} from "../lib/chatThreadActions";import { isTerminalFocused } from "../lib/terminalFocus";import { getLatestThreadForProject } from "../lib/threadSort";import { cn } from "../lib/utils";import {  selectProjectsAcrossEnvironments,  selectSidebarThreadsAcrossEnvironments,  useStore,} from "../store";import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";import {  ADDON_ICON_CLASS,  buildProjectActionItems,  buildRootGroups,  buildThreadActionItems,  type CommandPaletteActionItem,  type CommandPaletteSubmenuItem,  type CommandPaletteView,  filterCommandPaletteGroups,  getCommandPaletteInputPlaceholder,  getCommandPaletteMode,  ITEM_ICON_CLASS,  RECENT_THREAD_LIMIT,} from "./CommandPalette.logic";import { CommandPaletteResults } from "./CommandPaletteResults";import { ProjectFavicon } from "./ProjectFavicon";import { useServerKeybindings } from "../rpc/serverState";import { resolveShortcutCommand } from "../keybindings";import {  Command,  CommandDialog,  CommandDialogPopup,  CommandFooter,  CommandInput,  CommandPanel,} from "./ui/command";import { Kbd, KbdGroup } from "./ui/kbd";import { toastManager } from "./ui/toast";import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";import type { ChatComposerHandle } from "./chat/ChatComposer"; export function CommandPalette({ children }: { children: ReactNode }) {  const open = useCommandPaletteStore((store) => store.open);  const setOpen = useCommandPaletteStore((store) => store.setOpen);  const toggleOpen = useCommandPaletteStore((store) => store.toggleOpen);  const keybindings = useServerKeybindings();  const composerHandleRef = useRef<ChatComposerHandle | null>(null);  const routeTarget = useParams({    strict: false,    select: (params) => resolveThreadRouteTarget(params),  });  const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null;  const terminalOpen = useTerminalStateStore((state) =>    routeThreadRef      ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen      : false,  );   useEffect(() => {    const onKeyDown = (event: globalThis.KeyboardEvent) => {      if (event.defaultPrevented) return;      const command = resolveShortcutCommand(event, keybindings, {        context: {          terminalFocus: isTerminalFocused(),          terminalOpen,        },      });      if (command !== "commandPalette.toggle") {        return;      }      event.preventDefault();      event.stopPropagation();      toggleOpen();    };    window.addEventListener("keydown", onKeyDown);    return () => window.removeEventListener("keydown", onKeyDown);  }, [keybindings, terminalOpen, toggleOpen]);   return (    <ComposerHandleContext.Provider value={composerHandleRef}>      <CommandDialog open={open} onOpenChange={setOpen}>        {children}        <CommandPaletteDialog />      </CommandDialog>    </ComposerHandleContext.Provider>  );} function CommandPaletteDialog() {  const open = useCommandPaletteStore((store) => store.open);  const setOpen = useCommandPaletteStore((store) => store.setOpen);   useEffect(() => {    return () => {      setOpen(false);    };  }, [setOpen]);   if (!open) {    return null;  }   return <OpenCommandPaletteDialog />;} function OpenCommandPaletteDialog() {  const navigate = useNavigate();  const setOpen = useCommandPaletteStore((store) => store.setOpen);  const composerHandleRef = useComposerHandleContext();  const [query, setQuery] = useState("");  const deferredQuery = useDeferredValue(query);  const isActionsOnly = deferredQuery.startsWith(">");  const settings = useSettings();  const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =    useHandleNewThread();  const projects = useStore(useShallow(selectProjectsAcrossEnvironments));  const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments));  const keybindings = useServerKeybindings();  const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]);  const currentView = viewStack.at(-1) ?? null;  const paletteMode = getCommandPaletteMode({ currentView });   const projectTitleById = useMemo(    () => new Map<ProjectId, string>(projects.map((project) => [project.id, project.name])),    [projects],  );   const activeThreadId = activeThread?.id;  const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null;   const openProjectFromSearch = useMemo(    () => async (project: (typeof projects)[number]) => {      const latestThread = getLatestThreadForProject(        threads.filter((thread) => thread.environmentId === project.environmentId),        project.id,        settings.sidebarThreadSortOrder,      );      if (latestThread) {        await navigate({          to: "/$environmentId/$threadId",          params: buildThreadRouteParams(            scopeThreadRef(latestThread.environmentId, latestThread.id),          ),        });        return;      }       await handleNewThread(scopeProjectRef(project.environmentId, project.id), {        envMode: settings.defaultThreadEnvMode,      });    },    [      handleNewThread,      navigate,      settings.defaultThreadEnvMode,      settings.sidebarThreadSortOrder,      threads,    ],  );   const projectSearchItems = useMemo(    () =>      buildProjectActionItems({        projects,        valuePrefix: "project",        icon: (project) => (          <ProjectFavicon            environmentId={project.environmentId}            cwd={project.cwd}            className={ITEM_ICON_CLASS}          />        ),        runProject: openProjectFromSearch,      }),    [openProjectFromSearch, projects],  );   const projectThreadItems = useMemo(    () =>      buildProjectActionItems({        projects,        valuePrefix: "new-thread-in",        icon: (project) => (          <ProjectFavicon            environmentId={project.environmentId}            cwd={project.cwd}            className={ITEM_ICON_CLASS}          />        ),        runProject: async (project) => {          await startNewThreadInProjectFromContext(            {              activeDraftThread,              activeThread,              defaultProjectRef,              defaultThreadEnvMode: settings.defaultThreadEnvMode,              handleNewThread,            },            scopeProjectRef(project.environmentId, project.id),          );        },      }),    [      activeDraftThread,      activeThread,      defaultProjectRef,      handleNewThread,      projects,      settings.defaultThreadEnvMode,    ],  );   const allThreadItems = useMemo(    () =>      buildThreadActionItems({        threads,        ...(activeThreadId ? { activeThreadId } : {}),        projectTitleById,        sortOrder: settings.sidebarThreadSortOrder,        icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,        runThread: async (thread) => {          await navigate({            to: "/$environmentId/$threadId",            params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)),          });        },      }),    [activeThreadId, navigate, projectTitleById, settings.sidebarThreadSortOrder, threads],  );  const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT);   function pushView(item: CommandPaletteSubmenuItem): void {    setViewStack((previousViews) => [      ...previousViews,      {        addonIcon: item.addonIcon,        groups: item.groups,        ...(item.initialQuery ? { initialQuery: item.initialQuery } : {}),      },    ]);    setQuery(item.initialQuery ?? "");  }   function popView(): void {    setViewStack((previousViews) => previousViews.slice(0, -1));    setQuery("");  }   function handleQueryChange(nextQuery: string): void {    setQuery(nextQuery);    if (nextQuery === "" && currentView?.initialQuery) {      popView();    }  }   const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [];   if (projects.length > 0) {    const activeProjectTitle = currentProjectId      ? (projectTitleById.get(currentProjectId) ?? null)      : null;     if (activeProjectTitle) {      actionItems.push({        kind: "action",        value: "action:new-thread",        searchTerms: ["new thread", "chat", "create", "draft"],        title: (          <>            New thread in <span className="font-semibold">{activeProjectTitle}</span>          </>        ),        icon: <SquarePenIcon className={ITEM_ICON_CLASS} />,        shortcutCommand: "chat.new",        run: async () => {          await startNewThreadFromContext({            activeDraftThread,            activeThread,            defaultProjectRef,            defaultThreadEnvMode: settings.defaultThreadEnvMode,            handleNewThread,          });        },      });    }     actionItems.push({      kind: "submenu",      value: "action:new-thread-in",      searchTerms: ["new thread", "project", "pick", "choose", "select"],      title: "New thread in...",      icon: <SquarePenIcon className={ITEM_ICON_CLASS} />,      addonIcon: <SquarePenIcon className={ADDON_ICON_CLASS} />,      groups: [{ value: "projects", label: "Projects", items: projectThreadItems }],    });  }   actionItems.push({    kind: "action",    value: "action:settings",    searchTerms: ["settings", "preferences", "configuration", "keybindings"],    title: "Open settings",    icon: <SettingsIcon className={ITEM_ICON_CLASS} />,    run: async () => {      await navigate({ to: "/settings" });    },  });   const rootGroups = buildRootGroups({ actionItems, recentThreadItems });  const activeGroups = currentView ? currentView.groups : rootGroups;   const displayedGroups = filterCommandPaletteGroups({    activeGroups,    query: deferredQuery,    isInSubmenu: currentView !== null,    projectSearchItems: projectSearchItems,    threadSearchItems: allThreadItems,  });   const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode);  const isSubmenu = paletteMode === "submenu";   function handleKeyDown(event: KeyboardEvent<HTMLInputElement>): void {    if (event.key === "Backspace" && query === "" && isSubmenu) {      event.preventDefault();      popView();    }  }   function executeItem(item: CommandPaletteActionItem | CommandPaletteSubmenuItem): void {    if (item.kind === "submenu") {      pushView(item);      return;    }     if (!item.keepOpen) {      setOpen(false);    }     void item.run().catch((error: unknown) => {      toastManager.add({        type: "error",        title: "Unable to run command",        description: error instanceof Error ? error.message : "An unexpected error occurred.",      });    });  }   return (    <CommandDialogPopup      aria-label="Command palette"      className="overflow-hidden p-0"      data-testid="command-palette"      finalFocus={() => {        composerHandleRef?.current?.focusAtEnd();        return false;      }}    >      <Command        key={viewStack.length}        aria-label="Command palette"        autoHighlight="always"        mode="none"        onValueChange={handleQueryChange}        value={query}      >        <CommandInput          placeholder={inputPlaceholder}          wrapperClassName={            isSubmenu              ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto [&_[data-slot=autocomplete-start-addon]]:cursor-pointer"              : undefined          }          {...(isSubmenu            ? {                startAddon: (                  <button                    type="button"                    className="flex cursor-pointer items-center"                    aria-label="Back"                    onClick={popView}                  >                    <ArrowLeftIcon />                  </button>                ),              }            : {})}          onKeyDown={handleKeyDown}        />        <CommandPanel className="max-h-[min(28rem,70vh)]">          <CommandPaletteResults            groups={displayedGroups}            isActionsOnly={isActionsOnly}            keybindings={keybindings}            onExecuteItem={executeItem}          />        </CommandPanel>        <CommandFooter className="gap-3 max-sm:flex-col max-sm:items-start">          <div className="flex items-center gap-3">            <KbdGroup className="items-center gap-1.5">              <Kbd>                <ArrowUpIcon />              </Kbd>              <Kbd>                <ArrowDownIcon />              </Kbd>              <span className={cn("text-muted-foreground/80")}>Navigate</span>            </KbdGroup>            <KbdGroup className="items-center gap-1.5">              <Kbd>Enter</Kbd>              <span className={cn("text-muted-foreground/80")}>Select</span>            </KbdGroup>            {isSubmenu ? (              <KbdGroup className="items-center gap-1.5">                <Kbd>Backspace</Kbd>                <span className={cn("text-muted-foreground/80")}>Back</span>              </KbdGroup>            ) : null}            <KbdGroup className="items-center gap-1.5">              <Kbd>Esc</Kbd>              <span className={cn("text-muted-foreground/80")}>Close</span>            </KbdGroup>          </div>        </CommandFooter>      </Command>    </CommandDialogPopup>  );}