460 lines
16 KiB
TypeScript
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
|