/** * useSFTP Hook - SFTP 逻辑复用 * * 从 SFTPView 提取状态管理和业务逻辑 */ import { useState, useEffect, useCallback, useRef } from 'react' import { useSessionStore } from '../stores' import { useTabStore, type ConnectionInstance } from '../stores/tabStore' import { useConnectionPool, getConnectionStore, getSessionConfig } from '../stores/connection' import { createLogger } from '../utils/logger' import type { FileItem, TransferTask, SortField, SortOrder } from '../../shared/types/sftp.types' import { downloadRemoteFile, localPath as toLocalPath, remotePath as toRemotePath, uploadRemoteFile, } from '../platform/sftp' import { joinLocalPath } from '../utils/localPath' const logger = createLogger('useSFTP') // 本地目录条目,与 Tauri 文件命令返回结构对齐。 interface LocalDirEntry { name: string path?: string type?: 'file' | 'directory' | 'symlink' size?: number modified?: number permissions?: string /** 兼容旧字段 */ isDirectory?: boolean mtime?: number } // ssh2 readdir 条目 interface RemoteDirEntry { filename?: string longname?: string attrs?: { size?: number mtime?: number mode?: number } } const S_IFMT = 0o170000 const S_IFDIR = 0o040000 const S_IFLNK = 0o120000 function remoteEntryType(entry: RemoteDirEntry): FileItem['type'] { const mode = entry.attrs?.mode if (typeof mode === 'number') { const t = mode & S_IFMT if (t === S_IFDIR) return 'directory' if (t === S_IFLNK) return 'symlink' return 'file' } const long = entry.longname || '' if (long.startsWith('d')) return 'directory' if (long.startsWith('l')) return 'symlink' return 'file' } function localEntryType(entry: LocalDirEntry): FileItem['type'] { if (entry.type === 'directory' || entry.type === 'symlink' || entry.type === 'file') { return entry.type } return entry.isDirectory ? 'directory' : 'file' } export interface UseSFTPOptions { tabId: string } export interface UploadSource { path: string name?: string size?: number } export interface TransferBatchResult { completed: number failed: number errors: string[] } export interface UseSFTPReturn { // 会话 sessions: import('../stores/sessionStore').Session[] // 本地状态 localPath: string localFiles: FileItem[] localSelected: Set setLocalSelected: (selected: Set | ((prev: Set) => Set)) => void localHistory: string[] localHistoryIndex: number navigateLocal: (path: string) => void navigateLocalBack: () => void navigateLocalForward: () => void loadLocalDirectory: (path: string) => Promise // 远程状态 remotePath: string remoteFiles: FileItem[] remoteSelected: Set setRemoteSelected: (selected: Set | ((prev: Set) => Set)) => void remoteHistory: string[] remoteHistoryIndex: number navigateRemote: (path: string) => void navigateRemoteBack: () => void navigateRemoteForward: () => void loadRemoteDirectory: (path: string) => Promise // 连接状态 connectionId: string | null sftpId: string | null isConnected: boolean isConnecting: boolean connect: (sessionId: string) => Promise disconnect: () => Promise // 传输 transferQueue: TransferTask[] uploadFiles: (files: UploadSource[], destinationRemotePath?: string) => Promise downloadFiles: (files?: FileItem[], destinationLocalPath?: string) => Promise removeTransfer: (taskId: string) => void // 远程文件操作 deleteRemote: (files: FileItem[]) => Promise renameRemote: (file: FileItem, newName: string) => Promise getRemoteStat: (filePath: string) => Promise<{ size: number mode: number mtime: number atime: number isDirectory: boolean isFile: boolean isSymbolicLink: boolean } | null> chmodRemote: (filePath: string, mode: number) => Promise // 排序 sortField: SortField sortOrder: SortOrder setSortField: (field: SortField) => void setSortOrder: (order: SortOrder) => void // 错误 error: string | null clearError: () => void } function fileNameFromPath(filePath: string): string { return filePath.split(/[\\/]/).filter(Boolean).pop() || filePath } export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn { const { sessions } = useSessionStore() const { updateTabConnectionInstance, updateTab } = useTabStore() const { createConnection } = useConnectionPool() // Local state const [localPath, setLocalPath] = useState('') const [localFiles, setLocalFiles] = useState([]) const [localSelected, setLocalSelected] = useState>(new Set()) const [localHistory, setLocalHistory] = useState([]) const [localHistoryIndex, setLocalHistoryIndex] = useState(0) // Remote state const [remotePath, setRemotePath] = useState('/') const [remoteFiles, setRemoteFiles] = useState([]) const [remoteSelected, setRemoteSelected] = useState>(new Set()) const [remoteHistory, setRemoteHistory] = useState(['/']) const [remoteHistoryIndex, setRemoteHistoryIndex] = useState(0) // Connection state const [connectionId, setConnectionId] = useState(null) const [sftpId, setSftpId] = useState(null) const [isConnected, setIsConnected] = useState(false) const [isConnecting, setIsConnecting] = useState(false) const [error, setError] = useState(null) // Transfer state const [transferQueue, setTransferQueue] = useState([]) const removeTransfer = useCallback((taskId: string) => { setTransferQueue((tasks) => tasks.filter((task) => task.id !== taskId)) }, []) // Sort state const [sortField, setSortField] = useState('name') const [sortOrder, setSortOrder] = useState('asc') // Initialize home path useEffect(() => { logger.info('SFTP Initializing home path') if (window.opsxApi) { window.opsxApi .getHomePath?.() .then((result: { success: boolean; path?: string }) => { logger.debug('Home path result', result) if (result.success && result.path) { setLocalPath(result.path) setLocalHistory([result.path]) setLocalHistoryIndex(0) logger.info(`Home path set: ${result.path}`) // Load local directory loadLocalDirectory(result.path) } else { logger.error('Failed to get home path') } }) .catch((err: unknown) => { logger.error(`getHomePath error: ${err instanceof Error ? err.message : String(err)}`) }) } else { logger.warn('opsxApi not available') } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Load local directory const loadLocalDirectory = useCallback(async (path: string) => { logger.info('Loading local directory', path) if (!window.opsxApi?.localReadDir) { logger.warn('localReadDir not available') return } try { const result = (await window.opsxApi.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: LocalDirEntry) => { const type = localEntryType(f) return { name: f.name, path: f.path || joinLocalPath(path, f.name), type, size: f.size || 0, modified: f.modified ?? f.mtime ?? Date.now() / 1000, permissions: f.permissions || (type === 'directory' ? 'drwxr-xr-x' : type === 'symlink' ? 'lrwxrwxrwx' : '-rw-r--r--'), } }) setLocalFiles(files) logger.info(`Loaded ${files.length} local files`) } else { logger.error(`Failed to load local directory: ${result.error}`) } } catch (err: unknown) { logger.error(`loadLocalDirectory error: ${err instanceof Error ? err.message : String(err)}`, err) } }, []) // Load remote directory(可传入 sftpIdOverride,避免 setState 尚未提交时列表为空) const loadRemoteDirectory = useCallback( async (dirPath: string, sftpIdOverride?: string) => { const activeSftpId = sftpIdOverride ?? sftpId logger.info('Loading remote directory', { path: dirPath, sftpId: activeSftpId }) if (!activeSftpId || !window.opsxApi) { logger.error('sftpId or opsxApi not available') return } try { const result = await window.opsxApi.sftpListDirectory(activeSftpId, dirPath) logger.debug('sftpListDirectory result', result) if (result.success && result.files) { const files: FileItem[] = result.files .map((f: unknown) => { const entry = f as RemoteDirEntry const name = entry.filename || '' if (!name || name === '.' || name === '..') return null const type = remoteEntryType(entry) const joined = dirPath === '/' ? `/${name}` : `${dirPath}/${name}` return { name, path: joined.replace(/\/+/g, '/'), type, size: entry.attrs?.size || 0, modified: entry.attrs?.mtime || Date.now() / 1000, permissions: entry.longname?.substring(0, 10) || '-rw-r--r--', } satisfies FileItem }) .filter((f): f is FileItem => f !== null) setRemoteFiles(files) setError(null) logger.info(`Loaded ${files.length} remote files`) } else { const msg = result.error || '列出远程目录失败' logger.error(`Failed to load remote directory: ${msg}`) setRemoteFiles([]) setError(msg) } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) logger.error(`loadRemoteDirectory error: ${msg}`, err) setRemoteFiles([]) setError(`加载远程目录失败: ${msg}`) } }, [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) => { logger.info('SFTP CONNECT', { sessionId }) if (!sessionId) { logger.error('No sessionId provided') return } setIsConnecting(true) setError(null) try { const config = await getSessionConfig(sessionId) logger.debug('Config', `${config?.username}@${config?.host}:${config?.port}`) if (!config) { setError('Session not found') logger.error('Session config not found') return } // Create connection logger.debug('Creating connection...', tabId) const result = await createConnection(tabId, config) logger.debug('Connection result', result) if (!result.success || !result.connectionId) { setError(result.error || 'Failed to create connection') logger.error(`Connection failed: ${result.error}`) return } const connId = result.connectionId setConnectionId(connId) logger.info(`Connection created: ${connId}`) // Create SFTP logger.debug('Creating SFTP session...', connId) const sftpResult = await window.opsxApi?.sftpConnect(connId) logger.debug('SFTP result', sftpResult) if (!sftpResult?.success || !sftpResult.sftpId) { setError(sftpResult?.error || 'Failed to create SFTP session') logger.error(`SFTP creation failed: ${sftpResult?.error}`) return } setSftpId(sftpResult.sftpId) setIsConnected(true) logger.info(`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}` }) // 初始远程目录:优先用户家目录,失败再回退 / // 必须传入 sftpId,避免 setState 尚未提交导致列表为空 const preferred = config.username === 'root' ? '/root' : `/home/${config.username}` let startPath = preferred const probe = await window.opsxApi?.sftpListDirectory(sftpResult.sftpId, preferred) if (!probe?.success) { startPath = '/' } setRemotePath(startPath) setRemoteHistory([startPath]) setRemoteHistoryIndex(0) await loadRemoteDirectory(startPath, sftpResult.sftpId) logger.info('SFTP initialization complete') } 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 (connectionId) { try { await getConnectionStore().releaseConnection(connectionId, tabId) logger.info('SFTP connection released', { connectionId, sftpId }) } catch (err) { logger.error('Error releasing SFTP connection', err) } } setIsConnected(false) setSftpId(null) setConnectionId(null) setRemoteFiles([]) }, [connectionId, sftpId, tabId]) // Upload files (real implementation via IPC) const uploadFiles = useCallback( async (files: UploadSource[], destinationRemotePath = remotePath): Promise => { if (files.length === 0 || !sftpId || !window.opsxApi) { const error = !sftpId ? 'SFTP 尚未连接' : '没有可上传的文件' logger.warn('uploadFiles: missing files, sftpId, or opsxApi') return { completed: 0, failed: files.length || 1, errors: [error] } } const newTasks: TransferTask[] = [] const errors: string[] = [] for (const file of files) { const fileName = file.name || fileNameFromPath(file.path) let total = file.size if (total === undefined) { const statsResult = (await window.opsxApi.localGetStats(file.path)) as { success?: boolean stats?: { size?: number; type?: string; isDirectory?: boolean } error?: string } if (!statsResult?.success || !statsResult.stats) { errors.push(`${fileName}: ${statsResult?.error || '无法读取本地文件信息'}`) continue } if (statsResult.stats.type === 'directory' || statsResult.stats.isDirectory) { errors.push(`${fileName}: 暂不支持上传文件夹`) continue } total = statsResult.stats.size || 0 } newTasks.push({ id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`, type: 'upload', fileName, filePath: file.path, status: 'pending', progress: 0, total, transferred: 0, }) } if (newTasks.length === 0) { return { completed: 0, failed: errors.length, errors } } setTransferQueue((prev) => [...prev, ...newTasks]) // Set up progress listener scoped to this batch const progressHandler = ( _event: unknown, eventSftpId: string, progress: { transferred: number; total: number; percentage: number } ) => { if (eventSftpId !== sftpId) return // Update the first task that is currently transferring setTransferQueue((prev) => { const idx = prev.findIndex((t) => t.type === 'upload' && t.status === 'transferring') if (idx === -1) return prev const t = prev[idx] return [ ...prev.slice(0, idx), { ...t, transferred: progress.transferred, total: progress.total, progress: progress.percentage }, ...prev.slice(idx + 1), ] }) } window.opsxApi.onSFTPProgress(progressHandler as (...args: unknown[]) => void) let completed = 0 try { for (const task of newTasks) { const remoteFilePath = `${destinationRemotePath}/${task.fileName}`.replace(/\/+/g, '/') logger.debug('Uploading', `${task.filePath} -> ${remoteFilePath}`) // Mark as transferring setTransferQueue((prev) => prev.map((t) => (t.id === task.id ? { ...t, status: 'transferring', startTime: Date.now() } : t)) ) try { const result = await uploadRemoteFile({ sftpId, localPath: toLocalPath(task.filePath), remotePath: toRemotePath(remoteFilePath), }) if (result.success) { completed += 1 setTransferQueue((prev) => prev.map((t) => t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t ) ) logger.info(`Upload complete: ${task.fileName}`) } else { errors.push(`${task.fileName}: ${result.error || '上传失败'}`) setTransferQueue((prev) => prev.map((t) => t.id === task.id ? { ...t, status: 'failed', error: result.error || 'Upload failed' } : t ) ) logger.error(`Upload failed: ${task.fileName} - ${result.error}`) } } catch (err: unknown) { logger.error('Upload failed', err) errors.push(`${task.fileName}: ${err instanceof Error ? err.message : '上传失败'}`) setTransferQueue((prev) => prev.map((t) => t.id === task.id ? { ...t, status: 'failed', error: err instanceof Error ? err.message : 'Upload failed' } : t ) ) } } // Refresh remote directory after all uploads await loadRemoteDirectory(remotePath) } finally { window.opsxApi.removeListener?.('sftp:progress', progressHandler as (...args: unknown[]) => void) } return { completed, failed: errors.length, errors } }, [sftpId, remotePath, loadRemoteDirectory] ) // Download files (real implementation via IPC) const downloadFiles = useCallback( async (files?: FileItem[], destinationLocalPath = localPath): Promise => { if (!sftpId || !window.opsxApi) { return { completed: 0, failed: 1, errors: ['SFTP 尚未连接'] } } const selectedFiles = files ?? remoteFiles.filter((f) => remoteSelected.has(f.name)) const filesToDownload = selectedFiles.filter((f) => f.type !== 'directory') const errors = selectedFiles.filter((f) => f.type === 'directory').map((f) => `${f.name}: 暂不支持下载文件夹`) if (filesToDownload.length === 0) { if (errors.length === 0) errors.push('请先选择要下载的文件') return { completed: 0, failed: errors.length, errors } } const newTasks: TransferTask[] = filesToDownload.map((file) => ({ id: `download-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, type: 'download' as const, fileName: file.name, filePath: file.path, status: 'pending' as const, progress: 0, total: file.size, transferred: 0, })) setTransferQueue((prev) => [...prev, ...newTasks]) // Set up progress listener const progressHandler = ( _event: unknown, eventSftpId: string, progress: { transferred: number; total: number; percentage: number } ) => { if (eventSftpId !== sftpId) return setTransferQueue((prev) => { const idx = prev.findIndex((t) => t.type === 'download' && t.status === 'transferring') if (idx === -1) return prev const t = prev[idx] return [ ...prev.slice(0, idx), { ...t, transferred: progress.transferred, total: progress.total, progress: progress.percentage }, ...prev.slice(idx + 1), ] }) } window.opsxApi.onSFTPProgress(progressHandler as (...args: unknown[]) => void) let completed = 0 try { for (const task of newTasks) { const localFilePath = joinLocalPath(destinationLocalPath, task.fileName) logger.debug('Downloading', `${task.filePath} -> ${localFilePath}`) setTransferQueue((prev) => prev.map((t) => (t.id === task.id ? { ...t, status: 'transferring', startTime: Date.now() } : t)) ) try { const result = await downloadRemoteFile({ sftpId, remotePath: toRemotePath(task.filePath), localPath: toLocalPath(localFilePath), }) if (result.success) { completed += 1 setTransferQueue((prev) => prev.map((t) => t.id === task.id ? { ...t, status: 'completed', transferred: t.total, progress: 100 } : t ) ) logger.info(`Download complete: ${task.fileName}`) } else { errors.push(`${task.fileName}: ${result.error || '下载失败'}`) setTransferQueue((prev) => prev.map((t) => t.id === task.id ? { ...t, status: 'failed', error: result.error || 'Download failed' } : t ) ) logger.error(`Download failed: ${task.fileName} - ${result.error}`) } } catch (err: unknown) { logger.error('Download failed', err) errors.push(`${task.fileName}: ${err instanceof Error ? err.message : '下载失败'}`) setTransferQueue((prev) => prev.map((t) => t.id === task.id ? { ...t, status: 'failed', error: err instanceof Error ? err.message : 'Download failed' } : t ) ) } } // Refresh local directory after all downloads await loadLocalDirectory(localPath) } finally { window.opsxApi.removeListener?.('sftp:progress', progressHandler as (...args: unknown[]) => void) } return { completed, failed: errors.length, errors } }, [sftpId, remoteFiles, remoteSelected, localPath, loadLocalDirectory] ) // Delete remote files/directories const deleteRemote = useCallback( async (files: FileItem[]): Promise => { if (!sftpId || files.length === 0 || !window.opsxApi) { logger.warn('deleteRemote: missing sftpId, files, or opsxApi') return false } let allSuccess = true for (const file of files) { logger.debug('Deleting remote', `${file.path} (isDir: ${file.type === 'directory'})`) try { const result = await window.opsxApi.sftpDelete(sftpId, file.path, file.type === 'directory') if (result.success) { logger.info(`Deleted: ${file.name}`) } else { logger.error(`Delete failed: ${file.name} - ${result.error}`) allSuccess = false } } catch (err: unknown) { logger.error(`Delete error: ${file.name} - ${err instanceof Error ? err.message : String(err)}`, err) allSuccess = false } } // Refresh remote directory after deletion if (allSuccess || files.length > 0) { loadRemoteDirectory(remotePath) } return allSuccess }, [sftpId, remotePath, loadRemoteDirectory] ) // Rename remote file/directory const renameRemote = useCallback( async (file: FileItem, newName: string): Promise => { if (!sftpId || !window.opsxApi) { logger.warn('renameRemote: missing sftpId or opsxApi') return false } const dirPath = file.path.substring(0, file.path.lastIndexOf('/')) const newPath = dirPath ? `${dirPath}/${newName}` : `/${newName}` try { const result = await window.opsxApi.sftpRename(sftpId, file.path, newPath) if (result.success) { logger.info(`Renamed: ${file.name} -> ${newName}`) loadRemoteDirectory(remotePath) return true } else { logger.error(`Rename failed: ${result.error}`) return false } } catch (err: unknown) { logger.error(`Rename error: ${err instanceof Error ? err.message : String(err)}`, err) return false } }, [sftpId, remotePath, loadRemoteDirectory] ) // Get remote file stat const getRemoteStat = useCallback( async (filePath: string) => { if (!sftpId || !window.opsxApi) { logger.warn('getRemoteStat: missing sftpId or opsxApi') return null } try { const result = (await window.opsxApi.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: unknown) { logger.error(`Stat error: ${err instanceof Error ? err.message : String(err)}`, err) return null } }, [sftpId] ) // Chmod remote file const chmodRemote = useCallback( async (filePath: string, mode: number): Promise => { if (!sftpId || !window.opsxApi) { logger.warn('chmodRemote: missing sftpId or opsxApi') return false } try { const result = await window.opsxApi.sftpChmod(sftpId, filePath, mode) if (result.success) { logger.info(`Chmod success: ${filePath} -> ${mode.toString(8)}`) loadRemoteDirectory(remotePath) return true } else { logger.error(`Chmod failed: ${result.error}`) return false } } catch (err: unknown) { logger.error(`Chmod error: ${err instanceof Error ? err.message : String(err)}`, err) return false } }, [sftpId, remotePath, loadRemoteDirectory] ) // Cleanup on unmount — release pool ref instead of hard-disconnecting shared sessions const connectionIdRef = useRef(connectionId) connectionIdRef.current = connectionId useEffect(() => { return () => { const cid = connectionIdRef.current if (cid) { void getConnectionStore().releaseConnection(cid, tabId) } } }, [tabId]) 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, removeTransfer, // Remote file operations deleteRemote, renameRemote, getRemoteStat, chmodRemote, sortField, sortOrder, setSortField, setSortOrder, error, clearError: () => setError(null), } }