Files
opsxshell/src/components/common/CommandPalette.tsx
T
Logan.LiandCursor fea6d8db04 feat: Pro 买断门禁、SFTP 精简与批量多机改造
新增 entitlement/license IPC 与 Waffo 一次性结账服务骨架,挂载 AI/转发/代理/串口/广播/批量导入导出门禁;批量执行改为结果矩阵并复用连接池,SFTP 工具栏图标化并去掉已连接条,同时完善跨平台本地终端与窗口配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 11:38:30 +08:00

474 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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