核心功能: - SSH终端: 密码/密钥认证、XTerm.js终端、多主题配色、IP高亮 - SFTP管理: 双栏文件管理、上传下载、传输队列 - 端口转发: 本地/远程转发、SOCKS5动态代理、规则管理 架构改进: - 拆分大型组件 (SFTPView/TabBar/Sidebar) - 添加统一日志和错误处理 - 修复React严格模式下的终端黑屏问题 UI更新: - 新增端口转发视图 - 更新UI预览文档 - 左侧栏右键菜单支持SFTP快捷打开
277 lines
7.9 KiB
TypeScript
277 lines
7.9 KiB
TypeScript
/**
|
|
* SFTPView - SFTP 文件传输主组件 (简化版)
|
|
*
|
|
* 使用子组件拆分后的简化版本
|
|
*/
|
|
|
|
import { useState, useCallback } from 'react'
|
|
import { useSFTP } from '../hooks'
|
|
import { FileListPanel } from './sftp/FileListPanel'
|
|
import { TransferPanel } from './sftp/TransferPanel'
|
|
import { Toolbar } from './sftp/Toolbar'
|
|
import { ConnectionPanel } from './sftp/ConnectionPanel'
|
|
import { ContextMenu, type MenuItem } from './sftp/ContextMenu'
|
|
import { sortFiles } from './sftp/utils'
|
|
import type { FileItem, SortField, SortOrder } from '../../shared/types/sftp.types'
|
|
|
|
interface SFTPViewProps {
|
|
tabId: string
|
|
isActive?: boolean
|
|
}
|
|
|
|
export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPViewProps) {
|
|
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,
|
|
error,
|
|
} = useSFTP({ tabId })
|
|
|
|
// 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)
|
|
|
|
// 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])
|
|
|
|
// Local context menu
|
|
const showLocalContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
|
e.preventDefault()
|
|
const items: MenuItem[] = [
|
|
{
|
|
icon: '📂',
|
|
label: '打开',
|
|
action: () => file.type === 'directory' && navigateLocal(file.path),
|
|
},
|
|
{
|
|
icon: '📤',
|
|
label: '上传',
|
|
action: () => {},
|
|
},
|
|
]
|
|
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
|
}, [navigateLocal])
|
|
|
|
// Remote context menu
|
|
const showRemoteContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
|
e.preventDefault()
|
|
const items: MenuItem[] = [
|
|
{
|
|
icon: '📂',
|
|
label: '打开',
|
|
action: () => file.type === 'directory' && navigateRemote(file.path),
|
|
},
|
|
{
|
|
icon: '📥',
|
|
label: '下载',
|
|
action: () => {
|
|
setRemoteSelected(new Set([file.name]))
|
|
downloadFiles()
|
|
},
|
|
},
|
|
{
|
|
icon: '🗑️',
|
|
label: '删除',
|
|
danger: true,
|
|
action: () => {},
|
|
},
|
|
]
|
|
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
|
}, [navigateRemote, downloadFiles, setRemoteSelected])
|
|
|
|
// Navigate up
|
|
const navigateLocalUp = useCallback(() => {
|
|
const parent = localPath.split('/').slice(0, -1).join('/') || '/'
|
|
navigateLocal(parent)
|
|
}, [localPath, navigateLocal])
|
|
|
|
const navigateRemoteUp = useCallback(() => {
|
|
const parent = remotePath.split('/').slice(0, -1).join('/') || '/'
|
|
navigateRemote(parent)
|
|
}, [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])
|
|
|
|
// Handle upload
|
|
const handleUpload = useCallback(() => {
|
|
const input = document.createElement('input')
|
|
input.type = 'file'
|
|
input.multiple = true
|
|
input.onchange = (e) => {
|
|
uploadFiles((e.target as HTMLInputElement).files)
|
|
}
|
|
input.click()
|
|
}, [uploadFiles])
|
|
|
|
// Handle new folder
|
|
const handleNewFolder = useCallback(() => {
|
|
const name = prompt('输入文件夹名称:')
|
|
if (name && window.electronAPI?.localMkdir) {
|
|
window.electronAPI.localMkdir(`${localPath}/${name}`).then(() => {
|
|
loadLocalDirectory(localPath)
|
|
})
|
|
}
|
|
}, [localPath, loadLocalDirectory])
|
|
|
|
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}
|
|
onDownload={downloadFiles}
|
|
onRefresh={() => {
|
|
loadLocalDirectory(localPath)
|
|
if (isConnected) loadRemoteDirectory(remotePath)
|
|
}}
|
|
onNewFolder={handleNewFolder}
|
|
/>
|
|
|
|
{/* File Panels */}
|
|
<div className="flex-1 flex gap-4 p-4 min-h-0">
|
|
{/* Local Panel */}
|
|
<FileListPanel
|
|
title="本地"
|
|
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)}
|
|
/>
|
|
|
|
{/* Remote Panel */}
|
|
<FileListPanel
|
|
title="远程"
|
|
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)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Transfer Panel */}
|
|
<TransferPanel
|
|
tasks={transferQueue}
|
|
isOpen={isTransferPanelOpen}
|
|
onToggle={() => setIsTransferPanelOpen(!isTransferPanelOpen)}
|
|
onCancel={() => {}}
|
|
onRemove={() => {}}
|
|
/>
|
|
|
|
{/* Global Context Menu */}
|
|
{contextMenu && (
|
|
<ContextMenu
|
|
x={contextMenu.x}
|
|
y={contextMenu.y}
|
|
items={contextMenu.items}
|
|
onClose={() => setContextMenu(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|