新增 entitlement/license IPC 与 Waffo 一次性结账服务骨架,挂载 AI/转发/代理/串口/广播/批量导入导出门禁;批量执行改为结果矩阵并复用连接池,SFTP 工具栏图标化并去掉已连接条,同时完善跨平台本地终端与窗口配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
474 lines
16 KiB
TypeScript
474 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, useEntitlementStore, type Session } from '../../stores'
|
||
import { disposeAndCloseAllTabs, disposeAndCloseTab } from '../../utils/disposeTab'
|
||
|
||
// ─── 类型定义 ────────────────────────────────────────────────────────
|
||
|
||
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, 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) {
|
||
void disposeAndCloseTab(activeTabId)
|
||
}
|
||
},
|
||
},
|
||
{
|
||
id: 'cmd:close-all-tabs',
|
||
type: 'command',
|
||
label: '关闭所有标签',
|
||
description: '关闭所有标签页并回到欢迎页',
|
||
icon: <X size={16} />,
|
||
action: () => {
|
||
void disposeAndCloseAllTabs()
|
||
},
|
||
},
|
||
{
|
||
id: 'cmd:new-portforward',
|
||
type: 'command',
|
||
label: '新建端口转发',
|
||
description: '打开端口转发管理标签(Pro)',
|
||
icon: <ChevronRight size={16} />,
|
||
action: () => {
|
||
if (!useEntitlementStore.getState().requirePro('portForward')) return
|
||
addTab({ title: '端口转发', type: 'portforward' })
|
||
},
|
||
},
|
||
{
|
||
id: 'cmd:upgrade-pro',
|
||
type: 'command',
|
||
label: '升级 OpsXShell Pro',
|
||
description: '查看专业能力对比与激活',
|
||
icon: <ChevronRight size={16} />,
|
||
action: () => useEntitlementStore.getState().openUpgradeTab(),
|
||
},
|
||
]
|
||
|
||
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, 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
|