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

+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,