v1.3.0: 主题系统+代码分割+设置重构+命令片段库+命令面板+连接面板 | 19 files +2141/-257 | tsc零错误 构建成功
This commit is contained in:
1 parent
caba5f4c0d
commit
1f2de032a4
19 files changed
+2015
-131
No files matched your search
+15
-10
@@ -40,10 +40,12 @@ v1.5.0 安全升级 ─── 1 周 ──→ 安全 55→85, 代码 80→85
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.2.0 体验升级 (2 周) — 🚧 进行中
|
## v1.2.0 体验升级 (2 周) — ✅ 已完成
|
||||||
|
|
||||||
> **目标**: 快捷键补全、视觉打磨、交互优化,接近 Termius 体验水平
|
> **目标**: 快捷键补全、视觉打磨、交互优化,接近 Termius 体验水平
|
||||||
|
|
||||||
|
### ✅ 全部完成 (提交: caba5f4)
|
||||||
|
|
||||||
### 🟡 P1 — 重要
|
### 🟡 P1 — 重要
|
||||||
|
|
||||||
| # | 任务 | 类型 | 预计 | 验收标准 |
|
| # | 任务 | 类型 | 预计 | 验收标准 |
|
||||||
@@ -71,18 +73,21 @@ v1.5.0 安全升级 ─── 1 周 ──→ 安全 55→85, 代码 80→85
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.3.0 测试覆盖 (2 周)
|
## v1.3.0 专业体验 (2 周) — 🚧 进行中
|
||||||
|
|
||||||
> **目标**: 核心模块测试覆盖率从 10% 提升到 50%
|
> **目标**: 主题系统、命令面板、Snippets、UI 打磨,达到专业级体验
|
||||||
|
|
||||||
详细计划见 [TDD_PLAN.md](./TDD_PLAN.md)
|
### 🟡 P1 — 重要
|
||||||
|
|
||||||
| # | 模块 | 当前覆盖 | 目标覆盖 | 预计 |
|
| # | 任务 | 类型 | 预计 | 验收标准 |
|
||||||
|---|------|----------|----------|------|
|
|---|------|------|------|----------|
|
||||||
| 1 | stores (tab/connection/settings/session) | 0% | 70% | 3天 |
|
| 1 | 主题系统 Dark/Light/System | 新增 | 3天 | CSS 变量驱动,跟随系统自动切换,设置页实时预览 |
|
||||||
| 2 | hooks (useTerminal/useSFTP/useConnection) | 0% | 50% | 3天 |
|
| 2 | Vite 代码分割 | 优化 | 1天 | manualChunks 拆分 xterm/react/lucide,消除 500KB warning |
|
||||||
| 3 | electron services (ssh/sftp/database/crypto) | 0% | 60% | 4天 |
|
| 3 | 设置面板视觉重构 | 重构 | 2天 | 专业设置页布局 + 快捷键自定义 Tab + 版本号自动读取 |
|
||||||
| 4 | 组件渲染测试 (WelcomeView/TabBar/SFTPView) | 5% | 40% | 3天 |
|
| 4 | 命令片段库 Snippets | 新增 | 3天 | 创建/编辑/删除/搜索命令片段,快捷键插入到终端 |
|
||||||
|
| 5 | 全局命令面板 | 新增 | 2天 | Ctrl+Shift+P 快速搜索操作/会话/命令片段 |
|
||||||
|
| 6 | 连接状态面板 | 新增 | 2天 | 实时监控连接、流量统计、延迟显示、重连按钮 |
|
||||||
|
| 7 | UI 打磨 — 全局细节优化 | 完善 | 2天 | 过渡动画统一、微交互(hover/active 反馈)、视觉一致性 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+39
-1
@@ -3,15 +3,18 @@ import Sidebar from './components/Sidebar'
|
|||||||
import TabBar from './components/TabBar'
|
import TabBar from './components/TabBar'
|
||||||
import TabContent from './components/TabContent'
|
import TabContent from './components/TabContent'
|
||||||
import StatusBar from './components/StatusBar'
|
import StatusBar from './components/StatusBar'
|
||||||
|
import ConnectionPanel from './components/ConnectionPanel'
|
||||||
import SessionDialog from './components/SessionDialog'
|
import SessionDialog from './components/SessionDialog'
|
||||||
import QuickConnectDialog from './components/QuickConnectDialog'
|
import QuickConnectDialog from './components/QuickConnectDialog'
|
||||||
|
import { CommandPalette } from './components/common/CommandPalette'
|
||||||
import { useTabStore } from './stores/tabStore'
|
import { useTabStore } from './stores/tabStore'
|
||||||
import { useSessionStore, type Session } from './stores'
|
import { useSessionStore, useCommandPaletteStore, type Session } from './stores'
|
||||||
import { createLogger } from './utils/logger'
|
import { createLogger } from './utils/logger'
|
||||||
import { useToast } from './hooks/useToast'
|
import { useToast } from './hooks/useToast'
|
||||||
import { ToastContainer } from './components/common/ToastContainer'
|
import { ToastContainer } from './components/common/ToastContainer'
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
import { useIsMobile } from './hooks/useMediaQuery'
|
import { useIsMobile } from './hooks/useMediaQuery'
|
||||||
|
import { useTheme } from './hooks/useTheme'
|
||||||
|
|
||||||
const logger = createLogger('App')
|
const logger = createLogger('App')
|
||||||
|
|
||||||
@@ -21,7 +24,11 @@ function App() {
|
|||||||
const [editSession, setEditSession] = useState<Session | null>(null)
|
const [editSession, setEditSession] = useState<Session | null>(null)
|
||||||
const { activeTabId } = useTabStore()
|
const { activeTabId } = useTabStore()
|
||||||
const { sessions } = useSessionStore()
|
const { sessions } = useSessionStore()
|
||||||
|
const { close: closeCommandPalette } = useCommandPaletteStore()
|
||||||
const { toasts, toast, removeToast } = useToast()
|
const { toasts, toast, removeToast } = useToast()
|
||||||
|
useTheme()
|
||||||
|
|
||||||
|
// Keyboard shortcuts (Ctrl+Shift+P now handled internally via commandPaletteStore)
|
||||||
useKeyboardShortcuts()
|
useKeyboardShortcuts()
|
||||||
|
|
||||||
// Responsive: detect mobile
|
// Responsive: detect mobile
|
||||||
@@ -49,6 +56,12 @@ function App() {
|
|||||||
setMobileSidebarOpen(false)
|
setMobileSidebarOpen(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Open settings tab
|
||||||
|
const handleOpenSettings = useCallback(() => {
|
||||||
|
const { addTab } = useTabStore.getState()
|
||||||
|
addTab({ title: '设置', type: 'settings' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 导出会话
|
// 导出会话
|
||||||
const handleExportSessions = async () => {
|
const handleExportSessions = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -305,6 +318,9 @@ function App() {
|
|||||||
{/* Status bar */}
|
{/* Status bar */}
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
|
|
||||||
|
{/* Connection management panel (self-contained via connectionPanelStore) */}
|
||||||
|
<ConnectionPanel />
|
||||||
|
|
||||||
{/* Dialogs */}
|
{/* Dialogs */}
|
||||||
<SessionDialog
|
<SessionDialog
|
||||||
isOpen={isSessionDialogOpen}
|
isOpen={isSessionDialogOpen}
|
||||||
@@ -320,6 +336,28 @@ function App() {
|
|||||||
onClose={() => setIsQuickConnectOpen(false)}
|
onClose={() => setIsQuickConnectOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Command Palette — isOpen/close managed via commandPaletteStore */}
|
||||||
|
<CommandPalette
|
||||||
|
onNewSession={() => {
|
||||||
|
closeCommandPalette()
|
||||||
|
setEditSession(null)
|
||||||
|
setIsSessionDialogOpen(true)
|
||||||
|
}}
|
||||||
|
onQuickConnect={() => {
|
||||||
|
closeCommandPalette()
|
||||||
|
setIsQuickConnectOpen(true)
|
||||||
|
}}
|
||||||
|
onOpenSettings={() => {
|
||||||
|
closeCommandPalette()
|
||||||
|
handleOpenSettings()
|
||||||
|
}}
|
||||||
|
onEditSession={(session) => {
|
||||||
|
closeCommandPalette()
|
||||||
|
setEditSession(session)
|
||||||
|
setIsSessionDialogOpen(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Toast Notifications */}
|
{/* Toast Notifications */}
|
||||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
+320
-98
@@ -1,29 +1,97 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
import { useSettingsStore } from '../stores/settingsStore'
|
import { useSettingsStore } from '../stores/settingsStore'
|
||||||
import {
|
import {
|
||||||
Monitor, Palette, Keyboard, Shield, Database, Terminal
|
Monitor, Palette, Terminal, Keyboard, Shield, Database, Info, Search, X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { themes } from '../themes/terminal'
|
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() {
|
export default function SettingsView() {
|
||||||
const { settings, isLoaded, loadSettings, setSetting } = useSettingsStore()
|
const { settings, isLoaded, loadSettings, setSetting } = useSettingsStore()
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTab>('general')
|
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(() => {
|
useEffect(() => {
|
||||||
loadSettings()
|
if (window.electronAPI) {
|
||||||
|
window.electronAPI.getVersion().then((v: string) => setAppVersion(v))
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const tabs = [
|
// 当前 tab 元信息
|
||||||
{ id: 'general' as SettingsTab, label: '常规', icon: Monitor },
|
const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab])
|
||||||
{ 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 },
|
|
||||||
]
|
|
||||||
|
|
||||||
|
// ── 搜索过滤 ──────────────────────────────────────────────
|
||||||
|
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) {
|
if (!isLoaded) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex items-center justify-center bg-dark-900">
|
<div className="h-full flex items-center justify-center bg-dark-900">
|
||||||
@@ -32,43 +100,47 @@ export default function SettingsView() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 渲染内容区 ────────────────────────────────────────────
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
|
// ──────── 常规 ─────────
|
||||||
case 'general':
|
case 'general':
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-5">
|
||||||
<h2 className="text-xl font-semibold text-gray-200">常规设置</h2>
|
<SettingCard>
|
||||||
<div className="space-y-4">
|
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
label="启动时恢复会话"
|
label="启动时恢复会话"
|
||||||
description="应用启动时自动恢复上次的会话"
|
description="应用启动时自动恢复上次的会话"
|
||||||
checked={settings.restoreOnStart}
|
checked={settings.restoreOnStart}
|
||||||
onChange={(v) => setSetting('restoreOnStart', v)}
|
onChange={(v) => setSetting('restoreOnStart', v)}
|
||||||
/>
|
/>
|
||||||
|
</SettingCard>
|
||||||
|
<SettingCard>
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
label="自动保存会话"
|
label="自动保存会话"
|
||||||
description="自动保存会话配置更改"
|
description="自动保存会话配置更改"
|
||||||
checked={settings.autoSave}
|
checked={settings.autoSave}
|
||||||
onChange={(v) => setSetting('autoSave', v)}
|
onChange={(v) => setSetting('autoSave', v)}
|
||||||
/>
|
/>
|
||||||
|
</SettingCard>
|
||||||
|
<SettingCard>
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
label="显示通知"
|
label="显示通知"
|
||||||
description="在桌面显示连接状态通知"
|
description="在桌面显示连接状态通知"
|
||||||
checked={settings.showNotifications}
|
checked={settings.showNotifications}
|
||||||
onChange={(v) => setSetting('showNotifications', v)}
|
onChange={(v) => setSetting('showNotifications', v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingCard>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ──────── 外观 ─────────
|
||||||
case 'appearance':
|
case 'appearance':
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-5">
|
||||||
<h2 className="text-xl font-semibold text-gray-200">外观设置</h2>
|
<SettingCard>
|
||||||
<div className="space-y-4">
|
<SettingSectionTitle>主题</SettingSectionTitle>
|
||||||
<div className="p-4 bg-dark-800 rounded-lg">
|
<div className="grid grid-cols-3 gap-3 mt-3">
|
||||||
<div className="font-medium text-gray-200 mb-3">主题</div>
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
{[
|
{[
|
||||||
{ value: 'dark', label: '深色' },
|
{ value: 'dark', label: '深色' },
|
||||||
{ value: 'light', label: '浅色' },
|
{ value: 'light', label: '浅色' },
|
||||||
@@ -87,37 +159,35 @@ export default function SettingsView() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SettingCard>
|
||||||
|
|
||||||
<div className="p-4 bg-dark-800 rounded-lg">
|
<SettingCard>
|
||||||
<div className="font-medium text-gray-200 mb-3">语言</div>
|
<SettingSectionTitle>语言</SettingSectionTitle>
|
||||||
<select
|
<select
|
||||||
value={settings.language}
|
value={settings.language}
|
||||||
onChange={(e) => setSetting('language', e.target.value as any)}
|
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"
|
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="zh-CN">简体中文</option>
|
||||||
<option value="en-US">English</option>
|
<option value="en-US">English</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</SettingCard>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ──────── 终端 ─────────
|
||||||
case 'terminal':
|
case 'terminal':
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-5">
|
||||||
<h2 className="text-xl font-semibold text-gray-200">终端设置</h2>
|
<SettingCard>
|
||||||
<div className="space-y-4">
|
<SettingSectionTitle>字体</SettingSectionTitle>
|
||||||
<div className="p-4 bg-dark-800 rounded-lg">
|
<div className="grid grid-cols-2 gap-4 mt-3">
|
||||||
<div className="font-medium text-gray-200 mb-3">字体</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-gray-500">字体族</label>
|
<label className="text-sm text-gray-500">字体族</label>
|
||||||
<select
|
<select
|
||||||
value={settings.fontFamily}
|
value={settings.fontFamily}
|
||||||
onChange={(e) => setSetting('fontFamily', e.target.value)}
|
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"
|
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>JetBrains Mono</option>
|
||||||
<option>Fira Code</option>
|
<option>Fira Code</option>
|
||||||
@@ -130,7 +200,7 @@ export default function SettingsView() {
|
|||||||
<select
|
<select
|
||||||
value={settings.fontSize}
|
value={settings.fontSize}
|
||||||
onChange={(e) => setSetting('fontSize', parseInt(e.target.value))}
|
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"
|
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) => (
|
{[12, 13, 14, 15, 16, 18, 20].map((size) => (
|
||||||
<option key={size} value={size}>{size}px</option>
|
<option key={size} value={size}>{size}px</option>
|
||||||
@@ -138,11 +208,11 @@ export default function SettingsView() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SettingCard>
|
||||||
|
|
||||||
<div className="p-4 bg-dark-800 rounded-lg">
|
<SettingCard>
|
||||||
<div className="font-medium text-gray-200 mb-3">终端配色</div>
|
<SettingSectionTitle>终端配色</SettingSectionTitle>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2 mt-3">
|
||||||
{Object.entries(themes).map(([key, theme]) => (
|
{Object.entries(themes).map(([key, theme]) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
@@ -161,52 +231,89 @@ export default function SettingsView() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SettingCard>
|
||||||
</div>
|
|
||||||
</div>
|
</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':
|
case 'ssh':
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-5">
|
||||||
<h2 className="text-xl font-semibold text-gray-200">SSH 设置</h2>
|
<SettingCard>
|
||||||
<div className="space-y-4">
|
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
label="保持连接活跃"
|
label="保持连接活跃"
|
||||||
description="定期发送心跳包保持连接"
|
description="定期发送心跳包保持连接"
|
||||||
checked={settings.keepAlive}
|
checked={settings.keepAlive}
|
||||||
onChange={(v) => setSetting('keepAlive', v)}
|
onChange={(v) => setSetting('keepAlive', v)}
|
||||||
/>
|
/>
|
||||||
|
</SettingCard>
|
||||||
|
|
||||||
<div className="p-4 bg-dark-800 rounded-lg">
|
<SettingCard>
|
||||||
<div className="font-medium text-gray-200 mb-3">默认端口</div>
|
<SettingSectionTitle>默认端口</SettingSectionTitle>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={settings.defaultPort}
|
value={settings.defaultPort}
|
||||||
onChange={(e) => setSetting('defaultPort', parseInt(e.target.value) || 22)}
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingCard>
|
||||||
|
|
||||||
<div className="p-4 bg-dark-800 rounded-lg">
|
<SettingCard>
|
||||||
<div className="font-medium text-gray-200 mb-3">超时设置 (秒)</div>
|
<SettingSectionTitle>超时设置 (秒)</SettingSectionTitle>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={settings.timeout}
|
value={settings.timeout}
|
||||||
onChange={(e) => setSetting('timeout', parseInt(e.target.value) || 30)}
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingCard>
|
||||||
|
|
||||||
{/* 专业连接管理设置 */}
|
{/* 专业连接管理 */}
|
||||||
<div className="p-4 bg-dark-800 rounded-lg border border-accent-blue/30">
|
<SettingCard highlight>
|
||||||
<div className="font-medium text-gray-200 mb-2 flex items-center gap-2">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-accent-blue">专业版</span>
|
<span className="text-accent-blue text-xs font-semibold uppercase tracking-wider">Pro</span>
|
||||||
连接管理
|
<SettingSectionTitle className="mb-0">连接管理</SettingSectionTitle>
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 mb-4">
|
|
||||||
配置连接池行为,优化资源使用和性能
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">配置连接池行为,优化资源使用和性能</p>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -218,7 +325,7 @@ export default function SettingsView() {
|
|||||||
max={50}
|
max={50}
|
||||||
value={settings.maxConnections || 10}
|
value={settings.maxConnections || 10}
|
||||||
onChange={(e) => setSetting('maxConnections', parseInt(e.target.value) || 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"
|
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 className="text-xs text-gray-600 mt-1">最大并发 SSH 连接数</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -230,7 +337,7 @@ export default function SettingsView() {
|
|||||||
max={20}
|
max={20}
|
||||||
value={settings.maxBackgroundConnections || 5}
|
value={settings.maxBackgroundConnections || 5}
|
||||||
onChange={(e) => setSetting('maxBackgroundConnections', parseInt(e.target.value) || 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"
|
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 className="text-xs text-gray-600 mt-1">非活跃标签保持连接数</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,7 +352,7 @@ export default function SettingsView() {
|
|||||||
max={120}
|
max={120}
|
||||||
value={Math.floor((settings.idleTimeoutMs || 5 * 60 * 1000) / 60000)}
|
value={Math.floor((settings.idleTimeoutMs || 5 * 60 * 1000) / 60000)}
|
||||||
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 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"
|
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 className="text-xs text-gray-600 mt-1">无操作后进入挂起</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -257,19 +364,19 @@ export default function SettingsView() {
|
|||||||
max={240}
|
max={240}
|
||||||
value={Math.floor((settings.suspendTimeoutMs || 30 * 60 * 1000) / 60000)}
|
value={Math.floor((settings.suspendTimeoutMs || 30 * 60 * 1000) / 60000)}
|
||||||
onChange={(e) => setSetting('suspendTimeoutMs', (parseInt(e.target.value) || 30) * 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"
|
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 className="text-xs text-gray-600 mt-1">挂起后自动断开</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 space-y-4">
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
label="启用终端快照"
|
label="启用终端快照"
|
||||||
description="切换标签时保存终端内容,快速恢复显示"
|
description="切换标签时保存终端内容,快速恢复显示"
|
||||||
checked={settings.enableSnapshots !== false}
|
checked={settings.enableSnapshots !== false}
|
||||||
onChange={(v) => setSetting('enableSnapshots', v)}
|
onChange={(v) => setSetting('enableSnapshots', v)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
label="自动重连"
|
label="自动重连"
|
||||||
description="连接断开后自动尝试重连(最多 3 次)"
|
description="连接断开后自动尝试重连(最多 3 次)"
|
||||||
@@ -278,46 +385,84 @@ export default function SettingsView() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SettingCard>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ──────── 安全 ─────────
|
||||||
case 'security':
|
case 'security':
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-5">
|
||||||
<h2 className="text-xl font-semibold text-gray-200">安全设置</h2>
|
<SettingCard>
|
||||||
<div className="space-y-4">
|
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
label="加密存储密码"
|
label="加密存储密码"
|
||||||
description="使用 AES-256 加密存储密码"
|
description="使用 AES-256-GCM 加密存储密码"
|
||||||
checked={settings.encryptPasswords}
|
checked={settings.encryptPasswords}
|
||||||
onChange={(v) => setSetting('encryptPasswords', v)}
|
onChange={(v) => setSetting('encryptPasswords', v)}
|
||||||
/>
|
/>
|
||||||
|
</SettingCard>
|
||||||
|
<SettingCard>
|
||||||
<SettingToggle
|
<SettingToggle
|
||||||
label="锁定屏幕时断开连接"
|
label="锁定屏幕时断开连接"
|
||||||
description="系统锁屏时自动断开 SSH 连接"
|
description="系统锁屏时自动断开 SSH 连接"
|
||||||
checked={settings.lockOnSleep}
|
checked={settings.lockOnSleep}
|
||||||
onChange={(v) => setSetting('lockOnSleep', v)}
|
onChange={(v) => setSetting('lockOnSleep', v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingCard>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ──────── 关于 ─────────
|
||||||
case 'about':
|
case 'about':
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-5">
|
||||||
<h2 className="text-xl font-semibold text-gray-200">关于 OpsXShell</h2>
|
<SettingCard>
|
||||||
<div className="p-6 bg-dark-800 rounded-lg text-center">
|
<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 mx-auto mb-4">
|
<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" />
|
<Terminal className="w-10 h-10 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-gray-200 mb-1">OpsXShell</h3>
|
<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-500 mb-4">版本 {appVersion}</p>
|
||||||
<p className="text-sm text-gray-400 max-w-md mx-auto">
|
<p className="text-sm text-gray-400 max-w-md text-center">
|
||||||
跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠 · 操作便捷人性化
|
跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠 · 操作便捷人性化
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</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>
|
||||||
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -326,40 +471,117 @@ export default function SettingsView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 主布局 ────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex bg-dark-900">
|
<div className="h-full flex bg-dark-900">
|
||||||
{/* Sidebar */}
|
{/* ── 左侧导航 ── */}
|
||||||
<div className="w-48 bg-dark-800 border-r border-dark-700 p-4">
|
<div className="w-56 flex-shrink-0 bg-dark-800/50 border-r border-dark-700 flex flex-col">
|
||||||
<nav className="space-y-1">
|
{/* 搜索框 */}
|
||||||
{tabs.map((tab) => {
|
<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 Icon = tab.icon
|
||||||
|
const active = activeTab === tab.id
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
|
||||||
activeTab === tab.id
|
active
|
||||||
? 'bg-accent-blue text-white'
|
? 'bg-accent-blue/15 text-white'
|
||||||
: 'text-gray-400 hover:bg-dark-700 hover:text-gray-200'
|
: 'text-gray-400 hover:bg-dark-700/60 hover:text-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className={`w-4 h-4 flex-shrink-0 ${active ? 'text-accent-blue' : ''}`} />
|
||||||
{tab.label}
|
<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>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* ── 右侧内容区 ── */}
|
||||||
<div className="flex-1 p-8 overflow-y-auto">
|
<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()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
</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({
|
function SettingToggle({
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
@@ -372,20 +594,20 @@ function SettingToggle({
|
|||||||
onChange: (value: boolean) => void
|
onChange: (value: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-4 bg-dark-800 rounded-lg">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div className="mr-4">
|
||||||
<div className="font-medium text-gray-200">{label}</div>
|
<div className="font-medium text-gray-200 text-sm">{label}</div>
|
||||||
<div className="text-sm text-gray-500">{description}</div>
|
<div className="text-xs text-gray-500 mt-0.5">{description}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onChange(!checked)}
|
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'
|
checked ? 'bg-accent-blue' : 'bg-dark-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
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>
|
</button>
|
||||||
|
|||||||
@@ -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 { useEffect, useState, useMemo } from 'react'
|
||||||
import { useTabStore, type ConnectionStatus } from '../stores/tabStore'
|
import { useTabStore, type ConnectionStatus } from '../stores/tabStore'
|
||||||
|
import { usePoolStore } from '../stores/connection/poolStore'
|
||||||
import { useConnectionStore, type ConnectionState } from '../stores/connection'
|
import { useConnectionStore, type ConnectionState } from '../stores/connection'
|
||||||
|
import { useConnectionPanelStore } from '../stores/connectionPanelStore'
|
||||||
|
|
||||||
// ============== Status Display Config ==============
|
// ============== Status Display Config ==============
|
||||||
|
|
||||||
@@ -80,6 +82,7 @@ function mapTabStatusToConnectionState(status?: ConnectionStatus): ConnectionSta
|
|||||||
// ============== Component ==============
|
// ============== Component ==============
|
||||||
|
|
||||||
export default function StatusBar() {
|
export default function StatusBar() {
|
||||||
|
const togglePanel = useConnectionPanelStore((s) => s.toggle)
|
||||||
const [version, setVersion] = useState('1.0.0')
|
const [version, setVersion] = useState('1.0.0')
|
||||||
const [currentTime, setCurrentTime] = useState(new Date())
|
const [currentTime, setCurrentTime] = useState(new Date())
|
||||||
|
|
||||||
@@ -87,10 +90,13 @@ export default function StatusBar() {
|
|||||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||||
const tabs = useTabStore((s) => s.tabs)
|
const tabs = useTabStore((s) => s.tabs)
|
||||||
|
|
||||||
// Connection store (pool)
|
// Connection store (pool) — subscribe to connections to reactively update
|
||||||
const connections = useConnectionStore().config // trigger re-renders via store subscription
|
const connections = usePoolStore((s) => s.connections)
|
||||||
const poolStore = useConnectionStore()
|
const poolStore = useConnectionStore()
|
||||||
|
|
||||||
|
// Total connection count for the panel badge
|
||||||
|
const totalConnections = connections.size
|
||||||
|
|
||||||
// Derive the active tab
|
// Derive the active tab
|
||||||
const activeTab = useMemo(
|
const activeTab = useMemo(
|
||||||
() => tabs.find((t) => t.id === activeTabId),
|
() => tabs.find((t) => t.id === activeTabId),
|
||||||
@@ -158,8 +164,15 @@ export default function StatusBar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-7 bg-dark-800 border-t border-dark-700 flex items-center px-3 text-xs text-white select-none">
|
<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 */}
|
{/* Left: Connection status area — clickable to toggle ConnectionPanel */}
|
||||||
<div className={`flex items-center gap-1.5 ${statusCfg.colorClass} ${statusCfg.animateClass}`}>
|
<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}
|
{statusCfg.icon}
|
||||||
<span>{statusCfg.label}</span>
|
<span>{statusCfg.label}</span>
|
||||||
{hostLabel && (
|
{hostLabel && (
|
||||||
@@ -168,7 +181,13 @@ export default function StatusBar() {
|
|||||||
<span className="text-gray-300">{hostLabel}</span>
|
<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 */}
|
{/* Center: Transfer stats */}
|
||||||
{transferLabel && (
|
{transferLabel && (
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -10,6 +10,7 @@ export { default as Sidebar } from './Sidebar'
|
|||||||
export { default as TabBar } from './TabBar'
|
export { default as TabBar } from './TabBar'
|
||||||
export { default as TabContent } from './TabContent'
|
export { default as TabContent } from './TabContent'
|
||||||
export { default as StatusBar } from './StatusBar'
|
export { default as StatusBar } from './StatusBar'
|
||||||
|
export { default as ConnectionPanel } from './ConnectionPanel'
|
||||||
|
|
||||||
// 功能组件 (当前在根目录,将来移到对应目录)
|
// 功能组件 (当前在根目录,将来移到对应目录)
|
||||||
export { default as TerminalView } from './TerminalView'
|
export { default as TerminalView } from './TerminalView'
|
||||||
@@ -34,3 +35,6 @@ export {
|
|||||||
export { default as Button } from './common/Button'
|
export { default as Button } from './common/Button'
|
||||||
export { default as Modal } from './common/Modal'
|
export { default as Modal } from './common/Modal'
|
||||||
export { default as EmptyState } from './common/EmptyState'
|
export { default as EmptyState } from './common/EmptyState'
|
||||||
|
|
||||||
|
// Snippets
|
||||||
|
export { default as SnippetPanel } from './snippets/SnippetPanel'
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ export type { Toast, ToastType } from './useToast'
|
|||||||
|
|
||||||
export { useMediaQuery, useIsMobile } from './useMediaQuery'
|
export { useMediaQuery, useIsMobile } from './useMediaQuery'
|
||||||
|
|
||||||
|
export { useTheme } from './useTheme'
|
||||||
|
|
||||||
// 待实现
|
// 待实现
|
||||||
// export { useTerminal } from './useTerminal'
|
// export { useTerminal } from './useTerminal'
|
||||||
// export { useConnection } from './useConnection'
|
// export { useConnection } from './useConnection'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* useKeyboardShortcuts - 全局快捷键管理 Hook
|
* useKeyboardShortcuts - 全局快捷键管理 Hook (v1.3.0)
|
||||||
*
|
*
|
||||||
* 在 App.tsx 挂载,处理全局键盘快捷键:
|
* 在 App.tsx 挂载,处理全局键盘快捷键:
|
||||||
* - Ctrl+Tab / Ctrl+Shift+Tab: 切换标签
|
* - Ctrl+Tab / Ctrl+Shift+Tab: 切换标签
|
||||||
@@ -7,10 +7,12 @@
|
|||||||
* - Ctrl+W: 关闭当前标签
|
* - Ctrl+W: 关闭当前标签
|
||||||
* - F11: 全屏切换
|
* - F11: 全屏切换
|
||||||
* - Ctrl+Shift+N: 新建标签
|
* - Ctrl+Shift+N: 新建标签
|
||||||
|
* - Ctrl+Shift+P: 打开命令面板 (通过 commandPaletteStore)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useTabStore } from '../stores/tabStore'
|
import { useTabStore } from '../stores/tabStore'
|
||||||
|
import { useCommandPaletteStore } from '../stores/commandPaletteStore'
|
||||||
import { createLogger } from '../utils/logger'
|
import { createLogger } from '../utils/logger'
|
||||||
|
|
||||||
const logger = createLogger('KeyboardShortcuts')
|
const logger = createLogger('KeyboardShortcuts')
|
||||||
@@ -37,6 +39,8 @@ function shouldSkipInInput(e: KeyboardEvent): boolean {
|
|||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Tab') return false
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Tab') return false
|
||||||
// F11:始终生效
|
// F11:始终生效
|
||||||
if ( e.key === 'F11') return false
|
if ( e.key === 'F11') return false
|
||||||
|
// Ctrl+Shift+P:命令面板始终生效
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'p' || e.key === 'P')) return false
|
||||||
// 其他快捷键在输入框中不触发
|
// 其他快捷键在输入框中不触发
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -46,6 +50,13 @@ export function useKeyboardShortcuts(): void {
|
|||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
const ctrlOrMeta = e.ctrlKey || e.metaKey
|
const ctrlOrMeta = e.ctrlKey || e.metaKey
|
||||||
|
|
||||||
|
// ---- Ctrl+Shift+P: 切换命令面板 (通过 commandPaletteStore) ----
|
||||||
|
if (ctrlOrMeta && e.shiftKey && (e.key === 'p' || e.key === 'P')) {
|
||||||
|
e.preventDefault()
|
||||||
|
useCommandPaletteStore.getState().toggle()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Ctrl+Tab: 切换到下一个标签 ----
|
// ---- Ctrl+Tab: 切换到下一个标签 ----
|
||||||
if (ctrlOrMeta && e.key === 'Tab' && !e.shiftKey) {
|
if (ctrlOrMeta && e.key === 'Tab' && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import { useSettingsStore } from '../stores/settingsStore'
|
||||||
|
import { darkColors, lightColors, type AppThemeMode, type ColorMap } from '../themes/app-theme'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the *effective* theme ('dark' | 'light') from the user preference,
|
||||||
|
* including the 'system' → prefers-color-scheme mapping.
|
||||||
|
*/
|
||||||
|
function resolveEffectiveTheme(preference: AppThemeMode): 'dark' | 'light' {
|
||||||
|
if (preference !== 'system') return preference
|
||||||
|
if (typeof window === 'undefined') return 'dark'
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a colour map to `document.documentElement` as CSS custom properties.
|
||||||
|
*/
|
||||||
|
function applyCSSVars(colors: ColorMap) {
|
||||||
|
const root = document.documentElement
|
||||||
|
for (const [key, value] of Object.entries(colors)) {
|
||||||
|
root.style.setProperty(`--color-${key}`, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useTheme — keeps the DOM in sync with the user's theme preference.
|
||||||
|
*
|
||||||
|
* • Reads `settings.theme` from the settings store.
|
||||||
|
* • Listens to `prefers-color-scheme` changes when set to 'system'.
|
||||||
|
* • Writes `data-theme="dark" | "light"` on `<html>`.
|
||||||
|
* • Writes `--color-*` CSS custom properties on `<html>`.
|
||||||
|
*/
|
||||||
|
export function useTheme() {
|
||||||
|
const theme = useSettingsStore((s) => s.settings.theme)
|
||||||
|
const isLoaded = useSettingsStore((s) => s.isLoaded)
|
||||||
|
|
||||||
|
const effective = useMemo(() => resolveEffectiveTheme(theme), [theme])
|
||||||
|
|
||||||
|
// Apply theme to DOM whenever effective theme changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoaded) return
|
||||||
|
|
||||||
|
const colors = effective === 'dark' ? darkColors : lightColors
|
||||||
|
|
||||||
|
// Set data attribute for potential direct CSS selectors
|
||||||
|
document.documentElement.setAttribute('data-theme', effective)
|
||||||
|
|
||||||
|
// Apply CSS custom properties
|
||||||
|
applyCSSVars(colors)
|
||||||
|
|
||||||
|
// Update body bg & colour (mirrors index.css body rules but via vars)
|
||||||
|
document.body.style.backgroundColor = 'var(--color-dark-900)'
|
||||||
|
document.body.style.color = 'var(--color-text-primary)'
|
||||||
|
}, [effective, isLoaded])
|
||||||
|
|
||||||
|
// Listen for system colour-scheme changes when preference is 'system'
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme !== 'system' || typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const mql = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
|
||||||
|
const handler = (e: MediaQueryListEvent) => {
|
||||||
|
const newEffective = e.matches ? 'dark' : 'light'
|
||||||
|
const colors = e.matches ? darkColors : lightColors
|
||||||
|
document.documentElement.setAttribute('data-theme', newEffective)
|
||||||
|
applyCSSVars(colors)
|
||||||
|
document.body.style.backgroundColor = 'var(--color-dark-900)'
|
||||||
|
document.body.style.color = 'var(--color-text-primary)'
|
||||||
|
}
|
||||||
|
|
||||||
|
mql.addEventListener('change', handler)
|
||||||
|
return () => mql.removeEventListener('change', handler)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
return { theme, effective }
|
||||||
|
}
|
||||||
+64
-6
@@ -2,6 +2,64 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
* OpsXShell v1.3.0 — Theme CSS Variables
|
||||||
|
*
|
||||||
|
* The default (no data-theme attribute) keeps dark mode as before so
|
||||||
|
* that the very first paint looks correct even before React hydrates.
|
||||||
|
* useTheme.ts will immediately set data-theme + override vars on mount.
|
||||||
|
* =================================================================== */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ---- Dark (default, matches original hard-coded values) ---- */
|
||||||
|
--color-dark-900: #0f172a;
|
||||||
|
--color-dark-800: #1e293b;
|
||||||
|
--color-dark-700: #334155;
|
||||||
|
--color-dark-600: #475569;
|
||||||
|
|
||||||
|
--color-text-primary: #e2e8f0;
|
||||||
|
--color-text-secondary: #cbd5e1;
|
||||||
|
--color-text-muted: #94a3b8;
|
||||||
|
--color-text-faint: #64748b;
|
||||||
|
--color-text-dim: #475569;
|
||||||
|
|
||||||
|
--color-accent-blue: #3b82f6;
|
||||||
|
--color-accent-green: #10b981;
|
||||||
|
--color-accent-purple: #8b5cf6;
|
||||||
|
--color-accent-orange: #f97316;
|
||||||
|
|
||||||
|
--color-scrollbar-track: #1e293b;
|
||||||
|
--color-scrollbar-thumb: #475569;
|
||||||
|
--color-scrollbar-thumb-h: #64748b;
|
||||||
|
|
||||||
|
--color-terminal-bg: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Light theme ---- */
|
||||||
|
:root[data-theme='light'] {
|
||||||
|
--color-dark-900: #ffffff;
|
||||||
|
--color-dark-800: #f3f4f6;
|
||||||
|
--color-dark-700: #e5e7eb;
|
||||||
|
--color-dark-600: #d1d5db;
|
||||||
|
|
||||||
|
--color-text-primary: #1f2937;
|
||||||
|
--color-text-secondary: #374151;
|
||||||
|
--color-text-muted: #6b7280;
|
||||||
|
--color-text-faint: #9ca3af;
|
||||||
|
--color-text-dim: #d1d5db;
|
||||||
|
|
||||||
|
--color-accent-blue: #2563eb;
|
||||||
|
--color-accent-green: #059669;
|
||||||
|
--color-accent-purple: #7c3aed;
|
||||||
|
--color-accent-orange: #ea580c;
|
||||||
|
|
||||||
|
--color-scrollbar-track: #f3f4f6;
|
||||||
|
--color-scrollbar-thumb: #d1d5db;
|
||||||
|
--color-scrollbar-thumb-h: #9ca3af;
|
||||||
|
|
||||||
|
--color-terminal-bg: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -14,8 +72,8 @@ body {
|
|||||||
sans-serif;
|
sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background-color: #0f172a;
|
background-color: var(--color-dark-900);
|
||||||
color: #e2e8f0;
|
color: var(--color-text-primary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,21 +89,21 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #1e293b;
|
background: var(--color-scrollbar-track);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #475569;
|
background: var(--color-scrollbar-thumb);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #64748b;
|
background: var(--color-scrollbar-thumb-h);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Terminal styles */
|
/* Terminal styles */
|
||||||
.terminal-container {
|
.terminal-container {
|
||||||
background-color: #0f172a;
|
background-color: var(--color-terminal-bg);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* CommandPaletteStore — 命令面板状态管理
|
||||||
|
*
|
||||||
|
* 管理 VS Code 风格命令面板 (Ctrl+Shift+P) 的开关状态
|
||||||
|
* 与 UI 组件解耦,任何组件都可触发打开/关闭
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
interface CommandPaletteState {
|
||||||
|
isOpen: boolean
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
open: () => void
|
||||||
|
close: () => void
|
||||||
|
toggle: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCommandPaletteStore = create<CommandPaletteState>((set) => ({
|
||||||
|
isOpen: false,
|
||||||
|
|
||||||
|
open: () => set({ isOpen: true }),
|
||||||
|
|
||||||
|
close: () => set({ isOpen: false }),
|
||||||
|
|
||||||
|
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
export default useCommandPaletteStore
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Connection Panel Store - 连接面板开关状态
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
interface ConnectionPanelState {
|
||||||
|
isOpen: boolean
|
||||||
|
open: () => void
|
||||||
|
close: () => void
|
||||||
|
toggle: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useConnectionPanelStore = create<ConnectionPanelState>((set) => ({
|
||||||
|
isOpen: false,
|
||||||
|
open: () => set({ isOpen: true }),
|
||||||
|
close: () => set({ isOpen: false }),
|
||||||
|
toggle: () => set((s) => ({ isOpen: !s.isOpen })),
|
||||||
|
}))
|
||||||
@@ -18,5 +18,12 @@ export {
|
|||||||
getSessionConfig,
|
getSessionConfig,
|
||||||
} from './connection'
|
} from './connection'
|
||||||
|
|
||||||
|
// 命令片段
|
||||||
|
export { useSnippetStore } from './snippetStore'
|
||||||
|
export type { Snippet } from './snippetStore'
|
||||||
|
|
||||||
|
// 命令面板
|
||||||
|
export { useCommandPaletteStore } from './commandPaletteStore'
|
||||||
|
|
||||||
// 类型导出
|
// 类型导出
|
||||||
export type { ConnectionState } from './connection'
|
export type { ConnectionState } from './connection'
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* SnippetStore — 命令片段库状态管理
|
||||||
|
*
|
||||||
|
* 管理用户保存的常用命令片段,支持 CRUD 和搜索
|
||||||
|
* 数据通过 electronAPI.settingsGet/settingsSet 持久化
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { createLogger } from '../utils/logger'
|
||||||
|
|
||||||
|
const logger = createLogger('SnippetStore')
|
||||||
|
|
||||||
|
export interface Snippet {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
content: string
|
||||||
|
tags: string[]
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnippetState {
|
||||||
|
snippets: Snippet[]
|
||||||
|
isLoaded: boolean
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
loadSnippets: () => Promise<void>
|
||||||
|
addSnippet: (name: string, content: string, tags?: string[]) => Promise<void>
|
||||||
|
updateSnippet: (id: string, updates: Partial<Pick<Snippet, 'name' | 'content' | 'tags'>>) => Promise<void>
|
||||||
|
deleteSnippet: (id: string) => Promise<void>
|
||||||
|
searchSnippets: (query: string) => Snippet[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 持久化到 settings 的 key */
|
||||||
|
const STORAGE_KEY = 'snippets'
|
||||||
|
|
||||||
|
/** 生成唯一 ID */
|
||||||
|
function generateId(): string {
|
||||||
|
return `snippet-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将片段数据保存到 electron settings */
|
||||||
|
async function persistSnippets(snippets: Snippet[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.settingsSet(STORAGE_KEY, snippets)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to persist snippets', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSnippetStore = create<SnippetState>((set, get) => ({
|
||||||
|
snippets: [],
|
||||||
|
isLoaded: false,
|
||||||
|
|
||||||
|
loadSnippets: async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI?.settingsGet(STORAGE_KEY)
|
||||||
|
if (result?.success && Array.isArray(result.value)) {
|
||||||
|
set({ snippets: result.value as Snippet[], isLoaded: true })
|
||||||
|
} else {
|
||||||
|
set({ isLoaded: true })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to load snippets', error)
|
||||||
|
set({ isLoaded: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addSnippet: async (name, content, tags = []) => {
|
||||||
|
const now = Date.now()
|
||||||
|
const snippet: Snippet = {
|
||||||
|
id: generateId(),
|
||||||
|
name,
|
||||||
|
content,
|
||||||
|
tags,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
const snippets = [...get().snippets, snippet]
|
||||||
|
set({ snippets })
|
||||||
|
await persistSnippets(snippets)
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSnippet: async (id, updates) => {
|
||||||
|
const snippets = get().snippets.map((s) =>
|
||||||
|
s.id === id
|
||||||
|
? { ...s, ...updates, updatedAt: Date.now() }
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
set({ snippets })
|
||||||
|
await persistSnippets(snippets)
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteSnippet: async (id) => {
|
||||||
|
const snippets = get().snippets.filter((s) => s.id !== id)
|
||||||
|
set({ snippets })
|
||||||
|
await persistSnippets(snippets)
|
||||||
|
},
|
||||||
|
|
||||||
|
searchSnippets: (query) => {
|
||||||
|
const { snippets } = get()
|
||||||
|
if (!query.trim()) return snippets
|
||||||
|
|
||||||
|
const lowerQuery = query.toLowerCase().trim()
|
||||||
|
return snippets.filter(
|
||||||
|
(s) =>
|
||||||
|
s.name.toLowerCase().includes(lowerQuery) ||
|
||||||
|
s.content.toLowerCase().includes(lowerQuery) ||
|
||||||
|
s.tags.some((tag) => tag.toLowerCase().includes(lowerQuery))
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* OpsXShell v1.3.0 — App Theme System
|
||||||
|
*
|
||||||
|
* CSS-variable-driven theme definitions for Dark / Light / System modes.
|
||||||
|
* Terminal themes (terminal.ts) remain independent — this only covers the
|
||||||
|
* Chrome / UI layer.
|
||||||
|
*
|
||||||
|
* Design references:
|
||||||
|
* - Dark: existing slate-blue palette
|
||||||
|
* - Light: VS Code Light+ inspired — white canvas, soft grey borders, blue accent
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type AppThemeMode = 'dark' | 'light' | 'system'
|
||||||
|
|
||||||
|
/** Flat map of CSS custom-property suffix → colour value. */
|
||||||
|
export type ColorMap = Record<string, string>
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dark palette (unchanged from original hard-coded values)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const darkColors: ColorMap = {
|
||||||
|
// Background surfaces (deepest → shallowest)
|
||||||
|
'dark-900': '#0f172a',
|
||||||
|
'dark-800': '#1e293b',
|
||||||
|
'dark-700': '#334155',
|
||||||
|
'dark-600': '#475569',
|
||||||
|
|
||||||
|
// Text
|
||||||
|
'text-primary': '#e2e8f0', // gray-200
|
||||||
|
'text-secondary': '#cbd5e1', // gray-300
|
||||||
|
'text-muted': '#94a3b8', // gray-400
|
||||||
|
'text-faint': '#64748b', // gray-500
|
||||||
|
'text-dim': '#475569', // gray-600
|
||||||
|
|
||||||
|
// Accent
|
||||||
|
'accent-blue': '#3b82f6',
|
||||||
|
'accent-green': '#10b981',
|
||||||
|
'accent-purple': '#8b5cf6',
|
||||||
|
'accent-orange': '#f97316',
|
||||||
|
|
||||||
|
// UI chrome
|
||||||
|
'scrollbar-track': '#1e293b',
|
||||||
|
'scrollbar-thumb': '#475569',
|
||||||
|
'scrollbar-thumb-h': '#64748b',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Light palette (VS Code Light+ inspired)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const lightColors: ColorMap = {
|
||||||
|
// Background surfaces
|
||||||
|
'dark-900': '#ffffff',
|
||||||
|
'dark-800': '#f3f4f6', // gray-100
|
||||||
|
'dark-700': '#e5e7eb', // gray-200
|
||||||
|
'dark-600': '#d1d5db', // gray-300
|
||||||
|
|
||||||
|
// Text
|
||||||
|
'text-primary': '#1f2937', // gray-800
|
||||||
|
'text-secondary': '#374151', // gray-700
|
||||||
|
'text-muted': '#6b7280', // gray-500
|
||||||
|
'text-faint': '#9ca3af', // gray-400
|
||||||
|
'text-dim': '#d1d5db', // gray-300
|
||||||
|
|
||||||
|
// Accent (kept the same hue family, slightly adjusted for light bg)
|
||||||
|
'accent-blue': '#2563eb',
|
||||||
|
'accent-green': '#059669',
|
||||||
|
'accent-purple': '#7c3aed',
|
||||||
|
'accent-orange': '#ea580c',
|
||||||
|
|
||||||
|
// UI chrome
|
||||||
|
'scrollbar-track': '#f3f4f6',
|
||||||
|
'scrollbar-thumb': '#d1d5db',
|
||||||
|
'scrollbar-thumb-h': '#9ca3af',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: build a CSS-variable block string for injection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function buildCSSVars(colors: ColorMap): string {
|
||||||
|
return Object.entries(colors)
|
||||||
|
.map(([key, value]) => ` --color-${key}: ${value};`)
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
+11
-9
@@ -7,18 +7,20 @@ export default {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
// Background surfaces — driven by CSS custom properties set per theme
|
||||||
dark: {
|
dark: {
|
||||||
900: '#0f172a',
|
900: 'var(--color-dark-900)',
|
||||||
800: '#1e293b',
|
800: 'var(--color-dark-800)',
|
||||||
700: '#334155',
|
700: 'var(--color-dark-700)',
|
||||||
600: '#475569',
|
600: 'var(--color-dark-600)',
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
blue: '#3b82f6',
|
blue: 'var(--color-accent-blue)',
|
||||||
green: '#10b981',
|
green: 'var(--color-accent-green)',
|
||||||
purple: '#8b5cf6',
|
purple: 'var(--color-accent-purple)',
|
||||||
}
|
orange: 'var(--color-accent-orange)',
|
||||||
}
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
|||||||
@@ -9,6 +9,16 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
'vendor-xterm': ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-search', '@xterm/addon-webgl'],
|
||||||
|
'vendor-react': ['react', 'react-dom'],
|
||||||
|
'vendor-lucide': ['lucide-react'],
|
||||||
|
'vendor-zustand': ['zustand'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Reference in new issue
Block a user