380 lines · 12.0 KB · TypeScript
RawDownload
import {  type KeybindingCommand,  type FilesystemBrowseEntry,  THREAD_JUMP_KEYBINDING_COMMANDS,} from "@t3tools/contracts";import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";import * as Arr from "effect/Array";import * as Result from "effect/Result";import { type ReactNode } from "react";import { sortThreads } from "../lib/threadSort";import { formatRelativeTimeLabel } from "../timestampFormat";import { type Project, type SidebarThreadSummary, type Thread } from "../types"; export const RECENT_THREAD_LIMIT = 12;export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80";export const ADDON_ICON_CLASS = "size-4"; export interface CommandPaletteItem {  readonly kind: "action" | "submenu";  readonly value: string;  readonly searchTerms: ReadonlyArray<string>;  readonly title: ReactNode;  readonly description?: string;  readonly timestamp?: string;  readonly icon: ReactNode;  readonly disabled?: boolean;  /** Optional content rendered inline before the title text. */  readonly titleLeadingContent?: ReactNode;  /** Optional content rendered inline after the title text (before the timestamp). */  readonly titleTrailingContent?: ReactNode;  readonly shortcutCommand?: KeybindingCommand;} export interface CommandPaletteActionItem extends CommandPaletteItem {  readonly kind: "action";  readonly keepOpen?: boolean;  readonly run: () => Promise<void>;} export interface CommandPaletteSubmenuItem extends CommandPaletteItem {  readonly kind: "submenu";  readonly addonIcon: ReactNode;  readonly groups: ReadonlyArray<CommandPaletteGroup>;  readonly initialQuery?: string;} export interface CommandPaletteGroup {  readonly value: string;  readonly label: string;  readonly items: ReadonlyArray<CommandPaletteActionItem | CommandPaletteSubmenuItem>;} export interface CommandPaletteView {  readonly addonIcon: ReactNode;  readonly groups: ReadonlyArray<CommandPaletteGroup>;  readonly initialQuery?: string;} export function enumerateCommandPaletteItems(  items: ReadonlyArray<CommandPaletteActionItem>,): CommandPaletteActionItem[] {  return items.map((item, index) => {    const shortcutCommand = THREAD_JUMP_KEYBINDING_COMMANDS[index];    if (shortcutCommand) return { ...item, shortcutCommand };     const { shortcutCommand: _shortcutCommand, ...itemWithoutShortcut } = item;    return itemWithoutShortcut;  });} export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; export function filterBrowseEntries(input: {  browseEntries: ReadonlyArray<FilesystemBrowseEntry>;  browseFilterQuery: string;  highlightedItemValue: string | null;}): {  filteredEntries: FilesystemBrowseEntry[];  highlightedEntry: FilesystemBrowseEntry | null;  exactEntry: FilesystemBrowseEntry | null;} {  const lowerFilter = input.browseFilterQuery.toLowerCase();  const showHidden = input.browseFilterQuery.startsWith(".");   const filteredEntries = input.browseEntries.filter(    (entry) =>      entry.name.toLowerCase().startsWith(lowerFilter) &&      (showHidden || !entry.name.startsWith(".")),  );   let highlightedEntry: FilesystemBrowseEntry | null = null;  if (input.highlightedItemValue?.startsWith("browse:")) {    const highlightedPath = input.highlightedItemValue.slice("browse:".length);    highlightedEntry = filteredEntries.find((entry) => entry.fullPath === highlightedPath) ?? null;  }   const exactEntry =    input.browseFilterQuery.length > 0      ? (filteredEntries.find((entry) => entry.name === input.browseFilterQuery) ?? null)      : null;   return { filteredEntries, highlightedEntry, exactEntry };} export function normalizeSearchText(value: string): string {  return value.trim().toLowerCase().replace(/\s+/g, " ");} export function buildProjectActionItems(input: {  projects: ReadonlyArray<Project>;  valuePrefix: string;  icon: (project: Project) => ReactNode;  runProject: (project: Project) => Promise<void>;  searchTerms?: (project: Project) => ReadonlyArray<string>;  shortcutCommand?: KeybindingCommand;}): CommandPaletteActionItem[] {  return input.projects.map((project) => ({    kind: "action",    value: `${input.valuePrefix}:${project.environmentId}:${project.id}`,    searchTerms: [project.title, project.workspaceRoot, ...(input.searchTerms?.(project) ?? [])],    title: project.title,    description: project.workspaceRoot,    icon: input.icon(project),    ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}),    run: async () => {      await input.runProject(project);    },  }));} export type BuildThreadActionItemsThread = Pick<  SidebarThreadSummary,  "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title"> & {  updatedAt: string;  latestUserMessageAt?: string | null;}; export function buildThreadActionItems<TThread extends BuildThreadActionItemsThread>(input: {  threads: ReadonlyArray<TThread>;  activeThreadId?: Thread["id"];  projectTitleById: ReadonlyMap<Project["id"], string>;  sortOrder: SidebarThreadSortOrder;  icon: ReactNode;  /** Optional content rendered inline before the title text per-thread. */  renderLeadingContent?: (thread: TThread) => ReactNode;  /** Optional content rendered inline after the title text per-thread. */  renderTrailingContent?: (thread: TThread) => ReactNode;  runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>;  limit?: number;}): CommandPaletteActionItem[] {  const sortedThreads = sortThreads(    input.threads.filter((thread) => thread.archivedAt === null),    input.sortOrder,  );  const visibleThreads =    input.limit === undefined ? sortedThreads : sortedThreads.slice(0, input.limit);   return visibleThreads.map((thread) => {    const projectTitle = input.projectTitleById.get(thread.projectId);    const descriptionParts: string[] = [];     if (projectTitle) {      descriptionParts.push(projectTitle);    }    if (thread.branch) {      descriptionParts.push(`#${thread.branch}`);    }    if (thread.id === input.activeThreadId) {      descriptionParts.push("Current thread");    }     const leadingContent = input.renderLeadingContent?.(thread);    const trailingContent = input.renderTrailingContent?.(thread);     return Object.assign(      {        kind: "action" as const,        value: `thread:${thread.id}`,        searchTerms: [thread.title, projectTitle ?? ``, thread.branch ?? ``],        title: thread.title,        description: descriptionParts.join(` · `),        timestamp: formatRelativeTimeLabel(          thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,        ),        icon: input.icon,      },      leadingContent ? { titleLeadingContent: leadingContent } : {},      trailingContent ? { titleTrailingContent: trailingContent } : {},      {        run: async () => {          await input.runThread(thread);        },      },    );  });} function rankSearchFieldMatch(field: string, normalizedQuery: string): number {  const normalizedField = normalizeSearchText(field);  if (normalizedField.length === 0 || !normalizedField.includes(normalizedQuery)) {    return Number.NEGATIVE_INFINITY;  }  if (normalizedField === normalizedQuery) {    return 3;  }  if (normalizedField.startsWith(normalizedQuery)) {    return 2;  }  return 1;} function rankCommandPaletteItemMatch(  item: CommandPaletteActionItem | CommandPaletteSubmenuItem,  normalizedQuery: string,): number {  const terms = item.searchTerms.filter((term) => term.length > 0);  if (terms.length === 0) {    return 0;  }   for (const [index, field] of terms.entries()) {    const fieldRank = rankSearchFieldMatch(field, normalizedQuery);    if (fieldRank !== Number.NEGATIVE_INFINITY) {      return 1_000 - index * 100 + fieldRank;    }  }   return 0;} export function filterCommandPaletteGroups(input: {  activeGroups: ReadonlyArray<CommandPaletteGroup>;  query: string;  isInSubmenu: boolean;  projectSearchItems: ReadonlyArray<CommandPaletteActionItem>;  threadSearchItems: ReadonlyArray<CommandPaletteActionItem>;}): CommandPaletteGroup[] {  const isActionsFilter = input.query.startsWith(">");  const searchQuery = isActionsFilter ? input.query.slice(1) : input.query;  const normalizedQuery = normalizeSearchText(searchQuery);   if (normalizedQuery.length === 0) {    if (isActionsFilter) {      return input.activeGroups.filter((group) => group.value === "actions");    }    return [...input.activeGroups];  }   let baseGroups = [...input.activeGroups];  if (isActionsFilter) {    baseGroups = baseGroups.filter((group) => group.value === "actions");  } else if (!input.isInSubmenu) {    baseGroups = baseGroups.filter((group) => group.value !== "recent-threads");  }   const searchableGroups = [...baseGroups];  if (!input.isInSubmenu && !isActionsFilter) {    if (input.projectSearchItems.length > 0) {      searchableGroups.push({        value: "projects-search",        label: "Projects",        items: input.projectSearchItems,      });    }    if (input.threadSearchItems.length > 0) {      searchableGroups.push({        value: "threads-search",        label: "Threads",        items: input.threadSearchItems,      });    }  }   return searchableGroups.flatMap((group) => {    const items = Arr.filterMap(group.items, (item, index) => {      const haystack = normalizeSearchText(item.searchTerms.join(" "));      if (!haystack.includes(normalizedQuery)) {        return Result.failVoid;      }       return Result.succeed({        item,        index,        rank: rankCommandPaletteItemMatch(item, normalizedQuery),      });    })      .toSorted((left, right) => right.rank - left.rank || left.index - right.index)      .map((entry) => entry.item);     if (items.length === 0) {      return [];    }     return [{ value: group.value, label: group.label, items }];  });} export function buildBrowseGroups(input: {  browseEntries: ReadonlyArray<FilesystemBrowseEntry>;  browseQuery: string;  canBrowseUp: boolean;  upIcon: ReactNode;  directoryIcon: ReactNode;  browseUp: () => void;  browseTo: (name: string) => void;}): CommandPaletteGroup[] {  const items: CommandPaletteActionItem[] = [];   if (input.canBrowseUp) {    items.push({      kind: "action",      value: "browse:up",      searchTerms: [input.browseQuery, ".."],      title: "..",      icon: input.upIcon,      keepOpen: true,      run: async () => {        input.browseUp();      },    });  }   for (const entry of input.browseEntries) {    items.push({      kind: "action",      value: `browse:${entry.fullPath}`,      searchTerms: [input.browseQuery, entry.fullPath, entry.name],      title: entry.name,      icon: input.directoryIcon,      keepOpen: true,      run: async () => {        input.browseTo(entry.name);      },    });  }   return [{ value: "directories", label: "Directories", items }];} export function getCommandPaletteMode(input: {  currentView: CommandPaletteView | null;  isBrowsing: boolean;}): CommandPaletteMode {  if (input.currentView) {    return input.isBrowsing ? "submenu-browse" : "submenu";  }  return input.isBrowsing ? "root-browse" : "root";} export function buildRootGroups(input: {  actionItems: ReadonlyArray<CommandPaletteActionItem | CommandPaletteSubmenuItem>;  recentThreadItems: ReadonlyArray<CommandPaletteActionItem>;}): CommandPaletteGroup[] {  const groups: CommandPaletteGroup[] = [];  if (input.actionItems.length > 0) {    groups.push({ value: "actions", label: "Actions", items: input.actionItems });  }  if (input.recentThreadItems.length > 0) {    groups.push({      value: "recent-threads",      label: "Recent Threads",      items: input.recentThreadItems,    });  }  return groups;} export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): string {  switch (mode) {    case "root":      return "Search commands, projects, and threads...";    case "root-browse":      return "Enter project path (e.g. ~/projects/my-app)";    case "submenu":      return "Search...";    case "submenu-browse":      return "Enter path (e.g. ~/projects/my-app)";  }}