Files
opsxshell/src/hooks/useSFTP.ts
T
Logan.LiandCursor fea6d8db04 feat: Pro 买断门禁、SFTP 精简与批量多机改造
新增 entitlement/license IPC 与 Waffo 一次性结账服务骨架,挂载 AI/转发/代理/串口/广播/批量导入导出门禁;批量执行改为结果矩阵并复用连接池,SFTP 工具栏图标化并去掉已连接条,同时完善跨平台本地终端与窗口配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 11:38:30 +08:00

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),
}
}