v1.3.0: 主题系统+代码分割+设置重构+命令片段库+命令面板+连接面板 | 19 files +2141/-257 | tsc零错误 构建成功
This commit is contained in:
1 parent
caba5f4c0d
commit
1f2de032a4
19 files changed
+2142
-258
No files matched your search
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* 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,
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
Settings,
|
||||
} 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:open-settings',
|
||||
type: 'command',
|
||||
label: '打开设置',
|
||||
description: '应用偏好设置',
|
||||
icon: <Settings size={16} />,
|
||||
action: () => onOpenSettings(),
|
||||
},
|
||||
{
|
||||
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-sftp',
|
||||
type: 'command',
|
||||
label: '新建 SFTP 浏览器',
|
||||
description: '打开 SFTP 文件管理标签',
|
||||
icon: <FolderOpen size={16} />,
|
||||
action: () => addTab({ title: 'SFTP', type: 'sftp' }),
|
||||
},
|
||||
{
|
||||
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" />
|
||||
|
||||
{/* 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-in fade-in slide-in-from-top-4 duration-150"
|
||||
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-gray-400 flex-shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索命令、会话或片段..."
|
||||
className="flex-1 bg-transparent text-gray-200 placeholder-gray-500 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-gray-500 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-gray-500">
|
||||
<Search size={32} className="mb-2 opacity-30" />
|
||||
<p className="text-sm">没有找到匹配的结果</p>
|
||||
<p className="text-xs text-gray-600 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-gray-500 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-gray-600">{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-gray-100'
|
||||
: 'text-gray-300 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-gray-500'
|
||||
}`}
|
||||
>
|
||||
{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-gray-500 truncate">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shortcut */}
|
||||
{item.shortcut && (
|
||||
<kbd className="flex-shrink-0 text-[11px] text-gray-500 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-gray-600">
|
||||
<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
|
||||
Reference in new issue
Block a user