- 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
536 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|