This commit is contained in:
Baobhan Sith
2025-04-29 19:37:01 +08:00
parent 03d3df88e9
commit 5e4e174c27
7 changed files with 586 additions and 11 deletions
+289 -1
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<Stats> {
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<void> {
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[]> { // 使用本地接口 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<void> {
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 */
+22 -2
View File
@@ -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
};
@@ -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<HTMLDivElement | null>(null); // ( Composable)
// const scrollIntervalId = ref<number | null>(null); // useFileManagerDragAndDrop
// +++ +++
const clipboardState = ref<ClipboardState>({ hasContent: false });
const clipboardSourcePaths = ref<string[]>([]); //
const clipboardSourceBaseDir = ref<string>(''); //
const rowSizeMultiplier = ref(1.0); // , store
// --- ( useFileManagerKeyboardNavigation) ---
// const selectedIndex = ref<number>(-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 });
</tbody>
<!-- File List State -->
<tbody v-else @contextmenu.prevent="showContextMenu($event)">
<tbody v-else> <!-- Remove context menu handler from tbody -->
<!-- '..' Entry -->
<tr v-if="currentSftpManager?.currentPath.value !== '/'"
class="transition-colors duration-150 cursor-pointer select-none"
@@ -10,6 +10,13 @@ export interface ContextMenuItem {
disabled?: boolean;
}
// 定义剪贴板状态类型
export interface ClipboardState {
hasContent: boolean;
operation?: 'copy' | 'cut';
// 可以添加 sourcePaths: string[] 等更多信息,但对于禁用/启用粘贴,hasContent 就够了
}
// 定义 Composable 的输入参数类型
export interface UseFileManagerContextMenuOptions {
selectedItems: Ref<Set<string>>;
@@ -18,6 +25,7 @@ export interface UseFileManagerContextMenuOptions {
currentPath: Ref<string>;
isConnected: Ref<boolean>;
isSftpReady: Ref<boolean>;
clipboardState: Ref<Readonly<ClipboardState>>; // +++ 新增:剪贴板状态 +++
t: ReturnType<typeof useI18n>['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;
@@ -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
+20 -2
View File
@@ -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:",
+20 -2
View File
@@ -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": "请输入新文件夹的名称:",