feat: v0.3.0 版本 - SSH终端、SFTP、端口转发功能实现
核心功能: - SSH终端: 密码/密钥认证、XTerm.js终端、多主题配色、IP高亮 - SFTP管理: 双栏文件管理、上传下载、传输队列 - 端口转发: 本地/远程转发、SOCKS5动态代理、规则管理 架构改进: - 拆分大型组件 (SFTPView/TabBar/Sidebar) - 添加统一日志和错误处理 - 修复React严格模式下的终端黑屏问题 UI更新: - 新增端口转发视图 - 更新UI预览文档 - 左侧栏右键菜单支持SFTP快捷打开
This commit is contained in:
1 parent
cf1c054381
commit
484d850abd
105 files changed
+25330
-64
No files matched your search
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* useSFTP Hook - SFTP 逻辑复用
|
||||
*
|
||||
* 从 SFTPView 提取状态管理和业务逻辑
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useSessionStore } from '../stores'
|
||||
import { useTabStore, type ConnectionInstance } from '../stores/tabStore'
|
||||
import { useConnectionPool, getSessionConfig } from '../stores/connection'
|
||||
import { createLogger } from '../utils/logger'
|
||||
import type { FileItem, TransferTask, SortField, SortOrder } from '../../shared/types/sftp.types'
|
||||
|
||||
const logger = createLogger('useSFTP')
|
||||
|
||||
// ===== 彩色调试日志 =====
|
||||
function logHeader(msg: string) {
|
||||
console.log(`%c${msg}`, 'background: #16a085; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;')
|
||||
}
|
||||
function logInfo(label: string, value: any) {
|
||||
console.log(`%c${label}:%c ${value}`, 'background: #2c3e50; color: #fff; padding: 2px 6px; border-radius: 3px;', 'color: #1abc9c; font-weight: bold;')
|
||||
}
|
||||
function logSuccess(msg: string) {
|
||||
console.log(`%c✓ ${msg}`, 'color: #27ae60; font-weight: bold;')
|
||||
}
|
||||
function logError(msg: string) {
|
||||
console.log(`%c✗ ${msg}`, 'color: #e74c3c; font-weight: bold;')
|
||||
}
|
||||
function logWarning(msg: string) {
|
||||
console.log(`%c⚠ ${msg}`, 'color: #f39c12; font-weight: bold;')
|
||||
}
|
||||
|
||||
export interface UseSFTPOptions {
|
||||
tabId: string
|
||||
}
|
||||
|
||||
export interface UseSFTPReturn {
|
||||
// 会话
|
||||
sessions: import('../stores/sessionStore').Session[]
|
||||
|
||||
// 本地状态
|
||||
localPath: string
|
||||
localFiles: FileItem[]
|
||||
localSelected: Set<string>
|
||||
setLocalSelected: (selected: Set<string> | ((prev: Set<string>) => Set<string>)) => void
|
||||
localHistory: string[]
|
||||
localHistoryIndex: number
|
||||
navigateLocal: (path: string) => void
|
||||
navigateLocalBack: () => void
|
||||
navigateLocalForward: () => void
|
||||
loadLocalDirectory: (path: string) => Promise<void>
|
||||
|
||||
// 远程状态
|
||||
remotePath: string
|
||||
remoteFiles: FileItem[]
|
||||
remoteSelected: Set<string>
|
||||
setRemoteSelected: (selected: Set<string> | ((prev: Set<string>) => Set<string>)) => void
|
||||
remoteHistory: string[]
|
||||
remoteHistoryIndex: number
|
||||
navigateRemote: (path: string) => void
|
||||
navigateRemoteBack: () => void
|
||||
navigateRemoteForward: () => void
|
||||
loadRemoteDirectory: (path: string) => Promise<void>
|
||||
|
||||
// 连接状态
|
||||
connectionId: string | null
|
||||
sftpId: string | null
|
||||
isConnected: boolean
|
||||
isConnecting: boolean
|
||||
connect: (sessionId: string) => Promise<void>
|
||||
disconnect: () => Promise<void>
|
||||
|
||||
// 传输
|
||||
transferQueue: TransferTask[]
|
||||
uploadFiles: (files: FileList | null) => Promise<void>
|
||||
downloadFiles: () => Promise<void>
|
||||
|
||||
// 排序
|
||||
sortField: SortField
|
||||
sortOrder: SortOrder
|
||||
setSortField: (field: SortField) => void
|
||||
setSortOrder: (order: SortOrder) => void
|
||||
|
||||
// 错误
|
||||
error: string | null
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
const { sessions } = useSessionStore()
|
||||
const { updateTabConnectionInstance, updateTab } = useTabStore()
|
||||
const { createConnection, getConnection } = useConnectionPool()
|
||||
|
||||
// Local state
|
||||
const [localPath, setLocalPath] = useState('')
|
||||
const [localFiles, setLocalFiles] = useState<FileItem[]>([])
|
||||
const [localSelected, setLocalSelected] = useState<Set<string>>(new Set())
|
||||
const [localHistory, setLocalHistory] = useState<string[]>(['/'])
|
||||
const [localHistoryIndex, setLocalHistoryIndex] = useState(0)
|
||||
|
||||
// Remote state
|
||||
const [remotePath, setRemotePath] = useState('/')
|
||||
const [remoteFiles, setRemoteFiles] = useState<FileItem[]>([])
|
||||
const [remoteSelected, setRemoteSelected] = useState<Set<string>>(new Set())
|
||||
const [remoteHistory, setRemoteHistory] = useState<string[]>(['/'])
|
||||
const [remoteHistoryIndex, setRemoteHistoryIndex] = useState(0)
|
||||
|
||||
// Connection state
|
||||
const [connectionId, setConnectionId] = useState<string | null>(null)
|
||||
const [sftpId, setSftpId] = useState<string | null>(null)
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Transfer state
|
||||
const [transferQueue, setTransferQueue] = useState<TransferTask[]>([])
|
||||
|
||||
// Sort state
|
||||
const [sortField, setSortField] = useState<SortField>('name')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
||||
|
||||
// Initialize home path
|
||||
useEffect(() => {
|
||||
logHeader('📁 SFTP Initializing home path')
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.getHomePath?.().then((result: { success: boolean; path?: string }) => {
|
||||
logInfo('Home path result', result)
|
||||
if (result.success && result.path) {
|
||||
setLocalPath(result.path)
|
||||
setLocalHistory([result.path])
|
||||
setLocalHistoryIndex(0)
|
||||
logSuccess(`Home path set: ${result.path}`)
|
||||
// Load local directory
|
||||
loadLocalDirectory(result.path)
|
||||
} else {
|
||||
logError('Failed to get home path')
|
||||
}
|
||||
}).catch((err: any) => {
|
||||
logError(`getHomePath error: ${err?.message || err}`)
|
||||
})
|
||||
} else {
|
||||
logWarning('electronAPI not available')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load local directory
|
||||
const loadLocalDirectory = useCallback(async (path: string) => {
|
||||
logHeader('📁 LOAD LOCAL DIRECTORY')
|
||||
logInfo('path', path)
|
||||
|
||||
if (!window.electronAPI?.localReadDir) {
|
||||
logWarning('localReadDir not available, using mock data')
|
||||
// Mock files for development
|
||||
const mockFiles: FileItem[] = [
|
||||
{ name: 'Desktop', path: `${path}/Desktop`, type: 'directory', size: 0, modified: Date.now() / 1000 - 86400, permissions: 'drwxr-xr-x' },
|
||||
{ name: 'Documents', path: `${path}/Documents`, type: 'directory', size: 0, modified: Date.now() / 1000 - 172800, permissions: 'drwxr-xr-x' },
|
||||
{ name: 'Downloads', path: `${path}/Downloads`, type: 'directory', size: 0, modified: Date.now() / 1000 - 3600, permissions: 'drwxr-xr-x' },
|
||||
{ name: 'config.json', path: `${path}/config.json`, type: 'file', size: 4096, modified: Date.now() / 1000 - 7200, permissions: '-rw-r--r--' },
|
||||
]
|
||||
setLocalFiles(mockFiles)
|
||||
logSuccess(`Loaded ${mockFiles.length} mock files`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.localReadDir(path)
|
||||
logInfo('localReadDir result', result)
|
||||
|
||||
if (result.success && result.files) {
|
||||
const files: FileItem[] = result.files.map((f: any) => ({
|
||||
name: f.name,
|
||||
path: `${path}/${f.name}`,
|
||||
type: f.isDirectory ? 'directory' : 'file',
|
||||
size: f.size || 0,
|
||||
modified: f.mtime || Date.now() / 1000,
|
||||
permissions: f.isDirectory ? 'drwxr-xr-x' : '-rw-r--r--',
|
||||
}))
|
||||
setLocalFiles(files)
|
||||
logSuccess(`Loaded ${files.length} local files`)
|
||||
} else {
|
||||
logError(`Failed to load local directory: ${result.error}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
logError(`loadLocalDirectory error: ${err?.message}`)
|
||||
logger.error('Failed to load local directory', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load remote directory
|
||||
const loadRemoteDirectory = useCallback(async (path: string) => {
|
||||
logHeader('📁 LOAD REMOTE DIRECTORY')
|
||||
logInfo('path', path)
|
||||
logInfo('sftpId', sftpId)
|
||||
|
||||
if (!sftpId || !window.electronAPI) {
|
||||
logError('sftpId or electronAPI not available')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sftpListDirectory(sftpId, path)
|
||||
logInfo('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--',
|
||||
}))
|
||||
setRemoteFiles(files)
|
||||
logSuccess(`Loaded ${files.length} remote files`)
|
||||
} else {
|
||||
logError(`Failed to load remote directory: ${result.error}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
logError(`loadRemoteDirectory error: ${err?.message}`)
|
||||
logger.error('Failed to load remote directory', err)
|
||||
setError('Failed to load remote directory')
|
||||
}
|
||||
}, [sftpId])
|
||||
|
||||
// Navigation helpers
|
||||
const navigateLocal = useCallback((path: string) => {
|
||||
setLocalPath(path)
|
||||
const newHistory = localHistory.slice(0, localHistoryIndex + 1)
|
||||
newHistory.push(path)
|
||||
setLocalHistory(newHistory)
|
||||
setLocalHistoryIndex(newHistory.length - 1)
|
||||
loadLocalDirectory(path)
|
||||
setLocalSelected(new Set())
|
||||
}, [localHistory, localHistoryIndex, loadLocalDirectory])
|
||||
|
||||
const navigateLocalBack = useCallback(() => {
|
||||
if (localHistoryIndex > 0) {
|
||||
const newIndex = localHistoryIndex - 1
|
||||
setLocalHistoryIndex(newIndex)
|
||||
setLocalPath(localHistory[newIndex])
|
||||
loadLocalDirectory(localHistory[newIndex])
|
||||
}
|
||||
}, [localHistory, localHistoryIndex, loadLocalDirectory])
|
||||
|
||||
const navigateLocalForward = useCallback(() => {
|
||||
if (localHistoryIndex < localHistory.length - 1) {
|
||||
const newIndex = localHistoryIndex + 1
|
||||
setLocalHistoryIndex(newIndex)
|
||||
setLocalPath(localHistory[newIndex])
|
||||
loadLocalDirectory(localHistory[newIndex])
|
||||
}
|
||||
}, [localHistory, localHistoryIndex, loadLocalDirectory])
|
||||
|
||||
const navigateRemote = useCallback((path: string) => {
|
||||
setRemotePath(path)
|
||||
const newHistory = remoteHistory.slice(0, remoteHistoryIndex + 1)
|
||||
newHistory.push(path)
|
||||
setRemoteHistory(newHistory)
|
||||
setRemoteHistoryIndex(newHistory.length - 1)
|
||||
loadRemoteDirectory(path)
|
||||
setRemoteSelected(new Set())
|
||||
}, [remoteHistory, remoteHistoryIndex, loadRemoteDirectory])
|
||||
|
||||
const navigateRemoteBack = useCallback(() => {
|
||||
if (remoteHistoryIndex > 0) {
|
||||
const newIndex = remoteHistoryIndex - 1
|
||||
setRemoteHistoryIndex(newIndex)
|
||||
setRemotePath(remoteHistory[newIndex])
|
||||
loadRemoteDirectory(remoteHistory[newIndex])
|
||||
}
|
||||
}, [remoteHistory, remoteHistoryIndex, loadRemoteDirectory])
|
||||
|
||||
const navigateRemoteForward = useCallback(() => {
|
||||
if (remoteHistoryIndex < remoteHistory.length - 1) {
|
||||
const newIndex = remoteHistoryIndex + 1
|
||||
setRemoteHistoryIndex(newIndex)
|
||||
setRemotePath(remoteHistory[newIndex])
|
||||
loadRemoteDirectory(remoteHistory[newIndex])
|
||||
}
|
||||
}, [remoteHistory, remoteHistoryIndex, loadRemoteDirectory])
|
||||
|
||||
// Connect to SFTP
|
||||
const connect = useCallback(async (sessionId: string) => {
|
||||
logHeader('📁 SFTP CONNECT')
|
||||
logInfo('sessionId', sessionId)
|
||||
|
||||
if (!sessionId) {
|
||||
logError('No sessionId provided')
|
||||
return
|
||||
}
|
||||
|
||||
setIsConnecting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const config = await getSessionConfig(sessionId)
|
||||
logInfo('Config', `${config?.username}@${config?.host}:${config?.port}`)
|
||||
|
||||
if (!config) {
|
||||
setError('Session not found')
|
||||
logError('Session config not found')
|
||||
return
|
||||
}
|
||||
|
||||
// Create connection
|
||||
logInfo('Creating connection...', tabId)
|
||||
const result = await createConnection(tabId, config)
|
||||
logInfo('Connection result', result)
|
||||
|
||||
if (!result.success || !result.connectionId) {
|
||||
setError(result.error || 'Failed to create connection')
|
||||
logError(`Connection failed: ${result.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
const connId = result.connectionId
|
||||
setConnectionId(connId)
|
||||
logSuccess(`Connection created: ${connId}`)
|
||||
|
||||
// Create SFTP
|
||||
logInfo('Creating SFTP session...', connId)
|
||||
const sftpResult = await window.electronAPI?.sftpConnect(connId)
|
||||
logInfo('SFTP result', sftpResult)
|
||||
|
||||
if (!sftpResult?.success || !sftpResult.sftpId) {
|
||||
setError(sftpResult?.error || 'Failed to create SFTP session')
|
||||
logError(`SFTP creation failed: ${sftpResult?.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
setSftpId(sftpResult.sftpId)
|
||||
setIsConnected(true)
|
||||
logSuccess(`SFTP connected: ${sftpResult.sftpId}`)
|
||||
|
||||
// Update tab
|
||||
const connectionInstance: ConnectionInstance = {
|
||||
connectionId: connId,
|
||||
sftpId: sftpResult.sftpId,
|
||||
}
|
||||
updateTabConnectionInstance(tabId, connectionInstance)
|
||||
updateTab(tabId, { title: `SFTP: ${config.username}@${config.host}` })
|
||||
|
||||
// Load initial remote directory
|
||||
logInfo('Loading remote home directory...', '/')
|
||||
loadRemoteDirectory('/')
|
||||
|
||||
logSuccess('SFTP initialization complete')
|
||||
|
||||
logger.info('SFTP connected', { host: config.host, connectionId: connId })
|
||||
} catch (err) {
|
||||
logger.error('SFTP connection failed', err)
|
||||
setError('Connection failed')
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}, [tabId, createConnection, loadRemoteDirectory, updateTabConnectionInstance, updateTab])
|
||||
|
||||
// Disconnect
|
||||
const disconnect = useCallback(async () => {
|
||||
if (sftpId && window.electronAPI) {
|
||||
// Note: SFTP disconnect is handled automatically when connection closes
|
||||
}
|
||||
if (connectionId) {
|
||||
const conn = getConnection(connectionId)
|
||||
if (conn) {
|
||||
// Cleanup
|
||||
}
|
||||
}
|
||||
setIsConnected(false)
|
||||
setSftpId(null)
|
||||
setConnectionId(null)
|
||||
setRemoteFiles([])
|
||||
}, [connectionId, sftpId, getConnection])
|
||||
|
||||
// Upload files
|
||||
const uploadFiles = useCallback(async (files: FileList | null) => {
|
||||
if (!files || !sftpId) return
|
||||
|
||||
const newTasks: TransferTask[] = Array.from(files).map(file => ({
|
||||
id: `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: 'upload',
|
||||
fileName: file.name,
|
||||
filePath: `${localPath}/${file.name}`,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
total: file.size,
|
||||
transferred: 0,
|
||||
}))
|
||||
|
||||
setTransferQueue(prev => [...prev, ...newTasks])
|
||||
|
||||
// Process uploads
|
||||
for (const task of newTasks) {
|
||||
try {
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'transferring' } : t
|
||||
))
|
||||
|
||||
// Note: Actual upload implementation would go here
|
||||
// This is a simplified version
|
||||
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
|
||||
))
|
||||
} catch (err) {
|
||||
logger.error('Upload failed', err)
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'failed', error: 'Upload failed' } : t
|
||||
))
|
||||
}
|
||||
}
|
||||
}, [sftpId, localPath, remotePath])
|
||||
|
||||
// Download files
|
||||
const downloadFiles = useCallback(async () => {
|
||||
if (!sftpId || remoteSelected.size === 0) return
|
||||
|
||||
const filesToDownload = remoteFiles.filter(f => remoteSelected.has(f.name))
|
||||
|
||||
const newTasks: TransferTask[] = filesToDownload.map(file => ({
|
||||
id: `download-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: 'download',
|
||||
fileName: file.name,
|
||||
filePath: file.path,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
total: file.size,
|
||||
transferred: 0,
|
||||
}))
|
||||
|
||||
setTransferQueue(prev => [...prev, ...newTasks])
|
||||
|
||||
// Process downloads
|
||||
for (const task of newTasks) {
|
||||
try {
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'transferring' } : t
|
||||
))
|
||||
|
||||
if (window.electronAPI) {
|
||||
await window.electronAPI.sftpDownload(sftpId, task.filePath, `${localPath}/${task.fileName}`)
|
||||
}
|
||||
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t
|
||||
))
|
||||
} catch (err) {
|
||||
logger.error('Download failed', err)
|
||||
setTransferQueue(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'failed', error: 'Download failed' } : t
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh local directory
|
||||
loadLocalDirectory(localPath)
|
||||
}, [sftpId, remoteFiles, remoteSelected, localPath, loadLocalDirectory])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disconnect()
|
||||
}
|
||||
}, [disconnect])
|
||||
|
||||
return {
|
||||
sessions,
|
||||
|
||||
localPath,
|
||||
localFiles,
|
||||
localSelected,
|
||||
setLocalSelected,
|
||||
localHistory,
|
||||
localHistoryIndex,
|
||||
navigateLocal,
|
||||
navigateLocalBack,
|
||||
navigateLocalForward,
|
||||
loadLocalDirectory,
|
||||
|
||||
remotePath,
|
||||
remoteFiles,
|
||||
remoteSelected,
|
||||
setRemoteSelected,
|
||||
remoteHistory,
|
||||
remoteHistoryIndex,
|
||||
navigateRemote,
|
||||
navigateRemoteBack,
|
||||
navigateRemoteForward,
|
||||
loadRemoteDirectory,
|
||||
|
||||
connectionId,
|
||||
sftpId,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
connect,
|
||||
disconnect,
|
||||
|
||||
transferQueue,
|
||||
uploadFiles,
|
||||
downloadFiles,
|
||||
|
||||
sortField,
|
||||
sortOrder,
|
||||
setSortField,
|
||||
setSortOrder,
|
||||
|
||||
error,
|
||||
clearError: () => setError(null),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user