Implement unified file editing for all non-media files

Major improvements:
- Remove separate view/edit modes - editing state can view content too
- Expand text editing support to ALL file types except media/binary files
- Add realistic undo functionality for copy/cut operations
- Implement moveSSHItem API for proper cross-directory file moves
- Add file existence checks to prevent copy failures
- Enhanced error logging with full command and path information

Key changes:
- FileWindow: Expand editable file types to exclude only media extensions
- FileViewer: Remove view mode toggle, direct editing interface
- Backend: Add moveItem API endpoint for cut operations
- Backend: Add file existence verification before copy operations
- Frontend: Complete undo system for copy (delete copied files) and cut (move back to original location)

File type handling:
- Media files (jpg, mp3, mp4, etc.) → Display only
- All other files → Direct text editing (js, py, txt, config files, unknown extensions)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ZacharyZcR
2025-09-16 22:53:54 +08:00
parent cae9097034
commit 2ea5383ef0
5 changed files with 385 additions and 50 deletions

View File

@@ -1418,6 +1418,117 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
});
});
// New API for moving files/folders across directories (for cut operation)
app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
const { sessionId, oldPath, newPath, hostId, userId } = req.body;
const sshConn = sshSessions[sessionId];
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!oldPath || !newPath) {
return res
.status(400)
.json({ error: "Old path and new path are required" });
}
sshConn.lastActive = Date.now();
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
const moveCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
sshConn.client.exec(moveCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH moveItem error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let outputData = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
fileLogger.error(`Permission denied moving: ${oldPath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
toast: {
type: "error",
message: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
},
});
}
return;
}
});
stream.on("close", (code) => {
if (outputData.includes("SUCCESS")) {
if (!res.headersSent) {
res.json({
message: "Item moved successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item moved: ${oldPath} -> ${newPath}`,
},
});
}
return;
}
if (code !== 0) {
fileLogger.error(
`SSH moveItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
if (!res.headersSent) {
return res.status(500).json({
error: `Command failed: ${errorData}`,
toast: { type: "error", message: `Move failed: ${errorData}` },
});
}
return;
}
if (!res.headersSent) {
res.json({
message: "Item moved successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item moved: ${oldPath} -> ${newPath}`,
},
});
}
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH moveItem stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
const {
sessionId,
@@ -1541,7 +1652,34 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
// Extract source name
const sourceName = sourcePath.split('/').pop() || 'copied_item';
// Skip file existence check to avoid SSH hanging - just use timestamp for uniqueness
// First check if source file exists
const escapedSourceForCheck = sourcePath.replace(/'/g, "'\"'\"'");
const checkExistsCommand = `test -e '${escapedSourceForCheck}'`;
const checkExists = await new Promise<boolean>((resolve) => {
sshConn.client.exec(checkExistsCommand, (err, stream) => {
if (err) {
fileLogger.error("File existence check error:", err);
resolve(false);
return;
}
stream.on("close", (code) => {
fileLogger.info("File existence check completed", { sourcePath, exists: code === 0 });
resolve(code === 0);
});
stream.on("error", () => resolve(false));
});
});
if (!checkExists) {
return res.status(404).json({
error: `Source file not found: ${sourcePath}`,
toast: { type: "error", message: `Source file not found: ${sourceName}` }
});
}
// Use timestamp for uniqueness
const timestamp = Date.now().toString().slice(-8);
const nameWithoutExt = sourceName.includes('.')
? sourceName.substring(0, sourceName.lastIndexOf('.'))
@@ -1621,11 +1759,28 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
fileLogger.info("Copy command completed", { code, errorData, hasError: errorData.length > 0 });
if (code !== 0) {
fileLogger.error(`SSH copyItem command failed with code ${code}: ${errorData}`);
const fullErrorInfo = errorData || stdoutData || 'No error message available';
fileLogger.error(`SSH copyItem command failed with code ${code}`, {
operation: "file_copy_failed",
sessionId,
sourcePath,
targetPath,
command: copyCommand,
exitCode: code,
errorData,
stdoutData,
fullErrorInfo
});
if (!res.headersSent) {
return res.status(500).json({
error: `Copy failed: ${errorData}`,
toast: { type: "error", message: `Copy failed: ${errorData}` }
error: `Copy failed: ${fullErrorInfo}`,
toast: { type: "error", message: `Copy failed: ${fullErrorInfo}` },
debug: {
sourcePath,
targetPath,
exitCode: code,
command: copyCommand
}
});
}
return;

View File

@@ -30,6 +30,7 @@ import {
deleteSSHItem,
copySSHItem,
renameSSHItem,
moveSSHItem,
connectSSH,
getSSHStatus,
identifySSHSymlink
@@ -76,9 +77,14 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
// 撤销历史
interface UndoAction {
type: 'delete' | 'paste' | 'rename' | 'create';
type: 'copy' | 'cut' | 'delete';
description: string;
data: any;
data: {
operation: 'copy' | 'cut';
copiedFiles?: { originalPath: string; targetPath: string; targetName: string }[];
deletedFiles?: { path: string; name: string }[];
targetDirectory?: string;
};
timestamp: number;
}
@@ -305,6 +311,25 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
currentHost?.userId?.toString()
);
}
// 记录删除历史(虽然无法真正撤销)
const deletedFiles = files.map(file => ({
path: file.path,
name: file.name
}));
const undoAction: UndoAction = {
type: 'delete',
description: `删除了 ${files.length} 个项目`,
data: {
operation: 'cut', // Placeholder
deletedFiles,
targetDirectory: currentPath
},
timestamp: Date.now()
};
setUndoHistory(prev => [...prev.slice(-9), undoAction]);
toast.success(t("fileManager.itemsDeletedSuccessfully", { count: files.length }));
loadDirectory(currentPath);
clearSelection();
@@ -440,7 +465,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
}
};
async function handleFileOpen(file: FileItem) {
async function handleFileOpen(file: FileItem, editMode: boolean = false) {
if (file.type === 'directory') {
setCurrentPath(file.path);
} else if (file.type === 'link') {
@@ -458,6 +483,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
const offsetX = 120 + (windowCount * 30);
const offsetY = 120 + (windowCount * 30);
const windowTitle = file.name; // 移除模式标识由FileViewer内部控制
// 创建窗口组件工厂函数
const createWindowComponent = (windowId: string) => (
<FileWindow
@@ -471,7 +498,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
);
openWindow({
title: file.name,
title: windowTitle,
x: offsetX,
y: offsetY,
width: 800,
@@ -483,6 +510,16 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
}
}
// 专门的文件编辑函数
function handleFileEdit(file: FileItem) {
handleFileOpen(file, true);
}
// 专门的文件查看函数(只读)
function handleFileView(file: FileItem) {
handleFileOpen(file, false);
}
function handleContextMenu(event: React.MouseEvent, file?: FileItem) {
event.preventDefault();
@@ -540,16 +577,18 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
copiedItems.push(result.uniqueName || file.name);
successCount++;
} else {
// 剪切操作:移动文件
const newPath = currentPath.endsWith('/')
// 剪切操作:移动文件到目标目录
const targetPath = currentPath.endsWith('/')
? `${currentPath}${file.name}`
: `${currentPath}/${file.name}`;
if (file.path !== newPath) {
await renameSSHItem(
// 只有当目标路径与原路径不同时才移动
if (file.path !== targetPath) {
// 使用专门的 moveSSHItem API 进行跨目录移动
await moveSSHItem(
sshSessionId,
file.path,
newPath,
targetPath,
currentHost?.id,
currentHost?.userId?.toString()
);
@@ -564,13 +603,49 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
// 记录撤销历史
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 (operation === 'copy') {
const copiedFiles = files.slice(0, successCount).map((file, index) => ({
originalPath: file.path,
targetPath: `${currentPath}/${copiedItems[index] || file.name}`,
targetName: copiedItems[index] || file.name
}));
const undoAction: UndoAction = {
type: 'copy',
description: `复制了 ${successCount} 个项目`,
data: {
operation: 'copy',
copiedFiles,
targetDirectory: currentPath
},
timestamp: Date.now()
};
setUndoHistory(prev => [...prev.slice(-9), undoAction]); // 保持最多10个撤销记录
} else if (operation === 'cut') {
// 剪切操作:记录移动信息,撤销时可以移回原位置
const movedFiles = files.slice(0, successCount).map(file => {
const targetPath = currentPath.endsWith('/')
? `${currentPath}${file.name}`
: `${currentPath}/${file.name}`;
return {
originalPath: file.path,
targetPath: targetPath,
targetName: file.name
};
});
const undoAction: UndoAction = {
type: 'cut',
description: `移动了 ${successCount} 个项目`,
data: {
operation: 'cut',
copiedFiles: movedFiles, // 复用copiedFiles字段存储移动信息
targetDirectory: currentPath
},
timestamp: Date.now()
};
setUndoHistory(prev => [...prev.slice(-9), undoAction]);
}
}
// 显示成功提示
@@ -606,7 +681,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
}
}
function handleUndo() {
async function handleUndo() {
if (undoHistory.length === 0) {
toast.info("没有可撤销的操作");
return;
@@ -614,27 +689,100 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
const lastAction = undoHistory[undoHistory.length - 1];
// 移除最后一个撤销记录
setUndoHistory(prev => prev.slice(0, -1));
try {
await ensureSSHConnection();
toast.success(`已撤销:${lastAction.description}`);
// 根据不同操作类型执行撤销逻辑
switch (lastAction.type) {
case 'copy':
// 复制操作的撤销:删除复制的目标文件
if (lastAction.data.copiedFiles) {
let successCount = 0;
for (const copiedFile of lastAction.data.copiedFiles) {
try {
const isDirectory = files.find(f => f.path === copiedFile.targetPath)?.type === 'directory';
await deleteSSHItem(
sshSessionId!,
copiedFile.targetPath,
isDirectory,
currentHost?.id,
currentHost?.userId?.toString()
);
successCount++;
} catch (error: any) {
console.error(`Failed to delete copied file ${copiedFile.targetName}:`, error);
toast.error(`删除复制文件 ${copiedFile.targetName} 失败: ${error.message}`);
}
}
// 根据不同操作类型执行撤销逻辑
switch (lastAction.type) {
case 'paste':
// 粘贴操作的撤销:删除粘贴的文件或移回原位置
toast.info("撤销粘贴操作需要手动处理");
break;
case 'delete':
// 删除操作的撤销:恢复删除的文件
toast.info("删除操作暂时无法撤销");
break;
default:
toast.info("该操作暂时无法撤销");
if (successCount > 0) {
// 移除最后一个撤销记录
setUndoHistory(prev => prev.slice(0, -1));
toast.success(`已撤销复制操作:删除了 ${successCount} 个复制的文件`);
} else {
toast.error("撤销失败:无法删除任何复制的文件");
return;
}
} else {
toast.error("撤销失败:找不到复制的文件信息");
return;
}
break;
case 'cut':
// 剪切操作的撤销:将文件移回原位置
if (lastAction.data.copiedFiles) {
let successCount = 0;
for (const movedFile of lastAction.data.copiedFiles) {
try {
// 将文件从当前位置移回原位置
await moveSSHItem(
sshSessionId!,
movedFile.targetPath, // 当前位置(目标路径)
movedFile.originalPath, // 移回原位置
currentHost?.id,
currentHost?.userId?.toString()
);
successCount++;
} catch (error: any) {
console.error(`Failed to move back file ${movedFile.targetName}:`, error);
toast.error(`移回文件 ${movedFile.targetName} 失败: ${error.message}`);
}
}
if (successCount > 0) {
// 移除最后一个撤销记录
setUndoHistory(prev => prev.slice(0, -1));
toast.success(`已撤销移动操作:移回了 ${successCount} 个文件到原位置`);
} else {
toast.error("撤销失败:无法移回任何文件");
return;
}
} else {
toast.error("撤销失败:找不到移动的文件信息");
return;
}
break;
case 'delete':
// 删除操作无法真正撤销(文件已从服务器删除)
toast.info("删除操作无法撤销:文件已从服务器永久删除");
// 仍然移除历史记录,因为用户已经知道了这个限制
setUndoHistory(prev => prev.slice(0, -1));
return;
default:
toast.error("不支持撤销此类操作");
return;
}
// 刷新文件列表
loadDirectory(currentPath);
} catch (error: any) {
toast.error(`撤销操作失败: ${error.message || 'Unknown error'}`);
console.error("Undo failed:", error);
}
// 刷新文件列表
loadDirectory(currentPath);
}
function handleRenameFile(file: FileItem) {

View File

@@ -436,6 +436,7 @@ export function FileViewer({
</div>
<div className="flex items-center gap-2">
{/* 编辑工具栏 - 直接显示,无需切换 */}
{isEditable && (
<>
<Button
@@ -705,7 +706,7 @@ export function FileViewer({
{renderHighlightedText(editedContent)}
</div>
) : (
// 没有搜索时显示可编辑的textarea
// 直接显示可编辑的textarea
<textarea
value={editedContent}
onChange={(e) => handleContentChange(e.target.value)}
@@ -716,8 +717,9 @@ export function FileViewer({
)}
</div>
) : (
// 只有非可编辑文件(媒体文件)才显示为只读
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto bg-background text-foreground">
{content ? renderHighlightedText(content) : 'File is empty'}
{editedContent || content || 'File is empty'}
</div>
)}
</div>

View File

@@ -37,6 +37,7 @@ interface FileWindowProps {
sshHost: SSHHost;
initialX?: number;
initialY?: number;
// readOnly参数已移除由FileViewer内部根据文件类型决定
}
export function FileWindow({
@@ -112,17 +113,23 @@ export function FileWindow({
file.size = contentSize;
}
// 根据文件类型决定是否可编辑
const editableExtensions = [
'txt', 'md', 'js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'cs',
'php', 'rb', 'go', 'rs', 'html', 'css', 'scss', 'less', 'json', 'xml',
'yaml', 'yml', 'toml', 'ini', 'conf', 'sh', 'bat', 'ps1'
// 根据文件类型决定是否可编辑:除了媒体文件,其他都可编辑
const mediaExtensions = [
// 图片文件
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'tiff', 'ico',
// 音频文件
'mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a', 'wma',
// 视频文件
'mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'm4v',
// 压缩文件
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz',
// 二进制文件
'exe', 'dll', 'so', 'dylib', 'bin', 'iso'
];
const extension = file.name.split('.').pop()?.toLowerCase();
const hasNoExtension = !file.name.includes('.') || file.name.startsWith('.');
// 已知可编辑扩展名或无后缀文件默认可编辑(按文本处理)
setIsEditable(editableExtensions.includes(extension || '') || hasNoExtension);
// 只有媒体文件和二进制文件不可编辑,其他所有文件都可编辑
setIsEditable(!mediaExtensions.includes(extension || ''));
} catch (error: any) {
console.error('Failed to load file:', error);
@@ -293,7 +300,7 @@ export function FileWindow({
content={pendingContent || content}
savedContent={content}
isLoading={isLoading}
isEditable={isEditable}
isEditable={isEditable} // 移除强制只读模式由FileViewer内部控制
onContentChange={handleContentChange}
onSave={(newContent) => handleSave(newContent)}
onDownload={handleDownload}

View File

@@ -1178,6 +1178,29 @@ export async function renameSSHItem(
return response.data;
} catch (error) {
handleApiError(error, "rename SSH item");
throw error;
}
}
export async function moveSSHItem(
sessionId: string,
oldPath: string,
newPath: string,
hostId?: number,
userId?: string,
): Promise<any> {
try {
const response = await fileManagerApi.put("/ssh/moveItem", {
sessionId,
oldPath,
newPath,
hostId,
userId,
});
return response.data;
} catch (error) {
handleApiError(error, "move SSH item");
throw error;
}
}