Files
Termix/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx
ZacharyZcR 42c6f1e2d0 修复文件管理器侧边栏显示问题
- 修复目录树API数据格式不匹配:listSSHFiles返回{files, path}对象而非数组
- 修复侧边栏滚动问题:添加thin-scrollbar类保持样式一致性
- 修复Recent和Pin文件点击行为:区分文件和文件夹处理逻辑
- 增强侧边栏高度约束:确保滚动容器正确工作
- 优化TypeScript类型安全:更新listSSHFiles返回类型定义

主要改进:
1. 侧边栏目录树现在正确显示所有文件夹而不是只有根目录
2. Recent和Pinned文件点击时正确打开文件而不是当作文件夹处理
3. 侧边栏支持滚动查看所有内容,滚动条样式与主容器一致
4. API错误处理更加健壮,避免undefined导致的运行时错误
2025-09-17 10:16:30 +08:00

1591 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef, useCallback } from "react";
import { FileManagerGrid } from "./FileManagerGrid";
import { FileManagerSidebar } from "./FileManagerSidebar";
import { FileManagerContextMenu } from "./FileManagerContextMenu";
import { useFileSelection } from "./hooks/useFileSelection";
import { useDragAndDrop } from "./hooks/useDragAndDrop";
import { WindowManager, useWindowManager } from "./components/WindowManager";
import { FileWindow } from "./components/FileWindow";
import { DiffWindow } from "./components/DiffWindow";
import { useDragToDesktop } from "../../../hooks/useDragToDesktop";
import { useDragToSystemDesktop } from "../../../hooks/useDragToSystemDesktop";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
Upload,
FolderPlus,
FilePlus,
RefreshCw,
Search,
Grid3X3,
List,
Eye,
Settings
} from "lucide-react";
import { TerminalWindow } from "./components/TerminalWindow";
import type { SSHHost, FileItem } from "../../../types/index.js";
import {
listSSHFiles,
uploadSSHFile,
downloadSSHFile,
createSSHFile,
createSSHFolder,
deleteSSHItem,
copySSHItem,
renameSSHItem,
moveSSHItem,
connectSSH,
getSSHStatus,
identifySSHSymlink,
addRecentFile,
addPinnedFile,
removePinnedFile,
addFolderShortcut,
getPinnedFiles
} from "@/ui/main-axios.ts";
import type { SidebarItem } from "./FileManagerSidebar";
interface FileManagerModernProps {
initialHost?: SSHHost | null;
onClose?: () => void;
}
// 内部组件,使用窗口管理器
function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
const { openWindow } = useWindowManager();
const { t } = useTranslation();
// State
const [currentHost, setCurrentHost] = useState<SSHHost | null>(initialHost || null);
const [currentPath, setCurrentPath] = useState("/");
const [files, setFiles] = useState<FileItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
// Context menu state
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
isVisible: boolean;
files: FileItem[];
}>({
x: 0,
y: 0,
isVisible: false,
files: []
});
// 操作状态
const [clipboard, setClipboard] = useState<{
files: FileItem[];
operation: 'copy' | 'cut';
} | null>(null);
// 撤销历史
interface UndoAction {
type: 'copy' | 'cut' | 'delete';
description: string;
data: {
operation: 'copy' | 'cut';
copiedFiles?: { originalPath: string; targetPath: string; targetName: string }[];
deletedFiles?: { path: string; name: string }[];
targetDirectory?: string;
};
timestamp: number;
}
const [undoHistory, setUndoHistory] = useState<UndoAction[]>([]);
// 编辑状态
const [editingFile, setEditingFile] = useState<FileItem | null>(null);
const [isCreatingNewFile, setIsCreatingNewFile] = useState(false);
// Hooks
const {
selectedFiles,
selectFile,
selectAll,
clearSelection,
setSelection
} = useFileSelection();
const { isDragging, dragHandlers } = useDragAndDrop({
onFilesDropped: handleFilesDropped,
onError: (error) => toast.error(error),
maxFileSize: 100 // 100MB
});
// 拖拽到桌面功能
const dragToDesktop = useDragToDesktop({
sshSessionId: sshSessionId || '',
sshHost: currentHost!
});
// 系统级拖拽到桌面功能(新方案)
const systemDrag = useDragToSystemDesktop({
sshSessionId: sshSessionId || '',
sshHost: currentHost!
});
// 初始化SSH连接
useEffect(() => {
if (currentHost) {
initializeSSHConnection();
}
}, [currentHost]);
// 文件列表更新
useEffect(() => {
if (sshSessionId) {
loadDirectory(currentPath);
}
}, [sshSessionId, currentPath]);
// 文件拖拽到外部处理
const handleFileDragStart = useCallback((files: FileItem[]) => {
// 记录当前拖拽的文件
systemDrag.startDragToSystem(files, {
enableToast: true,
onSuccess: () => {
clearSelection();
},
onError: (error) => {
console.error('拖拽失败:', error);
}
});
}, [systemDrag, clearSelection]);
const handleFileDragEnd = useCallback((e: DragEvent) => {
// 检查是否拖拽到窗口外
const margin = 50;
const isOutside = (
e.clientX < margin ||
e.clientX > window.innerWidth - margin ||
e.clientY < margin ||
e.clientY > window.innerHeight - margin
);
if (isOutside) {
// 延迟执行,避免与其他事件冲突
setTimeout(() => {
systemDrag.handleDragEnd(e);
}, 100);
} else {
// 取消拖拽
systemDrag.cancelDragToSystem();
}
}, [systemDrag]);
async function initializeSSHConnection() {
if (!currentHost) return;
try {
setIsLoading(true);
console.log("Initializing SSH connection for host:", currentHost.name, "ID:", currentHost.id);
// 使用主机ID作为会话ID
const sessionId = currentHost.id.toString();
console.log("Using session ID:", sessionId);
// 调用connectSSH建立连接
console.log("Connecting to SSH with config:", {
hostId: currentHost.id,
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
authType: currentHost.authType,
credentialId: currentHost.credentialId,
userId: currentHost.userId
});
const result = await connectSSH(sessionId, {
hostId: currentHost.id,
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
password: currentHost.password,
sshKey: currentHost.key,
keyPassword: currentHost.keyPassword,
authType: currentHost.authType,
credentialId: currentHost.credentialId,
userId: currentHost.userId
});
console.log("SSH connection result:", result);
setSshSessionId(sessionId);
console.log("SSH session ID set to:", sessionId);
} catch (error: any) {
console.error("SSH connection failed:", error);
toast.error(t("fileManager.failedToConnect") + ": " + (error.message || error));
} finally {
setIsLoading(false);
}
}
async function loadDirectory(path: string) {
if (!sshSessionId) {
console.error("Cannot load directory: no SSH session ID");
return;
}
try {
setIsLoading(true);
console.log("Loading directory:", path, "with session ID:", sshSessionId);
// 首先检查SSH连接状态
try {
const status = await getSSHStatus(sshSessionId);
console.log("SSH connection status:", status);
if (!status.connected) {
console.log("SSH not connected, attempting to reconnect...");
await initializeSSHConnection();
return; // 重连后会触发useEffect重新加载目录
}
} catch (statusError) {
console.log("Failed to get SSH status, attempting to reconnect...");
await initializeSSHConnection();
return;
}
const response = await listSSHFiles(sshSessionId, path);
console.log("Directory response from backend:", response);
// 处理新的返回格式 { files: FileItem[], path: string }
const files = Array.isArray(response) ? response : response?.files || [];
console.log("Directory contents loaded:", files.length, "items");
console.log("Files with sizes:", files.map(f => ({ name: f.name, size: f.size, type: f.type })));
setFiles(files);
clearSelection();
} catch (error: any) {
console.error("Failed to load directory:", error);
// 如果是连接错误,尝试重连
if (error.message?.includes("connection") || error.message?.includes("established")) {
console.log("Connection error detected, attempting to reconnect...");
await initializeSSHConnection();
} else {
toast.error(t("fileManager.failedToLoadDirectory") + ": " + (error.message || error));
}
} finally {
setIsLoading(false);
}
}
function handleFilesDropped(fileList: FileList) {
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
Array.from(fileList).forEach(file => {
handleUploadFile(file);
});
}
async function handleUploadFile(file: File) {
if (!sshSessionId) return;
try {
// 确保SSH连接有效
await ensureSSHConnection();
const targetPath = currentPath.endsWith('/')
? `${currentPath}${file.name}`
: `${currentPath}/${file.name}`;
await uploadSSHFile(sshSessionId, targetPath, file);
toast.success(t("fileManager.fileUploadedSuccessfully", { name: file.name }));
loadDirectory(currentPath);
} catch (error: any) {
if (error.message?.includes('connection') || error.message?.includes('established')) {
toast.error(`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`);
} else {
toast.error(t("fileManager.failedToUploadFile"));
}
console.error("Upload failed:", error);
}
}
async function handleDownloadFile(file: FileItem) {
if (!sshSessionId) return;
try {
// 确保SSH连接有效
await ensureSSHConnection();
const response = await downloadSSHFile(sshSessionId, file.path);
if (response?.content) {
// 转换为blob并触发下载
const byteCharacters = atob(response.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: response.mimeType || 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = response.fileName || file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(t("fileManager.fileDownloadedSuccessfully", { name: file.name }));
}
} catch (error: any) {
if (error.message?.includes('connection') || error.message?.includes('established')) {
toast.error(`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`);
} else {
toast.error(t("fileManager.failedToDownloadFile"));
}
console.error("Download failed:", error);
}
}
async function handleDeleteFiles(files: FileItem[]) {
if (!sshSessionId || files.length === 0) return;
try {
// 确保SSH连接有效
await ensureSSHConnection();
for (const file of files) {
await deleteSSHItem(
sshSessionId,
file.path,
file.type === 'directory', // isDirectory
currentHost?.id,
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();
} catch (error: any) {
if (error.message?.includes('connection') || error.message?.includes('established')) {
toast.error(`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`);
} else {
toast.error(t("fileManager.failedToDeleteItems"));
}
console.error("Delete failed:", error);
}
}
function handleCreateNewFolder() {
const baseName = "NewFolder";
const uniqueName = generateUniqueName(baseName, 'directory');
const folderPath = currentPath.endsWith('/')
? `${currentPath}${uniqueName}`
: `${currentPath}/${uniqueName}`;
// 直接进入编辑模式,使用唯一名字
const newFolder: FileItem = {
name: uniqueName,
type: 'directory',
path: folderPath
};
console.log('Starting edit for new folder with unique name:', newFolder);
setEditingFile(newFolder);
setIsCreatingNewFile(true);
}
function handleCreateNewFile() {
const baseName = "NewFile.txt";
const uniqueName = generateUniqueName(baseName, 'file');
const filePath = currentPath.endsWith('/')
? `${currentPath}${uniqueName}`
: `${currentPath}/${uniqueName}`;
// 直接进入编辑模式,使用唯一名字
const newFile: FileItem = {
name: uniqueName,
type: 'file',
path: filePath,
size: 0
};
console.log('Starting edit for new file with unique name:', newFile);
setEditingFile(newFile);
setIsCreatingNewFile(true);
}
// Handle symlink resolution
const handleSymlinkClick = async (file: FileItem) => {
if (!currentHost || !sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
try {
// 确保SSH连接有效
let currentSessionId = sshSessionId;
try {
const status = await getSSHStatus(currentSessionId);
if (!status.connected) {
const result = await connectSSH(currentSessionId, {
hostId: currentHost.id,
host: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
authType: currentHost.authType,
password: currentHost.password,
key: currentHost.key,
keyPassword: currentHost.keyPassword,
credentialId: currentHost.credentialId
});
if (!result.success) {
throw new Error(t("fileManager.failedToReconnectSSH"));
}
}
} catch (sessionErr) {
throw sessionErr;
}
const symlinkInfo = await identifySSHSymlink(currentSessionId, file.path);
if (symlinkInfo.type === "directory") {
// 如果软链接指向目录,导航到它
setCurrentPath(symlinkInfo.target);
} else if (symlinkInfo.type === "file") {
// 如果软链接指向文件,打开文件
// 计算窗口位置(稍微错开)
const windowCount = Date.now() % 10;
const offsetX = 120 + (windowCount * 30);
const offsetY = 120 + (windowCount * 30);
// 创建目标文件对象
const targetFile: FileItem = {
...file,
path: symlinkInfo.target
};
// 创建窗口组件工厂函数
const createWindowComponent = (windowId: string) => (
<FileWindow
windowId={windowId}
file={targetFile}
sshSessionId={currentSessionId}
sshHost={currentHost}
initialX={offsetX}
initialY={offsetY}
/>
);
openWindow({
title: file.name,
x: offsetX,
y: offsetY,
width: 800,
height: 600,
isMaximized: false,
isMinimized: false,
component: createWindowComponent
});
}
} catch (error: any) {
toast.error(
error?.response?.data?.error ||
error?.message ||
t("fileManager.failedToResolveSymlink")
);
}
};
async function handleFileOpen(file: FileItem, editMode: boolean = false) {
if (file.type === 'directory') {
setCurrentPath(file.path);
} else if (file.type === 'link') {
// 处理软链接
await handleSymlinkClick(file);
} else {
// 在新窗口中打开文件
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
// 计算窗口位置(稍微错开)
const windowCount = Date.now() % 10; // 简单的偏移计算
const offsetX = 120 + (windowCount * 30);
const offsetY = 120 + (windowCount * 30);
const windowTitle = file.name; // 移除模式标识由FileViewer内部控制
// 创建窗口组件工厂函数
const createWindowComponent = (windowId: string) => (
<FileWindow
windowId={windowId}
file={file}
sshSessionId={sshSessionId}
sshHost={currentHost}
initialX={offsetX}
initialY={offsetY}
/>
);
openWindow({
title: windowTitle,
x: offsetX,
y: offsetY,
width: 800,
height: 600,
isMaximized: false,
isMinimized: false,
component: createWindowComponent
});
}
}
// 专门的文件编辑函数
function handleFileEdit(file: FileItem) {
handleFileOpen(file, true);
}
// 专门的文件查看函数(只读)
function handleFileView(file: FileItem) {
handleFileOpen(file, false);
}
function handleContextMenu(event: React.MouseEvent, file?: FileItem) {
event.preventDefault();
// 如果右键点击的文件已经在选中列表中,使用所有选中的文件
// 如果右键点击的文件不在选中列表中,只使用这一个文件
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,
y: event.clientY,
isVisible: true,
files
});
}
function handleCopyFiles(files: FileItem[]) {
setClipboard({ files, operation: 'copy' });
toast.success(t("fileManager.filesCopiedToClipboard", { count: files.length }));
}
function handleCutFiles(files: FileItem[]) {
setClipboard({ files, operation: 'cut' });
toast.success(t("fileManager.filesCutToClipboard", { count: files.length }));
}
async function handlePasteFiles() {
if (!clipboard || !sshSessionId) return;
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 targetPath = currentPath.endsWith('/')
? `${currentPath}${file.name}`
: `${currentPath}/${file.name}`;
// 只有当目标路径与原路径不同时才移动
if (file.path !== targetPath) {
// 使用专门的 moveSSHItem API 进行跨目录移动
await moveSSHItem(
sshSessionId,
file.path,
targetPath,
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) {
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]);
}
}
// 显示成功提示
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'}`);
}
}
async function handleUndo() {
if (undoHistory.length === 0) {
toast.info("没有可撤销的操作");
return;
}
const lastAction = undoHistory[undoHistory.length - 1];
try {
await ensureSSHConnection();
// 根据不同操作类型执行撤销逻辑
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}`);
}
}
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);
}
}
function handleRenameFile(file: FileItem) {
setEditingFile(file);
}
// 确保SSH连接有效
async function ensureSSHConnection() {
if (!sshSessionId || !currentHost) return;
try {
const status = await getSSHStatus(sshSessionId);
console.log('SSH connection status:', status);
if (!status.connected) {
console.log('SSH not connected, attempting to reconnect...');
await connectSSH(sshSessionId, {
hostId: currentHost.id,
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
password: currentHost.password,
sshKey: currentHost.key,
keyPassword: currentHost.keyPassword,
authType: currentHost.authType,
credentialId: currentHost.credentialId,
userId: currentHost.userId
});
console.log('SSH reconnection successful');
}
} catch (error) {
console.log('SSH connection check/reconnect failed:', error);
throw error;
}
}
// 处理重命名/创建确认
async function handleRenameConfirm(file: FileItem, newName: string) {
if (!sshSessionId) return;
try {
// 确保SSH连接有效
await ensureSSHConnection();
if (isCreatingNewFile) {
// 新建项目:直接创建最终名字
console.log('Creating new item:', {
type: file.type,
name: newName,
path: currentPath,
hostId: currentHost?.id,
userId: currentHost?.userId
});
if (file.type === 'file') {
await createSSHFile(
sshSessionId,
currentPath,
newName,
"",
currentHost?.id,
currentHost?.userId?.toString()
);
toast.success(t("fileManager.fileCreatedSuccessfully", { name: newName }));
} else if (file.type === 'directory') {
await createSSHFolder(
sshSessionId,
currentPath,
newName,
currentHost?.id,
currentHost?.userId?.toString()
);
toast.success(t("fileManager.folderCreatedSuccessfully", { name: newName }));
}
setIsCreatingNewFile(false);
} else {
// 现有项目:重命名
console.log('Renaming existing item:', {
from: file.path,
to: newName,
hostId: currentHost?.id,
userId: currentHost?.userId
});
await renameSSHItem(
sshSessionId,
file.path,
newName,
currentHost?.id,
currentHost?.userId?.toString()
);
toast.success(t("fileManager.itemRenamedSuccessfully", { name: newName }));
}
// 清除编辑状态
setEditingFile(null);
loadDirectory(currentPath);
} catch (error: any) {
console.error("Rename failed with error:", {
error,
oldPath,
newName,
message: error.message
});
if (error.message?.includes('connection') || error.message?.includes('established')) {
toast.error(`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`);
} else {
toast.error(t("fileManager.failedToRenameItem"));
}
}
}
// 开始编辑文件名
function handleStartEdit(file: FileItem) {
setEditingFile(file);
}
// 取消编辑(现在也会保留项目)
async function handleCancelEdit() {
if (isCreatingNewFile && editingFile) {
// 取消时也使用默认名字创建项目
console.log('Creating item with default name on cancel:', editingFile.name);
await handleRenameConfirm(editingFile, editingFile.name);
} else {
setEditingFile(null);
}
}
// 生成唯一名字(处理重名冲突)
function generateUniqueName(baseName: string, type: 'file' | 'directory'): string {
const existingNames = files.map(f => f.name.toLowerCase());
let candidateName = baseName;
let counter = 1;
// 如果名字已存在,尝试添加数字后缀
while (existingNames.includes(candidateName.toLowerCase())) {
if (type === 'file' && baseName.includes('.')) {
// 对于文件,在文件名和扩展名之间添加数字
const lastDotIndex = baseName.lastIndexOf('.');
const nameWithoutExt = baseName.substring(0, lastDotIndex);
const extension = baseName.substring(lastDotIndex);
candidateName = `${nameWithoutExt}${counter}${extension}`;
} else {
// 对于文件夹或没有扩展名的文件,直接添加数字
candidateName = `${baseName}${counter}`;
}
counter++;
}
console.log(`Generated unique name: ${baseName} -> ${candidateName}`);
return candidateName;
}
// 拖拽处理:文件/文件夹拖到文件夹 = 移动操作
async function handleFileDrop(draggedFiles: FileItem[], targetFolder: FileItem) {
if (!sshSessionId || targetFolder.type !== 'directory') return;
try {
await ensureSSHConnection();
let successCount = 0;
const movedItems: string[] = [];
for (const file of draggedFiles) {
try {
const targetPath = targetFolder.path.endsWith('/')
? `${targetFolder.path}${file.name}`
: `${targetFolder.path}/${file.name}`;
// 只有当目标路径与原路径不同时才移动
if (file.path !== targetPath) {
await moveSSHItem(
sshSessionId,
file.path,
targetPath,
currentHost?.id,
currentHost?.userId?.toString()
);
movedItems.push(file.name);
successCount++;
}
} catch (error: any) {
console.error(`Failed to move file ${file.name}:`, error);
toast.error(`移动 ${file.name} 失败: ${error.message}`);
}
}
if (successCount > 0) {
// 记录撤销历史
const movedFiles = draggedFiles.slice(0, successCount).map((file, index) => {
const targetPath = targetFolder.path.endsWith('/')
? `${targetFolder.path}${file.name}`
: `${targetFolder.path}/${file.name}`;
return {
originalPath: file.path,
targetPath: targetPath,
targetName: file.name
};
});
const undoAction: UndoAction = {
type: 'cut',
description: `拖拽移动了 ${successCount} 个项目到 ${targetFolder.name}`,
data: {
operation: 'cut',
copiedFiles: movedFiles,
targetDirectory: targetFolder.path
},
timestamp: Date.now()
};
setUndoHistory(prev => [...prev.slice(-9), undoAction]);
toast.success(`成功移动了 ${successCount} 个项目到 ${targetFolder.name}`);
loadDirectory(currentPath);
clearSelection(); // 清除选中状态
}
} catch (error: any) {
console.error('Drag move operation failed:', error);
toast.error(`移动操作失败: ${error.message}`);
}
}
// 拖拽处理:文件拖到文件 = diff对比操作
function handleFileDiff(file1: FileItem, file2: FileItem) {
if (file1.type !== 'file' || file2.type !== 'file') {
toast.error('只能对比两个文件');
return;
}
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
// 使用专用的DiffWindow进行文件对比
console.log('Opening diff comparison:', file1.name, 'vs', file2.name);
// 计算窗口位置
const offsetX = 100;
const offsetY = 80;
// 创建diff窗口
const windowId = `diff-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const createWindowComponent = (windowId: string) => (
<DiffWindow
windowId={windowId}
file1={file1}
file2={file2}
sshSessionId={sshSessionId}
sshHost={currentHost}
initialX={offsetX}
initialY={offsetY}
/>
);
openWindow({
id: windowId,
type: 'diff',
title: `文件对比: ${file1.name}${file2.name}`,
isMaximized: false,
component: createWindowComponent,
zIndex: Date.now()
});
toast.success(`正在对比文件: ${file1.name}${file2.name}`);
}
// 拖拽到桌面处理函数
async function handleDragToDesktop(files: FileItem[]) {
if (!currentHost || !sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
try {
// 优先使用新的系统级拖拽方案
if (systemDrag.isFileSystemAPISupported) {
await systemDrag.handleDragToSystem(files, {
enableToast: true,
onSuccess: () => {
console.log('系统级拖拽成功');
},
onError: (error) => {
console.error('系统级拖拽失败:', error);
}
});
} else {
// 降级到Electron方案
if (files.length === 1) {
await dragToDesktop.dragFileToDesktop(files[0]);
} else if (files.length > 1) {
await dragToDesktop.dragFilesToDesktop(files);
}
}
} catch (error: any) {
console.error('拖拽到桌面失败:', error);
toast.error(`拖拽失败: ${error.message || '未知错误'}`);
}
}
// 打开终端处理函数
function handleOpenTerminal(path: string) {
if (!currentHost) {
toast.error(t("fileManager.noHostSelected"));
return;
}
// 创建终端窗口
const windowCount = Date.now() % 10;
const offsetX = 200 + (windowCount * 40);
const offsetY = 150 + (windowCount * 40);
const createTerminalComponent = (windowId: string) => (
<TerminalWindow
windowId={windowId}
hostConfig={currentHost}
initialPath={path}
initialX={offsetX}
initialY={offsetY}
/>
);
openWindow({
title: `终端 - ${currentHost.name}:${path}`,
x: offsetX,
y: offsetY,
width: 800,
height: 500,
isMaximized: false,
isMinimized: false,
component: createTerminalComponent
});
toast.success(t("fileManager.terminalWithPath", { host: currentHost.name, path }));
}
// 运行可执行文件处理函数
function handleRunExecutable(file: FileItem) {
if (!currentHost) {
toast.error(t("fileManager.noHostSelected"));
return;
}
if (file.type !== 'file' || !file.executable) {
toast.error(t("fileManager.onlyRunExecutableFiles"));
return;
}
// 获取文件所在目录
const fileDir = file.path.substring(0, file.path.lastIndexOf('/'));
const fileName = file.name;
const executeCmd = `./${fileName}`;
// 创建执行用的终端窗口
const windowCount = Date.now() % 10;
const offsetX = 250 + (windowCount * 40);
const offsetY = 200 + (windowCount * 40);
const createExecutionTerminal = (windowId: string) => (
<TerminalWindow
windowId={windowId}
hostConfig={currentHost}
initialPath={fileDir}
initialX={offsetX}
initialY={offsetY}
executeCommand={executeCmd} // 自动执行命令
/>
);
openWindow({
title: t("fileManager.runningFile", { file: file.name }),
x: offsetX,
y: offsetY,
width: 800,
height: 500,
isMaximized: false,
isMinimized: false,
component: createExecutionTerminal
});
toast.success(t("fileManager.runningFile", { file: file.name }));
}
// 加载固定文件列表
async function loadPinnedFiles() {
if (!currentHost?.id) return;
try {
const pinnedData = await getPinnedFiles(currentHost.id);
const pinnedPaths = new Set(pinnedData.map((item: any) => item.path));
setPinnedFiles(pinnedPaths);
} catch (error) {
console.error('Failed to load pinned files:', error);
}
}
// PIN文件
async function handlePinFile(file: FileItem) {
if (!currentHost?.id) return;
try {
await addPinnedFile(currentHost.id, file.path, file.name);
setPinnedFiles(prev => new Set([...prev, file.path]));
setSidebarRefreshTrigger(prev => prev + 1); // 触发侧边栏刷新
toast.success(`文件"${file.name}"已固定`);
} catch (error) {
console.error('Failed to pin file:', error);
toast.error('固定文件失败');
}
}
// UNPIN文件
async function handleUnpinFile(file: FileItem) {
if (!currentHost?.id) return;
try {
await removePinnedFile(currentHost.id, file.path);
setPinnedFiles(prev => {
const newSet = new Set(prev);
newSet.delete(file.path);
return newSet;
});
setSidebarRefreshTrigger(prev => prev + 1); // 触发侧边栏刷新
toast.success(`文件"${file.name}"已取消固定`);
} catch (error) {
console.error('Failed to unpin file:', error);
toast.error('取消固定失败');
}
}
// 添加文件夹快捷方式
async function handleAddShortcut(path: string) {
if (!currentHost?.id) return;
try {
const folderName = path.split('/').pop() || path;
await addFolderShortcut(currentHost.id, path, folderName);
setSidebarRefreshTrigger(prev => prev + 1); // 触发侧边栏刷新
toast.success(`文件夹快捷方式"${folderName}"已添加`);
} catch (error) {
console.error('Failed to add shortcut:', error);
toast.error('添加快捷方式失败');
}
}
// 检查文件是否已固定
function isPinnedFile(file: FileItem): boolean {
return pinnedFiles.has(file.path);
}
// 记录最近访问的文件
async function recordRecentFile(file: FileItem) {
if (!currentHost?.id || file.type === 'directory') return;
try {
await addRecentFile(currentHost.id, file.path, file.name);
setSidebarRefreshTrigger(prev => prev + 1); // 触发侧边栏刷新
} catch (error) {
console.error('Failed to record recent file:', error);
}
}
// 处理侧边栏文件打开
async function handleSidebarFileOpen(sidebarItem: SidebarItem) {
// 将SidebarItem转换为FileItem格式
const file: FileItem = {
name: sidebarItem.name,
path: sidebarItem.path,
type: 'file' // recent和pinned都是文件类型
};
// 调用常规的文件打开处理
await handleFileOpen(file);
}
// 处理文件打开
async function handleFileOpen(file: FileItem) {
if (file.type === 'directory') {
// 如果是目录,切换到该目录
setCurrentPath(file.path);
} else {
// 如果是文件,记录到最近访问并打开文件窗口
await recordRecentFile(file);
// 创建文件窗口
const windowCount = Date.now() % 10;
const offsetX = 100 + (windowCount * 30);
const offsetY = 100 + (windowCount * 30);
const createFileWindow = (windowId: string) => (
<FileWindow
windowId={windowId}
file={file}
sshHost={currentHost!}
sshSessionId={sshSessionId!}
initialX={offsetX}
initialY={offsetY}
/>
);
openWindow({
title: file.name,
x: offsetX,
y: offsetY,
width: 800,
height: 600,
isMaximized: false,
isMinimized: false,
component: createFileWindow
});
}
}
// 加载固定文件列表(当主机或连接改变时)
useEffect(() => {
if (currentHost?.id) {
loadPinnedFiles();
}
}, [currentHost?.id]);
// 过滤文件并添加新建的临时项目
let filteredFiles = files.filter(file =>
file.name.toLowerCase().includes(searchQuery.toLowerCase())
);
// 如果正在创建新项目,将其添加到列表中
if (isCreatingNewFile && editingFile) {
// 检查是否已经存在同名项目,避免重复
const exists = filteredFiles.some(f => f.path === editingFile.path);
if (!exists && editingFile.name.toLowerCase().includes(searchQuery.toLowerCase())) {
filteredFiles = [editingFile, ...filteredFiles]; // 将新项目放在前面
}
}
if (!currentHost) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<p className="text-lg text-muted-foreground mb-4">
{t("fileManager.selectHostToStart")}
</p>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-dark-bg">
{/* 工具栏 */}
<div className="flex-shrink-0 border-b border-dark-border">
<div className="flex items-center justify-between p-3">
<div className="flex items-center gap-2">
<h2 className="font-semibold text-white">
{currentHost.name}
</h2>
<span className="text-sm text-muted-foreground">
{currentHost.ip}:{currentHost.port}
</span>
</div>
<div className="flex items-center gap-2">
{/* 搜索 */}
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("fileManager.searchFiles")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 w-48 h-9 bg-dark-bg-button border-dark-border"
/>
</div>
{/* 视图切换 */}
<div className="flex border border-dark-border rounded-md">
<Button
variant={viewMode === 'grid' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('grid')}
className="rounded-r-none h-9"
>
<Grid3X3 className="w-4 h-4" />
</Button>
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
className="rounded-l-none h-9"
>
<List className="w-4 h-4" />
</Button>
</div>
{/* 操作按钮 */}
<Button
variant="outline"
size="sm"
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.onchange = (e) => {
const files = (e.target as HTMLInputElement).files;
if (files) handleFilesDropped(files);
};
input.click();
}}
className="h-9"
>
<Upload className="w-4 h-4 mr-2" />
{t("fileManager.upload")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleCreateNewFolder}
className="h-9"
>
<FolderPlus className="w-4 h-4 mr-2" />
{t("fileManager.newFolder")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleCreateNewFile}
className="h-9"
>
<FilePlus className="w-4 h-4 mr-2" />
{t("fileManager.newFile")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => loadDirectory(currentPath)}
className="h-9"
>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
</div>
</div>
{/* 主内容区域 */}
<div className="flex-1 flex" {...dragHandlers}>
{/* 左侧边栏 */}
<div className="w-64 flex-shrink-0 h-full">
<FileManagerSidebar
currentHost={currentHost}
currentPath={currentPath}
onPathChange={setCurrentPath}
onLoadDirectory={loadDirectory}
onFileOpen={handleSidebarFileOpen}
sshSessionId={sshSessionId}
refreshTrigger={sidebarRefreshTrigger}
/>
</div>
{/* 右侧文件网格 */}
<div className="flex-1 relative">
<FileManagerGrid
files={filteredFiles}
selectedFiles={selectedFiles}
onFileSelect={() => {}} // 不再需要这个回调使用onSelectionChange
onFileOpen={handleFileOpen}
onSelectionChange={setSelection}
currentPath={currentPath}
isLoading={isLoading}
onPathChange={setCurrentPath}
onRefresh={() => loadDirectory(currentPath)}
onUpload={handleFilesDropped}
onDownload={(files) => files.forEach(handleDownloadFile)}
onContextMenu={handleContextMenu}
viewMode={viewMode}
onRename={handleRenameConfirm}
editingFile={editingFile}
onStartEdit={handleStartEdit}
onCancelEdit={handleCancelEdit}
onDelete={handleDeleteFiles}
onCopy={handleCopyFiles}
onCut={handleCutFiles}
onPaste={handlePasteFiles}
onUndo={handleUndo}
onFileDrop={handleFileDrop}
onFileDiff={handleFileDiff}
onSystemDragStart={handleFileDragStart}
onSystemDragEnd={handleFileDragEnd}
/>
{/* 右键菜单 */}
<FileManagerContextMenu
x={contextMenu.x}
y={contextMenu.y}
files={contextMenu.files}
isVisible={contextMenu.isVisible}
onClose={() => setContextMenu(prev => ({ ...prev, isVisible: false }))}
onDownload={(files) => files.forEach(handleDownloadFile)}
onRename={handleRenameFile}
onCopy={handleCopyFiles}
onCut={handleCutFiles}
onPaste={handlePasteFiles}
onDelete={handleDeleteFiles}
onUpload={() => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.onchange = (e) => {
const files = (e.target as HTMLInputElement).files;
if (files) handleFilesDropped(files);
};
input.click();
}}
onNewFolder={handleCreateNewFolder}
onNewFile={handleCreateNewFile}
onRefresh={() => loadDirectory(currentPath)}
hasClipboard={!!clipboard}
onDragToDesktop={() => handleDragToDesktop(contextMenu.files)}
onOpenTerminal={(path) => handleOpenTerminal(path)}
onRunExecutable={(file) => handleRunExecutable(file)}
onPinFile={handlePinFile}
onUnpinFile={handleUnpinFile}
onAddShortcut={handleAddShortcut}
isPinned={isPinnedFile}
currentPath={currentPath}
/>
</div>
</div>
</div>
);
}
// 主要的导出组件,包装了 WindowManager
export function FileManagerModern({ initialHost, onClose }: FileManagerModernProps) {
return (
<WindowManager>
<FileManagerContent initialHost={initialHost} onClose={onClose} />
</WindowManager>
);
}