/** * CommandPalette - 全局命令面板 (v1.3.0) * * VS Code 风格 Ctrl+Shift+P 命令面板 * 支持搜索命令、会话、片段,键盘导航 * 片段数据从 snippetStore 实时读取 */ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Search, Globe, Code2, Zap, Plus, X, ChevronRight, Settings, Laptop, } from 'lucide-react' import { useTabStore } from '../../stores/tabStore' import { useSessionStore, useSnippetStore, useCommandPaletteStore, type Session } from '../../stores' // ─── 类型定义 ──────────────────────────────────────────────────────── type CommandPaletteItemType = 'command' | 'session' | 'snippet' interface CommandPaletteItem { id: string type: CommandPaletteItemType label: string description?: string shortcut?: string icon: React.ReactNode action: () => void } interface CommandPaletteProps { onNewSession: () => void onQuickConnect: () => void onOpenSettings: () => void onEditSession?: (session: Session) => void } // ─── 类型 badge 颜色映射 ────────────────────────────────────────────── const TYPE_BADGE: Record = { command: { label: '命令', className: 'bg-accent-blue/20 text-accent-blue' }, session: { label: '会话', className: 'bg-accent-green/20 text-accent-green' }, snippet: { label: '片段', className: 'bg-accent-purple/20 text-accent-purple' }, } // ─── 组件 ────────────────────────────────────────────────────────────── export function CommandPalette({ onNewSession, onQuickConnect, onOpenSettings, onEditSession, }: CommandPaletteProps) { const [query, setQuery] = useState('') const [selectedIndex, setSelectedIndex] = useState(0) const inputRef = useRef(null) const listRef = useRef(null) const { sessions } = useSessionStore() const { snippets } = useSnippetStore() const { isOpen, close } = useCommandPaletteStore() const { addTab, closeTab, tabs, activeTabId } = useTabStore() // ── 构建命令列表 ── const buildCommands = useCallback((): CommandPaletteItem[] => { const items: CommandPaletteItem[] = [] // 内置命令 const builtinCommands: CommandPaletteItem[] = [ { id: 'cmd:new-session', type: 'command', label: '新建会话', description: '创建新的 SSH 会话配置', shortcut: undefined, icon: , action: () => onNewSession(), }, { id: 'cmd:quick-connect', type: 'command', label: '快速连接', description: '直接输入主机信息连接', icon: , action: () => onQuickConnect(), }, { id: 'cmd:local-terminal', type: 'command', label: '本地终端', description: '打开本机 Shell', shortcut: 'Ctrl+Shift+T', icon: , action: () => addTab({ title: 'Local', type: 'terminal', isLocal: true }), }, { id: 'cmd:open-settings', type: 'command', label: '打开设置', description: '应用偏好设置', icon: , action: () => onOpenSettings(), }, { id: 'cmd:new-tab', type: 'command', label: '新建标签页', description: '打开一个新标签页', shortcut: 'Ctrl+Shift+N', icon: , action: () => addTab({ title: '新标签', type: 'welcome' }), }, { id: 'cmd:close-tab', type: 'command', label: '关闭当前标签', description: '关闭当前活动的标签页', shortcut: 'Ctrl+W', icon: , action: () => { if (activeTabId && tabs.length > 1) { closeTab(activeTabId) } }, }, { id: 'cmd:close-all-tabs', type: 'command', label: '关闭所有标签', description: '关闭所有标签页并回到欢迎页', icon: , action: () => useTabStore.getState().closeAllTabs(), }, { id: 'cmd:new-portforward', type: 'command', label: '新建端口转发', description: '打开端口转发管理标签', icon: , action: () => addTab({ title: '端口转发', type: 'portforward' }), }, ] items.push(...builtinCommands) // 会话列表 — 从 useSessionStore 读取 for (const session of sessions) { items.push({ id: `session:${session.id}`, type: 'session', label: session.name, description: `${session.username}@${session.host}:${session.port}`, icon: , action: () => { addTab({ title: session.name, type: 'terminal', sessionId: session.id, }) }, }) // 编辑会话命令 if (onEditSession) { items.push({ id: `session-edit:${session.id}`, type: 'command', label: `编辑会话: ${session.name}`, description: `${session.username}@${session.host}:${session.port}`, icon: , action: () => onEditSession(session), }) } } // 片段列表 — 从 snippetStore 读取真实数据 for (const snippet of snippets) { items.push({ id: `snippet:${snippet.id}`, type: 'snippet', label: snippet.name, description: snippet.content.length > 60 ? snippet.content.slice(0, 60) + '…' : snippet.content, icon: , action: () => { // 片段插入到当前终端的逻辑(后续可通过 dedicated event 扩展) console.log('[CommandPalette] Insert snippet:', snippet.content) }, }) } return items }, [sessions, snippets, addTab, closeTab, activeTabId, tabs.length, onNewSession, onQuickConnect, onOpenSettings, onEditSession]) // ── 过滤结果 ── const filteredItems = useMemo(() => { const allItems = buildCommands() if (!query.trim()) return allItems const lowerQuery = query.toLowerCase() return allItems.filter((item) => { if (item.label.toLowerCase().includes(lowerQuery)) return true if (item.description?.toLowerCase().includes(lowerQuery)) return true if (item.shortcut?.toLowerCase().includes(lowerQuery)) return true return false }) }, [query, buildCommands]) // ── 重置选中索引 ── useEffect(() => { setSelectedIndex(0) }, [query]) // ── 打开时聚焦输入框 ── useEffect(() => { if (isOpen) { setQuery('') setSelectedIndex(0) // 延迟聚焦,等待动画完成 requestAnimationFrame(() => { inputRef.current?.focus() }) } }, [isOpen]) // ── 滚动到选中项 ── useEffect(() => { if (!listRef.current) return const selectedEl = listRef.current.querySelector(`[data-index="${selectedIndex}"]`) if (selectedEl) { selectedEl.scrollIntoView({ block: 'nearest' }) } }, [selectedIndex]) // ── 键盘导航 ── const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case 'ArrowDown': e.preventDefault() setSelectedIndex((prev) => prev < filteredItems.length - 1 ? prev + 1 : 0 ) break case 'ArrowUp': e.preventDefault() setSelectedIndex((prev) => prev > 0 ? prev - 1 : filteredItems.length - 1 ) break case 'Enter': e.preventDefault() if (filteredItems[selectedIndex]) { filteredItems[selectedIndex].action() close() } break case 'Escape': e.preventDefault() close() break } }, [filteredItems, selectedIndex, close] ) // ── 背景点击关闭 ── const handleBackdropClick = useCallback( (e: React.MouseEvent) => { if (e.target === e.currentTarget) { close() } }, [close] ) // ── 按类型分组 ── const groupedItems = useMemo(() => { const groups: { type: CommandPaletteItemType; items: CommandPaletteItem[] }[] = [] const typeOrder: CommandPaletteItemType[] = ['command', 'session', 'snippet'] for (const type of typeOrder) { const typeItems = filteredItems.filter((i) => i.type === type) if (typeItems.length > 0) { groups.push({ type, items: typeItems }) } } return groups }, [filteredItems]) // 计算全局索引偏移 let runningIndex = 0 if (!isOpen) return null return (
{/* Backdrop */}
{/* Palette container */}
{/* Search input */}
setQuery(e.target.value)} placeholder="搜索命令、会话或片段..." className="flex-1 bg-transparent text-content-primary placeholder-ph outline-none text-sm" autoComplete="off" spellCheck={false} /> ESC
{/* Results list */}
{filteredItems.length === 0 ? (

没有找到匹配的结果

试试其他关键词

) : ( groupedItems.map((group) => { const groupStartIndex = runningIndex runningIndex += group.items.length return (
{/* Group header */}
{TYPE_BADGE[group.type].label} {group.items.length}
{/* Group items */} {group.items.map((item, idx) => { const globalIndex = groupStartIndex + idx const isSelected = globalIndex === selectedIndex return (
{ item.action() close() }} onMouseEnter={() => setSelectedIndex(globalIndex)} > {/* Icon */} {item.icon} {/* Label + description */}
{item.label}
{item.description && (
{item.description}
)}
{/* Shortcut */} {item.shortcut && ( {item.shortcut} )} {/* Type badge */} {TYPE_BADGE[item.type].label}
) })}
) }) )}
{/* Footer hint */}
↑↓ 导航 ↵ 执行 esc 关闭
{filteredItems.length} 个结果
) } export default CommandPalette