v1.3.0: 主题系统+代码分割+设置重构+命令片段库+命令面板+连接面板 | 19 files +2141/-257 | tsc零错误 构建成功

This commit is contained in:
OpsXShell Dev committed 2026-04-18 19:25:45 +08:00
1 parent caba5f4c0d
commit 1f2de032a4
19 files changed
+2142 -258

No files matched your search

+335
View File
@@ -0,0 +1,335 @@
/**
* ConnectionPanel - 连接状态管理面板
*
* 右侧滑出面板,实时监控所有连接状态、流量统计、支持重连/断开操作。
*/
import { useState, useCallback, useMemo } from 'react'
import {
Wifi,
WifiOff,
RefreshCw,
X,
Clock,
HardDrive,
Activity,
} from 'lucide-react'
import { useConnectionStore } from '../stores/connection'
import { usePoolStore } from '../stores/connection/poolStore'
import { useConnectionPanelStore } from '../stores/connectionPanelStore'
import type { PooledConnection, ConnectionState } from '../stores/connection/types'
import EmptyState from './common/EmptyState'
// ============== Helpers ==============
/** 格式化字节 */
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const k = 1024
const i = Math.floor(Math.log(bytes) / Math.log(k))
const value = bytes / Math.pow(k, i)
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
/** 格式化连接时长 (ms → readable) */
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000)
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ${seconds % 60}s`
const hours = Math.floor(minutes / 60)
return `${hours}h ${minutes % 60}m`
}
// ============== Status Dot ==============
interface StatusDotProps {
state: ConnectionState
}
const STATUS_DOT_COLORS: Record<ConnectionState, string> = {
connected: 'bg-green-400',
connecting: 'bg-yellow-400 animate-pulse',
background: 'bg-green-400 opacity-60',
suspended: 'bg-gray-400',
error: 'bg-red-400',
disconnected: 'bg-gray-500',
reconnecting: 'bg-yellow-400 animate-pulse',
}
function StatusDot({ state }: StatusDotProps) {
return (
<span
className={`inline-block w-2 h-2 rounded-full ${STATUS_DOT_COLORS[state] ?? 'bg-gray-500'}`}
/>
)
}
// ============== Status Label ==============
const STATUS_LABELS: Record<ConnectionState, string> = {
connected: '已连接',
connecting: '连接中',
background: '后台',
suspended: '已挂起',
error: '错误',
disconnected: '已断开',
reconnecting: '重连中',
}
// ============== Connection Card ==============
interface ConnectionCardProps {
conn: PooledConnection
onReconnect: (id: string) => void
onDisconnect: (id: string) => void
reconnectingId: string | null
}
function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: ConnectionCardProps) {
const isReconnecting = reconnectingId === conn.connectionId
const canReconnect =
conn.state === 'suspended' || conn.state === 'error' || conn.state === 'disconnected'
const canDisconnect =
conn.state !== 'disconnected' && conn.state !== 'error'
const durationMs = Date.now() - conn.stats.connectedAt
const sessionCount = conn.shellIds.size + conn.sftpIds.size
return (
<div className="bg-dark-900 rounded-lg p-3 space-y-2.5">
{/* Header: host + status */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<StatusDot state={conn.state} />
<span className="text-sm text-gray-200 truncate font-medium">
{conn.config.username}@{conn.config.host}
</span>
</div>
<span
className={`text-xs px-1.5 py-0.5 rounded ${
conn.state === 'connected'
? 'bg-green-400/10 text-green-400'
: conn.state === 'connecting' || conn.state === 'reconnecting'
? 'bg-yellow-400/10 text-yellow-400'
: conn.state === 'background'
? 'bg-green-400/10 text-green-400 opacity-70'
: conn.state === 'suspended'
? 'bg-gray-400/10 text-gray-400'
: conn.state === 'error'
? 'bg-red-400/10 text-red-400'
: 'bg-gray-500/10 text-gray-500'
}`}
>
{STATUS_LABELS[conn.state]}
</span>
</div>
{/* Details */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-400">
{/* Port */}
<span className="flex items-center gap-1">
<Activity className="w-3 h-3" />
:{conn.config.port ?? 22}
</span>
{/* Duration */}
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatDuration(durationMs)}
</span>
{/* Transfer */}
{conn.stats.bytesTransferred > 0 && (
<span className="flex items-center gap-1">
<HardDrive className="w-3 h-3" />
{formatBytes(conn.stats.bytesTransferred)}
</span>
)}
{/* Sessions */}
{sessionCount > 0 && (
<span>
{conn.shellIds.size} shell{conn.sftpIds.size > 0 ? ` / ${conn.sftpIds.size} sftp` : ''}
</span>
)}
{/* Ref count */}
{conn.refCount > 1 && <span>×{conn.refCount} 引用</span>}
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
{canReconnect && (
<button
type="button"
onClick={() => onReconnect(conn.connectionId)}
disabled={isReconnecting}
className="flex items-center gap-1 px-2 py-1 rounded text-xs
bg-accent-blue/10 text-accent-blue hover:bg-accent-blue/20
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RefreshCw className={`w-3 h-3 ${isReconnecting ? 'animate-spin' : ''}`} />
{isReconnecting ? '重连中...' : '重连'}
</button>
)}
{canDisconnect && (
<button
type="button"
onClick={() => onDisconnect(conn.connectionId)}
className="flex items-center gap-1 px-2 py-1 rounded text-xs
bg-red-400/10 text-red-400 hover:bg-red-400/20 transition-colors"
>
<WifiOff className="w-3 h-3" />
断开
</button>
)}
</div>
{/* Error message */}
{conn.error && (
<p className="text-xs text-red-400 truncate" title={conn.error}>
{conn.error}
</p>
)}
</div>
)
}
// ============== Stats Overview ==============
interface StatsBarProps {
total: number
connected: number
background: number
error: number
}
function StatsOverview({ total, connected, background, error }: StatsBarProps) {
const items = [
{ label: '总计', value: total, color: 'text-gray-300' },
{ label: '活跃', value: connected, color: 'text-green-400' },
{ label: '后台', value: background, color: 'text-yellow-400' },
{ label: '断开/错误', value: error, color: 'text-red-400' },
]
return (
<div className="grid grid-cols-4 gap-2">
{items.map((item) => (
<div key={item.label} className="bg-dark-900 rounded-lg p-2 text-center">
<div className={`text-lg font-semibold ${item.color}`}>{item.value}</div>
<div className="text-[10px] text-gray-500">{item.label}</div>
</div>
))}
</div>
)
}
// ============== Main Panel ==============
export default function ConnectionPanel() {
const isOpen = useConnectionPanelStore((s) => s.isOpen)
const close = useConnectionPanelStore((s) => s.close)
const connections = usePoolStore((s) => s.connections)
const {
resumeConnection,
disconnectConnection,
getStats,
} = useConnectionStore()
const [reconnectingId, setReconnectingId] = useState<string | null>(null)
// Derive connections list and stats
const { connList, stats } = useMemo(() => {
const list = Array.from(connections.values())
const s = getStats()
return { connList: list, stats: s }
}, [connections, getStats])
// Handlers
const handleReconnect = useCallback(
async (connectionId: string) => {
setReconnectingId(connectionId)
try {
await resumeConnection(connectionId)
} finally {
setReconnectingId(null)
}
},
[resumeConnection],
)
const handleDisconnect = useCallback(
async (connectionId: string) => {
await disconnectConnection(connectionId)
},
[disconnectConnection],
)
if (!isOpen) return null
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/30 z-40"
onClick={close}
/>
{/* Panel */}
<div
className="fixed top-0 right-0 bottom-0 w-[360px] z-50
bg-dark-800/95 backdrop-blur border-l border-dark-700
flex flex-col shadow-xl"
>
{/* Header */}
<div className="flex items-center justify-between px-4 h-12 border-b border-dark-700 shrink-0">
<h2 className="text-sm font-semibold text-gray-200 flex items-center gap-2">
<Wifi className="w-4 h-4 text-accent-blue" />
连接管理
</h2>
<button
type="button"
onClick={close}
className="p-1 rounded hover:bg-dark-700 text-gray-400 hover:text-gray-200 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Stats Overview */}
<div className="px-4 py-3 border-b border-dark-700 shrink-0">
<StatsOverview
total={stats.total}
connected={stats.connected}
background={stats.background}
error={stats.error + stats.suspended}
/>
</div>
{/* Connection List */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-2">
{connList.length === 0 ? (
<EmptyState
icon={WifiOff}
title="暂无连接"
description="当前没有任何活跃连接,连接到服务器后将在此显示。"
/>
) : (
connList.map((conn) => (
<ConnectionCard
key={conn.connectionId}
conn={conn}
onReconnect={handleReconnect}
onDisconnect={handleDisconnect}
reconnectingId={reconnectingId}
/>
))
)}
</div>
</div>
</>
)
}
+447 -225
View File
@@ -1,29 +1,97 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, useMemo } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import {
Monitor, Palette, Keyboard, Shield, Database, Terminal
Monitor, Palette, Terminal, Keyboard, Shield, Database, Info, Search, X,
} from 'lucide-react'
import { themes } from '../themes/terminal'
type SettingsTab = 'general' | 'appearance' | 'terminal' | 'ssh' | 'security' | 'about'
// ─── Tab 类型 ───────────────────────────────────────────────
type SettingsTab = 'general' | 'appearance' | 'terminal' | 'shortcuts' | 'ssh' | 'security' | 'about'
interface TabMeta {
id: SettingsTab
label: string
description: string
icon: React.ComponentType<{ className?: string }>
}
const TAB_LIST: TabMeta[] = [
{ id: 'general', label: '常规', description: '应用启动与通知', icon: Monitor },
{ id: 'appearance', label: '外观', description: '主题与语言偏好', icon: Palette },
{ id: 'terminal', label: '终端', description: '字体与配色方案', icon: Terminal },
{ id: 'shortcuts', label: '快捷键', description: '键盘快捷键管理', icon: Keyboard },
{ id: 'ssh', label: 'SSH', description: '连接与认证设置', icon: Shield },
{ id: 'security', label: '安全', description: '加密与隐私设置', icon: Database },
{ id: 'about', label: '关于', description: '版本与许可信息', icon: Info },
]
// ─── 快捷键数据 ─────────────────────────────────────────────
interface ShortcutEntry {
action: string
keys: string
category: string
}
const SHORTCUTS: ShortcutEntry[] = [
{ action: '切换到下一个标签', keys: 'Ctrl+Tab', category: '标签管理' },
{ action: '切换到上一个标签', keys: 'Ctrl+Shift+Tab', category: '标签管理' },
{ action: '跳转到第 N 个标签', keys: 'Ctrl+1 … Ctrl+9', category: '标签管理' },
{ action: '关闭当前标签', keys: 'Ctrl+W', category: '标签管理' },
{ action: '新建标签', keys: 'Ctrl+Shift+N', category: '标签管理' },
{ action: '切换全屏', keys: 'F11', category: '视图' },
{ action: '新建会话', keys: 'Ctrl+N', category: '会话' },
{ action: '快速连接', keys: 'Ctrl+K', category: '会话' },
{ action: '命令面板', keys: 'Ctrl+Shift+P', category: '工具' },
]
// ─── 主组件 ─────────────────────────────────────────────────
export default function SettingsView() {
const { settings, isLoaded, loadSettings, setSetting } = useSettingsStore()
const [activeTab, setActiveTab] = useState<SettingsTab>('general')
const [searchQuery, setSearchQuery] = useState('')
const [appVersion, setAppVersion] = useState<string>('—')
useEffect(() => { loadSettings() }, []) // eslint-disable-line react-hooks/exhaustive-deps
// 获取版本号(参考 StatusBar 实现)
useEffect(() => {
loadSettings()
if (window.electronAPI) {
window.electronAPI.getVersion().then((v: string) => setAppVersion(v))
}
}, [])
const tabs = [
{ id: 'general' as SettingsTab, label: '常规', icon: Monitor },
{ id: 'appearance' as SettingsTab, label: '外观', icon: Palette },
{ id: 'terminal' as SettingsTab, label: '终端', icon: Keyboard },
{ id: 'ssh' as SettingsTab, label: 'SSH', icon: Shield },
{ id: 'security' as SettingsTab, label: '安全', icon: Database },
{ id: 'about' as SettingsTab, label: '关于', icon: Monitor },
]
// 当前 tab 元信息
const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab])
// ── 搜索过滤 ──────────────────────────────────────────────
const filtered = useMemo((): SettingsTab[] | null => {
if (!searchQuery.trim()) return null // null 表示不过滤,正常显示
const q = searchQuery.toLowerCase()
const keywords: Record<SettingsTab, string[]> = {
general: ['启动', '恢复', '会话', '自动保存', '通知', 'restore', 'notification'],
appearance: ['主题', '深色', '浅色', '语言', '中文', 'english', 'theme', 'language'],
terminal: ['字体', 'font', '配色', 'terminal', 'monokai', 'dracula'],
shortcuts: ['快捷键', '键盘', 'keyboard', 'shortcut', 'hotkey'],
ssh: ['ssh', '连接', '端口', '超时', '心跳', '连接池', '重连', '快照'],
security: ['加密', '安全', '密码', '锁屏', 'encrypt', 'lock'],
about: ['版本', '关于', '许可', 'version', 'about', 'license'],
}
const matchIds = (Object.entries(keywords) as [SettingsTab, string[]][])
.filter(([, kws]) => kws.some((k: string) => k.includes(q) || q.includes(k)))
.map(([id]) => id)
return matchIds.length > 0 ? matchIds : []
}, [searchQuery])
// 如果搜索过滤后有结果,自动跳转到第一个匹配 tab
useEffect(() => {
if (filtered && filtered.length > 0 && !filtered.includes(activeTab)) {
setActiveTab(filtered[0])
}
}, [filtered]) // eslint-disable-line react-hooks/exhaustive-deps
const visibleTabs = filtered ? TAB_LIST.filter((t) => filtered.includes(t.id)) : TAB_LIST
// ── 加载态 ────────────────────────────────────────────────
if (!isLoaded) {
return (
<div className="h-full flex items-center justify-center bg-dark-900">
@@ -32,292 +100,369 @@ export default function SettingsView() {
)
}
// ── 渲染内容区 ────────────────────────────────────────────
const renderContent = () => {
switch (activeTab) {
// ──────── 常规 ─────────
case 'general':
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-200">常规设置</h2>
<div className="space-y-4">
<div className="space-y-5">
<SettingCard>
<SettingToggle
label="启动时恢复会话"
description="应用启动时自动恢复上次的会话"
checked={settings.restoreOnStart}
onChange={(v) => setSetting('restoreOnStart', v)}
/>
</SettingCard>
<SettingCard>
<SettingToggle
label="自动保存会话"
description="自动保存会话配置更改"
checked={settings.autoSave}
onChange={(v) => setSetting('autoSave', v)}
/>
</SettingCard>
<SettingCard>
<SettingToggle
label="显示通知"
description="在桌面显示连接状态通知"
checked={settings.showNotifications}
onChange={(v) => setSetting('showNotifications', v)}
/>
</div>
</SettingCard>
</div>
)
// ──────── 外观 ─────────
case 'appearance':
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-200">外观设置</h2>
<div className="space-y-4">
<div className="p-4 bg-dark-800 rounded-lg">
<div className="font-medium text-gray-200 mb-3">主题</div>
<div className="grid grid-cols-3 gap-3">
{[
{ value: 'dark', label: '深色' },
{ value: 'light', label: '浅色' },
{ value: 'system', label: '跟随系统' },
].map((theme) => (
<button
key={theme.value}
onClick={() => setSetting('theme', theme.value as any)}
className={`p-3 rounded-lg border text-sm transition-colors ${
settings.theme === theme.value
? 'border-accent-blue bg-accent-blue/20 text-accent-blue'
: 'border-dark-700 hover:border-dark-600 text-gray-300'
}`}
>
{theme.label}
</button>
))}
</div>
<div className="space-y-5">
<SettingCard>
<SettingSectionTitle>主题</SettingSectionTitle>
<div className="grid grid-cols-3 gap-3 mt-3">
{[
{ value: 'dark', label: '深色' },
{ value: 'light', label: '浅色' },
{ value: 'system', label: '跟随系统' },
].map((theme) => (
<button
key={theme.value}
onClick={() => setSetting('theme', theme.value as any)}
className={`p-3 rounded-lg border text-sm transition-colors ${
settings.theme === theme.value
? 'border-accent-blue bg-accent-blue/20 text-accent-blue'
: 'border-dark-700 hover:border-dark-600 text-gray-300'
}`}
>
{theme.label}
</button>
))}
</div>
</SettingCard>
<div className="p-4 bg-dark-800 rounded-lg">
<div className="font-medium text-gray-200 mb-3">语言</div>
<select
value={settings.language}
onChange={(e) => setSetting('language', e.target.value as any)}
className="w-full px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300 focus:outline-none focus:border-accent-blue"
>
<option value="zh-CN">简体中文</option>
<option value="en-US">English</option>
</select>
</div>
</div>
<SettingCard>
<SettingSectionTitle>语言</SettingSectionTitle>
<select
value={settings.language}
onChange={(e) => setSetting('language', e.target.value as any)}
className="w-full mt-3 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
>
<option value="zh-CN">简体中文</option>
<option value="en-US">English</option>
</select>
</SettingCard>
</div>
)
// ──────── 终端 ─────────
case 'terminal':
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-200">终端设置</h2>
<div className="space-y-4">
<div className="p-4 bg-dark-800 rounded-lg">
<div className="font-medium text-gray-200 mb-3">字体</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm text-gray-500">字体族</label>
<select
value={settings.fontFamily}
onChange={(e) => setSetting('fontFamily', e.target.value)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300"
>
<option>JetBrains Mono</option>
<option>Fira Code</option>
<option>Consolas</option>
<option>Monaco</option>
</select>
</div>
<div>
<label className="text-sm text-gray-500">字体大小</label>
<select
value={settings.fontSize}
onChange={(e) => setSetting('fontSize', parseInt(e.target.value))}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300"
>
{[12, 13, 14, 15, 16, 18, 20].map((size) => (
<option key={size} value={size}>{size}px</option>
))}
</select>
</div>
<div className="space-y-5">
<SettingCard>
<SettingSectionTitle>字体</SettingSectionTitle>
<div className="grid grid-cols-2 gap-4 mt-3">
<div>
<label className="text-sm text-gray-500">字体族</label>
<select
value={settings.fontFamily}
onChange={(e) => setSetting('fontFamily', e.target.value)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
>
<option>JetBrains Mono</option>
<option>Fira Code</option>
<option>Consolas</option>
<option>Monaco</option>
</select>
</div>
<div>
<label className="text-sm text-gray-500">字体大小</label>
<select
value={settings.fontSize}
onChange={(e) => setSetting('fontSize', parseInt(e.target.value))}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
>
{[12, 13, 14, 15, 16, 18, 20].map((size) => (
<option key={size} value={size}>{size}px</option>
))}
</select>
</div>
</div>
</SettingCard>
<div className="p-4 bg-dark-800 rounded-lg">
<div className="font-medium text-gray-200 mb-3">终端配色</div>
<div className="grid grid-cols-4 gap-2">
{Object.entries(themes).map(([key, theme]) => (
<button
key={key}
onClick={() => setSetting('terminalTheme', key as any)}
className={`p-3 rounded-lg border text-sm transition-colors ${
settings.terminalTheme === key
? 'border-accent-blue bg-accent-blue/20 text-accent-blue'
: 'border-dark-700 hover:border-dark-600 text-gray-300'
}`}
>
<div
className="w-full h-6 rounded mb-1"
style={{ backgroundColor: theme.background }}
/>
{theme.name}
</button>
))}
</div>
<SettingCard>
<SettingSectionTitle>终端配色</SettingSectionTitle>
<div className="grid grid-cols-4 gap-2 mt-3">
{Object.entries(themes).map(([key, theme]) => (
<button
key={key}
onClick={() => setSetting('terminalTheme', key as any)}
className={`p-3 rounded-lg border text-sm transition-colors ${
settings.terminalTheme === key
? 'border-accent-blue bg-accent-blue/20 text-accent-blue'
: 'border-dark-700 hover:border-dark-600 text-gray-300'
}`}
>
<div
className="w-full h-6 rounded mb-1"
style={{ backgroundColor: theme.background }}
/>
{theme.name}
</button>
))}
</div>
</div>
</SettingCard>
</div>
)
// ──────── 快捷键 ───────
case 'shortcuts':
return (
<div className="space-y-5">
<SettingCard>
<div className="overflow-hidden rounded-lg border border-dark-700">
<table className="w-full text-sm">
<thead>
<tr className="bg-dark-800/60 text-gray-400 text-left">
<th className="px-4 py-2.5 font-medium">操作</th>
<th className="px-4 py-2.5 font-medium">快捷键</th>
<th className="px-4 py-2.5 font-medium">分类</th>
</tr>
</thead>
<tbody>
{SHORTCUTS.map((s, i) => (
<tr
key={i}
className="border-t border-dark-700/60 hover:bg-dark-800/40 transition-colors"
>
<td className="px-4 py-2.5 text-gray-200">{s.action}</td>
<td className="px-4 py-2.5">
<kbd className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-dark-700 border border-dark-600 text-xs text-gray-300 font-mono">
{s.keys}
</kbd>
</td>
<td className="px-4 py-2.5 text-gray-500">{s.category}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-gray-600 mt-3">
快捷键自定义功能将在后续版本中支持。
</p>
</SettingCard>
</div>
)
// ──────── SSH ──────────
case 'ssh':
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-200">SSH 设置</h2>
<div className="space-y-4">
<div className="space-y-5">
<SettingCard>
<SettingToggle
label="保持连接活跃"
description="定期发送心跳包保持连接"
checked={settings.keepAlive}
onChange={(v) => setSetting('keepAlive', v)}
/>
</SettingCard>
<div className="p-4 bg-dark-800 rounded-lg">
<div className="font-medium text-gray-200 mb-3">默认端口</div>
<input
type="number"
value={settings.defaultPort}
onChange={(e) => setSetting('defaultPort', parseInt(e.target.value) || 22)}
className="w-32 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300"
/>
<SettingCard>
<SettingSectionTitle>默认端口</SettingSectionTitle>
<input
type="number"
value={settings.defaultPort}
onChange={(e) => setSetting('defaultPort', parseInt(e.target.value) || 22)}
className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
/>
</SettingCard>
<SettingCard>
<SettingSectionTitle>超时设置 (秒)</SettingSectionTitle>
<input
type="number"
value={settings.timeout}
onChange={(e) => setSetting('timeout', parseInt(e.target.value) || 30)}
className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
/>
</SettingCard>
{/* 专业连接管理 */}
<SettingCard highlight>
<div className="flex items-center gap-2 mb-1">
<span className="text-accent-blue text-xs font-semibold uppercase tracking-wider">Pro</span>
<SettingSectionTitle className="mb-0">连接管理</SettingSectionTitle>
</div>
<p className="text-sm text-gray-500 mb-4">配置连接池行为,优化资源使用和性能</p>
<div className="p-4 bg-dark-800 rounded-lg">
<div className="font-medium text-gray-200 mb-3">超时设置 (秒)</div>
<input
type="number"
value={settings.timeout}
onChange={(e) => setSetting('timeout', parseInt(e.target.value) || 30)}
className="w-32 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300"
/>
</div>
{/* 专业连接管理设置 */}
<div className="p-4 bg-dark-800 rounded-lg border border-accent-blue/30">
<div className="font-medium text-gray-200 mb-2 flex items-center gap-2">
<span className="text-accent-blue">专业版</span>
连接管理
</div>
<div className="text-sm text-gray-500 mb-4">
配置连接池行为,优化资源使用和性能
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-500">最大连接数</label>
<input
type="number"
min={1}
max={50}
value={settings.maxConnections || 10}
onChange={(e) => setSetting('maxConnections', parseInt(e.target.value) || 10)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
/>
<div className="text-xs text-gray-600 mt-1">最大并发 SSH 连接数</div>
</div>
<div>
<label className="text-sm text-gray-500">最大后台连接</label>
<input
type="number"
min={1}
max={20}
value={settings.maxBackgroundConnections || 5}
onChange={(e) => setSetting('maxBackgroundConnections', parseInt(e.target.value) || 5)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
/>
<div className="text-xs text-gray-600 mt-1">非活跃标签保持连接数</div>
</div>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-500">最大连接数</label>
<input
type="number"
min={1}
max={50}
value={settings.maxConnections || 10}
onChange={(e) => setSetting('maxConnections', parseInt(e.target.value) || 10)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300"
/>
<div className="text-xs text-gray-600 mt-1">最大并发 SSH 连接数</div>
</div>
<div>
<label className="text-sm text-gray-500">最大后台连接</label>
<input
type="number"
min={1}
max={20}
value={settings.maxBackgroundConnections || 5}
onChange={(e) => setSetting('maxBackgroundConnections', parseInt(e.target.value) || 5)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300"
/>
<div className="text-xs text-gray-600 mt-1">非活跃标签保持连接数</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-500">空闲超时 (分钟)</label>
<input
type="number"
min={1}
max={120}
value={Math.floor((settings.idleTimeoutMs || 5 * 60 * 1000) / 60000)}
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
/>
<div className="text-xs text-gray-600 mt-1">无操作后进入挂起</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-500">空闲超时 (分钟)</label>
<input
type="number"
min={1}
max={120}
value={Math.floor((settings.idleTimeoutMs || 5 * 60 * 1000) / 60000)}
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300"
/>
<div className="text-xs text-gray-600 mt-1">无操作后进入挂起</div>
</div>
<div>
<label className="text-sm text-gray-500">挂起超时 (分钟)</label>
<input
type="number"
min={1}
max={240}
value={Math.floor((settings.suspendTimeoutMs || 30 * 60 * 1000) / 60000)}
onChange={(e) => setSetting('suspendTimeoutMs', (parseInt(e.target.value) || 30) * 60000)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300"
/>
<div className="text-xs text-gray-600 mt-1">挂起后自动断开</div>
</div>
<div>
<label className="text-sm text-gray-500">挂起超时 (分钟)</label>
<input
type="number"
min={1}
max={240}
value={Math.floor((settings.suspendTimeoutMs || 30 * 60 * 1000) / 60000)}
onChange={(e) => setSetting('suspendTimeoutMs', (parseInt(e.target.value) || 30) * 60000)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
/>
<div className="text-xs text-gray-600 mt-1">挂起后自动断开</div>
</div>
</div>
<div className="pt-2 space-y-4">
<SettingToggle
label="启用终端快照"
description="切换标签时保存终端内容,快速恢复显示"
checked={settings.enableSnapshots !== false}
onChange={(v) => setSetting('enableSnapshots', v)}
/>
<SettingToggle
label="自动重连"
description="连接断开后自动尝试重连(最多3次)"
description="连接断开后自动尝试重连(最多 3 次)"
checked={settings.autoReconnect !== false}
onChange={(v) => setSetting('autoReconnect', v)}
/>
</div>
</div>
</div>
</SettingCard>
</div>
)
// ──────── 安全 ─────────
case 'security':
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-200">安全设置</h2>
<div className="space-y-4">
<div className="space-y-5">
<SettingCard>
<SettingToggle
label="加密存储密码"
description="使用 AES-256 加密存储密码"
description="使用 AES-256-GCM 加密存储密码"
checked={settings.encryptPasswords}
onChange={(v) => setSetting('encryptPasswords', v)}
/>
</SettingCard>
<SettingCard>
<SettingToggle
label="锁定屏幕时断开连接"
description="系统锁屏时自动断开 SSH 连接"
checked={settings.lockOnSleep}
onChange={(v) => setSetting('lockOnSleep', v)}
/>
</div>
</SettingCard>
</div>
)
// ──────── 关于 ─────────
case 'about':
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-200">关于 OpsXShell</h2>
<div className="p-6 bg-dark-800 rounded-lg text-center">
<div className="w-20 h-20 bg-dark-700 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Terminal className="w-10 h-10 text-white" />
<div className="space-y-5">
<SettingCard>
<div className="flex flex-col items-center py-6">
<div className="w-20 h-20 bg-dark-700 rounded-2xl flex items-center justify-center mb-4">
<Terminal className="w-10 h-10 text-white" />
</div>
<h3 className="text-xl font-semibold text-gray-200 mb-1">OpsXShell</h3>
<p className="text-sm text-gray-500 mb-4">版本 {appVersion}</p>
<p className="text-sm text-gray-400 max-w-md text-center">
跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠 · 操作便捷人性化
</p>
</div>
<h3 className="text-xl font-semibold text-gray-200 mb-1">OpsXShell</h3>
<p className="text-sm text-gray-500 mb-4">版本 1.0.0</p>
<p className="text-sm text-gray-400 max-w-md mx-auto">
跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠 · 操作便捷人性化
</SettingCard>
<SettingCard>
<SettingSectionTitle>技术栈</SettingSectionTitle>
<div className="grid grid-cols-2 gap-x-6 gap-y-2 mt-3 text-sm">
{[
['Electron', '28+'],
['React', '18'],
['TypeScript', '5'],
['Vite', '5'],
['Zustand', '4.4+'],
['XTerm.js', '5.5+'],
['Tailwind CSS', '3.3+'],
['Lucide React', '0.294+'],
].map(([name, ver]) => (
<div key={name} className="flex justify-between py-1 border-b border-dark-700/40">
<span className="text-gray-400">{name}</span>
<span className="text-gray-500 font-mono">{ver}</span>
</div>
))}
</div>
</SettingCard>
<SettingCard>
<SettingSectionTitle>开源许可</SettingSectionTitle>
<p className="text-sm text-gray-400 mt-2">
OpsXShell 基于 MIT 许可证开源。本项目使用了以下开源组件:
</p>
</div>
<ul className="mt-2 text-sm text-gray-500 list-disc list-inside space-y-1">
<li>ssh2 — MIT License</li>
<li>xterm.js — MIT License</li>
<li>zmodem-ts — MIT License</li>
<li>better-sqlite3 — MIT License</li>
<li>lucide-react — ISC License</li>
</ul>
</SettingCard>
</div>
)
@@ -326,40 +471,117 @@ export default function SettingsView() {
}
}
// ── 主布局 ────────────────────────────────────────────────
return (
<div className="h-full flex bg-dark-900">
{/* Sidebar */}
<div className="w-48 bg-dark-800 border-r border-dark-700 p-4">
<nav className="space-y-1">
{tabs.map((tab) => {
{/* ── 左侧导航 ── */}
<div className="w-56 flex-shrink-0 bg-dark-800/50 border-r border-dark-700 flex flex-col">
{/* 搜索框 */}
<div className="p-3 border-b border-dark-700">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500" />
<input
type="text"
placeholder="搜索设置..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-8 py-1.5 text-sm bg-dark-900 border border-dark-700 rounded-lg text-gray-300 placeholder-gray-600 focus:outline-none focus:border-accent-blue/60"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Tab 列表 */}
<nav className="flex-1 p-2 space-y-0.5 overflow-y-auto">
{visibleTabs.map((tab) => {
const Icon = tab.icon
const active = activeTab === tab.id
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
activeTab === tab.id
? 'bg-accent-blue text-white'
: 'text-gray-400 hover:bg-dark-700 hover:text-gray-200'
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
active
? 'bg-accent-blue/15 text-white'
: 'text-gray-400 hover:bg-dark-700/60 hover:text-gray-200'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
<Icon className={`w-4 h-4 flex-shrink-0 ${active ? 'text-accent-blue' : ''}`} />
<div className="min-w-0">
<div className="text-sm font-medium truncate">{tab.label}</div>
<div className={`text-xs truncate ${active ? 'text-gray-400' : 'text-gray-600'}`}>
{tab.description}
</div>
</div>
</button>
)
})}
</nav>
</div>
{/* Content */}
<div className="flex-1 p-8 overflow-y-auto">
{renderContent()}
{/* ── 右侧内容区 ── */}
<div className="flex-1 flex flex-col min-w-0">
{/* Header bar */}
<div className="flex-shrink-0 px-8 pt-6 pb-4 border-b border-dark-700/50">
<h1 className="text-xl font-semibold text-gray-100">{currentMeta.label}</h1>
<p className="text-sm text-gray-500 mt-0.5">{currentMeta.description}</p>
</div>
{/* 内容 */}
<div className="flex-1 px-8 py-6 overflow-y-auto">
{renderContent()}
</div>
</div>
</div>
)
}
// 设置开关组件
// ─── 子组件 ─────────────────────────────────────────────────
/** 卡片容器 */
function SettingCard({
children,
highlight = false,
}: {
children: React.ReactNode
highlight?: boolean
}) {
return (
<div
className={`rounded-xl p-5 ${
highlight
? 'bg-dark-800/80 border border-accent-blue/20'
: 'bg-dark-800/50 border border-dark-700/50'
}`}
>
{children}
</div>
)
}
/** 区域小标题 */
function SettingSectionTitle({
children,
className = '',
}: {
children: React.ReactNode
className?: string
}) {
return (
<div className={`text-sm font-semibold text-gray-300 mb-1 ${className}`}>
{children}
</div>
)
}
/** 开关设置项 */
function SettingToggle({
label,
description,
@@ -372,20 +594,20 @@ function SettingToggle({
onChange: (value: boolean) => void
}) {
return (
<div className="flex items-center justify-between p-4 bg-dark-800 rounded-lg">
<div>
<div className="font-medium text-gray-200">{label}</div>
<div className="text-sm text-gray-500">{description}</div>
<div className="flex items-center justify-between">
<div className="mr-4">
<div className="font-medium text-gray-200 text-sm">{label}</div>
<div className="text-xs text-gray-500 mt-0.5">{description}</div>
</div>
<button
onClick={() => onChange(!checked)}
className={`relative w-12 h-6 rounded-full transition-colors ${
className={`relative flex-shrink-0 w-11 h-6 rounded-full transition-colors ${
checked ? 'bg-accent-blue' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
checked ? 'left-7' : 'left-1'
checked ? 'left-6' : 'left-1'
}`}
/>
</button>
+25 -6
View File
@@ -1,7 +1,9 @@
import { Wifi, WifiOff, AlertCircle, Loader2, HardDrive, Clock } from 'lucide-react'
import { Wifi, WifiOff, AlertCircle, Loader2, HardDrive, Clock, ChevronUp } from 'lucide-react'
import { useEffect, useState, useMemo } from 'react'
import { useTabStore, type ConnectionStatus } from '../stores/tabStore'
import { usePoolStore } from '../stores/connection/poolStore'
import { useConnectionStore, type ConnectionState } from '../stores/connection'
import { useConnectionPanelStore } from '../stores/connectionPanelStore'
// ============== Status Display Config ==============
@@ -80,6 +82,7 @@ function mapTabStatusToConnectionState(status?: ConnectionStatus): ConnectionSta
// ============== Component ==============
export default function StatusBar() {
const togglePanel = useConnectionPanelStore((s) => s.toggle)
const [version, setVersion] = useState('1.0.0')
const [currentTime, setCurrentTime] = useState(new Date())
@@ -87,10 +90,13 @@ export default function StatusBar() {
const activeTabId = useTabStore((s) => s.activeTabId)
const tabs = useTabStore((s) => s.tabs)
// Connection store (pool)
const connections = useConnectionStore().config // trigger re-renders via store subscription
// Connection store (pool) — subscribe to connections to reactively update
const connections = usePoolStore((s) => s.connections)
const poolStore = useConnectionStore()
// Total connection count for the panel badge
const totalConnections = connections.size
// Derive the active tab
const activeTab = useMemo(
() => tabs.find((t) => t.id === activeTabId),
@@ -158,8 +164,15 @@ export default function StatusBar() {
return (
<div className="h-7 bg-dark-800 border-t border-dark-700 flex items-center px-3 text-xs text-white select-none">
{/* Left: Connection status indicator + host */}
<div className={`flex items-center gap-1.5 ${statusCfg.colorClass} ${statusCfg.animateClass}`}>
{/* Left: Connection status area — clickable to toggle ConnectionPanel */}
<button
type="button"
onClick={togglePanel}
className={`flex items-center gap-1.5 rounded px-1 py-0.5 -ml-1
hover:bg-dark-700/60 transition-colors cursor-pointer
${statusCfg.colorClass} ${statusCfg.animateClass}`}
title="点击打开连接管理面板"
>
{statusCfg.icon}
<span>{statusCfg.label}</span>
{hostLabel && (
@@ -168,7 +181,13 @@ export default function StatusBar() {
<span className="text-gray-300">{hostLabel}</span>
</>
)}
</div>
{totalConnections > 0 && (
<span className="ml-1 px-1.5 py-px rounded-full bg-accent-blue/20 text-accent-blue text-[10px] font-medium">
{totalConnections}
</span>
)}
<ChevronUp className="w-3 h-3 text-gray-500" />
</button>
{/* Center: Transfer stats */}
{transferLabel && (
+448
View File
@@ -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
+4
View File
@@ -10,6 +10,7 @@ export { default as Sidebar } from './Sidebar'
export { default as TabBar } from './TabBar'
export { default as TabContent } from './TabContent'
export { default as StatusBar } from './StatusBar'
export { default as ConnectionPanel } from './ConnectionPanel'
// 功能组件 (当前在根目录,将来移到对应目录)
export { default as TerminalView } from './TerminalView'
@@ -34,3 +35,6 @@ export {
export { default as Button } from './common/Button'
export { default as Modal } from './common/Modal'
export { default as EmptyState } from './common/EmptyState'
// Snippets
export { default as SnippetPanel } from './snippets/SnippetPanel'
+397
View File
@@ -0,0 +1,397 @@
/**
* SnippetPanel — 命令片段库侧边面板
*
* 功能:搜索、新建、编辑、删除命令片段,并插入到活跃终端
*/
import { useState, useEffect, useCallback } from 'react'
import { Code2, Plus, Search, Trash2, Copy, Pencil, X } from 'lucide-react'
import { useSnippetStore, type Snippet } from '../../stores/snippetStore'
import { useTabStore } from '../../stores/tabStore'
import Button from '../common/Button'
import Modal from '../common/Modal'
import EmptyState from '../common/EmptyState'
// ─── Snippet 编辑对话框 ─────────────────────────────────────────
interface SnippetFormData {
name: string
content: string
tags: string
}
interface SnippetFormProps {
isOpen: boolean
initial: SnippetFormData
title: string
confirmText: string
onConfirm: (data: SnippetFormData) => void
onClose: () => void
}
function SnippetFormModal({ isOpen, initial, title, confirmText, onConfirm, onClose }: SnippetFormProps) {
const [form, setForm] = useState<SnippetFormData>(initial)
useEffect(() => {
if (isOpen) {
setForm(initial)
}
}, [isOpen, initial])
const handleConfirm = () => {
if (!form.name.trim() || !form.content.trim()) return
onConfirm(form)
}
return (
<Modal
isOpen={isOpen}
title={title}
confirmText={confirmText}
onClose={onClose}
onConfirm={handleConfirm}
>
<div className="space-y-4">
{/* 名称 */}
<div>
<label className="block text-sm text-gray-400 mb-1">名称</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="例如:重启 Nginx"
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-accent-blue text-sm"
/>
</div>
{/* 命令内容 */}
<div>
<label className="block text-sm text-gray-400 mb-1">命令</label>
<textarea
value={form.content}
onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))}
placeholder="sudo systemctl restart nginx"
rows={4}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-accent-blue text-sm font-mono resize-none"
/>
</div>
{/* 标签 */}
<div>
<label className="block text-sm text-gray-400 mb-1">标签 <span className="text-gray-600">(逗号分隔)</span></label>
<input
type="text"
value={form.tags}
onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
placeholder="linux, nginx, ops"
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-accent-blue text-sm"
/>
</div>
</div>
</Modal>
)
}
const EMPTY_FORM: SnippetFormData = { name: '', content: '', tags: '' }
// ─── 主面板组件 ─────────────────────────────────────────────────
export default function SnippetPanel() {
const { snippets, isLoaded, loadSnippets, addSnippet, updateSnippet, deleteSnippet, searchSnippets } = useSnippetStore()
const { tabs, activeTabId } = useTabStore()
const [query, setQuery] = useState('')
const [copiedId, setCopiedId] = useState<string | null>(null)
// 编辑对话框状态
const [formOpen, setFormOpen] = useState(false)
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null)
const [formData, setFormData] = useState<SnippetFormData>(EMPTY_FORM)
const [formTitle, setFormTitle] = useState('新建片段')
const [formConfirmText, setFormConfirmText] = useState('创建')
// 初始化加载
useEffect(() => {
loadSnippets()
}, [loadSnippets])
// 复制反馈计时器
useEffect(() => {
if (!copiedId) return
const timer = setTimeout(() => setCopiedId(null), 1500)
return () => clearTimeout(timer)
}, [copiedId])
// 获取当前活跃终端的 shellId
const getActiveShellId = useCallback((): string | null => {
const activeTab = tabs.find((t) => t.id === activeTabId)
return activeTab?.connectionInstance?.shellId ?? null
}, [tabs, activeTabId])
// 插入命令到终端
const handleInsert = useCallback(async (content: string) => {
const shellId = getActiveShellId()
if (!shellId) return
try {
await window.electronAPI?.sshShellWrite(shellId, content + '\n')
} catch (err) {
console.error('Failed to insert snippet:', err)
}
}, [getActiveShellId])
// 复制到剪贴板
const handleCopy = useCallback(async (snippet: Snippet) => {
try {
await navigator.clipboard.writeText(snippet.content)
setCopiedId(snippet.id)
} catch {
// fallback
console.error('Clipboard write failed')
}
}, [])
// 新建片段
const handleCreate = () => {
setEditingSnippet(null)
setFormData(EMPTY_FORM)
setFormTitle('新建片段')
setFormConfirmText('创建')
setFormOpen(true)
}
// 编辑片段
const handleEdit = (snippet: Snippet) => {
setEditingSnippet(snippet)
setFormData({
name: snippet.name,
content: snippet.content,
tags: snippet.tags.join(', '),
})
setFormTitle('编辑片段')
setFormConfirmText('保存')
setFormOpen(true)
}
// 表单提交
const handleFormSubmit = async (data: SnippetFormData) => {
const tags = data.tags
.split(',')
.map((t) => t.trim())
.filter(Boolean)
if (editingSnippet) {
await updateSnippet(editingSnippet.id, {
name: data.name.trim(),
content: data.content.trim(),
tags,
})
} else {
await addSnippet(data.name.trim(), data.content.trim(), tags)
}
setFormOpen(false)
}
// 搜索结果
const filteredSnippets = query.trim() ? searchSnippets(query) : snippets
// 是否有活跃终端
const hasActiveTerminal = !!getActiveShellId()
return (
<div className="flex flex-col h-full bg-dark-800">
{/* 头部 */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-dark-700">
<Code2 className="w-5 h-5 text-accent-blue flex-shrink-0" />
<h2 className="text-sm font-semibold text-gray-200">命令片段</h2>
</div>
{/* 搜索框 */}
<div className="p-3 border-b border-dark-700">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索片段..."
className="w-full pl-9 pr-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-accent-blue"
/>
{query && (
<button
onClick={() => setQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 hover:bg-dark-600 rounded"
>
<X className="w-3.5 h-3.5 text-gray-500" />
</button>
)}
</div>
</div>
{/* 片段列表 */}
<div className="flex-1 overflow-y-auto">
{isLoaded && filteredSnippets.length === 0 ? (
<EmptyState
icon={Code2}
title={query ? '没有匹配的片段' : '暂无片段'}
description={query ? '尝试其他关键词搜索' : '保存常用命令,快速插入到终端'}
action={
!query
? { label: '新建片段', onClick: handleCreate }
: undefined
}
/>
) : (
<div className="p-2 space-y-1">
{filteredSnippets.map((snippet) => (
<SnippetItem
key={snippet.id}
snippet={snippet}
copied={copiedId === snippet.id}
canInsert={hasActiveTerminal}
onInsert={handleInsert}
onCopy={handleCopy}
onEdit={handleEdit}
onDelete={deleteSnippet}
/>
))}
</div>
)}
</div>
{/* 底部新建按钮 */}
<div className="p-3 border-t border-dark-700">
<Button
variant="primary"
size="sm"
className="w-full"
icon={<Plus className="w-4 h-4" />}
onClick={handleCreate}
>
新建片段
</Button>
</div>
{/* 编辑/新建对话框 */}
<SnippetFormModal
isOpen={formOpen}
initial={formData}
title={formTitle}
confirmText={formConfirmText}
onConfirm={handleFormSubmit}
onClose={() => setFormOpen(false)}
/>
</div>
)
}
// ─── 单个片段项 ─────────────────────────────────────────────────
interface SnippetItemProps {
snippet: Snippet
copied: boolean
canInsert: boolean
onInsert: (content: string) => void
onCopy: (snippet: Snippet) => void
onEdit: (snippet: Snippet) => void
onDelete: (id: string) => Promise<void>
}
function SnippetItem({ snippet, copied, canInsert, onInsert, onCopy, onEdit, onDelete }: SnippetItemProps) {
const [confirmDelete, setConfirmDelete] = useState(false)
// 命令预览:显示第一行,截断到 60 字符
const preview = snippet.content.split('\n')[0].slice(0, 60) + (snippet.content.length > 60 ? '…' : '')
return (
<div className="group rounded-lg border border-dark-700 hover:border-dark-600 bg-dark-850 p-2.5 transition-colors">
{/* 名称行 */}
<div className="flex items-start justify-between gap-2 mb-1">
<span className="text-sm font-medium text-gray-200 truncate flex-1" title={snippet.name}>
{snippet.name}
</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{/* 插入 */}
<button
onClick={() => onInsert(snippet.content)}
disabled={!canInsert}
title={canInsert ? '插入到终端' : '无活跃终端'}
className="p-1 rounded hover:bg-accent-blue/20 text-accent-blue disabled:text-gray-600 disabled:hover:bg-transparent transition-colors"
>
<Code2 className="w-3.5 h-3.5" />
</button>
{/* 复制 */}
<button
onClick={() => onCopy(snippet)}
title="复制"
className="p-1 rounded hover:bg-dark-600 text-gray-400 transition-colors"
>
{copied ? (
<span className="text-xs text-green-400 px-1">✓</span>
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
{/* 编辑 */}
<button
onClick={() => onEdit(snippet)}
title="编辑"
className="p-1 rounded hover:bg-dark-600 text-gray-400 transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
</button>
{/* 删除 */}
{confirmDelete ? (
<div className="flex items-center gap-0.5">
<button
onClick={async () => {
await onDelete(snippet.id)
setConfirmDelete(false)
}}
className="px-1.5 py-0.5 text-xs bg-red-600 hover:bg-red-500 text-white rounded transition-colors"
>
确认
</button>
<button
onClick={() => setConfirmDelete(false)}
className="px-1.5 py-0.5 text-xs text-gray-400 hover:text-gray-200 transition-colors"
>
取消
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(true)}
title="删除"
className="p-1 rounded hover:bg-red-500/20 text-gray-400 hover:text-red-400 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* 命令预览 */}
<p
className="text-xs font-mono text-gray-500 truncate mb-1.5"
title={snippet.content}
>
{preview}
</p>
{/* 标签 */}
{snippet.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{snippet.tags.map((tag) => (
<span
key={tag}
className="px-1.5 py-0.5 text-[10px] bg-dark-700 text-gray-400 rounded"
>
{tag}
</span>
))}
</div>
)}
</div>
)
}