Files
opsxshell/src/components/sftp/SFTPView.tsx
T
Logan.Li b0d0e3a5f3 feat(i18n): add multi-language support (ja/ko/vi/ru/zh-TW) with system auto-detection
- Extend AppLanguage to 7 locales: zh-CN, zh-TW, en, ja, ko, vi, ru
- Auto-detect system language via navigator.languages; fallback to English
- Add BCP47 locale mapping for date formatting and sorting
- Create 50 translation JSON files (5 languages × 10 namespaces, 1137 keys each)
- Update language selector with native language names
- Extend locale key parity test to cover all 7 languages (70 assertions)
- Add VNC/RDP desktop, serial terminal, AI agent, port forwarding, and SFTP features
- Add credential encryption, host metrics, session backup crypto, and zmodem bridge
- Update Rust backend: desktop services, host metrics, SSH/SFTP enhancements
2026-08-12 00:07:48 +08:00

536 lines
18 KiB
TypeScript

/**
* 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<SortField>('name')
const [sortOrder, setSortOrder] = useState<SortOrder>('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<FileItem | null>(null)
// Chmod dialog state
const [chmodFile, setChmodFile] = useState<FileItem | null>(null)
const [chmodInitialMode, setChmodInitialMode] = useState(0o644)
// Remote text editor
const [editFile, setEditFile] = useState<FileItem | null>(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<string>) => {
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<string>) => {
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: <FolderOpen className="w-4 h-4" />,
label: t('contextMenu.open'),
action: () => file.type === 'directory' && navigateLocal(file.path),
},
...(file.type !== 'directory'
? [{
icon: <Upload className="w-4 h-4" />,
label: t('contextMenu.upload'),
action: () => void performUpload([{ path: file.path, name: file.name, size: file.size }]),
} as MenuItem]
: []),
{ icon: <Copy className="w-4 h-4" />, label: t('contextMenu.copyPath'), separator: true, action: () => handleCopyPath(file.path) },
{
icon: <FileText className="w-4 h-4" />,
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: <FolderOpen className="w-4 h-4" />,
label: t('contextMenu.open'),
action: () => file.type === 'directory' && navigateRemote(file.path),
},
{
icon: <Download className="w-4 h-4" />,
label: t('contextMenu.download'),
action: () => void performDownload([file]),
},
...(file.type === 'file'
? [
{
icon: <FileCode className="w-4 h-4" />,
label: t('contextMenu.edit'),
action: () => setEditFile(file),
} as MenuItem,
]
: []),
{ icon: <Edit3 className="w-4 h-4" />, label: t('contextMenu.rename'), separator: true, action: () => handleRename(file) },
{ icon: <Copy className="w-4 h-4" />, label: t('contextMenu.copyPath'), action: () => handleCopyPath(file.path) },
{
icon: <FileText className="w-4 h-4" />,
label: t('contextMenu.properties'),
action: () => handleShowProperties(file),
},
{
icon: <ShieldCheck className="w-4 h-4" />,
label: t('contextMenu.permissions'),
action: () => handleChmod(file),
},
{ icon: <Trash2 className="w-4 h-4" />, 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 (
<div className="h-full flex flex-col bg-dark-900">
{/* Connection Status */}
<ConnectionPanel
isConnected={isConnected}
isConnecting={isConnecting}
error={error}
sessions={sessions}
showSelector={showConnectionSelector}
onConnect={(sessionId) => sessionId && connect(sessionId)}
onShowSelector={() => setShowConnectionSelector(true)}
onCloseSelector={() => setShowConnectionSelector(false)}
/>
{/* Toolbar */}
<Toolbar
isConnected={isConnected}
onUpload={handleUpload}
canDownload={remoteFiles.some((file) => remoteSelected.has(file.name))}
onDownload={() => void performDownload()}
onRefresh={() => {
loadLocalDirectory(localPath)
if (isConnected) loadRemoteDirectory(remotePath)
}}
onNewFolder={handleNewFolder}
/>
{/* File Panels */}
{!isConnected ? (
<div className="flex-1 flex items-center justify-center">
<EmptyState
icon={Unplug}
title={t('panel.notConnectedTitle')}
description={t('panel.notConnectedDesc')}
/>
</div>
) : (
<div className="flex-1 flex gap-4 p-4 min-h-0">
{/* Local Panel */}
<FileListPanel
title={t('panel.local')}
isLocal={true}
path={localPath}
files={sortFiles(localFiles, sortField, sortOrder)}
selected={localSelected}
history={localHistory}
historyIndex={localHistoryIndex}
sortField={sortField}
sortOrder={sortOrder}
onNavigate={navigateLocal}
onNavigateBack={navigateLocalBack}
onNavigateForward={navigateLocalForward}
onNavigateUp={navigateLocalUp}
onSelect={toggleLocalSelect}
onOpen={handleLocalOpen}
onContextMenu={showLocalContextMenu}
onSort={handleSort}
contextMenu={contextMenu}
onCloseContextMenu={() => setContextMenu(null)}
onDropEntry={handleDropEntry}
/>
{/* Remote Panel */}
<FileListPanel
title={t('panel.remote')}
isLocal={false}
path={remotePath}
files={sortFiles(remoteFiles, sortField, sortOrder)}
selected={remoteSelected}
history={remoteHistory}
historyIndex={remoteHistoryIndex}
sortField={sortField}
sortOrder={sortOrder}
onNavigate={navigateRemote}
onNavigateBack={navigateRemoteBack}
onNavigateForward={navigateRemoteForward}
onNavigateUp={navigateRemoteUp}
onSelect={toggleRemoteSelect}
onOpen={handleRemoteOpen}
onContextMenu={showRemoteContextMenu}
onSort={handleSort}
contextMenu={contextMenu}
onCloseContextMenu={() => setContextMenu(null)}
onDropFiles={isConnected ? handleDropFiles : undefined}
onDropEntry={handleDropEntry}
loading={isConnecting}
/>
</div>
)}
{/* Transfer Panel */}
<TransferPanel
tasks={transferQueue}
isOpen={isTransferPanelOpen}
onToggle={() => setIsTransferPanelOpen(!isTransferPanelOpen)}
onRemove={removeTransfer}
/>
{/* Global Context Menu */}
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={contextMenu.items}
onClose={() => setContextMenu(null)}
/>
)}
{PromptDialogComponent}
{/* File Properties Dialog */}
{propertiesFile && (
<FilePropertiesDialog
isOpen={!!propertiesFile}
onClose={() => 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 && (
<ChmodDialog
isOpen={!!chmodFile}
onClose={() => setChmodFile(null)}
fileName={chmodFile.name}
filePath={chmodFile.path}
initialMode={chmodInitialMode}
onConfirm={handleChmodConfirm}
/>
)}
{/* Remote text editor */}
{editFile && sftpId && (
<RemoteTextEditor
isOpen={!!editFile}
sftpId={sftpId}
remotePath={editFile.path}
fileName={editFile.name}
onClose={() => setEditFile(null)}
onSaved={() => toast.success(t('editor.saved'))}
/>
)}
{/* Toast Notifications */}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</div>
)
}