diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 25ccc0bd..e9ba2a37 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -1521,6 +1521,155 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => { }); }); +// Copy SSH file/directory +app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => { + const { sessionId, sourcePath, targetDir, hostId, userId } = req.body; + + if (!sessionId || !sourcePath || !targetDir) { + return res.status(400).json({ error: "Missing required parameters" }); + } + + const sshConn = sshSessions[sessionId]; + if (!sshConn || !sshConn.isConnected) { + return res.status(400).json({ error: "SSH session not found or not connected" }); + } + + sshConn.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + + try { + // Extract source name + const sourceName = sourcePath.split('/').pop() || 'copied_item'; + + // Skip file existence check to avoid SSH hanging - just use timestamp for uniqueness + const timestamp = Date.now().toString().slice(-8); + const nameWithoutExt = sourceName.includes('.') + ? sourceName.substring(0, sourceName.lastIndexOf('.')) + : sourceName; + const extension = sourceName.includes('.') + ? sourceName.substring(sourceName.lastIndexOf('.')) + : ''; + + // Always use timestamp suffix to ensure uniqueness without SSH calls + const uniqueName = `${nameWithoutExt}_copy_${timestamp}${extension}`; + + fileLogger.info("Using timestamp-based unique name", { originalName: sourceName, uniqueName }); + const targetPath = `${targetDir}/${uniqueName}`; + + // Escape paths for shell commands + const escapedSource = sourcePath.replace(/'/g, "'\"'\"'"); + const escapedTarget = targetPath.replace(/'/g, "'\"'\"'"); + + // Use cp with explicit flags to avoid hanging on prompts + // -f: force overwrite without prompting + // -r: recursive for directories + // -p: preserve timestamps, permissions + const copyCommand = `cp -fpr '${escapedSource}' '${escapedTarget}' 2>&1`; + + fileLogger.info("Starting file copy operation", { + operation: "file_copy_start", + sessionId, + sourcePath, + targetPath, + uniqueName, + command: copyCommand.substring(0, 200) + "..." // Log truncated command + }); + + // Add timeout to prevent hanging + const commandTimeout = setTimeout(() => { + fileLogger.error("Copy command timed out after 20 seconds", { + sourcePath, + targetPath, + command: copyCommand + }); + if (!res.headersSent) { + res.status(500).json({ + error: "Copy operation timed out", + toast: { type: "error", message: "Copy operation timed out. SSH connection may be unstable." } + }); + } + }, 20000); // 20 second timeout for better responsiveness + + sshConn.client.exec(copyCommand, (err, stream) => { + if (err) { + clearTimeout(commandTimeout); + fileLogger.error("SSH copyItem error:", err); + if (!res.headersSent) { + return res.status(500).json({ error: err.message }); + } + return; + } + + let errorData = ""; + let stdoutData = ""; + + // Monitor both stdout and stderr + stream.on("data", (data: Buffer) => { + const output = data.toString(); + stdoutData += output; + fileLogger.info("Copy command stdout", { output: output.substring(0, 200) }); + }); + + stream.stderr.on("data", (data: Buffer) => { + const output = data.toString(); + errorData += output; + fileLogger.info("Copy command stderr", { output: output.substring(0, 200) }); + }); + + stream.on("close", (code) => { + clearTimeout(commandTimeout); + fileLogger.info("Copy command completed", { code, errorData, hasError: errorData.length > 0 }); + + if (code !== 0) { + fileLogger.error(`SSH copyItem command failed with code ${code}: ${errorData}`); + if (!res.headersSent) { + return res.status(500).json({ + error: `Copy failed: ${errorData}`, + toast: { type: "error", message: `Copy failed: ${errorData}` } + }); + } + return; + } + + fileLogger.success("Item copied successfully", { + operation: "file_copy", + sessionId, + sourcePath, + targetPath, + uniqueName, + hostId, + userId, + }); + + if (!res.headersSent) { + res.json({ + message: "Item copied successfully", + sourcePath, + targetPath, + uniqueName, + toast: { + type: "success", + message: `Successfully copied to: ${uniqueName}`, + }, + }); + } + }); + + stream.on("error", (streamErr) => { + clearTimeout(commandTimeout); + fileLogger.error("SSH copyItem stream error:", streamErr); + if (!res.headersSent) { + res.status(500).json({ error: `Stream error: ${streamErr.message}` }); + } + }); + }); + + } catch (error: any) { + fileLogger.error("Copy operation error:", error); + res.status(500).json({ error: error.message }); + } +}); + // Helper function to determine MIME type based on file extension function getMimeType(fileName: string): string { const ext = fileName.split('.').pop()?.toLowerCase(); diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx index 5c65d07e..a5344b85 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx @@ -63,6 +63,11 @@ interface FileManagerGridProps { editingFile?: FileItem | null; onStartEdit?: (file: FileItem) => void; onCancelEdit?: () => void; + onDelete?: (files: FileItem[]) => void; + onCopy?: (files: FileItem[]) => void; + onCut?: (files: FileItem[]) => void; + onPaste?: () => void; + onUndo?: () => void; } const getFileIcon = (file: FileItem, viewMode: 'grid' | 'list' = 'grid') => { @@ -151,7 +156,12 @@ export function FileManagerGrid({ onRename, editingFile, onStartEdit, - onCancelEdit + onCancelEdit, + onDelete, + onCopy, + onCut, + onPaste, + onUndo }: FileManagerGridProps) { const { t } = useTranslation(); const gridRef = useRef(null); @@ -456,6 +466,11 @@ export function FileManagerGrid({ const handleFileClick = (file: FileItem, event: React.MouseEvent) => { event.stopPropagation(); + // 确保网格获得焦点以支持键盘事件 + if (gridRef.current) { + gridRef.current.focus(); + } + console.log('File clicked:', file.name, 'Current selected:', selectedFiles.length); if (event.detail === 2) { @@ -504,6 +519,11 @@ export function FileManagerGrid({ // 空白区域点击取消选择 const handleGridClick = (event: React.MouseEvent) => { + // 确保网格获得焦点以支持键盘事件 + if (gridRef.current) { + gridRef.current.focus(); + } + // 如果刚完成框选,不要清空选择 if (event.target === event.currentTarget && !isSelecting && !justFinishedSelecting) { onSelectionChange([]); @@ -513,7 +533,15 @@ export function FileManagerGrid({ // 键盘支持 useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - if (!gridRef.current?.contains(document.activeElement)) return; + // 检查是否有输入框或可编辑元素获得焦点,如果有则跳过 + const activeElement = document.activeElement; + if (activeElement && ( + activeElement.tagName === 'INPUT' || + activeElement.tagName === 'TEXTAREA' || + activeElement.contentEditable === 'true' + )) { + return; + } switch (event.key) { case 'Escape': @@ -527,10 +555,38 @@ export function FileManagerGrid({ onSelectionChange([...files]); } break; + case 'c': + case 'C': + if ((event.ctrlKey || event.metaKey) && selectedFiles.length > 0 && onCopy) { + event.preventDefault(); + onCopy(selectedFiles); + } + break; + case 'x': + case 'X': + if ((event.ctrlKey || event.metaKey) && selectedFiles.length > 0 && onCut) { + event.preventDefault(); + onCut(selectedFiles); + } + break; + case 'v': + case 'V': + if ((event.ctrlKey || event.metaKey) && onPaste) { + event.preventDefault(); + onPaste(); + } + break; + case 'z': + case 'Z': + if ((event.ctrlKey || event.metaKey) && onUndo) { + event.preventDefault(); + onUndo(); + } + break; case 'Delete': - if (selectedFiles.length > 0) { + if (selectedFiles.length > 0 && onDelete) { // 触发删除操作 - console.log('Delete selected files:', selectedFiles); + onDelete(selectedFiles); } break; case 'F2': @@ -548,7 +604,7 @@ export function FileManagerGrid({ document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); - }, [selectedFiles, files, onSelectionChange, onRefresh]); + }, [selectedFiles, files, onSelectionChange, onRefresh, onDelete, onCopy, onCut, onPaste, onUndo]); if (isLoading) { return ( diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx index 3ec6e2ec..a24aa265 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx @@ -28,6 +28,7 @@ import { createSSHFile, createSSHFolder, deleteSSHItem, + copySSHItem, renameSSHItem, connectSSH, getSSHStatus, @@ -73,6 +74,16 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { operation: 'copy' | 'cut'; } | null>(null); + // 撤销历史 + interface UndoAction { + type: 'delete' | 'paste' | 'rename' | 'create'; + description: string; + data: any; + timestamp: number; + } + + const [undoHistory, setUndoHistory] = useState([]); + // 编辑状态 const [editingFile, setEditingFile] = useState(null); const [isCreatingNewFile, setIsCreatingNewFile] = useState(false); @@ -475,7 +486,15 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { function handleContextMenu(event: React.MouseEvent, file?: FileItem) { event.preventDefault(); - const files = file ? [file] : selectedFiles; + // 如果右键点击的文件已经在选中列表中,使用所有选中的文件 + // 如果右键点击的文件不在选中列表中,只使用这一个文件 + let files: FileItem[]; + if (file) { + const isFileSelected = selectedFiles.some(f => f.path === file.path); + files = isFileSelected ? selectedFiles : [file]; + } else { + files = selectedFiles; + } setContextMenu({ x: event.clientX, @@ -495,12 +514,127 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { toast.success(t("fileManager.filesCutToClipboard", { count: files.length })); } - function handlePasteFiles() { + async function handlePasteFiles() { if (!clipboard || !sshSessionId) return; - // TODO: 实现粘贴功能 - // 这里需要根据剪贴板操作类型(copy/cut)来执行相应的操作 - toast.info("粘贴功能正在开发中..."); + try { + await ensureSSHConnection(); + + const { files, operation } = clipboard; + + // 处理复制和剪切操作 + let successCount = 0; + const copiedItems: string[] = []; + + for (const file of files) { + try { + if (operation === 'copy') { + // 复制操作:调用复制API + const result = await copySSHItem( + sshSessionId, + file.path, + currentPath, + currentHost?.id, + currentHost?.userId?.toString() + ); + copiedItems.push(result.uniqueName || file.name); + successCount++; + } else { + // 剪切操作:移动文件 + const newPath = currentPath.endsWith('/') + ? `${currentPath}${file.name}` + : `${currentPath}/${file.name}`; + + if (file.path !== newPath) { + await renameSSHItem( + sshSessionId, + file.path, + newPath, + currentHost?.id, + currentHost?.userId?.toString() + ); + successCount++; + } + } + } catch (error: any) { + console.error(`Failed to ${operation} file ${file.name}:`, error); + toast.error(`${operation === 'copy' ? '复制' : '移动'} ${file.name} 失败: ${error.message}`); + } + } + + // 记录撤销历史 + if (successCount > 0) { + const undoAction: UndoAction = { + type: 'paste', + description: `移动了 ${successCount} 个项目`, + data: { files: files.slice(0, successCount), operation, targetPath: currentPath }, + timestamp: Date.now() + }; + setUndoHistory(prev => [...prev.slice(-9), undoAction]); // 保持最多10个撤销记录 + } + + // 显示成功提示 + if (successCount > 0) { + const operationText = operation === 'copy' ? '复制' : '移动'; + if (operation === 'copy' && copiedItems.length > 0) { + // 显示复制的详细信息,包括重命名的文件 + const hasRenamed = copiedItems.some(name => + !files.some(file => file.name === name) + ); + + if (hasRenamed) { + toast.success(`已${operationText} ${successCount} 个项目,部分文件已自动重命名避免冲突`); + } else { + toast.success(`已${operationText} ${successCount} 个项目`); + } + } else { + toast.success(`已${operationText} ${successCount} 个项目`); + } + } + + // 刷新文件列表 + loadDirectory(currentPath); + clearSelection(); + + // 清空剪贴板(剪切操作后,复制操作保留剪贴板内容) + if (operation === 'cut') { + setClipboard(null); + } + + } catch (error: any) { + toast.error(`粘贴失败: ${error.message || 'Unknown error'}`); + } + } + + function handleUndo() { + if (undoHistory.length === 0) { + toast.info("没有可撤销的操作"); + return; + } + + const lastAction = undoHistory[undoHistory.length - 1]; + + // 移除最后一个撤销记录 + setUndoHistory(prev => prev.slice(0, -1)); + + toast.success(`已撤销:${lastAction.description}`); + + // 根据不同操作类型执行撤销逻辑 + switch (lastAction.type) { + case 'paste': + // 粘贴操作的撤销:删除粘贴的文件或移回原位置 + toast.info("撤销粘贴操作需要手动处理"); + break; + case 'delete': + // 删除操作的撤销:恢复删除的文件 + toast.info("删除操作暂时无法撤销"); + break; + default: + toast.info("该操作暂时无法撤销"); + } + + // 刷新文件列表 + loadDirectory(currentPath); } function handleRenameFile(file: FileItem) { @@ -801,6 +935,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { editingFile={editingFile} onStartEdit={handleStartEdit} onCancelEdit={handleCancelEdit} + onDelete={handleDeleteFiles} + onCopy={handleCopyFiles} + onCut={handleCutFiles} + onPaste={handlePasteFiles} + onUndo={handleUndo} /> {/* 右键菜单 */} diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index b27186d3..e1fc27b9 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1136,6 +1136,30 @@ export async function deleteSSHItem( } } +export async function copySSHItem( + sessionId: string, + sourcePath: string, + targetDir: string, + hostId?: number, + userId?: string, +): Promise { + try { + const response = await fileManagerApi.post("/ssh/copyItem", { + sessionId, + sourcePath, + targetDir, + hostId, + userId, + }, { + timeout: 60000, // 60秒超时,因为文件复制可能需要更长时间 + }); + return response.data; + } catch (error) { + handleApiError(error, "copy SSH item"); + throw error; + } +} + export async function renameSSHItem( sessionId: string, oldPath: string,