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
+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,
|
||||
|
||||
Reference in new issue
Block a user