新增 entitlement/license IPC 与 Waffo 一次性结账服务骨架,挂载 AI/转发/代理/串口/广播/批量导入导出门禁;批量执行改为结果矩阵并复用连接池,SFTP 工具栏图标化并去掉已连接条,同时完善跨平台本地终端与窗口配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
907 lines
30 KiB
TypeScript
907 lines
30 KiB
TypeScript
/**
|
|
* 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<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: UploadSource[], destinationRemotePath?: string) => Promise<TransferBatchResult>
|
|
downloadFiles: (files?: FileItem[], destinationLocalPath?: string) => Promise<TransferBatchResult>
|
|
removeTransfer: (taskId: string) => void
|
|
|
|
// 远程文件操作
|
|
deleteRemote: (files: FileItem[]) => Promise<boolean>
|
|
renameRemote: (file: FileItem, newName: string) => Promise<boolean>
|
|
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<boolean>
|
|
|
|
// 排序
|
|
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<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[]>([])
|
|
const removeTransfer = useCallback((taskId: string) => {
|
|
setTransferQueue((tasks) => tasks.filter((task) => task.id !== taskId))
|
|
}, [])
|
|
|
|
// Sort state
|
|
const [sortField, setSortField] = useState<SortField>('name')
|
|
const [sortOrder, setSortOrder] = useState<SortOrder>('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<TransferBatchResult> => {
|
|
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<TransferBatchResult> => {
|
|
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<boolean> => {
|
|
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<boolean> => {
|
|
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<boolean> => {
|
|
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),
|
|
}
|
|
}
|