spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
import * as React from 'react'
import { Command } from 'cmdk'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { cn } from '@/lib/utils'
import {
  buildCommands,
  CATEGORY_LABEL,
  type CommandCategory,
  type CommandContext,
  type CommandItem,
} from '@/lib/commands'

interface CommandPaletteProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  ctx: CommandContext
}

export function CommandPalette({ open, onOpenChange, ctx }: CommandPaletteProps) {
  const [search, setSearch] = React.useState('')
  const items = React.useMemo(() => buildCommands(ctx), [ctx])

  React.useEffect(() => {
    if (!open) setSearch('')
  }, [open])

  const grouped = React.useMemo(() => {
    const map = new Map<CommandCategory, CommandItem[]>()
    for (const it of items) {
      const list = map.get(it.category) ?? []
      list.push(it)
      map.set(it.category, list)
    }
    return Array.from(map.entries())
  }, [items])

  function runItem(item: CommandItem) {
    onOpenChange(false)
    // 让 dialog 关闭后再触发动作,避免 setState 冲突
    queueMicrotask(() => item.run())
  }

  return (
    <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
      <DialogPrimitive.Portal>
        <DialogPrimitive.Overlay
          className={cn(
            'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
            'data-[state=open]:animate-in data-[state=closed]:animate-out',
            'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
          )}
        />
        <DialogPrimitive.Content
          className={cn(
            'fixed left-1/2 top-[20%] z-50 w-full max-w-lg -translate-x-1/2 gap-0 border bg-popover p-0 text-popover-foreground shadow-lg',
            'data-[state=open]:animate-in data-[state=closed]:animate-out',
            'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
            'sm:rounded-lg',
          )}
          aria-describedby={undefined}
        >
          <DialogPrimitive.Title className="sr-only">命令面板</DialogPrimitive.Title>
          <Command label="命令面板" shouldFilter className="flex w-full flex-col">
            <Command.Input
              autoFocus
              value={search}
              onValueChange={setSearch}
              placeholder="输入命令或搜索…"
              className={cn(
                'flex h-11 w-full rounded-t-lg border-b border-border bg-transparent px-3 py-2 text-sm outline-none',
                'placeholder:text-muted-foreground',
              )}
            />
            <Command.List className="max-h-[60vh] overflow-y-auto p-2">
              <Command.Empty className="px-3 py-6 text-center text-xs text-muted-foreground">
                没有匹配的命令
              </Command.Empty>
              {grouped.map(([category, list]) => (
                <Command.Group
                  key={category}
                  heading={CATEGORY_LABEL[category]}
                  className={cn(
                    '[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5',
                    '[&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:font-semibold',
                    '[&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider',
                    '[&_[cmdk-group-heading]]:text-muted-foreground',
                  )}
                >
                  {list.map((item) => {
                    const Icon = item.icon
                    return (
                      <Command.Item
                        key={item.id}
                        value={`${item.label} ${item.keywords?.join(' ') ?? ''}`}
                        onSelect={() => runItem(item)}
                        className={cn(
                          'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none',
                          'data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground',
                        )}
                      >
                        <Icon className="h-3.5 w-3.5 text-muted-foreground" />
                        <span className="flex-1">{item.label}</span>
                        {item.shortcut && (
                          <kbd className="ml-auto rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
                            {item.shortcut}
                          </kbd>
                        )}
                      </Command.Item>
                    )
                  })}
                </Command.Group>
              ))}
            </Command.List>
          </Command>
        </DialogPrimitive.Content>
      </DialogPrimitive.Portal>
    </DialogPrimitive.Root>
  )
}

/**
 * 全局键盘 hook,监听 Cmd+K / Ctrl+K 唤起命令面板。
 * 输入框聚焦时不拦截。
 */
export function useCommandPaletteShortcut(setOpen: (open: boolean) => void) {
  React.useEffect(() => {
    function onKey(event: KeyboardEvent) {
      const isMod = event.metaKey || event.ctrlKey
      if (isMod && (event.key === 'k' || event.key === 'K')) {
        event.preventDefault()
        setOpen(true)
      }
    }
    window.addEventListener('keydown', onKey)
    return () => window.removeEventListener('keydown', onKey)
  }, [setOpen])
}