/** * SFTPView - SFTP 文件传输主组件 (简化版) * * 使用子组件拆分后的简化版本 */ import { useState, useCallback, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { FolderOpen, Upload, Download, Trash2, Edit3, Copy, FileText, ShieldCheck, Unplug, FileCode, } from 'lucide-react' import { useSFTP } from '../../hooks' import { useToast } from '../../hooks/useToast' import { FileListPanel } from './FileListPanel' import { TransferPanel } from './TransferPanel' import { Toolbar } from './Toolbar' import { ConnectionPanel } from './ConnectionPanel' import { ContextMenu, type MenuItem } from './ContextMenu' import { ToastContainer } from '../common/ToastContainer' import EmptyState from '../common/EmptyState' import { sortFiles } from './utils' import { usePromptDialog } from '../common/PromptDialog' import FilePropertiesDialog from './FilePropertiesDialog' import ChmodDialog from './ChmodDialog' import RemoteTextEditor from './RemoteTextEditor' import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types' import { parentPath } from '../../utils/localPath' import type { SftpDragEntry } from './dragDrop' interface SFTPViewProps { tabId: string isActive?: boolean sessionId?: string } export default function SFTPView({ tabId, isActive: _isActive = true, sessionId }: SFTPViewProps) { const { t } = useTranslation('sftp') const { sessions, localPath, localFiles, localSelected, setLocalSelected, localHistory, localHistoryIndex, navigateLocal, navigateLocalBack, navigateLocalForward, loadLocalDirectory, remotePath, remoteFiles, remoteSelected, setRemoteSelected, remoteHistory, remoteHistoryIndex, navigateRemote, navigateRemoteBack, navigateRemoteForward, loadRemoteDirectory, isConnected, isConnecting, connect, transferQueue, uploadFiles, downloadFiles, removeTransfer, deleteRemote, renameRemote, getRemoteStat, chmodRemote, error, sftpId, } = useSFTP({ tabId }) const { toasts, toast, removeToast } = useToast() const { prompt, PromptDialogComponent } = usePromptDialog() // UI State const [sortField, setSortField] = useState('name') const [sortOrder, setSortOrder] = useState('asc') const [isTransferPanelOpen, setIsTransferPanelOpen] = useState(true) const [showConnectionSelector, setShowConnectionSelector] = useState(false) const [contextMenu, setContextMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null) // Properties dialog state const [propertiesFile, setPropertiesFile] = useState(null) // Chmod dialog state const [chmodFile, setChmodFile] = useState(null) const [chmodInitialMode, setChmodInitialMode] = useState(0o644) // Remote text editor const [editFile, setEditFile] = useState(null) // Auto-connect when sessionId is provided and not connected const autoConnectAttempted = useRef(false) const connectRef = useRef(connect) connectRef.current = connect const isConnectedRef = useRef(isConnected) isConnectedRef.current = isConnected const isConnectingRef = useRef(isConnecting) isConnectingRef.current = isConnecting useEffect(() => { if (sessionId && !isConnectedRef.current && !isConnectingRef.current && !autoConnectAttempted.current) { autoConnectAttempted.current = true connectRef.current(sessionId) } }, [sessionId]) // Sort handler const handleSort = useCallback((field: SortField) => { if (sortField === field) { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc') } else { setSortField(field) setSortOrder('asc') } }, [sortField, sortOrder]) // Local selection const toggleLocalSelect = useCallback((name: string) => { setLocalSelected((prev: Set) => { const next = new Set(prev) if (next.has(name)) next.delete(name) else next.add(name) return next }) }, [setLocalSelected]) // Remote selection const toggleRemoteSelect = useCallback((name: string) => { setRemoteSelected((prev: Set) => { const next = new Set(prev) if (next.has(name)) next.delete(name) else next.add(name) return next }) }, [setRemoteSelected]) const reportTransfer = useCallback((action: 'upload' | 'download', result: { completed: number; failed: number; errors: string[] }) => { const actionLabel = action === 'upload' ? t('toast.uploadAction') : t('toast.downloadAction') if (result.failed > 0) { const detail = result.errors[0] || actionLabel toast.error(t('toast.uploadPartial', { action: actionLabel, completed: result.completed, failed: result.failed, detail, })) } else if (result.completed > 0) { toast.success(t('toast.uploadSuccess', { action: actionLabel, completed: result.completed })) } }, [t, toast]) const performUpload = useCallback(async (sources: Array<{ path: string; name?: string; size?: number; isDirectory?: boolean }>, targetPath?: string) => { const result = await uploadFiles(sources, targetPath) reportTransfer('upload', result) }, [reportTransfer, uploadFiles]) const performDownload = useCallback(async (files?: FileItem[], targetPath?: string) => { const result = await downloadFiles(files, targetPath) reportTransfer('download', result) }, [downloadFiles, reportTransfer]) // ===== Context menu handlers ===== // Rename handler const handleRename = useCallback(async (file: FileItem) => { const newName = await prompt({ title: t('dialog.renameTitle'), message: t('dialog.renameMessage'), placeholder: t('dialog.renamePlaceholder'), defaultValue: file.name, }) if (newName && newName !== file.name) { const ok = await renameRemote(file, newName) if (ok) { toast.success(t('toast.renamed', { name: newName })) } else { toast.error(t('toast.renameFailed')) } } }, [prompt, renameRemote, t, toast]) // Copy path handler const handleCopyPath = useCallback(async (filePath: string) => { try { await navigator.clipboard.writeText(filePath) toast.success(t('toast.pathCopied')) } catch { toast.error(t('toast.pathCopyFailed')) } }, [t, toast]) // Properties handler const handleShowProperties = useCallback((file: FileItem) => { setPropertiesFile(file) }, []) // Permissions (chmod) handler — fetch stat first, then show dialog const handleChmod = useCallback(async (file: FileItem) => { const stats = await getRemoteStat(file.path) const mode = stats?.mode ?? 0o644 setChmodFile(file) setChmodInitialMode(mode & 0o7777) }, [getRemoteStat]) // Chmod confirm callback const handleChmodConfirm = useCallback(async (filePath: string, mode: number) => { const ok = await chmodRemote(filePath, mode) if (ok) { toast.success(t('toast.chmodUpdated', { mode: mode.toString(8) })) return true } else { toast.error(t('toast.chmodFailed')) return false } }, [chmodRemote, toast]) // Local context menu const showLocalContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => { e.preventDefault() const items: MenuItem[] = [ { icon: , label: t('contextMenu.open'), action: () => file.type === 'directory' && navigateLocal(file.path), }, ...(file.type !== 'directory' ? [{ icon: , label: t('contextMenu.upload'), action: () => void performUpload([{ path: file.path, name: file.name, size: file.size }]), } as MenuItem] : []), { icon: , label: t('contextMenu.copyPath'), separator: true, action: () => handleCopyPath(file.path) }, { icon: , label: t('contextMenu.properties'), action: () => handleShowProperties(file), }, ] setContextMenu({ x: e.clientX, y: e.clientY, items }) }, [navigateLocal, handleCopyPath, handleShowProperties, performUpload]) // Remote context menu const showRemoteContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => { e.preventDefault() const items: MenuItem[] = [ { icon: , label: t('contextMenu.open'), action: () => file.type === 'directory' && navigateRemote(file.path), }, { icon: , label: t('contextMenu.download'), action: () => void performDownload([file]), }, ...(file.type === 'file' ? [ { icon: , label: t('contextMenu.edit'), action: () => setEditFile(file), } as MenuItem, ] : []), { icon: , label: t('contextMenu.rename'), separator: true, action: () => handleRename(file) }, { icon: , label: t('contextMenu.copyPath'), action: () => handleCopyPath(file.path) }, { icon: , label: t('contextMenu.properties'), action: () => handleShowProperties(file), }, { icon: , label: t('contextMenu.permissions'), action: () => handleChmod(file), }, { icon: , label: t('contextMenu.delete'), separator: true, danger: true, action: () => deleteRemote([file]) }, ] setContextMenu({ x: e.clientX, y: e.clientY, items }) }, [navigateRemote, performDownload, deleteRemote, handleRename, handleCopyPath, handleShowProperties, handleChmod]) // Navigate up const navigateLocalUp = useCallback(() => { navigateLocal(parentPath(localPath, true)) }, [localPath, navigateLocal]) const navigateRemoteUp = useCallback(() => { navigateRemote(parentPath(remotePath, false)) }, [remotePath, navigateRemote]) // Handle file open const handleLocalOpen = useCallback((file: FileItem) => { if (file.type === 'directory') { navigateLocal(file.path) } }, [navigateLocal]) const handleRemoteOpen = useCallback((file: FileItem) => { if (file.type === 'directory') { navigateRemote(file.path) } }, [navigateRemote]) // Upload selected local rows first; otherwise use the native picker so Rust // receives real absolute paths instead of browser-only File objects. const handleUpload = useCallback(async () => { const selectedFiles = localFiles.filter((file) => localSelected.has(file.name)) if (selectedFiles.length > 0) { await performUpload(selectedFiles.map((file) => ({ path: file.path, name: file.name, size: file.size, isDirectory: file.type === 'directory', }))) return } const result = await window.opsxApi?.showOpenDialog({ title: t('dialog.pickUploadTitle'), defaultPath: localPath, properties: ['openFile', 'openDirectory', 'multiSelections'], }) if (!result || result.canceled) return const paths = result.filePaths ?? (result.filePath ? [result.filePath] : []) if (paths.length > 0) { await performUpload(paths.map((path) => ({ path }))) } }, [localFiles, localPath, localSelected, performUpload, t]) // Handle drag-and-drop upload on remote panel const handleDropFiles = useCallback((files: FileList, targetPath: string) => { if (!isConnected) return const sources = Array.from(files).flatMap((file) => { const path = (file as File & { path?: string }).path return path ? [{ path, name: file.name, size: file.size }] : [] }) if (sources.length === 0) { toast.warning(t('toast.noDragPath')) return } void performUpload(sources, targetPath) }, [isConnected, performUpload, toast, t]) const handleDropEntry = useCallback((entry: SftpDragEntry, targetPath: string) => { if (!isConnected) return if (entry.source === 'local') { void performUpload( [{ path: entry.path, name: entry.name, size: entry.size, isDirectory: entry.type === 'directory' }], targetPath ) } else { void performDownload([{ name: entry.name, path: entry.path, type: entry.type, size: entry.size, modified: 0, permissions: '', }], targetPath) } }, [isConnected, performDownload, performUpload]) // Handle new folder const handleNewFolder = useCallback(async () => { const name = await prompt({ title: t('dialog.newFolderTitle'), message: t('dialog.newFolderMessage'), placeholder: t('dialog.newFolderPlaceholder'), }) if (name && window.opsxApi?.localMkdir) { window.opsxApi.localMkdir(`${localPath}/${name}`).then(() => { loadLocalDirectory(localPath) }) } }, [localPath, loadLocalDirectory, prompt, t]) return (
{/* Connection Status */} sessionId && connect(sessionId)} onShowSelector={() => setShowConnectionSelector(true)} onCloseSelector={() => setShowConnectionSelector(false)} /> {/* Toolbar */} remoteSelected.has(file.name))} onDownload={() => void performDownload()} onRefresh={() => { loadLocalDirectory(localPath) if (isConnected) loadRemoteDirectory(remotePath) }} onNewFolder={handleNewFolder} /> {/* File Panels */} {!isConnected ? (
) : (
{/* Local Panel */} setContextMenu(null)} onDropEntry={handleDropEntry} /> {/* Remote Panel */} setContextMenu(null)} onDropFiles={isConnected ? handleDropFiles : undefined} onDropEntry={handleDropEntry} loading={isConnecting} />
)} {/* Transfer Panel */} setIsTransferPanelOpen(!isTransferPanelOpen)} onRemove={removeTransfer} /> {/* Global Context Menu */} {contextMenu && ( setContextMenu(null)} /> )} {PromptDialogComponent} {/* File Properties Dialog */} {propertiesFile && ( setPropertiesFile(null)} fileName={propertiesFile.name} filePath={propertiesFile.path} fileType={propertiesFile.type} permissions={propertiesFile.permissions} initialSize={propertiesFile.size} initialModified={propertiesFile.modified} onFetchStat={isConnected ? getRemoteStat : undefined} /> )} {/* Chmod Dialog */} {chmodFile && ( setChmodFile(null)} fileName={chmodFile.name} filePath={chmodFile.path} initialMode={chmodInitialMode} onConfirm={handleChmodConfirm} /> )} {/* Remote text editor */} {editFile && sftpId && ( setEditFile(null)} onSaved={() => toast.success(t('editor.saved'))} /> )} {/* Toast Notifications */}
) }