diff --git a/packages/backend/src/services/sftp.service.ts b/packages/backend/src/services/sftp.service.ts index 189adc0..b9c9042 100644 --- a/packages/backend/src/services/sftp.service.ts +++ b/packages/backend/src/services/sftp.service.ts @@ -1,6 +1,14 @@ -import { Client, SFTPWrapper, Stats, WriteStream } from 'ssh2'; // Import WriteStream +import { Client, SFTPWrapper, Stats, WriteStream } from 'ssh2'; // Import WriteStream (Removed Dirent) import { WebSocket } from 'ws'; import { ClientState } from '../websocket'; // 导入统一的 ClientState +import * as pathModule from 'path'; // +++ Import path module +++ + +// +++ Define local interface for readdir results +++ +interface SftpDirEntry { + filename: string; + longname: string; + attrs: Stats; +} // 定义服务器状态的数据结构 (与前端 StatusMonitor.vue 匹配) // Note: This interface seems out of place here, but keeping it for now as it was in the original file. @@ -513,6 +521,286 @@ export class SftpService { } } + // +++ 新增:复制文件或目录 +++ + async copy(sessionId: string, sources: string[], destinationDir: string, requestId: string): Promise { + const state = this.clientStates.get(sessionId); + if (!state || !state.sftp) { + console.warn(`[SFTP Copy] SFTP 未准备好,无法在 ${sessionId} 上执行 copy (ID: ${requestId})`); + state?.ws.send(JSON.stringify({ type: 'sftp:copy:error', payload: 'SFTP 会话未就绪', requestId: requestId })); + return; + } + const sftp = state.sftp; + console.debug(`[SFTP ${sessionId}] Received copy request (ID: ${requestId}) Sources: ${sources.join(', ')}, Dest: ${destinationDir}`); + + const copiedItemsDetails: any[] = []; // Store details of successfully copied items + let firstError: Error | null = null; + + try { + // Ensure destination directory exists + try { + await this.ensureDirectoryExists(sftp, destinationDir); + } catch (ensureErr: any) { + console.error(`[SFTP ${sessionId}] Failed to ensure destination directory ${destinationDir} exists (ID: ${requestId}):`, ensureErr); + throw new Error(`无法创建或访问目标目录: ${ensureErr.message}`); + } + + for (const sourcePath of sources) { + const sourceName = pathModule.basename(sourcePath); + const destPath = pathModule.join(destinationDir, sourceName).replace(/\\/g, '/'); // Ensure forward slashes + + if (sourcePath === destPath) { + console.warn(`[SFTP ${sessionId}] Skipping copy: source and destination are the same (${sourcePath}) (ID: ${requestId})`); + continue; // Skip if source and destination are identical + } + + try { + const stats = await this.getStats(sftp, sourcePath); + if (stats.isDirectory()) { + console.log(`[SFTP ${sessionId}] Copying directory ${sourcePath} to ${destPath} (ID: ${requestId})`); + await this.copyDirectoryRecursive(sftp, sourcePath, destPath); + } else if (stats.isFile()) { + console.log(`[SFTP ${sessionId}] Copying file ${sourcePath} to ${destPath} (ID: ${requestId})`); + await this.copyFile(sftp, sourcePath, destPath); + } else { + // Handle symlinks or other types if necessary, for now just skip/warn + console.warn(`[SFTP ${sessionId}] Skipping copy of unsupported file type: ${sourcePath} (ID: ${requestId})`); + continue; + } + // Get stats of the *newly copied* item + const copiedStats = await this.getStats(sftp, destPath); + copiedItemsDetails.push(this.formatStatsToFileListItem(destPath, copiedStats)); + + } catch (copyErr: any) { + console.error(`[SFTP ${sessionId}] Error copying ${sourcePath} to ${destPath} (ID: ${requestId}):`, copyErr); + firstError = copyErr; // Store the first error encountered + break; // Stop processing further sources on error + } + } + + if (firstError) { + throw firstError; // Throw the first error to be caught below + } + + // Send success message with details of copied items + console.log(`[SFTP ${sessionId}] Copy operation completed successfully (ID: ${requestId}). Copied items: ${copiedItemsDetails.length}`); + state.ws.send(JSON.stringify({ + type: 'sftp:copy:success', + payload: { destination: destinationDir, items: copiedItemsDetails }, + requestId: requestId + })); + + } catch (error: any) { + console.error(`[SFTP ${sessionId}] Copy operation failed (ID: ${requestId}):`, error); + state.ws.send(JSON.stringify({ type: 'sftp:copy:error', payload: `复制操作失败: ${error.message}`, requestId: requestId })); + } + } + + // +++ 新增:移动文件或目录 +++ + async move(sessionId: string, sources: string[], destinationDir: string, requestId: string): Promise { + const state = this.clientStates.get(sessionId); + if (!state || !state.sftp) { + console.warn(`[SFTP Move] SFTP 未准备好,无法在 ${sessionId} 上执行 move (ID: ${requestId})`); + state?.ws.send(JSON.stringify({ type: 'sftp:move:error', payload: 'SFTP 会话未就绪', requestId: requestId })); + return; + } + const sftp = state.sftp; + console.debug(`[SFTP ${sessionId}] Received move request (ID: ${requestId}) Sources: ${sources.join(', ')}, Dest: ${destinationDir}`); + + const movedItemsDetails: any[] = []; + let firstError: Error | null = null; + + try { + // Ensure destination directory exists (important for move) + try { + await this.ensureDirectoryExists(sftp, destinationDir); + } catch (ensureErr: any) { + console.error(`[SFTP ${sessionId}] Failed to ensure destination directory ${destinationDir} exists for move (ID: ${requestId}):`, ensureErr); + throw new Error(`无法创建或访问目标目录: ${ensureErr.message}`); + } + + for (const oldPath of sources) { + const sourceName = pathModule.basename(oldPath); + const newPath = pathModule.join(destinationDir, sourceName).replace(/\\/g, '/'); // Ensure forward slashes + + if (oldPath === newPath) { + console.warn(`[SFTP ${sessionId}] Skipping move: source and destination are the same (${oldPath}) (ID: ${requestId})`); + continue; // Skip if source and destination are identical + } + + try { + console.log(`[SFTP ${sessionId}] Moving ${oldPath} to ${newPath} (ID: ${requestId})`); + await this.performRename(sftp, oldPath, newPath); // Use helper for rename logic + + // Get stats of the *moved* item at the new location + const movedStats = await this.getStats(sftp, newPath); + movedItemsDetails.push(this.formatStatsToFileListItem(newPath, movedStats)); + + } catch (moveErr: any) { + console.error(`[SFTP ${sessionId}] Error moving ${oldPath} to ${newPath} (ID: ${requestId}):`, moveErr); + firstError = moveErr; + break; // Stop on first error for move + } + } + + if (firstError) { + throw firstError; + } + + console.log(`[SFTP ${sessionId}] Move operation completed successfully (ID: ${requestId}). Moved items: ${movedItemsDetails.length}`); + state.ws.send(JSON.stringify({ + type: 'sftp:move:success', + payload: { sources: sources, destination: destinationDir, items: movedItemsDetails }, + requestId: requestId + })); + + } catch (error: any) { + console.error(`[SFTP ${sessionId}] Move operation failed (ID: ${requestId}):`, error); + state.ws.send(JSON.stringify({ type: 'sftp:move:error', payload: `移动操作失败: ${error.message}`, requestId: requestId })); + } + } + + // +++ 新增:辅助方法 - 复制文件 +++ + private copyFile(sftp: SFTPWrapper, sourcePath: string, destPath: string): Promise { + return new Promise((resolve, reject) => { + const readStream = sftp.createReadStream(sourcePath); + const writeStream = sftp.createWriteStream(destPath); + let errorOccurred = false; + + const onError = (err: Error) => { + if (errorOccurred) return; + errorOccurred = true; + // Ensure streams are destroyed on error + readStream.destroy(); + writeStream.destroy(); + console.error(`Error copying file ${sourcePath} to ${destPath}:`, err); + reject(new Error(`复制文件失败: ${err.message}`)); + }; + + readStream.on('error', onError); + writeStream.on('error', onError); + + writeStream.on('close', () => { // Use 'close' for write stream completion + if (!errorOccurred) { + resolve(); + } + }); + + readStream.pipe(writeStream); + }); + } + + // +++ 新增:辅助方法 - 递归复制目录 +++ + private async copyDirectoryRecursive(sftp: SFTPWrapper, sourcePath: string, destPath: string): Promise { + try { + // Create destination directory + await this.ensureDirectoryExists(sftp, destPath); + + // Read source directory contents + const items = await this.listDirectory(sftp, sourcePath); + + for (const item of items) { + const currentSourcePath = pathModule.join(sourcePath, item.filename).replace(/\\/g, '/'); + const currentDestPath = pathModule.join(destPath, item.filename).replace(/\\/g, '/'); + const itemStats = item.attrs; // Assuming readdir provides stats + + if (itemStats.isDirectory()) { + await this.copyDirectoryRecursive(sftp, currentSourcePath, currentDestPath); + } else if (itemStats.isFile()) { + await this.copyFile(sftp, currentSourcePath, currentDestPath); + } else { + console.warn(`[SFTP Copy Recurse] Skipping unsupported type: ${currentSourcePath}`); + } + } + } catch (error: any) { + console.error(`Error recursively copying directory ${sourcePath} to ${destPath}:`, error); + throw new Error(`递归复制目录失败: ${error.message}`); + } + } + + // +++ 新增:辅助方法 - 获取 Stats (Promise wrapper) +++ + private getStats(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.lstat(path, (err, stats) => { + if (err) { + reject(err); + } else { + resolve(stats); + } + }); + }); + } + + // +++ 新增:辅助方法 - 确保目录存在 (Promise wrapper) +++ + private ensureDirectoryExists(sftp: SFTPWrapper, dirPath: string): Promise { + return new Promise((resolve, reject) => { + sftp.stat(dirPath, (err: any, stats) => { // Cast err to any + if (err) { + // If error is 'No such file', create the directory + // Access err.code after casting to any + if (err.code === 'ENOENT' || (err.message && err.message.includes('No such file'))) { + sftp.mkdir(dirPath, (mkdirErr) => { + if (mkdirErr) { + reject(new Error(`创建目录失败 ${dirPath}: ${mkdirErr.message}`)); + } else { + console.log(`[SFTP Util] Created directory: ${dirPath}`); + resolve(); + } + }); + } else { + // Other stat error + reject(new Error(`检查目录失败 ${dirPath}: ${err.message}`)); + } + } else if (!stats.isDirectory()) { + // Path exists but is not a directory + reject(new Error(`路径 ${dirPath} 已存在但不是目录`)); + } else { + // Directory already exists + resolve(); + } + }); + }); + } + + // +++ 新增:辅助方法 - 列出目录内容 (Promise wrapper) +++ + private listDirectory(sftp: SFTPWrapper, path: string): Promise { // 使用本地接口 SftpDirEntry + return new Promise((resolve, reject) => { + sftp.readdir(path, (err, list) => { // list 的类型现在是 SftpDirEntry[] + if (err) { + reject(err); + } else { + resolve(list); + } + }); + }); + } + + // +++ 新增:辅助方法 - 执行重命名 (Promise wrapper) +++ + private performRename(sftp: SFTPWrapper, oldPath: string, newPath: string): Promise { + return new Promise((resolve, reject) => { + sftp.rename(oldPath, newPath, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + // +++ 新增:辅助方法 - 格式化 Stats 为 FileListItem +++ + private formatStatsToFileListItem(itemPath: string, stats: Stats): any { + return { + filename: pathModule.basename(itemPath), + longname: '', // stat doesn't provide longname, maybe generate a basic one? + attrs: { + size: stats.size, uid: stats.uid, gid: stats.gid, mode: stats.mode, + atime: stats.atime * 1000, mtime: stats.mtime * 1000, + isDirectory: stats.isDirectory(), isFile: stats.isFile(), isSymbolicLink: stats.isSymbolicLink(), + } + }; + } + + // --- File Upload Methods --- /** Start a new file upload */ diff --git a/packages/backend/src/websocket.ts b/packages/backend/src/websocket.ts index a46fdf6..fc2864e 100644 --- a/packages/backend/src/websocket.ts +++ b/packages/backend/src/websocket.ts @@ -988,7 +988,10 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re case 'sftp:unlink': case 'sftp:rename': case 'sftp:chmod': - case 'sftp:realpath': { + case 'sftp:realpath': + case 'sftp:copy': + case 'sftp:move': + { // Keep the outer grouping for common checks if (!sessionId || !state) { console.warn(`WebSocket: 收到来自 ${ws.username} 的 SFTP 请求 (${type}),但无活动会话。`); const errPayload: { message: string; requestId?: string } = { message: '无效的会话' }; @@ -1046,7 +1049,23 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re if (payload?.path) sftpService.realpath(sessionId, payload.path, requestId); else throw new Error("Missing 'path' in payload for realpath"); break; - default: throw new Error(`Unhandled SFTP type: ${type}`); + // Cases for copy and move are now handled within this inner switch + case 'sftp:copy': + if (Array.isArray(payload?.sources) && payload?.destination) { + sftpService.copy(sessionId, payload.sources, payload.destination, requestId); + } else throw new Error("Missing 'sources' (array) or 'destination' in payload for copy"); + break; + case 'sftp:move': + if (Array.isArray(payload?.sources) && payload?.destination) { + sftpService.move(sessionId, payload.sources, payload.destination, requestId); + } else throw new Error("Missing 'sources' (array) or 'destination' in payload for move"); + break; + default: + // Only throw error if the type wasn't handled by any SFTP case + console.warn(`WebSocket: Received unhandled SFTP message type inside SFTP block: ${type}`); + // Optionally send a specific error back, or rely on the outer catch + // ws.send(JSON.stringify({ type: 'sftp_error', payload: { message: `内部未处理的 SFTP 类型: ${type}`, requestId } })); + throw new Error(`Unhandled SFTP type: ${type}`); // Keep throwing for the outer catch } } catch (sftpCallError: any) { console.error(`WebSocket: Error preparing/calling SFTP service for ${type} (Request ID: ${requestId}):`, sftpCallError); @@ -1197,3 +1216,4 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re }; + diff --git a/packages/frontend/src/components/FileManager.vue b/packages/frontend/src/components/FileManager.vue index 7544f3d..3ce6db1 100644 --- a/packages/frontend/src/components/FileManager.vue +++ b/packages/frontend/src/components/FileManager.vue @@ -12,7 +12,7 @@ import { useFileEditorStore, type FileInfo } from '../stores/fileEditor.store'; import { useSessionStore } from '../stores/session.store'; import { useSettingsStore } from '../stores/settings.store'; // +++ 实例化 Settings Store +++ import { useFocusSwitcherStore } from '../stores/focusSwitcher.store'; // +++ 实例化焦点切换 Store +++ -import { useFileManagerContextMenu } from '../composables/file-manager/useFileManagerContextMenu'; // +++ 导入上下文菜单 Composable +++ +import { useFileManagerContextMenu, type ClipboardState } from '../composables/file-manager/useFileManagerContextMenu'; // +++ 导入上下文菜单 Composable 和 ClipboardState +++ import { useFileManagerSelection } from '../composables/file-manager/useFileManagerSelection'; // +++ 导入选择 Composable +++ import { useFileManagerDragAndDrop } from '../composables/file-manager/useFileManagerDragAndDrop'; // +++ 导入拖放 Composable +++ import { useFileManagerKeyboardNavigation } from '../composables/file-manager/useFileManagerKeyboardNavigation'; // +++ 导入键盘导航 Composable +++ @@ -129,6 +129,11 @@ const editablePath = ref(''); const fileListContainerRef = ref(null); // 文件列表容器引用 (保留,传递给 Composable) // const scrollIntervalId = ref(null); // 已移至 useFileManagerDragAndDrop +// +++ 新增:剪贴板状态 +++ +const clipboardState = ref({ hasContent: false }); +const clipboardSourcePaths = ref([]); // 存储源完整路径 +const clipboardSourceBaseDir = ref(''); // 存储源目录 + const rowSizeMultiplier = ref(1.0); // 新增:行大小(字体)乘数, 默认值会被 store 覆盖 // --- 键盘导航状态 (移至 useFileManagerKeyboardNavigation) --- // const selectedIndex = ref(-1); @@ -370,6 +375,62 @@ const handleNewFileContextMenuClick = () => { } }; +// +++ 新增:复制、剪切、粘贴处理函数 +++ +const handleCopy = () => { + if (!currentSftpManager.value || selectedItems.value.size === 0) return; + const manager = currentSftpManager.value; + clipboardSourcePaths.value = Array.from(selectedItems.value) + .map(filename => manager.joinPath(manager.currentPath.value, filename)); + clipboardState.value = { hasContent: true, operation: 'copy' }; + clipboardSourceBaseDir.value = manager.currentPath.value; // 记录源目录 + console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Copied to clipboard:`, clipboardSourcePaths.value); + // 可选:添加 UI 通知 +}; + +const handleCut = () => { + if (!currentSftpManager.value || selectedItems.value.size === 0) return; + const manager = currentSftpManager.value; + clipboardSourcePaths.value = Array.from(selectedItems.value) + .map(filename => manager.joinPath(manager.currentPath.value, filename)); + clipboardState.value = { hasContent: true, operation: 'cut' }; + clipboardSourceBaseDir.value = manager.currentPath.value; // 记录源目录 + console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Cut to clipboard:`, clipboardSourcePaths.value); + // 可选:添加 UI 通知 +}; + +const handlePaste = () => { + if (!currentSftpManager.value || !clipboardState.value.hasContent || clipboardSourcePaths.value.length === 0) return; + const manager = currentSftpManager.value; + const destinationDir = manager.currentPath.value; + const operation = clipboardState.value.operation; + const sources = clipboardSourcePaths.value; + const sourceBaseDir = clipboardSourceBaseDir.value; // 获取源目录 + + console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Pasting items. Operation: ${operation}, Sources: ${sources.join(', ')}, Destination: ${destinationDir}`); + + if (operation === 'copy') { + // 调用 SFTP 管理器的 copyItems 方法 (稍后添加) + manager.copyItems(sources, destinationDir); + } else if (operation === 'cut') { + // 调用 SFTP 管理器的 moveItems 方法 (稍后添加) + // 检查是否在同一目录下剪切粘贴(无效操作) + if (sourceBaseDir === destinationDir) { + console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot cut and paste in the same directory.`); + // 可选:显示警告通知 + return; + } + manager.moveItems(sources, destinationDir); + // 剪切后清空剪贴板 + clipboardState.value = { hasContent: false }; + clipboardSourcePaths.value = []; + clipboardSourceBaseDir.value = ''; + } + // 粘贴后不清空复制的剪贴板,允许重复粘贴 + // 清空选择可能不是最佳体验,用户可能想继续操作粘贴后的文件 + // clearSelection(); +}; + + // --- 文件上传触发器 (定义在此处,供 Composable 使用) --- const triggerFileUpload = () => { fileInputRef.value?.click(); }; @@ -422,6 +483,7 @@ const { currentPath: computed(() => currentSftpManager.value?.currentPath.value ?? '/'), isConnected: props.wsDeps.isConnected, isSftpReady: props.wsDeps.isSftpReady, + clipboardState: readonly(clipboardState), // +++ 传递剪贴板状态 (只读) +++ t, // --- 传递回调函数 --- // 修改:确保在调用前检查 currentSftpManager.value @@ -437,6 +499,9 @@ const { onChangePermissions: handleChangePermissionsContextMenuClick, onNewFolder: handleNewFolderContextMenuClick, onNewFile: handleNewFileContextMenuClick, + onCopy: handleCopy, // +++ 传递复制回调 +++ + onCut: handleCut, // +++ 传递剪切回调 +++ + onPaste: handlePaste, // +++ 传递粘贴回调 +++ }); // --- 目录加载与导航 --- @@ -1095,6 +1160,7 @@ defineExpose({ focusSearchInput, startPathEdit }); @click="fileListContainerRef?.focus()" @keydown="handleKeydown" @wheel="handleWheel" + @contextmenu.prevent="showContextMenu($event)" tabindex="0" :style="{ '--row-size-multiplier': rowSizeMultiplier }" > @@ -1179,7 +1245,7 @@ defineExpose({ focusSearchInput, startPathEdit }); - + >; @@ -18,6 +25,7 @@ export interface UseFileManagerContextMenuOptions { currentPath: Ref; isConnected: Ref; isSftpReady: Ref; + clipboardState: Ref>; // +++ 新增:剪贴板状态 +++ t: ReturnType['t']; // 使用 useI18n 获取 t 的类型 // --- 回调函数 --- onRefresh: () => void; @@ -28,6 +36,9 @@ export interface UseFileManagerContextMenuOptions { onChangePermissions: (item: FileListItem) => void; onNewFolder: () => void; onNewFile: () => void; + onCopy: () => void; // +++ 新增:复制回调 +++ + onCut: () => void; // +++ 新增:剪切回调 +++ + onPaste: () => void; // +++ 新增:粘贴回调 +++ } export function useFileManagerContextMenu(options: UseFileManagerContextMenuOptions) { @@ -38,6 +49,7 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti currentPath, isConnected, isSftpReady, + clipboardState, // +++ 解构剪贴板状态 +++ t, onRefresh, onUpload, @@ -47,6 +59,9 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti onChangePermissions, onNewFolder, onNewFile, + onCopy, // +++ 解构复制回调 +++ + onCut, // +++ 解构剪切回调 +++ + onPaste, // +++ 解构粘贴回调 +++ } = options; const contextMenuVisible = ref(false); @@ -77,24 +92,37 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti const selectionSize = selectedItems.value.size; const clickedItemIsSelected = targetItem && selectedItems.value.has(targetItem.filename); const canPerformActions = isConnected.value && isSftpReady.value; + const hasClipboardContent = clipboardState.value.hasContent; // +++ 获取剪贴板状态 +++ // Build context menu items (使用传入的回调) if (selectionSize > 1 && clickedItemIsSelected) { // Multi-selection menu menu = [ + // +++ 添加复制/剪切 +++ + { label: t('fileManager.actions.copy'), action: onCopy, disabled: !canPerformActions }, + { label: t('fileManager.actions.cut'), action: onCut, disabled: !canPerformActions }, { label: t('fileManager.actions.deleteMultiple', { count: selectionSize }), action: onDelete, disabled: !canPerformActions }, { label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !canPerformActions }, ]; } else if (targetItem && targetItem.filename !== '..') { // Single item (not '..') menu menu = [ + // +++ 添加复制/剪切 +++ + { label: t('fileManager.actions.copy'), action: onCopy, disabled: !canPerformActions }, + { label: t('fileManager.actions.cut'), action: onCut, disabled: !canPerformActions }, + // --- 分隔符 (视觉上,实际由 CSS 处理) --- + // { label: '---', action: () => {}, disabled: true }, { label: t('fileManager.actions.newFolder'), action: onNewFolder, disabled: !canPerformActions }, { label: t('fileManager.actions.newFile'), action: onNewFile, disabled: !canPerformActions }, { label: t('fileManager.actions.upload'), action: onUpload, disabled: !canPerformActions }, { label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !canPerformActions }, ]; if (targetItem.attrs.isFile) { - menu.splice(1, 0, { label: t('fileManager.actions.download', { name: targetItem.filename }), action: () => onDownload(targetItem), disabled: !canPerformActions }); + menu.splice(3, 0, { label: t('fileManager.actions.download', { name: targetItem.filename }), action: () => onDownload(targetItem), disabled: !canPerformActions }); // 调整插入位置 + } + // +++ 如果目标是文件夹,添加粘贴 +++ + if (targetItem.attrs.isDirectory) { + menu.splice(3, 0, { label: t('fileManager.actions.paste'), action: onPaste, disabled: !canPerformActions || !hasClipboardContent }); // 调整插入位置 } menu.push({ label: t('fileManager.actions.delete'), action: onDelete, disabled: !canPerformActions }); menu.push({ label: t('fileManager.actions.rename'), action: () => onRename(targetItem), disabled: !canPerformActions }); @@ -106,10 +134,16 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti { label: t('fileManager.actions.newFolder'), action: onNewFolder, disabled: !canPerformActions }, { label: t('fileManager.actions.newFile'), action: onNewFile, disabled: !canPerformActions }, { label: t('fileManager.actions.upload'), action: onUpload, disabled: !canPerformActions }, + // +++ 添加粘贴 +++ + { label: t('fileManager.actions.paste'), action: onPaste, disabled: !canPerformActions || !hasClipboardContent }, { label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !canPerformActions }, ]; } else { // Clicked on '..' - menu = [{ label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !canPerformActions }]; + menu = [ + // +++ 添加粘贴 (可以粘贴到上级目录) +++ + { label: t('fileManager.actions.paste'), action: onPaste, disabled: !canPerformActions || !hasClipboardContent }, + { label: t('fileManager.actions.refresh'), action: onRefresh, disabled: !canPerformActions } + ]; } contextMenuItems.value = menu; diff --git a/packages/frontend/src/composables/useSftpActions.ts b/packages/frontend/src/composables/useSftpActions.ts index ccd7113..128394e 100644 --- a/packages/frontend/src/composables/useSftpActions.ts +++ b/packages/frontend/src/composables/useSftpActions.ts @@ -406,6 +406,48 @@ export function createSftpActionsManager( }); }; + // +++ 新增:复制项目 +++ + const copyItems = (sourcePaths: string[], destinationDir: string) => { + if (!isSftpReady.value) { + uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'), { timeout: 5000 }); + console.warn(`[SFTP ${instanceSessionId}] 尝试复制项目但 SFTP 未就绪。`); + return; + } + if (sourcePaths.length === 0) return; + const requestId = generateRequestId(); + sendMessage({ + type: 'sftp:copy', + requestId: requestId, + payload: { sources: sourcePaths, destination: destinationDir } + }); + console.log(`[SFTP ${instanceSessionId}] 发送 sftp:copy 请求 (ID: ${requestId}) Sources: ${sourcePaths.join(', ')}, Dest: ${destinationDir}`); + // 可选:显示一个“正在复制...”的通知 + }; + + // +++ 新增:移动项目 +++ + const moveItems = (sourcePaths: string[], destinationDir: string) => { + if (!isSftpReady.value) { + uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'), { timeout: 5000 }); + console.warn(`[SFTP ${instanceSessionId}] 尝试移动项目但 SFTP 未就绪。`); + return; + } + if (sourcePaths.length === 0) return; + // 可以在这里再次检查源目录和目标目录是否相同,虽然 FileManager.vue 也检查了 + // const sourceDir = sourcePaths[0].substring(0, sourcePaths[0].lastIndexOf('/')) || '/'; + // if (sourceDir === destinationDir) { + // uiNotificationsStore.showWarning(t('fileManager.warnings.moveSameDirectory'), { timeout: 3000 }); + // return; + // } + const requestId = generateRequestId(); + sendMessage({ + type: 'sftp:move', // 使用 'sftp:move' 类型 + requestId: requestId, + payload: { sources: sourcePaths, destination: destinationDir } + }); + console.log(`[SFTP ${instanceSessionId}] 发送 sftp:move 请求 (ID: ${requestId}) Sources: ${sourcePaths.join(', ')}, Dest: ${destinationDir}`); + // 可选:显示一个“正在移动...”的通知 + }; + // --- Message Handlers --- @@ -716,6 +758,86 @@ export function createSftpActionsManager( } }; + // +++ 新增:处理复制成功 +++ + const onCopySuccess = (payload: MessagePayload, message: WebSocketMessage) => { + // 后端应发送 { destination: string, items: FileListItem[] | null } + const copyPayload = payload as { destination: string, items: FileListItem[] | null }; + const destinationDir = copyPayload.destination; + const newItems = copyPayload.items; + + console.log(`[SFTP ${instanceSessionId}] 复制成功到: ${destinationDir}`); + uiNotificationsStore.showSuccess(t('fileManager.notifications.copySuccess'), { timeout: 3000 }); // 添加成功通知 + + // 更新文件树 + const destNode = findNodeByPath(fileTree, destinationDir); + if (destNode && newItems) { + // 如果目标节点已加载,直接添加新项目 + if (destNode.childrenLoaded && destNode.children) { + newItems.forEach(item => addOrUpdateNodeInTree(destinationDir, item)); + } else { + // 如果目标节点未加载,标记为需要刷新 + destNode.childrenLoaded = false; + console.log(`[SFTP ${instanceSessionId}] 复制成功,但目标目录 ${destinationDir} 未加载,标记为需要刷新`); + // 如果复制发生在当前目录,触发刷新 + if (destinationDir === currentPathRef.value) { + loadDirectory(currentPathRef.value); + } + } + } else if (destNode && !newItems) { + // 成功但没有收到项目详情,标记目标目录需要刷新 + destNode.childrenLoaded = false; + console.warn(`[SFTP ${instanceSessionId}] Copy success to ${destinationDir} but no item details received. Marking parent for reload.`); + if (destinationDir === currentPathRef.value) { + loadDirectory(currentPathRef.value); + } + } else { + console.warn(`[SFTP ${instanceSessionId}] Copy success, but destination node ${destinationDir} not found in tree.`); + // 可能需要刷新根目录或采取其他措施 + } + }; + + // +++ 新增:处理移动成功 +++ + const onMoveSuccess = (payload: MessagePayload, message: WebSocketMessage) => { + // 后端应发送 { sources: string[], destination: string, items: FileListItem[] | null } + const movePayload = payload as { sources: string[], destination: string, items: FileListItem[] | null }; + const sourcePaths = movePayload.sources; + const destinationDir = movePayload.destination; + const newItems = movePayload.items; + + console.log(`[SFTP ${instanceSessionId}] 移动成功到: ${destinationDir}`); + uiNotificationsStore.showSuccess(t('fileManager.notifications.moveSuccess'), { timeout: 3000 }); // 添加成功通知 + + // 1. 从旧位置移除 + sourcePaths.forEach(oldPath => { + const oldParentPath = oldPath.substring(0, oldPath.lastIndexOf('/')) || '/'; + const oldFilename = oldPath.substring(oldPath.lastIndexOf('/') + 1); + removeNodeFromTree(oldParentPath, oldFilename); + }); + + // 2. 添加到新位置 + const destNode = findNodeByPath(fileTree, destinationDir); + if (destNode && newItems) { + if (destNode.childrenLoaded && destNode.children) { + newItems.forEach(item => addOrUpdateNodeInTree(destinationDir, item)); + } else { + destNode.childrenLoaded = false; // 标记需要刷新 + console.log(`[SFTP ${instanceSessionId}] 移动成功,但目标目录 ${destinationDir} 未加载,标记为需要刷新`); + if (destinationDir === currentPathRef.value) { + loadDirectory(currentPathRef.value); + } + } + } else if (destNode && !newItems) { + destNode.childrenLoaded = false; + console.warn(`[SFTP ${instanceSessionId}] Move success to ${destinationDir} but no item details received. Marking parent for reload.`); + if (destinationDir === currentPathRef.value) { + loadDirectory(currentPathRef.value); + } + } else { + console.warn(`[SFTP ${instanceSessionId}] Move success, but destination node ${destinationDir} not found in tree.`); + } + }; + + // *** 新增:处理上传成功 *** const onUploadSuccess = (payload: MessagePayload, message: WebSocketMessage) => { const newItem = payload as FileListItem | null; // 后端应发送 FileListItem 或 null @@ -750,6 +872,8 @@ export function createSftpActionsManager( 'sftp:rename:error': t('fileManager.errors.renameFailed'), 'sftp:chmod:error': t('fileManager.errors.chmodFailed'), 'sftp:writefile:error': t('fileManager.errors.saveFailed'), + 'sftp:copy:error': t('fileManager.errors.copyFailed'), // +++ + 'sftp:move:error': t('fileManager.errors.moveFailed'), // +++ }; const prefix = actionTypeMap[message.type] || t('fileManager.errors.generic'); // error.value = `${prefix}: ${errorPayload}`; // 使用通知 @@ -773,6 +897,11 @@ export function createSftpActionsManager( unregisterCallbacks.push(onMessage('sftp:rename:error', onActionError)); unregisterCallbacks.push(onMessage('sftp:chmod:error', onActionError)); unregisterCallbacks.push(onMessage('sftp:writefile:error', onActionError)); + // +++ 新增:监听复制/移动错误 +++ + unregisterCallbacks.push(onMessage('sftp:copy:success', onCopySuccess)); + unregisterCallbacks.push(onMessage('sftp:copy:error', onActionError)); + unregisterCallbacks.push(onMessage('sftp:move:success', onMoveSuccess)); + unregisterCallbacks.push(onMessage('sftp:move:error', onActionError)); // 移除 onUnmounted 块 @@ -808,6 +937,8 @@ export function createSftpActionsManager( changePermissions, readFile, writeFile, + copyItems, // +++ 暴露 copyItems +++ + moveItems, // +++ 暴露 moveItems +++ joinPath, // 暴露辅助函数 // clearSftpError, // 移除 clearSftpError diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json index 4b9294b..4f65d45 100644 --- a/packages/frontend/src/locales/en-US.json +++ b/packages/frontend/src/locales/en-US.json @@ -254,7 +254,10 @@ "save": "Save", "closeTab": "Close Tab", "closeEditor": "Close Editor", - "cdToTerminal": "Change terminal directory to current path" + "cdToTerminal": "Change terminal directory to current path", + "copy": "Copy", + "cut": "Cut", + "paste": "Paste" }, "headers": { "type": "Type", @@ -280,7 +283,22 @@ "saveFailed": "Failed to save file", "saveTimeout": "Save timed out", "fileExists": "File \"{name}\" already exists.", - "loadDirectoryFailed": "Failed to load directory" + "loadDirectoryFailed": "Failed to load directory", + "copyFailed": "Copy failed", + "moveFailed": "Move failed", + "sftpNotReady": "SFTP session not ready", + "sftpManagerNotFound": "SFTP manager not found", + "noActiveSession": "No active session found", + "terminalManagerNotFound": "Terminal manager not found", + "sendCommandFailed": "Failed to send command" + }, + "notifications": { + "copySuccess": "Copy successful", + "moveSuccess": "Move successful", + "cdCommandSent": "CD command sent to terminal" + }, + "warnings": { + "moveSameDirectory": "Cannot cut and paste in the same directory." }, "prompts": { "enterFolderName": "Enter the name for the new folder:", diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json index 19a9d61..ffe4e5d 100644 --- a/packages/frontend/src/locales/zh-CN.json +++ b/packages/frontend/src/locales/zh-CN.json @@ -254,7 +254,10 @@ "save": "保存", "closeTab": "关闭标签页", "closeEditor": "关闭编辑器", - "cdToTerminal": "将终端目录切换到当前路径" + "cdToTerminal": "将终端目录切换到当前路径", + "copy": "复制", + "cut": "剪切", + "paste": "粘贴" }, "headers": { "type": "类型", @@ -280,7 +283,22 @@ "saveFailed": "保存文件失败", "saveTimeout": "保存超时", "fileExists": "文件 \"{name}\" 已存在。", - "loadDirectoryFailed": "加载目录失败" + "loadDirectoryFailed": "加载目录失败", + "copyFailed": "复制失败", + "moveFailed": "移动失败", + "sftpNotReady": "SFTP 会话未就绪", + "sftpManagerNotFound": "SFTP 管理器未找到", + "noActiveSession": "未找到活动会话", + "terminalManagerNotFound": "未找到终端管理器", + "sendCommandFailed": "发送命令失败" + }, + "notifications": { + "copySuccess": "复制成功", + "moveSuccess": "移动成功", + "cdCommandSent": "CD 命令已发送到终端" + }, + "warnings": { + "moveSameDirectory": "不能在同一目录下剪切和粘贴。" }, "prompts": { "enterFolderName": "请输入新文件夹的名称:",