feat: release OpsXShell 2.1.0 native Tauri edition
This commit is contained in:
1 parent
e9a0d21758
commit
a3dba0c6ed
311 files changed
+31748
-14897
No files matched your search
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* SFTPView - SFTP 文件传输主组件 (简化版)
|
||||
*
|
||||
* 使用子组件拆分后的简化版本
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
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 type { SftpDragEntry } from './dragDrop'
|
||||
|
||||
interface SFTPViewProps {
|
||||
tabId: string
|
||||
isActive?: boolean
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export default function SFTPView({ tabId, isActive: _isActive = true, sessionId }: 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,
|
||||
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: '上传' | '下载', result: { completed: number; failed: number; errors: string[] }) => {
|
||||
if (result.failed > 0) {
|
||||
const detail = result.errors[0] || `${action}失败`
|
||||
toast.error(`${action}完成 ${result.completed} 个,失败 ${result.failed} 个:${detail}`)
|
||||
} else if (result.completed > 0) {
|
||||
toast.success(`${action}完成 ${result.completed} 个文件`)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
const performUpload = useCallback(async (sources: Array<{ path: string; name?: string; size?: number }>, targetPath?: string) => {
|
||||
const result = await uploadFiles(sources, targetPath)
|
||||
reportTransfer('上传', result)
|
||||
}, [reportTransfer, uploadFiles])
|
||||
|
||||
const performDownload = useCallback(async (files?: FileItem[], targetPath?: string) => {
|
||||
const result = await downloadFiles(files, targetPath)
|
||||
reportTransfer('下载', result)
|
||||
}, [downloadFiles, reportTransfer])
|
||||
|
||||
// ===== Context menu handlers =====
|
||||
|
||||
// Rename handler
|
||||
const handleRename = useCallback(async (file: FileItem) => {
|
||||
const newName = await prompt({
|
||||
title: '重命名',
|
||||
message: '输入新名称:',
|
||||
placeholder: '新名称',
|
||||
defaultValue: file.name,
|
||||
})
|
||||
if (newName && newName !== file.name) {
|
||||
const ok = await renameRemote(file, newName)
|
||||
if (ok) {
|
||||
toast.success(`已重命名为 ${newName}`)
|
||||
} else {
|
||||
toast.error('重命名失败')
|
||||
}
|
||||
}
|
||||
}, [prompt, renameRemote, toast])
|
||||
|
||||
// Copy path handler
|
||||
const handleCopyPath = useCallback(async (filePath: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(filePath)
|
||||
toast.success('路径已复制到剪贴板')
|
||||
} catch {
|
||||
toast.error('复制路径失败')
|
||||
}
|
||||
}, [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(`权限已更新为 ${mode.toString(8)}`)
|
||||
return true
|
||||
} else {
|
||||
toast.error('权限修改失败')
|
||||
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: '打开',
|
||||
action: () => file.type === 'directory' && navigateLocal(file.path),
|
||||
},
|
||||
...(file.type !== 'directory'
|
||||
? [{
|
||||
icon: <Upload className="w-4 h-4" />,
|
||||
label: '上传',
|
||||
action: () => void performUpload([{ path: file.path, name: file.name, size: file.size }]),
|
||||
} as MenuItem]
|
||||
: []),
|
||||
{ icon: <Copy className="w-4 h-4" />, label: '复制路径', separator: true, action: () => handleCopyPath(file.path) },
|
||||
{
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
label: '属性',
|
||||
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: '打开',
|
||||
action: () => file.type === 'directory' && navigateRemote(file.path),
|
||||
},
|
||||
{
|
||||
icon: <Download className="w-4 h-4" />,
|
||||
label: '下载',
|
||||
action: () => void performDownload([file]),
|
||||
},
|
||||
...(file.type === 'file'
|
||||
? [
|
||||
{
|
||||
icon: <FileCode className="w-4 h-4" />,
|
||||
label: '编辑',
|
||||
action: () => setEditFile(file),
|
||||
} as MenuItem,
|
||||
]
|
||||
: []),
|
||||
{ icon: <Edit3 className="w-4 h-4" />, label: '重命名', separator: true, action: () => handleRename(file) },
|
||||
{ icon: <Copy className="w-4 h-4" />, label: '复制路径', action: () => handleCopyPath(file.path) },
|
||||
{
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
label: '属性',
|
||||
action: () => handleShowProperties(file),
|
||||
},
|
||||
{
|
||||
icon: <ShieldCheck className="w-4 h-4" />,
|
||||
label: '权限',
|
||||
action: () => handleChmod(file),
|
||||
},
|
||||
{ icon: <Trash2 className="w-4 h-4" />, label: '删除', 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(() => {
|
||||
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])
|
||||
|
||||
// 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) && file.type !== 'directory'
|
||||
)
|
||||
if (selectedFiles.length > 0) {
|
||||
await performUpload(selectedFiles.map((file) => ({
|
||||
path: file.path,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
})))
|
||||
return
|
||||
}
|
||||
|
||||
if (localSelected.size > 0) {
|
||||
toast.warning('暂不支持上传文件夹,请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await window.opsxApi?.showOpenDialog({
|
||||
title: '选择要上传的文件',
|
||||
defaultPath: localPath,
|
||||
properties: ['openFile', '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, toast])
|
||||
|
||||
// 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('当前系统未提供拖拽文件路径,请使用“上传”按钮选择文件')
|
||||
return
|
||||
}
|
||||
void performUpload(sources, targetPath)
|
||||
}, [isConnected, performUpload, toast])
|
||||
|
||||
const handleDropEntry = useCallback((entry: SftpDragEntry, targetPath: string) => {
|
||||
if (!isConnected) return
|
||||
if (entry.type !== 'file') {
|
||||
toast.warning('暂不支持文件夹递归传输,请拖拽单个文件')
|
||||
return
|
||||
}
|
||||
if (entry.source === 'local') {
|
||||
void performUpload([{ path: entry.path, name: entry.name, size: entry.size }], targetPath)
|
||||
} else {
|
||||
void performDownload([{
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
type: entry.type,
|
||||
size: entry.size,
|
||||
modified: 0,
|
||||
permissions: '',
|
||||
}], targetPath)
|
||||
}
|
||||
}, [isConnected, performDownload, performUpload, toast])
|
||||
|
||||
// Handle new folder
|
||||
const handleNewFolder = useCallback(async () => {
|
||||
const name = await prompt({
|
||||
title: '新建文件夹',
|
||||
message: '输入文件夹名称:',
|
||||
placeholder: '文件夹名称',
|
||||
})
|
||||
if (name && window.opsxApi?.localMkdir) {
|
||||
window.opsxApi.localMkdir(`${localPath}/${name}`).then(() => {
|
||||
loadLocalDirectory(localPath)
|
||||
})
|
||||
}
|
||||
}, [localPath, loadLocalDirectory, prompt])
|
||||
|
||||
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) && file.type !== 'directory'
|
||||
)}
|
||||
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="未连接到远程服务器"
|
||||
description="请先连接 SSH 会话以使用 SFTP"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<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)}
|
||||
onDropEntry={handleDropEntry}
|
||||
/>
|
||||
|
||||
{/* 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)}
|
||||
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('文件已保存')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user