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:
1 parent
37e65be823
commit
514d62644e
77 files changed
+2563
-931
No files matched your search
+71
-52
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误边界组件
|
||||
*
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'}
|
||||
`}
|
||||
|
||||
@@ -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)
|
||||
Vendored
+40
-86
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user