Files
opsxshell/src/components/common/CommandPalette.tsx
T

460 lines
16 KiB
TypeScript

/**
* 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,
PanelLeft,
} 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<CommandPaletteItemType, { label: string; className: string }> = {
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<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(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: <Plus size={16} />,
action: () => onNewSession(),
},
{
id: 'cmd:quick-connect',
type: 'command',
label: '快速连接',
description: '直接输入主机信息连接',
icon: <Zap size={16} />,
action: () => onQuickConnect(),
},
{
id: 'cmd:local-terminal',
type: 'command',
label: '本地终端',
description: '打开本机 Shell',
shortcut: 'Ctrl+Shift+T',
icon: <Laptop size={16} />,
action: () => addTab({ title: 'Local', type: 'terminal', isLocal: true }),
},
{
id: 'cmd:open-settings',
type: 'command',
label: '打开设置',
description: '应用偏好设置',
icon: <Settings size={16} />,
action: () => onOpenSettings(),
},
{
id: 'cmd:toggle-sidebar',
type: 'command',
label: '折叠/展开侧栏',
description: '隐藏侧栏以专心操作终端',
shortcut: 'Ctrl+B',
icon: <PanelLeft size={16} />,
action: () => window.dispatchEvent(new CustomEvent('opsxshell:toggle-sidebar')),
},
{
id: 'cmd:new-tab',
type: 'command',
label: '新建标签页',
description: '打开一个新标签页',
shortcut: 'Ctrl+Shift+N',
icon: <Plus size={16} />,
action: () => addTab({ title: '新标签', type: 'welcome' }),
},
{
id: 'cmd:close-tab',
type: 'command',
label: '关闭当前标签',
description: '关闭当前活动的标签页',
shortcut: 'Ctrl+W',
icon: <X size={16} />,
action: () => {
if (activeTabId && tabs.length > 1) {
closeTab(activeTabId)
}
},
},
{
id: 'cmd:close-all-tabs',
type: 'command',
label: '关闭所有标签',
description: '关闭所有标签页并回到欢迎页',
icon: <X size={16} />,
action: () => useTabStore.getState().closeAllTabs(),
},
{
id: 'cmd:new-portforward',
type: 'command',
label: '新建端口转发',
description: '打开端口转发管理标签',
icon: <ChevronRight size={16} />,
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: <Globe size={16} />,
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: <Settings size={16} />,
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: <Code2 size={16} />,
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 (
<div
className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]"
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in" />
{/* Palette container */}
<div
className="relative w-[600px] max-h-[460px] bg-dark-800 border border-dark-700 rounded-lg shadow-2xl overflow-hidden flex flex-col animate-scale-in"
role="dialog"
aria-modal="true"
aria-label="命令面板"
>
{/* Search input */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-dark-700">
<Search size={18} className="text-txt-muted flex-shrink-0" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索命令、会话或片段..."
className="flex-1 bg-transparent text-content-primary placeholder-ph outline-none text-sm"
autoComplete="off"
spellCheck={false}
/>
<kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] text-txt-muted bg-dark-700 rounded border border-dark-600">
ESC
</kbd>
</div>
{/* Results list */}
<div ref={listRef} className="flex-1 overflow-y-auto py-1">
{filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-txt-muted">
<Search size={32} className="mb-2 opacity-30" />
<p className="text-sm">没有找到匹配的结果</p>
<p className="text-xs text-txt-faint mt-1">试试其他关键词</p>
</div>
) : (
groupedItems.map((group) => {
const groupStartIndex = runningIndex
runningIndex += group.items.length
return (
<div key={group.type}>
{/* Group header */}
<div className="flex items-center gap-2 px-4 py-1.5 text-[11px] font-medium text-txt-muted uppercase tracking-wider">
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${TYPE_BADGE[group.type].className}`}
>
{TYPE_BADGE[group.type].label}
</span>
<span className="flex-1 h-px bg-dark-700" />
<span className="text-txt-faint">{group.items.length}</span>
</div>
{/* Group items */}
{group.items.map((item, idx) => {
const globalIndex = groupStartIndex + idx
const isSelected = globalIndex === selectedIndex
return (
<div
key={item.id}
data-index={globalIndex}
className={`
flex items-center gap-3 px-4 py-2 cursor-pointer transition-colors duration-75
${isSelected
? 'bg-accent-blue/15 text-content-primary'
: 'text-content-secondary hover:bg-dark-700'
}
`}
onClick={() => {
item.action()
close()
}}
onMouseEnter={() => setSelectedIndex(globalIndex)}
>
{/* Icon */}
<span
className={`flex-shrink-0 w-5 h-5 flex items-center justify-center ${
isSelected ? 'text-accent-blue' : 'text-txt-muted'
}`}
>
{item.icon}
</span>
{/* Label + description */}
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{item.label}</div>
{item.description && (
<div className="text-[11px] text-txt-muted truncate">
{item.description}
</div>
)}
</div>
{/* Shortcut */}
{item.shortcut && (
<kbd className="flex-shrink-0 text-[11px] text-txt-muted bg-dark-700 px-1.5 py-0.5 rounded border border-dark-600">
{item.shortcut}
</kbd>
)}
{/* Type badge */}
<span
className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${TYPE_BADGE[item.type].className}`}
>
{TYPE_BADGE[item.type].label}
</span>
</div>
)
})}
</div>
)
})
)}
</div>
{/* Footer hint */}
<div className="flex items-center justify-between px-4 py-2 border-t border-dark-700 text-[11px] text-txt-faint">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 bg-dark-700 rounded border border-dark-600">↑↓</kbd>
导航
</span>
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 bg-dark-700 rounded border border-dark-600">↵</kbd>
执行
</span>
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 bg-dark-700 rounded border border-dark-600">esc</kbd>
关闭
</span>
</div>
<span>
{filteredItems.length} 个结果
</span>
</div>
</div>
</div>
)
}
export default CommandPalette