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])
}