v2.0.0: 主题系统+SSH私钥解析修复+会话持久化+连接池优化+UI打磨

核心修复:
- ssh-service.ts: 新增 resolvePrivateKey(),将私钥文件路径(~/.ssh/id_rsa)解析为内容后传给 ssh2,
  解决 'Cannot parse privateKey: Unsupported key format' 错误
- useTerminalConnection.ts + sessionHelper.ts: 根据 authType 过滤字段,password/agent 认证不再传 privateKey
- sessionStore.ts: addSession() 现在先调用 sessionCreate() 持久化到 SQLite,解决恢复标签'无可用连接信息'问题
- database-service.ts: updateSession() 补充 private_key_path 字段持久化
- SessionDialog.tsx: 默认 username='root'、privateKey='~/.ssh/id_rsa'

UI/UX:
- 语义化主题系统(app-theme.ts),强制深色模式,全局无边框设计
- macOS 标题栏支持双击最大化(window:toggleMaximize IPC)
- macOS 菜单栏('OpsXShell' 应用菜单 + Settings Cmd+,)
- WelcomeView 4卡片布局,移除 Settings 按钮
- TabBar/TabItem 背景色统一(bg-dark-800/900),消除白色边框线

架构优化:
- SFTP 生命周期修复(useSFTP.ts useRef + autoConnectAttempted guard)
- 连接池竞态修复,支持快速多标签连接
- IPC 接口规范化,安全错误处理
- Vite 代码分割优化

质量: lint零错误 | tsc零错误 | build成功 | 49 tests pass
This commit is contained in:
Logan.Li committed 2026-04-22 11:15:41 +08:00
1 parent 37e65be823
commit 514d62644e
77 files changed
+2563 -931

No files matched your search

+71 -52
View File
@@ -1,12 +1,8 @@
import { useEffect, useState, useCallback } from 'react'
import { useEffect, useState, useCallback, lazy, Suspense } from 'react'
import Sidebar from './components/Sidebar'
import TabBar from './components/TabBar'
import TabContent from './components/TabContent'
import StatusBar from './components/StatusBar'
import ConnectionPanel from './components/ConnectionPanel'
import SessionDialog from './components/SessionDialog'
import QuickConnectDialog from './components/QuickConnectDialog'
import { CommandPalette } from './components/common/CommandPalette'
import { useTabStore } from './stores/tabStore'
import { useSessionStore, useCommandPaletteStore, type Session } from './stores'
import { createLogger } from './utils/logger'
@@ -16,6 +12,12 @@ import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { useIsMobile } from './hooks/useMediaQuery'
import { useTheme } from './hooks/useTheme'
// Lazy load heavy components that are not always visible
const ConnectionPanel = lazy(() => import('./components/ConnectionPanel'))
const SessionDialog = lazy(() => import('./components/SessionDialog'))
const QuickConnectDialog = lazy(() => import('./components/QuickConnectDialog'))
const CommandPalette = lazy(() => import('./components/common/CommandPalette').then(m => ({ default: m.CommandPalette })))
const logger = createLogger('App')
function App() {
@@ -63,7 +65,7 @@ function App() {
}, [])
// 导出会话
const handleExportSessions = async () => {
const handleExportSessions = useCallback(async () => {
try {
const result = await window.electronAPI?.showSaveDialog({
title: '导出会话',
@@ -91,10 +93,10 @@ function App() {
logger.error('Export failed:', error)
toast.error('导出会话失败')
}
}
}, [sessions, toast])
// 导入会话
const handleImportSessions = async () => {
const handleImportSessions = useCallback(async () => {
try {
const result = await window.electronAPI?.showOpenDialog({
title: '导入会话',
@@ -130,7 +132,7 @@ function App() {
importSessions = data.sessions
} else if (data.hosts && Array.isArray(data.hosts)) {
// SSH config 格式 { hosts: [...] }
importSessions = data.hosts.map((h: any) => ({
importSessions = data.hosts.map((h: { name?: string; host?: string; port?: number; user?: string; password?: string; privateKey?: string; group?: string }) => ({
name: h.name || h.host,
host: h.host,
port: h.port || 22,
@@ -145,7 +147,7 @@ function App() {
// 不是 JSON,尝试 SSH Config 格式
const lines = fileResult.data.split('\n')
const hosts: Partial<Session>[] = []
let currentHost: any = null
let currentHost: { name?: string; host?: string; port?: number; username?: string; authType?: 'password' | 'key'; privateKey?: string } | null = null
for (const line of lines) {
const trimmed = line.trim()
@@ -185,9 +187,9 @@ function App() {
try {
await window.electronAPI?.sessionCreate({
name: session.name || `${session.username}@${session.host}`,
host: session.host,
host: session.host!,
port: session.port || 22,
username: session.username,
username: session.username!,
authType: session.authType || 'password',
password: session.password,
privateKey: session.privateKey,
@@ -210,7 +212,7 @@ function App() {
logger.error('Import failed:', error)
toast.error('导入会话失败')
}
}
}, [toast])
useEffect(() => {
// Listen for menu events from main process
@@ -226,6 +228,8 @@ function App() {
window.electronAPI.onMenuImportSessions(handleImportSessions)
window.electronAPI.onMenuExportSessions(handleExportSessions)
window.electronAPI.onMenuOpenSettings(handleOpenSettings)
}
return () => {
@@ -234,14 +238,21 @@ function App() {
window.electronAPI.removeAllListeners('menu-quick-connect')
window.electronAPI.removeAllListeners('menu-import-sessions')
window.electronAPI.removeAllListeners('menu-export-sessions')
window.electronAPI.removeAllListeners('menu-open-settings')
}
}
}, [sessions])
}, [handleExportSessions, handleImportSessions, handleOpenSettings])
return (
<div className="flex flex-col h-screen bg-dark-900 text-gray-200">
{/* Title bar spacer for macOS */}
<div className="h-8 bg-dark-900 drag"></div>
<div className="flex flex-col h-screen bg-dark-900 text-gray-200" style={{ backgroundColor: '#0f172a' }}>
{/* Title bar spacer for macOS — left area draggable, right area handles double-click */}
<div className="h-8 bg-dark-900 flex">
<div className="w-20 drag" />
<div
className="flex-1 no-drag"
onDoubleClick={() => window.electronAPI?.toggleMaximize()}
/>
</div>
{/* Main content */}
<div className="flex flex-1 overflow-hidden">
@@ -256,7 +267,7 @@ function App() {
{/* Sidebar container with transition */}
<div
className={`
transition-all duration-300 ease-in-out flex-shrink-0
transition-all duration-300 ease-in-out flex-shrink-0 h-full bg-dark-800
${isMobile
? `fixed inset-y-0 left-0 z-50 top-8 ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`
: sidebarCollapsed ? 'w-0 overflow-hidden' : 'w-64'
@@ -282,7 +293,7 @@ function App() {
</div>
{/* Main area */}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 flex flex-col min-w-0 bg-dark-800">
{/* Tab bar with mobile sidebar toggle */}
<div className="flex items-center">
{isMobile && (
@@ -319,44 +330,52 @@ function App() {
<StatusBar />
{/* Connection management panel (self-contained via connectionPanelStore) */}
<ConnectionPanel />
<Suspense fallback={null}>
<ConnectionPanel />
</Suspense>
{/* Dialogs */}
<SessionDialog
isOpen={isSessionDialogOpen}
onClose={() => {
setIsSessionDialogOpen(false)
setEditSession(null)
}}
editSession={editSession}
/>
<Suspense fallback={null}>
<SessionDialog
isOpen={isSessionDialogOpen}
onClose={() => {
setIsSessionDialogOpen(false)
setEditSession(null)
}}
editSession={editSession}
/>
</Suspense>
<QuickConnectDialog
isOpen={isQuickConnectOpen}
onClose={() => setIsQuickConnectOpen(false)}
/>
<Suspense fallback={null}>
<QuickConnectDialog
isOpen={isQuickConnectOpen}
onClose={() => setIsQuickConnectOpen(false)}
/>
</Suspense>
{/* 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)
}}
/>
<Suspense fallback={null}>
<CommandPalette
onNewSession={() => {
closeCommandPalette()
setEditSession(null)
setIsSessionDialogOpen(true)
}}
onQuickConnect={() => {
closeCommandPalette()
setIsQuickConnectOpen(true)
}}
onOpenSettings={() => {
closeCommandPalette()
handleOpenSettings()
}}
onEditSession={(session) => {
closeCommandPalette()
setEditSession(session)
setIsSessionDialogOpen(true)
}}
/>
</Suspense>
{/* Toast Notifications */}
<ToastContainer toasts={toasts} onRemove={removeToast} />
+3 -3
View File
@@ -281,11 +281,11 @@ export default function ConnectionPanel() {
{/* 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
bg-dark-800/95 backdrop-blur border-l border-dark-700/30
flex flex-col shadow-xl animate-slide-in-right"
>
{/* Header */}
<div className="flex items-center justify-between px-4 h-12 border-b border-dark-700 shrink-0">
<div className="flex items-center justify-between px-4 h-12 border-b border-dark-700/30 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" />
连接管理
@@ -300,7 +300,7 @@ export default function ConnectionPanel() {
</div>
{/* Stats Overview */}
<div className="px-4 py-3 border-b border-dark-700 shrink-0">
<div className="px-4 py-3 border-b border-dark-700/30 shrink-0">
<StatsOverview
total={stats.total}
connected={stats.connected}
+3 -3
View File
@@ -132,8 +132,8 @@ export default function PortForwardView({ tabId, isActive: _isActive = true }: P
} else {
setError(result?.error || '启动失败')
}
} catch (err: any) {
setError(err.message || '启动失败')
} catch (err: unknown) {
setError((err instanceof Error ? err.message : String(err)) || '启动失败')
} finally {
setIsLoading(false)
}
@@ -331,7 +331,7 @@ export default function PortForwardView({ tabId, isActive: _isActive = true }: P
].map((t) => (
<button
key={t.type}
onClick={() => setNewRule({ ...newRule, type: t.type as any })}
onClick={() => setNewRule({ ...newRule, type: t.type as 'local' | 'remote' | 'dynamic' })}
className={`p-3 rounded-lg border text-left transition-colors ${
newRule.type === t.type
? 'bg-accent-blue/20 border-accent-blue text-white'
+2 -2
View File
@@ -52,8 +52,8 @@ export default function QuickConnectDialog({ isOpen, onClose }: QuickConnectDial
username: '',
password: '',
})
} catch (err: any) {
setError(err.message || '连接失败')
} catch (err: unknown) {
setError((err instanceof Error ? err.message : String(err)) || '连接失败')
} finally {
setIsConnecting(false)
}
+18 -2
View File
@@ -4,7 +4,7 @@
* 使用子组件拆分后的简化版本
*/
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import {
FolderOpen, Upload, Download, Trash2,
Edit3, Copy, FileText, ShieldCheck,
@@ -28,9 +28,10 @@ import type { FileItem, SortField, SortOrder } from '../../shared/types/sftp.typ
interface SFTPViewProps {
tabId: string
isActive?: boolean
sessionId?: string
}
export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPViewProps) {
export default function SFTPView({ tabId, isActive: _isActive = true, sessionId }: SFTPViewProps) {
const {
sessions,
localPath,
@@ -83,6 +84,21 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
const [chmodFile, setChmodFile] = useState<FileItem | null>(null)
const [chmodInitialMode, setChmodInitialMode] = useState(0o644)
// Auto-connect when sessionId is provided and not connected
const autoConnectAttempted = useRef(false)
const connectRef = useRef(connect)
connectRef.current = connect
const isConnectedRef = useRef(isConnected)
isConnectedRef.current = isConnected
const isConnectingRef = useRef(isConnecting)
isConnectingRef.current = isConnecting
useEffect(() => {
if (sessionId && !isConnectedRef.current && !isConnectingRef.current && !autoConnectAttempted.current) {
autoConnectAttempted.current = true
connectRef.current(sessionId)
}
}, [sessionId])
// Sort handler
const handleSort = useCallback((field: SortField) => {
if (sortField === field) {
+6 -6
View File
@@ -18,10 +18,10 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
name: '',
host: '',
port: 22,
username: '',
username: 'root',
authType: 'password' as AuthType,
password: '',
privateKey: '',
privateKey: '~/.ssh/id_rsa',
passphrase: '',
group: 'default',
tags: '',
@@ -49,10 +49,10 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
name: '',
host: '',
port: 22,
username: '',
username: 'root',
authType: 'password',
password: '',
privateKey: '',
privateKey: '~/.ssh/id_rsa',
passphrase: '',
group: 'default',
tags: '',
@@ -73,10 +73,10 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
name: formData.name,
host: formData.host,
port: formData.port,
username: formData.username,
username: formData.username.trim() || 'root',
authType: formData.authType,
password: formData.password,
privateKey: formData.privateKey,
privateKey: formData.authType === 'key' ? (formData.privateKey.trim() || '~/.ssh/id_rsa') : formData.privateKey,
passphrase: formData.passphrase,
group: formData.group,
tags: formData.tags.split(',').map(t => t.trim()).filter(Boolean),
+27 -27
View File
@@ -148,11 +148,11 @@ export default function SettingsView() {
].map((theme) => (
<button
key={theme.value}
onClick={() => setSetting('theme', theme.value as any)}
onClick={() => setSetting('theme', theme.value as 'dark' | 'light' | 'system')}
className={`p-3 rounded-lg border text-sm transition-colors ${
settings.theme === theme.value
? 'border-accent-blue bg-accent-blue/20 text-accent-blue'
: 'border-dark-700 hover:border-dark-600 text-gray-300'
: 'border-dark-700/30 hover:border-dark-600 text-gray-300'
}`}
>
{theme.label}
@@ -165,8 +165,8 @@ export default function SettingsView() {
<SettingSectionTitle>语言</SettingSectionTitle>
<select
value={settings.language}
onChange={(e) => setSetting('language', e.target.value as any)}
className="w-full mt-3 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
onChange={(e) => setSetting('language', e.target.value as 'zh-CN' | 'en-US')}
className="w-full mt-3 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
>
<option value="zh-CN">简体中文</option>
<option value="en-US">English</option>
@@ -187,7 +187,7 @@ export default function SettingsView() {
<select
value={settings.fontFamily}
onChange={(e) => setSetting('fontFamily', e.target.value)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
>
<option>JetBrains Mono</option>
<option>Fira Code</option>
@@ -200,7 +200,7 @@ export default function SettingsView() {
<select
value={settings.fontSize}
onChange={(e) => setSetting('fontSize', parseInt(e.target.value))}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
>
{[12, 13, 14, 15, 16, 18, 20].map((size) => (
<option key={size} value={size}>{size}px</option>
@@ -216,11 +216,11 @@ export default function SettingsView() {
{Object.entries(themes).map(([key, theme]) => (
<button
key={key}
onClick={() => setSetting('terminalTheme', key as any)}
onClick={() => setSetting('terminalTheme', key as 'dracula' | 'monokai' | 'solarized' | 'one-dark')}
className={`p-3 rounded-lg border text-sm transition-colors ${
settings.terminalTheme === key
? 'border-accent-blue bg-accent-blue/20 text-accent-blue'
: 'border-dark-700 hover:border-dark-600 text-gray-300'
: 'border-dark-700/30 hover:border-dark-600 text-gray-300'
}`}
>
<div
@@ -240,7 +240,7 @@ export default function SettingsView() {
return (
<div className="space-y-5">
<SettingCard>
<div className="overflow-hidden rounded-lg border border-dark-700">
<div className="overflow-hidden rounded-lg border border-dark-700/30">
<table className="w-full text-sm">
<thead>
<tr className="bg-dark-800/60 text-gray-400 text-left">
@@ -253,7 +253,7 @@ export default function SettingsView() {
{SHORTCUTS.map((s, i) => (
<tr
key={i}
className="border-t border-dark-700/60 hover:bg-dark-800/40 transition-colors"
className="border-t border-dark-700/20 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">
@@ -293,7 +293,7 @@ export default function SettingsView() {
type="number"
value={settings.defaultPort}
onChange={(e) => setSetting('defaultPort', parseInt(e.target.value) || 22)}
className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
/>
</SettingCard>
@@ -303,7 +303,7 @@ export default function SettingsView() {
type="number"
value={settings.timeout}
onChange={(e) => setSetting('timeout', parseInt(e.target.value) || 30)}
className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
/>
</SettingCard>
@@ -325,7 +325,7 @@ export default function SettingsView() {
max={50}
value={settings.maxConnections || 10}
onChange={(e) => setSetting('maxConnections', parseInt(e.target.value) || 10)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
/>
<div className="text-xs text-gray-600 mt-1">最大并发 SSH 连接数</div>
</div>
@@ -337,7 +337,7 @@ export default function SettingsView() {
max={20}
value={settings.maxBackgroundConnections || 5}
onChange={(e) => setSetting('maxBackgroundConnections', parseInt(e.target.value) || 5)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
/>
<div className="text-xs text-gray-600 mt-1">非活跃标签保持连接数</div>
</div>
@@ -352,7 +352,7 @@ export default function SettingsView() {
max={120}
value={Math.floor((settings.idleTimeoutMs || 5 * 60 * 1000) / 60000)}
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
/>
<div className="text-xs text-gray-600 mt-1">无操作后进入挂起</div>
</div>
@@ -364,7 +364,7 @@ export default function SettingsView() {
max={240}
value={Math.floor((settings.suspendTimeoutMs || 30 * 60 * 1000) / 60000)}
onChange={(e) => setSetting('suspendTimeoutMs', (parseInt(e.target.value) || 30) * 60000)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
/>
<div className="text-xs text-gray-600 mt-1">挂起后自动断开</div>
</div>
@@ -442,7 +442,7 @@ export default function SettingsView() {
['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">
<div key={name} className="flex justify-between py-1 border-b border-dark-700/20">
<span className="text-gray-400">{name}</span>
<span className="text-gray-500 font-mono">{ver}</span>
</div>
@@ -475,9 +475,9 @@ export default function SettingsView() {
return (
<div className="h-full flex bg-dark-900">
{/* ── 左侧导航 ── */}
<div className="w-56 flex-shrink-0 bg-dark-800/50 border-r border-dark-700 flex flex-col">
<div className="w-56 flex-shrink-0 bg-dark-800 flex flex-col">
{/* 搜索框 */}
<div className="p-3 border-b border-dark-700">
<div className="p-3 border-b border-dark-700/20">
<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
@@ -485,7 +485,7 @@ export default function SettingsView() {
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"
className="w-full pl-8 pr-8 py-1.5 text-sm bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 placeholder-gray-600 focus:outline-none focus:border-accent-blue/60"
/>
{searchQuery && (
<button
@@ -507,16 +507,16 @@ export default function SettingsView() {
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all ${
active
? 'bg-accent-blue/15 text-white'
: 'text-gray-400 hover:bg-dark-700/60 hover:text-gray-200'
? 'bg-accent-blue text-white shadow-sm'
: 'text-gray-400 hover:bg-dark-700/50 hover:text-gray-200'
}`}
>
<Icon className={`w-4 h-4 flex-shrink-0 ${active ? 'text-accent-blue' : ''}`} />
<Icon className={`w-4 h-4 flex-shrink-0 ${active ? 'text-white' : ''}`} />
<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'}`}>
<div className={`text-xs truncate ${active ? 'text-white/70' : 'text-gray-600'}`}>
{tab.description}
</div>
</div>
@@ -529,7 +529,7 @@ export default function SettingsView() {
{/* ── 右侧内容区 ── */}
<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">
<div className="flex-shrink-0 px-8 pt-6 pb-4 border-b border-dark-700/20">
<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>
@@ -558,7 +558,7 @@ function SettingCard({
className={`rounded-xl p-5 ${
highlight
? 'bg-dark-800/80 border border-accent-blue/20'
: 'bg-dark-800/50 border border-dark-700/50'
: 'bg-dark-800/50 border border-dark-700/30'
}`}
>
{children}
+12 -11
View File
@@ -22,7 +22,7 @@ interface SidebarProps {
}
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }: SidebarProps) {
const { sessions, setSessions, removeSession } = useSessionStore()
const { sessions, setSessions, addSession, removeSession } = useSessionStore()
const { settings, setSetting } = useSettingsStore()
const { addTab } = useTabStore()
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
@@ -40,7 +40,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
// Sync width from settings on load
useEffect(() => {
if (settings.sidebarWidth && settings.sidebarWidth !== sidebarWidth) {
if (settings.sidebarWidth) {
setSidebarWidth(settings.sidebarWidth)
}
}, [settings.sidebarWidth])
@@ -144,18 +144,19 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
case 'edit':
onEditSession(session)
break
case 'duplicate':
case 'duplicate': {
// Duplicate session
const { id: _id, ...sessionWithoutId } = session
window.electronAPI?.sessionCreate({
...session,
id: undefined,
...sessionWithoutId,
name: `${session.name} (副本)`,
}).then((result) => {
if (result?.success && result.session) {
setSessions([...sessions, result.session])
addSession(result.session as Session)
}
})
break
}
case 'delete': {
const confirmed = await confirm({
title: '确认删除',
@@ -177,12 +178,12 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
<>
<div
ref={sidebarRef}
className="relative flex flex-shrink-0 bg-dark-800 border-r border-dark-700"
className="relative flex flex-col h-full bg-dark-800"
style={{ width: sidebarWidth }}
>
<div className="flex flex-col flex-1 min-w-0">
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-dark-700">
<div className="p-4 border-b border-dark-700/20">
<div className="flex items-center gap-2 mb-4">
<div className="w-8 h-8 bg-accent-blue rounded-lg flex items-center justify-center">
<Server className="w-5 h-5 text-white" />
@@ -209,7 +210,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
</div>
{/* Search */}
<div className="p-3 border-b border-dark-700">
<div className="p-3 border-b border-dark-700/20">
<SearchBox
value={searchTerm}
onChange={setSearchTerm}
@@ -231,7 +232,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
/>
{/* Footer */}
<div className="p-3 border-t border-dark-700">
<div className="p-3 border-t border-dark-700/20">
<button
onClick={() => addTab({ title: '设置', type: 'settings' })}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:bg-dark-700 rounded transition-colors"
+1 -1
View File
@@ -163,7 +163,7 @@ export default function StatusBar() {
: null
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 flex items-center px-3 text-xs text-white select-none">
{/* Left: Connection status area — clickable to toggle ConnectionPanel */}
<button
type="button"
+5 -4
View File
@@ -3,13 +3,13 @@
*/
import { Plus } from 'lucide-react'
import { useState, useCallback, useRef } from 'react'
import React, { useState, useCallback, useRef } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useConnectionPool } from '../stores/connection'
import { TabItem } from './tabbar/TabItem'
import { TabContextMenu } from './tabbar/TabContextMenu'
export default function TabBar() {
function TabBar() {
const {
tabs,
activeTabId,
@@ -58,7 +58,6 @@ export default function TabBar() {
setDragFromIndex(index)
e.dataTransfer.effectAllowed = 'move'
// Set a transparent drag image so we rely on the ghost
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (e.currentTarget instanceof HTMLDivElement) {
dragNodeRef.current = e.currentTarget
}
@@ -112,7 +111,7 @@ export default function TabBar() {
const currentTab = menuState.tabId ? tabs.find(t => t.id === menuState.tabId) : null
return (
<div className="flex items-center bg-dark-900 border-b border-dark-700 overflow-x-auto">
<div className="flex items-center bg-dark-800 overflow-x-auto">
{/* Tab List */}
{tabs.map((tab, index) => (
<TabItem
@@ -167,3 +166,5 @@ export default function TabBar() {
</div>
)
}
export default React.memo(TabBar)
+6 -3
View File
@@ -1,3 +1,4 @@
import React from 'react'
import { useTabStore } from '../stores/tabStore'
import WelcomeView from './WelcomeView'
import TerminalView from './TerminalView'
@@ -11,8 +12,8 @@ interface TabContentProps {
onQuickConnect?: () => void
}
export default function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentProps) {
const { tabs } = useTabStore()
function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentProps) {
const tabs = useTabStore((state) => state.tabs)
return (
<div className="relative w-full h-full">
@@ -27,7 +28,7 @@ export default function TabContent({ activeTabId, onNewSession, onQuickConnect }
content = <TerminalView tabId={tab.id} isActive={isActive} />
break
case 'sftp':
content = <SFTPView tabId={tab.id} isActive={isActive} />
content = <SFTPView tabId={tab.id} isActive={isActive} sessionId={tab.sessionId} />
break
case 'portforward':
content = <PortForwardView tabId={tab.id} isActive={isActive} />
@@ -54,3 +55,5 @@ export default function TabContent({ activeTabId, onNewSession, onQuickConnect }
</div>
)
}
export default React.memo(TabContent)
+9 -3
View File
@@ -182,7 +182,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
// 只注册一次监听器(防止严格模式下重复注册)
if (!listenerRegisteredRef.current) {
const handler = (event: any, connectionId: string, shellId: string, data: Buffer) => {
const handler = (event: unknown, connectionId: string, shellId: string, data: Buffer) => {
handleShellDataRef.current?.(event, connectionId, shellId, data)
}
window.electronAPI?.onShellData(handler)
@@ -192,6 +192,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
return () => {
window.removeEventListener('resize', handleResize)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // 只在挂载时运行
// 初始连接
@@ -210,6 +211,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
connect()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // 只在挂载时执行
// 处理活跃状态变化
@@ -234,15 +236,19 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
saveSnapshot()
}
}
}, [isActive, tabId, saveSnapshot, setShowSuspendedOverlay])
}, [isActive, tabId, saveSnapshot, setShowSuspendedOverlay, connectionIdRef, hasConnectedRef])
// 组件卸载时清理监听器
useEffect(() => {
return () => {
isConnectingRef.current = false
listenerRegisteredRef.current = false
window.electronAPI?.removeAllListeners('ssh:shellData')
// 精确移除当前组件注册的监听器,避免影响其他终端
if (handleShellDataRef.current) {
window.electronAPI?.removeListener('ssh:shellData', handleShellDataRef.current as (...args: unknown[]) => void)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const currentTab = getTabById(tabId)
+9 -22
View File
@@ -1,4 +1,4 @@
import { Terminal, FolderSync, ArrowRightLeft, Globe, Settings, Plus, Zap, Clock } from 'lucide-react'
import { Terminal, FolderSync, ArrowRightLeft, Globe, Plus, Zap, Clock } from 'lucide-react'
import { useSessionStore } from '../stores'
import { useTabStore } from '../stores/tabStore'
import EmptyState from './common/EmptyState'
@@ -24,17 +24,17 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
}
return (
<div className="h-full flex flex-col items-center justify-center bg-dark-900 p-8">
<div className="text-center mb-12">
<div className="h-full flex flex-col items-center justify-start bg-dark-900 p-8 overflow-y-auto">
<div className="text-center mb-12 pt-8">
<div className="w-20 h-20 bg-dark-700 rounded-2xl flex items-center justify-center mx-auto shadow-lg">
<Terminal className="w-10 h-10 text-white" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 w-full max-w-2xl mb-12">
<div className="grid grid-cols-2 gap-4 w-full max-w-2xl mb-12">
<button
onClick={onQuickConnect}
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-blue transition-all group"
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700/30 hover:border-accent-blue transition-all group"
>
<div className="w-12 h-12 bg-accent-blue/20 rounded-lg flex items-center justify-center group-hover:bg-accent-blue/30 transition-colors">
<Zap className="w-6 h-6 text-accent-blue" />
@@ -47,7 +47,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
<button
onClick={onNewSession}
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-green transition-all group"
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700/30 hover:border-accent-green transition-all group"
>
<div className="w-12 h-12 bg-accent-green/20 rounded-lg flex items-center justify-center group-hover:bg-accent-green/30 transition-colors">
<Plus className="w-6 h-6 text-white" />
@@ -60,7 +60,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
<button
onClick={() => addTab({ title: 'SFTP', type: 'sftp' })}
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-purple transition-all group"
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700/30 hover:border-accent-purple transition-all group"
>
<div className="w-12 h-12 bg-accent-purple/20 rounded-lg flex items-center justify-center group-hover:bg-accent-purple/30 transition-colors">
<FolderSync className="w-6 h-6 text-accent-purple" />
@@ -73,7 +73,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
<button
onClick={() => addTab({ title: '端口转发', type: 'portforward' })}
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-accent-orange transition-all group"
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700/30 hover:border-accent-orange transition-all group"
>
<div className="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center group-hover:bg-orange-500/30 transition-colors">
<ArrowRightLeft className="w-6 h-6 text-orange-400" />
@@ -83,19 +83,6 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
<div className="text-sm text-gray-500">本地/远程/SOCKS5 代理</div>
</div>
</button>
<button
onClick={() => addTab({ title: '设置', type: 'settings' })}
className="flex items-center gap-4 p-6 bg-dark-800 hover:bg-dark-700 rounded-xl border border-dark-700 hover:border-gray-500 transition-all group"
>
<div className="w-12 h-12 bg-gray-700/50 rounded-lg flex items-center justify-center group-hover:bg-gray-700 transition-colors">
<Settings className="w-6 h-6 text-gray-400" />
</div>
<div className="text-left">
<div className="font-semibold text-gray-200">设置</div>
<div className="text-sm text-gray-500">配置应用偏好</div>
</div>
</button>
</div>
{/* Recent connections */}
@@ -113,7 +100,7 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
<button
key={session.id}
onClick={() => handleSessionClick(session)}
className="w-full flex items-center justify-between p-4 bg-dark-800 hover:bg-dark-700 rounded-lg border border-dark-700 hover:border-accent-blue/50 transition-colors"
className="w-full flex items-center justify-between p-4 bg-dark-800 hover:bg-dark-700 rounded-lg border border-dark-700/30 hover:border-accent-blue/50 transition-colors"
>
<div className="flex items-center gap-3">
<Globe className="w-5 h-5 text-accent-blue" />
+54
View File
@@ -0,0 +1,54 @@
import { handleError } from '../../utils/error-handler'
interface DefaultFallbackProps {
error: Error
onReset: () => void
}
export default function DefaultFallback({ error, onReset }: DefaultFallbackProps) {
const appError = handleError(error)
return (
<div className="min-h-screen flex items-center justify-center bg-dark-900 p-4">
<div className="max-w-md w-full bg-dark-800 rounded-xl p-6 border border-dark-700">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-gray-200">出错了</h2>
<p className="text-sm text-gray-500">应用遇到意外问题</p>
</div>
</div>
<div className="bg-dark-900 rounded-lg p-4 mb-4">
<p className="text-sm text-gray-300 mb-2">{appError.message}</p>
{appError.recoverable && (
<p className="text-xs text-green-400">此错误可以恢复,请尝试刷新页面</p>
)}
</div>
<div className="flex gap-3">
<button
onClick={onReset}
className="flex-1 px-4 py-2 bg-accent-blue hover:bg-blue-600 text-white rounded-lg transition-colors text-sm font-medium"
>
刷新页面
</button>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-gray-300 rounded-lg transition-colors text-sm"
>
重启应用
</button>
</div>
<p className="mt-4 text-xs text-gray-600 text-center">
如果问题持续存在,请检查日志或联系支持
</p>
</div>
</div>
)
}
+1 -52
View File
@@ -7,7 +7,7 @@
import { Component, type ReactNode } from 'react'
import { createLogger } from '../../utils/logger'
import { handleError } from '../../utils/error-handler'
import DefaultFallback from './DefaultFallback'
const logger = createLogger('ErrorBoundary')
@@ -22,57 +22,6 @@ interface State {
error: Error | null
}
/**
* 默认错误_fallback UI
*/
function DefaultFallback({ error, onReset }: { error: Error; onReset: () => void }) {
const appError = handleError(error)
return (
<div className="min-h-screen flex items-center justify-center bg-dark-900 p-4">
<div className="max-w-md w-full bg-dark-800 rounded-xl p-6 border border-dark-700">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-gray-200">出错了</h2>
<p className="text-sm text-gray-500">应用遇到意外问题</p>
</div>
</div>
<div className="bg-dark-900 rounded-lg p-4 mb-4">
<p className="text-sm text-gray-300 mb-2">{appError.message}</p>
{appError.recoverable && (
<p className="text-xs text-green-400">此错误可以恢复,请尝试刷新页面</p>
)}
</div>
<div className="flex gap-3">
<button
onClick={onReset}
className="flex-1 px-4 py-2 bg-accent-blue hover:bg-blue-600 text-white rounded-lg transition-colors text-sm font-medium"
>
刷新页面
</button>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-gray-300 rounded-lg transition-colors text-sm"
>
重启应用
</button>
</div>
<p className="mt-4 text-xs text-gray-600 text-center">
如果问题持续存在,请检查日志或联系支持
</p>
</div>
</div>
)
}
/**
* 错误边界组件
*
+1 -1
View File
@@ -202,7 +202,7 @@ export function SessionTreeSkeleton({
{Array.from({ length: groups }).map((_, gIdx) => {
const count = sessionsPerGroup[0] + (gIdx % (sessionsPerGroup[1] - sessionsPerGroup[0] + 1))
return (
<div key={gIdx} className="border-b border-dark-700 last:border-b-0">
<div key={gIdx} className="border-b border-dark-700/30 last:border-b-0">
{/* 分组头 */}
<div className="flex items-center gap-2 px-4 py-2">
<Skeleton width={14} height={14} className="rounded-sm" />
+7 -7
View File
@@ -67,19 +67,19 @@ function ToastItemView({ toast, onClose }: ToastItemProps) {
}, [])
// 自动关闭
const handleClose = useCallback(() => {
setVisible(false)
// 等待退场动画结束后再移除
setTimeout(() => onClose(toast.id), 200)
}, [onClose, toast.id])
useEffect(() => {
if (toast.duration <= 0) return
const timer = setTimeout(() => {
handleClose()
}, toast.duration)
return () => clearTimeout(timer)
}, [toast.duration])
const handleClose = useCallback(() => {
setVisible(false)
// 等待退场动画结束后再移除
setTimeout(() => onClose(toast.id), 200)
}, [onClose, toast.id])
}, [toast.duration, handleClose])
return (
<div
+1 -1
View File
@@ -138,7 +138,7 @@ export default function ChmodDialog({
>
<div className="space-y-4 min-w-[360px]">
{/* File info */}
<div className="flex items-center gap-3 pb-3 border-b border-dark-600">
<div className="flex items-center gap-3 pb-3 border-b border-dark-600/30">
<Shield className="w-5 h-5 text-accent-blue" />
<div className="min-w-0 flex-1">
<p className="text-white font-medium truncate">{fileName}</p>
+1 -1
View File
@@ -70,7 +70,7 @@ export function ConnectionPanel({
// Disconnected State
return (
<>
<div className="flex items-center justify-between px-4 py-2 bg-dark-800 border-b border-dark-700">
<div className="flex items-center justify-between px-4 py-2 bg-dark-800 border-b border-dark-700/30">
<span className="text-sm text-gray-400">未连接</span>
<button
onClick={onShowSelector}
+1 -1
View File
@@ -15,7 +15,7 @@ export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSe
return (
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-40">
<div className="bg-dark-800 rounded-xl border border-dark-700 shadow-2xl w-full max-w-md overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700">
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700/30">
<h3 className="text-lg font-medium text-white">选择服务器</h3>
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded">
<X className="w-5 h-5 text-gray-400" />
+1 -1
View File
@@ -48,7 +48,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
{items.map((item, index) => (
<div key={index}>
{item.separator && index > 0 && (
<div className="my-1 border-t border-dark-600" />
<div className="my-1 border-t border-dark-600/30" />
)}
<button
onClick={() => {
+4 -4
View File
@@ -121,7 +121,7 @@ export function FileListPanel({
onDrop={handleDrop}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-dark-700 border-b border-dark-600">
<div className="flex items-center justify-between px-4 py-2 bg-dark-700 border-b border-dark-600/30">
<div className="flex items-center gap-2">
{isLocal ? <HardDrive className="w-4 h-4 text-accent-blue" /> : <Folder className="w-4 h-4 text-accent-green" />}
<span className="text-sm font-medium">{title}</span>
@@ -151,12 +151,12 @@ export function FileListPanel({
</div>
{/* Breadcrumb */}
<div className="px-4 py-2 border-b border-dark-700">
<div className="px-4 py-2 border-b border-dark-700/20">
<Breadcrumb path={path} onNavigate={onNavigate} onHome={() => onNavigate(isLocal ? '/' : '/')} />
</div>
{/* Column Headers */}
<div className="flex items-center px-4 py-2 border-b border-dark-700 text-xs text-gray-400">
<div className="flex items-center px-4 py-2 border-b border-dark-700/20 text-xs text-gray-400">
<SortHeader sortField={sortField} sortOrder={sortOrder} onSort={onSort} />
</div>
@@ -195,7 +195,7 @@ export function FileListPanel({
</div>
{/* Status Bar */}
<div className="px-4 py-2 border-t border-dark-700 text-xs text-gray-400">
<div className="px-4 py-2 border-t border-dark-700/20 text-xs text-gray-400">
{loading ? '加载中...' : selected.size > 0 ? `已选择 ${selected.size} 项` : `共 ${files.length} 项`}
</div>
+1 -1
View File
@@ -123,7 +123,7 @@ export default function FilePropertiesDialog({
>
<div className="space-y-4 min-w-[320px]">
{/* File icon + name header */}
<div className="flex items-center gap-3 pb-3 border-b border-dark-600">
<div className="flex items-center gap-3 pb-3 border-b border-dark-600/30">
<div className="p-2 bg-dark-700 rounded-lg">
<TypeIcon className="w-8 h-8 text-accent-blue" />
</div>
+1 -1
View File
@@ -23,7 +23,7 @@ export function FileRow({ file, isSelected, onSelect, onOpen, onContextMenu, onD
onContextMenu={onContextMenu}
draggable={file.type === 'file'}
onDragStart={onDragStart}
className={`h-10 flex items-center px-4 cursor-pointer border-b border-dark-700/50 transition-colors group ${
className={`h-10 flex items-center px-4 cursor-pointer border-b border-dark-700/20 transition-colors group ${
isSelected ? 'bg-accent-blue/20 hover:bg-accent-blue/25' : 'hover:bg-dark-700/50'
}`}
>
+1 -1
View File
@@ -20,7 +20,7 @@ export function SortHeader({ onSort, sortField, sortOrder }: SortHeaderProps) {
]
return (
<div className="h-9 flex items-center px-4 bg-dark-700/50 text-xs text-gray-500 border-b border-dark-700 select-none">
<div className="h-9 flex items-center px-4 bg-dark-700/50 text-xs text-gray-500 border-b border-dark-700/30 select-none">
{fields.map(({ key, label, width, align }) => (
<button
key={key}
+1 -1
View File
@@ -16,7 +16,7 @@ interface ToolbarProps {
export function Toolbar({ isConnected, onUpload, onDownload, onRefresh, onNewFolder }: ToolbarProps) {
return (
<div className="flex items-center justify-between px-4 py-2 bg-dark-800 border-b border-dark-700">
<div className="flex items-center justify-between px-4 py-2 bg-dark-800 border-b border-dark-700/30">
<div className="flex items-center gap-2">
<button
onClick={onUpload}
+2 -2
View File
@@ -21,7 +21,7 @@ export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: T
const completedCount = tasks.filter(t => t.status === 'completed').length
return (
<div className="bg-dark-800 border-t border-dark-700">
<div className="bg-dark-800 border-t border-dark-700/30">
{/* Header */}
<button
onClick={onToggle}
@@ -48,7 +48,7 @@ export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: T
{/* Transfer List */}
{isOpen && tasks.length > 0 && (
<div className="max-h-48 overflow-y-auto border-t border-dark-700">
<div className="max-h-48 overflow-y-auto border-t border-dark-700/30">
{tasks.map((task) => (
<TransferItem
key={task.id}
+1 -1
View File
@@ -62,7 +62,7 @@ export function SessionTree({
{Object.entries(grouped).map(([group, groupSessions]) => {
const isExpanded = expandedGroups.has(group)
return (
<div key={group} className="border-b border-dark-700 last:border-b-0">
<div key={group} className="border-b border-dark-700/20 last:border-b-0">
{/* Group Header */}
<button
onClick={() => onToggleGroup(group)}
+3 -3
View File
@@ -201,13 +201,13 @@ export default function SnippetPanel() {
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">
<div className="flex items-center gap-2 px-4 py-3 border-b border-dark-700/30">
<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="p-3 border-b border-dark-700/30">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
@@ -260,7 +260,7 @@ export default function SnippetPanel() {
</div>
{/* 底部新建按钮 */}
<div className="p-3 border-t border-dark-700">
<div className="p-3 border-t border-dark-700/30">
<Button
variant="primary"
size="sm"
+3 -3
View File
@@ -60,11 +60,11 @@ export function TabItem({
onDragEnd={onDragEnd}
className={`
group flex items-center gap-2 px-3 py-2 min-w-[120px] max-w-[200px]
border-r border-dark-700 cursor-pointer select-none
border-r border-dark-700/20 cursor-pointer select-none
transition-all duration-150 relative
${isActive
? 'bg-dark-800 text-gray-200'
: 'bg-dark-900 text-gray-500 hover:bg-dark-800 hover:text-gray-400'
? 'bg-dark-900 text-gray-200'
: 'bg-dark-800 text-gray-500 hover:bg-dark-700 hover:text-gray-400'
}
${isDragging ? 'opacity-50' : 'opacity-100'}
`}
+4 -1
View File
@@ -1,3 +1,4 @@
import { memo } from 'react'
import { Terminal as TerminalIcon } from 'lucide-react'
import type { ConnectionState } from '../../stores/connection'
@@ -10,7 +11,7 @@ export interface TerminalOverlayProps {
onResume: () => void
}
export default function TerminalOverlay({
function TerminalOverlay({
connectionState,
statusMessage,
showSuspendedOverlay,
@@ -96,3 +97,5 @@ export default function TerminalOverlay({
</>
)
}
export default memo(TerminalOverlay)
@@ -1,4 +1,4 @@
import React from 'react'
import React, { memo } from 'react'
import { Search, ChevronUp, ChevronDown, X } from 'lucide-react'
export interface TerminalSearchBarProps {
@@ -12,7 +12,7 @@ export interface TerminalSearchBarProps {
onClose: () => void
}
export default function TerminalSearchBar({
function TerminalSearchBar({
searchQuery,
searchResultCount,
searchResultIndex,
@@ -72,3 +72,5 @@ export default function TerminalSearchBar({
</div>
)
}
export default memo(TerminalSearchBar)
+40 -86
View File
@@ -1,93 +1,43 @@
// Type declarations for Electron IPC API exposed via preload
interface SSHConfig {
host: string
port?: number
username: string
password?: string
privateKey?: string
passphrase?: string
agent?: string
readyTimeout?: number
keepaliveInterval?: number
}
interface Session {
id: string
name: string
host: string
port: number
username: string
authType: 'password' | 'key' | 'agent'
password?: string
privateKey?: string
passphrase?: string
group?: string
tags?: string[]
}
interface IPCResponse<T = any> {
success: boolean
error?: string
connectionId?: string
shellId?: string
sftpId?: string
sessions?: Session[]
session?: Session
files?: any[]
result?: any
value?: any
path?: string
boundPort?: number
}
// All types imported from shared/types for consistency
interface ElectronAPI {
// SSH
sshConnect: (config: SSHConfig) => Promise<IPCResponse>
sshDisconnect: (connectionId: string) => Promise<IPCResponse>
sshExec: (connectionId: string, command: string) => Promise<IPCResponse>
sshCreateShell: (connectionId: string, options?: any, shellId?: string) => Promise<IPCResponse>
sshShellWrite: (shellId: string, data: string) => Promise<IPCResponse>
sshShellResize: (shellId: string, cols: number, rows: number) => Promise<IPCResponse>
sshForwardLocal: (connectionId: string, localPort: number, remoteHost: string, remotePort: number) => Promise<IPCResponse>
sshForwardRemote: (connectionId: string, remotePort: number, localHost: string, localPort: number) => Promise<IPCResponse>
sshForwardDynamic: (connectionId: string, localPort: number) => Promise<IPCResponse>
sshConnect: (config: import('../shared/types/ipc.types').SSHConnectRequest) => Promise<import('../shared/types/ipc.types').SSHConnectResponse>
sshDisconnect: (connectionId: string) => Promise<{ success: boolean; error?: string }>
sshExec: (connectionId: string, command: string) => Promise<{ success: boolean; result?: string; error?: string }>
sshCreateShell: (connectionId: string, options?: import('../shared/types/ipc.types').SSHCreateShellRequest, shellId?: string) => Promise<{ success: boolean; shellId?: string; error?: string }>
sshShellWrite: (shellId: string, data: string | Uint8Array) => Promise<{ success: boolean; error?: string }>
sshShellResize: (shellId: string, cols: number, rows: number) => Promise<{ success: boolean; error?: string }>
sshForwardLocal: (connectionId: string, localPort: number, remoteHost: string, remotePort: number) => Promise<import('../shared/types/ipc.types').SSHForwardResponse>
sshForwardRemote: (connectionId: string, remotePort: number, localHost: string, localPort: number) => Promise<import('../shared/types/ipc.types').SSHForwardResponse>
sshForwardDynamic: (connectionId: string, localPort: number) => Promise<import('../shared/types/ipc.types').SSHForwardResponse>
// Sessions
sessionGetAll: () => Promise<IPCResponse>
sessionGetById: (id: string) => Promise<IPCResponse>
sessionCreate: (session: Partial<Session>) => Promise<IPCResponse>
sessionUpdate: (id: string, updates: Partial<Session>) => Promise<IPCResponse>
sessionDelete: (id: string) => Promise<IPCResponse>
sessionGetAll: () => Promise<{ success: boolean; sessions?: import('../shared/types/session.types').Session[]; error?: string }>
sessionGetById: (id: string) => Promise<{ success: boolean; session?: import('../shared/types/session.types').Session; error?: string }>
sessionCreate: (session: Omit<import('../shared/types/session.types').Session, 'id'>) => Promise<import('../shared/types/ipc.types').SessionCreateResponse>
sessionUpdate: (id: string, updates: Partial<import('../shared/types/session.types').Session>) => Promise<import('../shared/types/ipc.types').SessionUpdateResponse>
sessionDelete: (id: string) => Promise<{ success: boolean; error?: string }>
// SFTP
sftpConnect: (connectionId: string) => Promise<IPCResponse>
sftpListDirectory: (sftpId: string, path: string) => Promise<IPCResponse>
sftpUpload: (sftpId: string, localPath: string, remotePath: string) => Promise<IPCResponse>
sftpDownload: (sftpId: string, remotePath: string, localPath: string) => Promise<IPCResponse>
sftpRename: (sftpId: string, oldPath: string, newPath: string) => Promise<IPCResponse>
sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => Promise<IPCResponse>
sftpMkdir: (sftpId: string, path: string) => Promise<IPCResponse>
sftpStat: (sftpId: string, filePath: string) => Promise<IPCResponse & {
stats?: {
size: number
mode: number
mtime: number
atime: number
isDirectory: boolean
isFile: boolean
isSymbolicLink: boolean
}
}>
sftpChmod: (sftpId: string, filePath: string, mode: number) => Promise<IPCResponse>
sftpConnect: (connectionId: string) => Promise<{ success: boolean; sftpId?: string; error?: string }>
sftpListDirectory: (sftpId: string, path: string) => Promise<{ success: boolean; files?: unknown[]; error?: string }>
sftpUpload: (sftpId: string, localPath: string, remotePath: string) => Promise<{ success: boolean; error?: string }>
sftpDownload: (sftpId: string, remotePath: string, localPath: string) => Promise<{ success: boolean; error?: string }>
sftpRename: (sftpId: string, oldPath: string, newPath: string) => Promise<{ success: boolean; error?: string }>
sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => Promise<{ success: boolean; error?: string }>
sftpMkdir: (sftpId: string, path: string) => Promise<{ success: boolean; error?: string }>
sftpStat: (sftpId: string, filePath: string) => Promise<{ success: boolean; stats?: unknown; error?: string }>
sftpChmod: (sftpId: string, filePath: string, mode: number) => Promise<{ success: boolean; error?: string }>
// Settings
settingsGet: (key: string) => Promise<IPCResponse>
settingsSet: (key: string, value: any) => Promise<IPCResponse>
settingsGet: (key: string) => Promise<{ success: boolean; value?: unknown; error?: string }>
settingsSet: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>
// Dialogs
showSaveDialog: (options: any) => Promise<{ canceled: boolean; filePath?: string }>
showOpenDialog: (options: any) => Promise<{ canceled: boolean; filePaths?: string[] }>
showSaveDialog: (options: import('../shared/types/ipc.types').DialogOptions) => Promise<import('../shared/types/ipc.types').DialogResult>
showOpenDialog: (options: import('../shared/types/ipc.types').DialogOptions) => Promise<import('../shared/types/ipc.types').DialogResult>
// File operations
readFile: (filePath: string) => Promise<{ success: boolean; data?: string; error?: string }>
@@ -96,23 +46,27 @@ interface ElectronAPI {
// Local filesystem
getHomePath: () => Promise<{ success: boolean; path?: string; error?: string }>
getDownloadsPath: () => Promise<string>
localReadDir: (dirPath: string) => Promise<IPCResponse>
localGetStats: (filePath: string) => Promise<IPCResponse>
localDelete: (filePath: string) => Promise<IPCResponse>
localRename: (oldPath: string, newPath: string) => Promise<IPCResponse>
localMkdir: (dirPath: string) => Promise<IPCResponse>
localReadDir: (dirPath: string) => Promise<unknown>
localGetStats: (filePath: string) => Promise<unknown>
localDelete: (filePath: string) => Promise<unknown>
localRename: (oldPath: string, newPath: string) => Promise<unknown>
localMkdir: (dirPath: string) => Promise<unknown>
// App
getVersion: () => Promise<string>
// Window
toggleMaximize: () => Promise<void>
// Events
onMenuNewSession: (callback: () => void) => void
onMenuQuickConnect: (callback: () => void) => void
onMenuImportSessions: (callback: () => void) => void
onMenuExportSessions: (callback: () => void) => void
onShellData: (callback: (event: any, connectionId: string, shellId: string, data: Buffer) => void) => void
onSFTPProgress: (callback: (event: any, sftpId: string, progress: any) => void) => void
removeListener: (channel: string, callback: (...args: any[]) => void) => void
onMenuOpenSettings: (callback: () => void) => void
onShellData: (callback: (event: Electron.IpcRendererEvent, connectionId: string, shellId: string, data: Buffer) => void) => void
onSFTPProgress: (callback: (event: Electron.IpcRendererEvent, sftpId: string, progress: { transferred: number; total: number; percentage: number }) => void) => void
removeListener: (channel: string, callback: (...args: unknown[]) => void) => void
removeAllListeners: (channel: string) => void
}
+1 -1
View File
@@ -89,7 +89,7 @@ export function useKeyboardShortcuts(): void {
e.preventDefault()
// Electron 渲染进程通过 IPC 请求全屏切换
if (window.electronAPI?.settingsSet) {
window.electronAPI.settingsGet('fullscreen').then((res: any) => {
window.electronAPI.settingsGet('fullscreen').then((res: { success: boolean; value?: unknown }) => {
const isFullscreen = res?.value === true
window.electronAPI?.settingsSet('fullscreen', !isFullscreen)
}).catch(() => {
+56 -39
View File
@@ -4,7 +4,7 @@
* 从 SFTPView 提取状态管理和业务逻辑
*/
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useSessionStore } from '../stores'
import { useTabStore, type ConnectionInstance } from '../stores/tabStore'
import { useConnectionPool, getSessionConfig } from '../stores/connection'
@@ -13,6 +13,14 @@ import type { FileItem, TransferTask, SortField, SortOrder } from '../../shared/
const logger = createLogger('useSFTP')
// 本地目录条目类型
interface LocalDirEntry {
name: string
isDirectory: boolean
size?: number
mtime?: number
}
export interface UseSFTPOptions {
tabId: string
}
@@ -132,12 +140,13 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
} else {
logger.error('Failed to get home path')
}
}).catch((err: any) => {
logger.error(`getHomePath error: ${err?.message || err}`)
}).catch((err: unknown) => {
logger.error(`getHomePath error: ${err instanceof Error ? err.message : String(err)}`)
})
} else {
logger.warn('electronAPI not available')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Load local directory
@@ -150,11 +159,11 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
}
try {
const result = await window.electronAPI.localReadDir(path)
const result = await window.electronAPI.localReadDir(path) as { success: boolean; files?: LocalDirEntry[]; error?: string }
logger.debug('localReadDir result', result)
if (result.success && result.files) {
const files: FileItem[] = result.files.map((f: any) => ({
const files: FileItem[] = result.files.map((f: LocalDirEntry) => ({
name: f.name,
path: `${path}/${f.name}`,
type: f.isDirectory ? 'directory' : 'file',
@@ -167,8 +176,8 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
} else {
logger.error(`Failed to load local directory: ${result.error}`)
}
} catch (err: any) {
logger.error(`loadLocalDirectory error: ${err?.message}`, err)
} catch (err: unknown) {
logger.error(`loadLocalDirectory error: ${err instanceof Error ? err.message : String(err)}`, err)
}
}, [])
@@ -186,21 +195,24 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
logger.debug('sftpListDirectory result', result)
if (result.success && result.files) {
const files: FileItem[] = result.files.map((f: any) => ({
name: f.filename,
path: `${path}/${f.filename}`.replace(/\/+/g, '/'),
type: f.longname?.startsWith('d') ? 'directory' : 'file',
size: f.attrs?.size || 0,
modified: f.attrs?.mtime || Date.now() / 1000,
permissions: f.longname?.substring(0, 10) || '-rw-r--r--',
}))
const files: FileItem[] = result.files.map((f: unknown) => {
const entry = f as { filename?: string; longname?: string; attrs?: { size?: number; mtime?: number } }
return {
name: entry.filename || '',
path: `${path}/${entry.filename || ''}`.replace(/\/+/g, '/'),
type: entry.longname?.startsWith('d') ? 'directory' : 'file',
size: entry.attrs?.size || 0,
modified: entry.attrs?.mtime || Date.now() / 1000,
permissions: entry.longname?.substring(0, 10) || '-rw-r--r--',
}
})
setRemoteFiles(files)
logger.info(`Loaded ${files.length} remote files`)
} else {
logger.error(`Failed to load remote directory: ${result.error}`)
}
} catch (err: any) {
logger.error(`loadRemoteDirectory error: ${err?.message}`, err)
} catch (err: unknown) {
logger.error(`loadRemoteDirectory error: ${err instanceof Error ? err.message : String(err)}`, err)
setError('Failed to load remote directory')
}
}, [sftpId])
@@ -373,7 +385,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
setTransferQueue(prev => [...prev, ...newTasks])
// Set up progress listener scoped to this batch
const progressHandler = (_event: any, _sftpId: string, progress: { transferred: number; total: number; percentage: number }) => {
const progressHandler = (_event: unknown, _sftpId: string, progress: { transferred: number; total: number; percentage: number }) => {
// Update the first task that is currently transferring
setTransferQueue(prev => {
const idx = prev.findIndex(t => t.type === 'upload' && t.status === 'transferring')
@@ -387,7 +399,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
})
}
window.electronAPI.onSFTPProgress(progressHandler)
window.electronAPI.onSFTPProgress(progressHandler as (...args: unknown[]) => void)
try {
for (const task of newTasks) {
@@ -413,10 +425,10 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
))
logger.error(`Upload failed: ${task.fileName} - ${result.error}`)
}
} catch (err: any) {
} catch (err: unknown) {
logger.error('Upload failed', err)
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'failed', error: err?.message || 'Upload failed' } : t
t.id === task.id ? { ...t, status: 'failed', error: err instanceof Error ? err.message : 'Upload failed' } : t
))
}
}
@@ -424,7 +436,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
// Refresh remote directory after all uploads
loadRemoteDirectory(remotePath)
} finally {
window.electronAPI.removeListener?.('sftp:progress', progressHandler)
window.electronAPI.removeListener?.('sftp:progress', progressHandler as (...args: unknown[]) => void)
}
}, [sftpId, localPath, remotePath, loadRemoteDirectory])
@@ -448,7 +460,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
setTransferQueue(prev => [...prev, ...newTasks])
// Set up progress listener
const progressHandler = (_event: any, _sftpId: string, progress: { transferred: number; total: number; percentage: number }) => {
const progressHandler = (_event: unknown, _sftpId: string, progress: { transferred: number; total: number; percentage: number }) => {
setTransferQueue(prev => {
const idx = prev.findIndex(t => t.type === 'download' && t.status === 'transferring')
if (idx === -1) return prev
@@ -461,7 +473,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
})
}
window.electronAPI.onSFTPProgress(progressHandler)
window.electronAPI.onSFTPProgress(progressHandler as (...args: unknown[]) => void)
try {
for (const task of newTasks) {
@@ -486,10 +498,10 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
))
logger.error(`Download failed: ${task.fileName} - ${result.error}`)
}
} catch (err: any) {
} catch (err: unknown) {
logger.error('Download failed', err)
setTransferQueue(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'failed', error: err?.message || 'Download failed' } : t
t.id === task.id ? { ...t, status: 'failed', error: err instanceof Error ? err.message : 'Download failed' } : t
))
}
}
@@ -497,7 +509,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
// Refresh local directory after all downloads
loadLocalDirectory(localPath)
} finally {
window.electronAPI.removeListener?.('sftp:progress', progressHandler)
window.electronAPI.removeListener?.('sftp:progress', progressHandler as (...args: unknown[]) => void)
}
}, [sftpId, remoteFiles, remoteSelected, localPath, loadLocalDirectory])
@@ -522,8 +534,8 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
logger.error(`Delete failed: ${file.name} - ${result.error}`)
allSuccess = false
}
} catch (err: any) {
logger.error(`Delete error: ${file.name} - ${err?.message}`, err)
} catch (err: unknown) {
logger.error(`Delete error: ${file.name} - ${err instanceof Error ? err.message : String(err)}`, err)
allSuccess = false
}
}
@@ -556,8 +568,8 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
logger.error(`Rename failed: ${result.error}`)
return false
}
} catch (err: any) {
logger.error(`Rename error: ${err?.message}`, err)
} catch (err: unknown) {
logger.error(`Rename error: ${err instanceof Error ? err.message : String(err)}`, err)
return false
}
}, [sftpId, remotePath, loadRemoteDirectory])
@@ -570,15 +582,15 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
}
try {
const result = await window.electronAPI.sftpStat(sftpId, filePath)
const result = await window.electronAPI.sftpStat(sftpId, filePath) as { success: boolean; stats?: { size: number; mode: number; mtime: number; atime: number; isDirectory: boolean; isFile: boolean; isSymbolicLink: boolean }; error?: string }
if (result.success && result.stats) {
return result.stats
} else {
logger.error(`Stat failed: ${result.error}`)
return null
}
} catch (err: any) {
logger.error(`Stat error: ${err?.message}`, err)
} catch (err: unknown) {
logger.error(`Stat error: ${err instanceof Error ? err.message : String(err)}`, err)
return null
}
}, [sftpId])
@@ -600,18 +612,23 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
logger.error(`Chmod failed: ${result.error}`)
return false
}
} catch (err: any) {
logger.error(`Chmod error: ${err?.message}`, err)
} catch (err: unknown) {
logger.error(`Chmod error: ${err instanceof Error ? err.message : String(err)}`, err)
return false
}
}, [sftpId, remotePath, loadRemoteDirectory])
// Cleanup on unmount
// Cleanup on unmount — use refs to avoid re-running when connectionId changes
const connectionIdRef = useRef(connectionId)
connectionIdRef.current = connectionId
useEffect(() => {
return () => {
disconnect()
const cid = connectionIdRef.current
if (cid && window.electronAPI) {
window.electronAPI.sshDisconnect(cid).catch(() => {})
}
}
}, [disconnect])
}, [])
return {
sessions,
+16 -12
View File
@@ -4,6 +4,7 @@ import { useTabStore, type ConnectionInstance } from '../stores/tabStore'
import { getSessionConfig, getConnectionStore, type ConnectionState } from '../stores/connection'
import { useTerminalSnapshotStore } from '../stores/terminalSnapshotStore'
import { createLogger } from '../utils/logger'
import type { SSHConnectionConfig } from '../../shared/types/session.types'
const logger = createLogger('useTerminalConnection')
@@ -121,14 +122,8 @@ export function useTerminalConnection({
try {
// 获取配置
let config: {
host: string
port: number
username: string
password?: string
privateKey?: string
passphrase?: string
} | null = null
let config: SSHConnectionConfig | null = null
let authType: string | undefined
if (currentTab.connectionConfig) {
config = {
@@ -139,14 +134,22 @@ export function useTerminalConnection({
privateKey: currentTab.connectionConfig.privateKey,
passphrase: currentTab.connectionConfig.passphrase,
}
authType = currentTab.connectionConfig.authType
} else if (currentTab.sessionId) {
config = await getSessionConfig(currentTab.sessionId)
authType = config?.authType
}
if (!config) {
throw new Error('无可用连接信息')
}
// 非密钥认证时,不传递 privateKey/passphrase,避免后端误解析
if (authType && authType !== 'key') {
delete config.privateKey
delete config.passphrase
}
// 创建连接
const result = await getConnectionStore().createConnection(tabId, config)
@@ -205,16 +208,17 @@ export function useTerminalConnection({
xtermRef.current?.focus()
} catch (error: any) {
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
setConnectionState('error')
updateTabStatus(tabId, 'error')
setStatusMessage(`错误: ${error.message}`)
xtermRef.current?.writeln(`\r\n\x1b[31m错误: ${error.message}\x1b[0m`)
setStatusMessage(`错误: ${errorMessage}`)
xtermRef.current?.writeln(`\r\n\x1b[31m错误: ${errorMessage}\x1b[0m`)
} finally {
isConnectingRef.current = false
hasConnectedRef.current = true
}
}, [tabId, updateTabConnectionInstance, updateTabStatus, getTabById, xtermRef])
}, [tabId, updateTabConnectionInstance, updateTabStatus, updateTab, getTabById, xtermRef])
// 恢复挂起的连接
const resumeConnection = useCallback(async () => {
+13 -12
View File
@@ -6,10 +6,9 @@ import { darkColors, lightColors, type AppThemeMode, type ColorMap } from '../th
* 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'
function resolveEffectiveTheme(_preference: AppThemeMode): 'dark' | 'light' {
// 当前版本固定使用 dark 主题,后续版本开放主题切换
return 'dark'
}
/**
@@ -32,26 +31,28 @@ function applyCSSVars(colors: ColorMap) {
*/
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)
if (effective === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
// 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])
document.body.style.backgroundColor = '#0f172a'
document.body.style.color = '#e2e8f0'
}, [effective])
// Listen for system colour-scheme changes when preference is 'system'
useEffect(() => {
@@ -64,8 +65,8 @@ export function useTheme() {
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)'
document.body.style.backgroundColor = '#0f172a'
document.body.style.color = '#e2e8f0'
}
mql.addEventListener('change', handler)
+32 -28
View File
@@ -17,16 +17,36 @@
--color-dark-700: #334155;
--color-dark-600: #475569;
/* Semantic tokens (dark default) */
--color-surface-base: #0f172a;
--color-surface-raised: #1e293b;
--color-surface-overlay: #334155;
--color-surface-inset: #475569;
--color-text-primary: #e2e8f0;
--color-text-secondary: #cbd5e1;
--color-text-muted: #94a3b8;
--color-text-faint: #64748b;
--color-text-dim: #475569;
--color-content-primary: #e2e8f0;
--color-content-secondary: #cbd5e1;
--color-content-tertiary: #94a3b8;
--color-content-inverse: #0f172a;
--color-border: #334155;
--color-border-strong: #475569;
--color-accent-blue: #3b82f6;
--color-accent-green: #10b981;
--color-accent-purple: #8b5cf6;
--color-accent-orange: #f97316;
--color-accent-red: #ef4444;
--color-feedback-success: #10b981;
--color-feedback-warning: #f59e0b;
--color-feedback-error: #ef4444;
--color-feedback-info: #3b82f6;
--color-scrollbar-track: #1e293b;
--color-scrollbar-thumb: #475569;
@@ -35,45 +55,20 @@
--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;
padding: 0;
box-sizing: border-box;
}
body {
html, body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--color-dark-900);
color: var(--color-text-primary);
background-color: #0f172a !important;
color: #e2e8f0;
overflow: hidden;
}
@@ -82,6 +77,15 @@ body {
height: 100vh;
}
/* Window chrome utilities */
.drag {
-webkit-app-region: drag;
}
.no-drag {
-webkit-app-region: no-drag;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
+2 -2
View File
@@ -201,10 +201,10 @@ export const usePoolStore = create<PoolState>((set, get) => ({
})
return { success: true, connectionId }
} catch (error: any) {
} catch (error: unknown) {
return {
success: false,
error: error.message || 'Unknown error creating connection',
error: error instanceof Error ? error.message : 'Unknown error creating connection',
}
}
},
+1
View File
@@ -26,6 +26,7 @@ export async function getSessionConfig(sessionId: string): Promise<ConnectionCon
password: session.password,
privateKey: session.privateKey,
passphrase: session.passphrase,
authType: session.authType,
}
} catch (error) {
logger.error('Failed to get session config:', error)
+13 -4
View File
@@ -34,10 +34,19 @@ export const useSessionStore = create<SessionState>((set) => ({
setSessions: (sessions) => set({ sessions }),
addSession: (session) => {
set((state) => ({
sessions: [...state.sessions, session],
}))
addSession: async (session) => {
// 同步到数据库
const result = await window.electronAPI?.sessionCreate(session)
if (result?.success && result.session) {
set((state) => ({
sessions: [...state.sessions, result.session!],
}))
} else {
// 失败时仍然添加到本地状态,避免 UI 卡住
set((state) => ({
sessions: [...state.sessions, session],
}))
}
},
removeSession: (id) => {
+2 -11
View File
@@ -1,22 +1,13 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { createLogger } from '../utils/logger'
import type { SSHConnectionConfig } from '../../shared/types/session.types'
const logger = createLogger('TabStore')
export type TabType = 'welcome' | 'terminal' | 'sftp' | 'portforward' | 'settings'
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
// SSH 连接配置(用于快速连接)
export interface SSHConnectionConfig {
host: string
port: number
username: string
password?: string
privateKey?: string
passphrase?: string
}
// 连接实例信息
export interface ConnectionInstance {
connectionId: string // 实际的 SSH 连接 ID
@@ -341,7 +332,7 @@ export const useTabStore = create<TabState>()(
logger.info('Restoring tabs from persisted state:', persisted.tabs.length, 'tabs')
// 将持久化的标签转换为运行时标签
const restoredTabs: Tab[] = persisted.tabs.map((tab: any) => ({
const restoredTabs: Tab[] = persisted.tabs.map((tab: Tab) => ({
...tab,
isActive: false,
connectionStatus: 'disconnected' as ConnectionStatus,
+46
View File
@@ -30,6 +30,12 @@ export const darkColors: ColorMap = {
'dark-700': '#334155',
'dark-600': '#475569',
// Semantic surface tokens
'surface-base': '#0f172a',
'surface-raised': '#1e293b',
'surface-overlay': '#334155',
'surface-inset': '#475569',
// Text
'text-primary': '#e2e8f0', // gray-200
'text-secondary': '#cbd5e1', // gray-300
@@ -37,11 +43,28 @@ export const darkColors: ColorMap = {
'text-faint': '#64748b', // gray-500
'text-dim': '#475569', // gray-600
// Content tokens
'content-primary': '#e2e8f0',
'content-secondary': '#cbd5e1',
'content-tertiary': '#94a3b8',
'content-inverse': '#0f172a',
// Border tokens
'border': '#334155',
'border-strong': '#475569',
// Accent
'accent-blue': '#3b82f6',
'accent-green': '#10b981',
'accent-purple': '#8b5cf6',
'accent-orange': '#f97316',
'accent-red': '#ef4444',
// Feedback tokens
'feedback-success': '#10b981',
'feedback-warning': '#f59e0b',
'feedback-error': '#ef4444',
'feedback-info': '#3b82f6',
// UI chrome
'scrollbar-track': '#1e293b',
@@ -60,6 +83,12 @@ export const lightColors: ColorMap = {
'dark-700': '#e5e7eb', // gray-200
'dark-600': '#d1d5db', // gray-300
// Semantic surface tokens
'surface-base': '#ffffff',
'surface-raised': '#f3f4f6',
'surface-overlay': '#e5e7eb',
'surface-inset': '#d1d5db',
// Text
'text-primary': '#1f2937', // gray-800
'text-secondary': '#374151', // gray-700
@@ -67,11 +96,28 @@ export const lightColors: ColorMap = {
'text-faint': '#9ca3af', // gray-400
'text-dim': '#d1d5db', // gray-300
// Content tokens
'content-primary': '#1f2937',
'content-secondary': '#374151',
'content-tertiary': '#6b7280',
'content-inverse': '#ffffff',
// Border tokens
'border': '#e5e7eb',
'border-strong': '#d1d5db',
// Accent (kept the same hue family, slightly adjusted for light bg)
'accent-blue': '#2563eb',
'accent-green': '#059669',
'accent-purple': '#7c3aed',
'accent-orange': '#ea580c',
'accent-red': '#dc2626',
// Feedback tokens
'feedback-success': '#059669',
'feedback-warning': '#d97706',
'feedback-error': '#dc2626',
'feedback-info': '#2563eb',
// UI chrome
'scrollbar-track': '#f3f4f6',