v1.7.0 #318

Merged
LukeGus merged 138 commits from dev-1.7.0 into main 2025-10-01 20:40:10 +00:00
8 changed files with 1540 additions and 1 deletions
Showing only changes of commit 49e7159939 - Show all commits
+27
View File
@@ -627,6 +627,33 @@
"folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully", "folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully",
"failedToCreateFolder": "Failed to create folder", "failedToCreateFolder": "Failed to create folder",
"itemDeletedSuccessfully": "{{type}} deleted successfully", "itemDeletedSuccessfully": "{{type}} deleted successfully",
"itemsDeletedSuccessfully": "{{count}} items deleted successfully",
"failedToDeleteItems": "Failed to delete items",
"dragFilesToUpload": "Drop files here to upload",
"emptyFolder": "This folder is empty",
"itemCount": "{{count}} items",
"selectedCount": "{{count}} selected",
"searchFiles": "Search files...",
"upload": "Upload",
"selectHostToStart": "Select a host to start file management",
"failedToConnect": "Failed to connect to SSH",
"failedToLoadDirectory": "Failed to load directory",
"noSSHConnection": "No SSH connection available",
"enterFolderName": "Enter folder name:",
"enterFileName": "Enter file name:",
"copy": "Copy",
"cut": "Cut",
"paste": "Paste",
"delete": "Delete",
"properties": "Properties",
"preview": "Preview",
"refresh": "Refresh",
"downloadFiles": "Download {{count}} files",
"copyFiles": "Copy {{count}} items",
"cutFiles": "Cut {{count}} items",
"deleteFiles": "Delete {{count}} items",
"filesCopiedToClipboard": "{{count}} items copied to clipboard",
"filesCutToClipboard": "{{count}} items cut to clipboard",
"failedToDeleteItem": "Failed to delete item", "failedToDeleteItem": "Failed to delete item",
"itemRenamedSuccessfully": "{{type}} renamed successfully", "itemRenamedSuccessfully": "{{type}} renamed successfully",
"failedToRenameItem": "Failed to rename item", "failedToRenameItem": "Failed to rename item",
+27
View File
@@ -642,6 +642,33 @@
"folderCreatedSuccessfully": "文件夹 \"{{name}}\" 创建成功", "folderCreatedSuccessfully": "文件夹 \"{{name}}\" 创建成功",
"failedToCreateFolder": "创建文件夹失败", "failedToCreateFolder": "创建文件夹失败",
"itemDeletedSuccessfully": "{{type}}删除成功", "itemDeletedSuccessfully": "{{type}}删除成功",
"itemsDeletedSuccessfully": "{{count}} 个项目删除成功",
"failedToDeleteItems": "删除项目失败",
"dragFilesToUpload": "拖拽文件到这里上传",
"emptyFolder": "此文件夹为空",
"itemCount": "{{count}} 个项目",
"selectedCount": "已选择 {{count}} 个",
"searchFiles": "搜索文件...",
"upload": "上传",
"selectHostToStart": "选择主机开始文件管理",
"failedToConnect": "连接SSH失败",
"failedToLoadDirectory": "加载目录失败",
"noSSHConnection": "无SSH连接可用",
"enterFolderName": "输入文件夹名称:",
"enterFileName": "输入文件名称:",
"copy": "复制",
"cut": "剪切",
"paste": "粘贴",
"delete": "删除",
"properties": "属性",
"preview": "预览",
"refresh": "刷新",
"downloadFiles": "下载 {{count}} 个文件",
"copyFiles": "复制 {{count}} 个项目",
"cutFiles": "剪切 {{count}} 个项目",
"deleteFiles": "删除 {{count}} 个项目",
"filesCopiedToClipboard": "{{count}} 个项目已复制到剪贴板",
"filesCutToClipboard": "{{count}} 个项目已剪切到剪贴板",
"failedToDeleteItem": "删除项目失败", "failedToDeleteItem": "删除项目失败",
"itemRenamedSuccessfully": "{{type}}重命名成功", "itemRenamedSuccessfully": "{{type}}重命名成功",
"failedToRenameItem": "重命名项目失败", "failedToRenameItem": "重命名项目失败",
@@ -3,10 +3,11 @@ import { FileManagerLeftSidebar } from "@/ui/Desktop/Apps/File Manager/FileManag
import { FileManagerHomeView } from "@/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx"; import { FileManagerHomeView } from "@/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx";
import { FileManagerFileEditor } from "@/ui/Desktop/Apps/File Manager/FileManagerFileEditor.tsx"; import { FileManagerFileEditor } from "@/ui/Desktop/Apps/File Manager/FileManagerFileEditor.tsx";
import { FileManagerOperations } from "@/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx"; import { FileManagerOperations } from "@/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx";
import { FileManagerModern } from "@/ui/Desktop/Apps/File Manager/FileManagerModern.tsx";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { FIleManagerTopNavbar } from "@/ui/Desktop/Apps/File Manager/FIleManagerTopNavbar.tsx"; import { FIleManagerTopNavbar } from "@/ui/Desktop/Apps/File Manager/FIleManagerTopNavbar.tsx";
import { cn } from "@/lib/utils.ts"; import { cn } from "@/lib/utils.ts";
import { Save, RefreshCw, Settings, Trash2 } from "lucide-react"; import { Save, RefreshCw, Settings, Trash2, Grid3X3, Sidebar } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -48,6 +49,7 @@ export function FileManager({
const [showOperations, setShowOperations] = useState(false); const [showOperations, setShowOperations] = useState(false);
const [currentPath, setCurrentPath] = useState("/"); const [currentPath, setCurrentPath] = useState("/");
const [useModernView, setUseModernView] = useState(true); // 默认使用现代视图
const [deletingItem, setDeletingItem] = useState<any | null>(null); const [deletingItem, setDeletingItem] = useState<any | null>(null);
@@ -518,6 +520,15 @@ export function FileManager({
}; };
if (!currentHost) { if (!currentHost) {
if (useModernView) {
return (
<FileManagerModern
initialHost={initialHost}
onClose={onClose}
/>
);
}
return ( return (
<div className="absolute inset-0 overflow-hidden rounded-md"> <div className="absolute inset-0 overflow-hidden rounded-md">
<div className="absolute top-0 left-0 w-64 h-full z-[20]"> <div className="absolute top-0 left-0 w-64 h-full z-[20]">
@@ -547,6 +558,30 @@ export function FileManager({
); );
} }
// 如果使用现代视图且有主机连接,显示现代文件管理器
if (useModernView && currentHost) {
return (
<div className="absolute inset-0 overflow-hidden rounded-md">
{/* 视图切换按钮 */}
<div className="absolute top-2 right-2 z-50">
<Button
variant="outline"
size="sm"
onClick={() => setUseModernView(false)}
className="bg-dark-bg/80 border-dark-border hover:bg-dark-hover"
title="切换到传统视图"
>
<Sidebar className="w-4 h-4" />
</Button>
</div>
<FileManagerModern
initialHost={currentHost}
onClose={onClose}
/>
</div>
);
}
return ( return (
<div className="absolute inset-0 overflow-hidden rounded-md"> <div className="absolute inset-0 overflow-hidden rounded-md">
<div className="absolute top-0 left-0 w-64 h-full z-[20]"> <div className="absolute top-0 left-0 w-64 h-full z-[20]">
@@ -577,6 +612,16 @@ export function FileManager({
/> />
</div> </div>
<div className="flex items-center justify-center gap-2 flex-1"> <div className="flex items-center justify-center gap-2 flex-1">
{/* 添加现代视图切换按钮 */}
<Button
variant="outline"
onClick={() => setUseModernView(true)}
className="w-[30px] h-[30px]"
title="切换到现代视图"
>
<Grid3X3 className="h-4 w-4" />
</Button>
<div className="p-0.25 w-px h-[30px] bg-dark-border"></div>
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowOperations(!showOperations)} onClick={() => setShowOperations(!showOperations)}
@@ -0,0 +1,328 @@
import React, { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import {
Download,
Edit3,
Copy,
Scissors,
Trash2,
Info,
Upload,
FolderPlus,
FilePlus,
RefreshCw,
Clipboard,
Eye,
Share
} from "lucide-react";
import { useTranslation } from "react-i18next";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
}
interface ContextMenuProps {
x: number;
y: number;
files: FileItem[];
isVisible: boolean;
onClose: () => void;
onDownload?: (files: FileItem[]) => void;
onRename?: (file: FileItem) => void;
onCopy?: (files: FileItem[]) => void;
onCut?: (files: FileItem[]) => void;
onDelete?: (files: FileItem[]) => void;
onProperties?: (file: FileItem) => void;
onUpload?: () => void;
onNewFolder?: () => void;
onNewFile?: () => void;
onRefresh?: () => void;
onPaste?: () => void;
onPreview?: (file: FileItem) => void;
hasClipboard?: boolean;
}
interface MenuItem {
icon: React.ReactNode;
label: string;
action: () => void;
shortcut?: string;
separator?: boolean;
disabled?: boolean;
danger?: boolean;
}
export function FileManagerContextMenu({
x,
y,
files,
isVisible,
onClose,
onDownload,
onRename,
onCopy,
onCut,
onDelete,
onProperties,
onUpload,
onNewFolder,
onNewFile,
onRefresh,
onPaste,
onPreview,
hasClipboard = false
}: ContextMenuProps) {
const { t } = useTranslation();
const [menuPosition, setMenuPosition] = useState({ x, y });
useEffect(() => {
if (!isVisible) return;
// 调整菜单位置避免超出屏幕
const adjustPosition = () => {
const menuWidth = 200;
const menuHeight = 300;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let adjustedX = x;
let adjustedY = y;
if (x + menuWidth > viewportWidth) {
adjustedX = viewportWidth - menuWidth - 10;
}
if (y + menuHeight > viewportHeight) {
adjustedY = viewportHeight - menuHeight - 10;
}
setMenuPosition({ x: adjustedX, y: adjustedY });
};
adjustPosition();
// 点击外部关闭菜单
const handleClickOutside = (event: MouseEvent) => {
onClose();
};
// 键盘支持
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isVisible, x, y, onClose]);
if (!isVisible) return null;
const isFileContext = files.length > 0;
const isSingleFile = files.length === 1;
const isMultipleFiles = files.length > 1;
const hasFiles = files.some(f => f.type === 'file');
const hasDirectories = files.some(f => f.type === 'directory');
// 构建菜单项
const menuItems: MenuItem[] = [];
if (isFileContext) {
// 文件/文件夹选中时的菜单
if (hasFiles && onPreview) {
menuItems.push({
icon: <Eye className="w-4 h-4" />,
label: t("fileManager.preview"),
action: () => onPreview(files[0]),
disabled: !isSingleFile || files[0].type !== 'file'
});
}
if (hasFiles && onDownload) {
menuItems.push({
icon: <Download className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.downloadFiles", { count: files.length })
: t("fileManager.downloadFile"),
action: () => onDownload(files),
shortcut: "Ctrl+D"
});
}
menuItems.push({ separator: true } as MenuItem);
if (isSingleFile && onRename) {
menuItems.push({
icon: <Edit3 className="w-4 h-4" />,
label: t("fileManager.rename"),
action: () => onRename(files[0]),
shortcut: "F2"
});
}
if (onCopy) {
menuItems.push({
icon: <Copy className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.copyFiles", { count: files.length })
: t("fileManager.copy"),
action: () => onCopy(files),
shortcut: "Ctrl+C"
});
}
if (onCut) {
menuItems.push({
icon: <Scissors className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.cutFiles", { count: files.length })
: t("fileManager.cut"),
action: () => onCut(files),
shortcut: "Ctrl+X"
});
}
menuItems.push({ separator: true } as MenuItem);
if (onDelete) {
menuItems.push({
icon: <Trash2 className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.deleteFiles", { count: files.length })
: t("fileManager.delete"),
action: () => onDelete(files),
shortcut: "Delete",
danger: true
});
}
menuItems.push({ separator: true } as MenuItem);
if (isSingleFile && onProperties) {
menuItems.push({
icon: <Info className="w-4 h-4" />,
label: t("fileManager.properties"),
action: () => onProperties(files[0])
});
}
} else {
// 空白区域右键菜单
if (onUpload) {
menuItems.push({
icon: <Upload className="w-4 h-4" />,
label: t("fileManager.uploadFile"),
action: onUpload,
shortcut: "Ctrl+U"
});
}
menuItems.push({ separator: true } as MenuItem);
if (onNewFolder) {
menuItems.push({
icon: <FolderPlus className="w-4 h-4" />,
label: t("fileManager.newFolder"),
action: onNewFolder,
shortcut: "Ctrl+Shift+N"
});
}
if (onNewFile) {
menuItems.push({
icon: <FilePlus className="w-4 h-4" />,
label: t("fileManager.newFile"),
action: onNewFile,
shortcut: "Ctrl+N"
});
}
menuItems.push({ separator: true } as MenuItem);
if (onRefresh) {
menuItems.push({
icon: <RefreshCw className="w-4 h-4" />,
label: t("fileManager.refresh"),
action: onRefresh,
shortcut: "F5"
});
}
if (hasClipboard && onPaste) {
menuItems.push({
icon: <Clipboard className="w-4 h-4" />,
label: t("fileManager.paste"),
action: onPaste,
shortcut: "Ctrl+V"
});
}
}
return (
<div
className="fixed inset-0 z-50"
onClick={(e) => e.stopPropagation()}
>
<div
className="absolute bg-dark-bg border border-dark-border rounded-lg shadow-lg py-1 min-w-[180px] max-w-[250px] z-50"
style={{
left: menuPosition.x,
top: menuPosition.y
}}
onClick={(e) => e.stopPropagation()}
>
{menuItems.map((item, index) => {
if (item.separator) {
return (
<div
key={`separator-${index}`}
className="border-t border-dark-border my-1"
/>
);
}
return (
<button
key={index}
className={cn(
"w-full px-3 py-2 text-left text-sm flex items-center justify-between",
"hover:bg-dark-hover transition-colors",
item.disabled && "opacity-50 cursor-not-allowed",
item.danger && "text-red-400 hover:bg-red-500/10"
)}
onClick={() => {
if (!item.disabled) {
item.action();
onClose();
}
}}
disabled={item.disabled}
>
<div className="flex items-center gap-3">
{item.icon}
<span>{item.label}</span>
</div>
{item.shortcut && (
<span className="text-xs text-muted-foreground">
{item.shortcut}
</span>
)}
</button>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,432 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils";
import {
Folder,
File,
FileText,
FileImage,
FileVideo,
FileAudio,
Archive,
Code,
Settings,
Download,
ChevronLeft,
ChevronRight,
MoreHorizontal
} from "lucide-react";
import { useTranslation } from "react-i18next";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
}
interface FileManagerGridProps {
files: FileItem[];
selectedFiles: FileItem[];
onFileSelect: (file: FileItem, multiSelect?: boolean) => void;
onFileOpen: (file: FileItem) => void;
onSelectionChange: (files: FileItem[]) => void;
currentPath: string;
isLoading?: boolean;
onPathChange: (path: string) => void;
onRefresh: () => void;
onUpload?: (files: FileList) => void;
onContextMenu?: (event: React.MouseEvent, file?: FileItem) => void;
}
const getFileIcon = (fileName: string, isDirectory: boolean) => {
if (isDirectory) {
return <Folder className="w-8 h-8 text-blue-400" />;
}
const ext = fileName.split('.').pop()?.toLowerCase();
const iconClass = "w-8 h-8";
switch (ext) {
case 'txt':
case 'md':
case 'readme':
return <FileText className={`${iconClass} text-gray-400`} />;
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
case 'bmp':
case 'svg':
return <FileImage className={`${iconClass} text-green-400`} />;
case 'mp4':
case 'avi':
case 'mkv':
case 'mov':
return <FileVideo className={`${iconClass} text-purple-400`} />;
case 'mp3':
case 'wav':
case 'flac':
case 'ogg':
return <FileAudio className={`${iconClass} text-pink-400`} />;
case 'zip':
case 'tar':
case 'gz':
case 'rar':
case '7z':
return <Archive className={`${iconClass} text-orange-400`} />;
case 'js':
case 'ts':
case 'jsx':
case 'tsx':
case 'py':
case 'java':
case 'cpp':
case 'c':
case 'cs':
case 'php':
case 'rb':
case 'go':
case 'rs':
return <Code className={`${iconClass} text-yellow-400`} />;
case 'json':
case 'xml':
case 'yaml':
case 'yml':
case 'toml':
case 'ini':
case 'conf':
case 'config':
return <Settings className={`${iconClass} text-cyan-400`} />;
default:
return <File className={`${iconClass} text-gray-400`} />;
}
};
const formatFileSize = (bytes?: number): string => {
if (!bytes) return '';
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
};
export function FileManagerGrid({
files,
selectedFiles,
onFileSelect,
onFileOpen,
onSelectionChange,
currentPath,
isLoading,
onPathChange,
onRefresh,
onUpload,
onContextMenu
}: FileManagerGridProps) {
const { t } = useTranslation();
const gridRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [dragCounter, setDragCounter] = useState(0);
const [isSelecting, setIsSelecting] = useState(false);
const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);
const [selectionRect, setSelectionRect] = useState<{ x: number; y: number; width: number; height: number } | null>(null);
// 路径导航
const pathParts = currentPath.split('/').filter(Boolean);
const navigateToPath = (index: number) => {
if (index === -1) {
onPathChange('/');
} else {
const newPath = '/' + pathParts.slice(0, index + 1).join('/');
onPathChange(newPath);
}
};
// 拖放处理
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragCounter(prev => prev + 1);
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragging(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragCounter(prev => prev - 1);
if (dragCounter <= 1) {
setIsDragging(false);
}
}, [dragCounter]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
setDragCounter(0);
if (onUpload && e.dataTransfer.files.length > 0) {
onUpload(e.dataTransfer.files);
}
}, [onUpload]);
// 文件选择处理
const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
event.stopPropagation();
if (event.detail === 2) {
// 双击打开
onFileOpen(file);
} else {
// 单击选择
const multiSelect = event.ctrlKey || event.metaKey;
const rangeSelect = event.shiftKey;
if (rangeSelect && selectedFiles.length > 0) {
// 范围选择 (Shift+点击)
const lastSelected = selectedFiles[selectedFiles.length - 1];
const currentIndex = files.findIndex(f => f.path === file.path);
const lastIndex = files.findIndex(f => f.path === lastSelected.path);
if (currentIndex !== -1 && lastIndex !== -1) {
const start = Math.min(currentIndex, lastIndex);
const end = Math.max(currentIndex, lastIndex);
const rangeFiles = files.slice(start, end + 1);
onSelectionChange(rangeFiles);
}
} else if (multiSelect) {
// 多选 (Ctrl+点击)
const isSelected = selectedFiles.some(f => f.path === file.path);
if (isSelected) {
onSelectionChange(selectedFiles.filter(f => f.path !== file.path));
} else {
onSelectionChange([...selectedFiles, file]);
}
} else {
// 单选
onFileSelect(file);
onSelectionChange([file]);
}
}
};
// 空白区域点击取消选择
const handleGridClick = (event: React.MouseEvent) => {
if (event.target === event.currentTarget) {
onSelectionChange([]);
}
};
// 键盘支持
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!gridRef.current?.contains(document.activeElement)) return;
switch (event.key) {
case 'Escape':
onSelectionChange([]);
break;
case 'a':
case 'A':
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
onSelectionChange([...files]);
}
break;
case 'Delete':
if (selectedFiles.length > 0) {
// 触发删除操作
console.log('Delete selected files:', selectedFiles);
}
break;
case 'F2':
if (selectedFiles.length === 1) {
// 触发重命名
console.log('Rename file:', selectedFiles[0]);
}
break;
case 'F5':
event.preventDefault();
onRefresh();
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [selectedFiles, files, onSelectionChange, onRefresh]);
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">{t("common.loading")}</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 gap-1 p-2 border-b border-dark-border">
<button
onClick={() => window.history.back()}
className="p-1 rounded hover:bg-dark-hover"
title="后退"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={() => window.history.forward()}
className="p-1 rounded hover:bg-dark-hover"
title="前进"
>
<ChevronRight className="w-4 h-4" />
</button>
<button
onClick={() => onPathChange(files.find(f => f.name === '..')?.path || '/')}
className="p-1 rounded hover:bg-dark-hover"
title="上级目录"
>
</button>
<button
onClick={onRefresh}
className="p-1 rounded hover:bg-dark-hover"
title="刷新"
>
<Download className="w-4 h-4" />
</button>
</div>
{/* 面包屑导航 */}
<div className="flex items-center px-3 py-2 text-sm">
<button
onClick={() => navigateToPath(-1)}
className="hover:text-blue-400 hover:underline"
>
/
</button>
{pathParts.map((part, index) => (
<React.Fragment key={index}>
<span className="mx-1 text-muted-foreground">/</span>
<button
onClick={() => navigateToPath(index)}
className="hover:text-blue-400 hover:underline"
>
{part}
</button>
</React.Fragment>
))}
</div>
</div>
{/* 主文件网格 */}
<div
ref={gridRef}
className={cn(
"flex-1 p-4 overflow-auto",
isDragging && "bg-blue-500/10 border-2 border-dashed border-blue-500"
)}
onClick={handleGridClick}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onContextMenu={(e) => onContextMenu?.(e)}
tabIndex={0}
>
{isDragging && (
<div className="absolute inset-0 flex items-center justify-center bg-blue-500/10 backdrop-blur-sm z-10">
<div className="text-center">
<Download className="w-12 h-12 mx-auto mb-2 text-blue-500" />
<p className="text-lg font-medium text-blue-500">
{t("fileManager.dragFilesToUpload")}
</p>
</div>
</div>
)}
{files.length === 0 ? (
<div className="h-full flex items-center justify-center">
<div className="text-center text-muted-foreground">
<Folder className="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>{t("fileManager.emptyFolder")}</p>
</div>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
{files.map((file) => {
const isSelected = selectedFiles.some(f => f.path === file.path);
return (
<div
key={file.path}
className={cn(
"group p-3 rounded-lg cursor-pointer transition-all",
"hover:bg-dark-hover border-2 border-transparent",
isSelected && "bg-blue-500/20 border-blue-500"
)}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu?.(e, file);
}}
>
<div className="flex flex-col items-center text-center">
{/* 文件图标 */}
<div className="mb-2">
{getFileIcon(file.name, file.type === 'directory')}
</div>
{/* 文件名 */}
<div className="w-full">
<p className="text-xs text-white truncate" title={file.name}>
{file.name}
</p>
{file.size && file.type === 'file' && (
<p className="text-xs text-muted-foreground mt-1">
{formatFileSize(file.size)}
</p>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* 状态栏 */}
<div className="flex-shrink-0 border-t border-dark-border px-4 py-2 text-xs text-muted-foreground">
<div className="flex justify-between items-center">
<span>
{files.length} {t("fileManager.itemCount", { count: files.length })}
</span>
{selectedFiles.length > 0 && (
<span>
{t("fileManager.selectedCount", { count: selectedFiles.length })}
</span>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,439 @@
import React, { useState, useEffect, useRef } from "react";
import { FileManagerGrid } from "./FileManagerGrid";
import { FileManagerContextMenu } from "./FileManagerContextMenu";
import { useFileSelection } from "./hooks/useFileSelection";
import { useDragAndDrop } from "./hooks/useDragAndDrop";
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 type { SSHHost } from "../../../types/index.js";
import {
listSSHFiles,
uploadSSHFile,
downloadSSHFile,
createSSHFile,
createSSHFolder,
deleteSSHItem,
renameSSHItem,
connectSSH
} from "@/ui/main-axios.ts";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
}
interface FileManagerModernProps {
initialHost?: SSHHost | null;
onClose?: () => void;
}
export function FileManagerModern({ initialHost, onClose }: FileManagerModernProps) {
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');
// 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);
// Hooks
const {
selectedFiles,
selectFile,
selectAll,
clearSelection,
setSelectedFiles
} = useFileSelection();
const { isDragging, dragHandlers } = useDragAndDrop({
onFilesDropped: handleFilesDropped,
onError: (error) => toast.error(error),
maxFileSize: 100 // 100MB
});
// 初始化SSH连接
useEffect(() => {
if (currentHost) {
initializeSSHConnection();
}
}, [currentHost]);
// 文件列表更新
useEffect(() => {
if (sshSessionId) {
loadDirectory(currentPath);
}
}, [sshSessionId, currentPath]);
async function initializeSSHConnection() {
if (!currentHost) return;
try {
setIsLoading(true);
const sessionId = await connectSSH(currentHost.id);
setSshSessionId(sessionId);
} catch (error: any) {
toast.error(t("fileManager.failedToConnect"));
console.error("SSH connection failed:", error);
} finally {
setIsLoading(false);
}
}
async function loadDirectory(path: string) {
if (!sshSessionId) return;
try {
setIsLoading(true);
const contents = await listSSHFiles(sshSessionId, path);
setFiles(contents || []);
clearSelection();
} catch (error: any) {
toast.error(t("fileManager.failedToLoadDirectory"));
console.error("Failed to load directory:", 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 {
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) {
toast.error(t("fileManager.failedToUploadFile"));
console.error("Upload failed:", error);
}
}
async function handleDownloadFile(file: FileItem) {
if (!sshSessionId) return;
try {
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) {
toast.error(t("fileManager.failedToDownloadFile"));
console.error("Download failed:", error);
}
}
async function handleDeleteFiles(files: FileItem[]) {
if (!sshSessionId || files.length === 0) return;
try {
for (const file of files) {
await deleteSSHItem(sshSessionId, file.path);
}
toast.success(t("fileManager.itemsDeletedSuccessfully", { count: files.length }));
loadDirectory(currentPath);
clearSelection();
} catch (error: any) {
toast.error(t("fileManager.failedToDeleteItems"));
console.error("Delete failed:", error);
}
}
async function handleCreateNewFolder() {
if (!sshSessionId) return;
const folderName = prompt(t("fileManager.enterFolderName"));
if (!folderName) return;
try {
const folderPath = currentPath.endsWith('/')
? `${currentPath}${folderName}`
: `${currentPath}/${folderName}`;
await createSSHFolder(sshSessionId, folderPath);
toast.success(t("fileManager.folderCreatedSuccessfully", { name: folderName }));
loadDirectory(currentPath);
} catch (error: any) {
toast.error(t("fileManager.failedToCreateFolder"));
console.error("Create folder failed:", error);
}
}
async function handleCreateNewFile() {
if (!sshSessionId) return;
const fileName = prompt(t("fileManager.enterFileName"));
if (!fileName) return;
try {
const filePath = currentPath.endsWith('/')
? `${currentPath}${fileName}`
: `${currentPath}/${fileName}`;
await createSSHFile(sshSessionId, filePath, "");
toast.success(t("fileManager.fileCreatedSuccessfully", { name: fileName }));
loadDirectory(currentPath);
} catch (error: any) {
toast.error(t("fileManager.failedToCreateFile"));
console.error("Create file failed:", error);
}
}
function handleFileOpen(file: FileItem) {
if (file.type === 'directory') {
setCurrentPath(file.path);
} else {
// 打开文件编辑器或预览
console.log("Open file:", file);
}
}
function handleContextMenu(event: React.MouseEvent, file?: FileItem) {
event.preventDefault();
const files = file ? [file] : 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 }));
}
// 过滤文件
const filteredFiles = files.filter(file =>
file.name.toLowerCase().includes(searchQuery.toLowerCase())
);
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={() => loadDirectory(currentPath)}
className="h-9"
>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
</div>
</div>
{/* 主内容区域 */}
<div className="flex-1 relative" {...dragHandlers}>
<FileManagerGrid
files={filteredFiles}
selectedFiles={selectedFiles}
onFileSelect={selectFile}
onFileOpen={handleFileOpen}
onSelectionChange={setSelectedFiles}
currentPath={currentPath}
isLoading={isLoading}
onPathChange={setCurrentPath}
onRefresh={() => loadDirectory(currentPath)}
onUpload={handleFilesDropped}
onContextMenu={handleContextMenu}
/>
{/* 右键菜单 */}
<FileManagerContextMenu
x={contextMenu.x}
y={contextMenu.y}
files={contextMenu.files}
isVisible={contextMenu.isVisible}
onClose={() => setContextMenu(prev => ({ ...prev, isVisible: false }))}
onDownload={(files) => files.forEach(handleDownloadFile)}
onCopy={handleCopyFiles}
onCut={handleCutFiles}
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}
/>
</div>
</div>
);
}
@@ -0,0 +1,159 @@
import { useState, useCallback } from 'react';
interface DragAndDropState {
isDragging: boolean;
dragCounter: number;
draggedFiles: File[];
}
interface UseDragAndDropProps {
onFilesDropped: (files: FileList) => void;
onError?: (error: string) => void;
maxFileSize?: number; // in MB
allowedTypes?: string[];
}
export function useDragAndDrop({
onFilesDropped,
onError,
maxFileSize = 100, // 100MB default
allowedTypes = [] // empty means all types allowed
}: UseDragAndDropProps) {
const [state, setState] = useState<DragAndDropState>({
isDragging: false,
dragCounter: 0,
draggedFiles: []
});
const validateFiles = useCallback((files: FileList): string | null => {
const maxSizeBytes = maxFileSize * 1024 * 1024;
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Check file size
if (file.size > maxSizeBytes) {
return `File "${file.name}" is too large. Maximum size is ${maxFileSize}MB.`;
}
// Check file type if restrictions exist
if (allowedTypes.length > 0) {
const fileExt = file.name.split('.').pop()?.toLowerCase();
const mimeType = file.type.toLowerCase();
const isAllowed = allowedTypes.some(type => {
// Check by extension
if (type.startsWith('.')) {
return fileExt === type.slice(1);
}
// Check by MIME type
if (type.includes('/')) {
return mimeType === type || mimeType.startsWith(type.replace('*', ''));
}
// Check by category
switch (type) {
case 'image':
return mimeType.startsWith('image/');
case 'video':
return mimeType.startsWith('video/');
case 'audio':
return mimeType.startsWith('audio/');
case 'text':
return mimeType.startsWith('text/');
default:
return false;
}
});
if (!isAllowed) {
return `File type "${file.type || 'unknown'}" is not allowed.`;
}
}
}
return null;
}, [maxFileSize, allowedTypes]);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState(prev => ({
...prev,
dragCounter: prev.dragCounter + 1
}));
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setState(prev => ({
...prev,
isDragging: true
}));
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState(prev => {
const newCounter = prev.dragCounter - 1;
return {
...prev,
dragCounter: newCounter,
isDragging: newCounter > 0
};
});
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Set dropEffect to indicate what operation is allowed
e.dataTransfer.dropEffect = 'copy';
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: []
});
const files = e.dataTransfer.files;
if (files.length === 0) {
return;
}
const validationError = validateFiles(files);
if (validationError) {
onError?.(validationError);
return;
}
onFilesDropped(files);
}, [validateFiles, onFilesDropped, onError]);
const resetDragState = useCallback(() => {
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: []
});
}, []);
return {
isDragging: state.isDragging,
dragHandlers: {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop
},
resetDragState
};
}
@@ -0,0 +1,82 @@
import { useState, useCallback } from 'react';
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
}
export function useFileSelection() {
const [selectedFiles, setSelectedFiles] = useState<FileItem[]>([]);
const selectFile = useCallback((file: FileItem, multiSelect = false) => {
if (multiSelect) {
setSelectedFiles(prev => {
const isSelected = prev.some(f => f.path === file.path);
if (isSelected) {
return prev.filter(f => f.path !== file.path);
} else {
return [...prev, file];
}
});
} else {
setSelectedFiles([file]);
}
}, []);
const selectRange = useCallback((files: FileItem[], startFile: FileItem, endFile: FileItem) => {
const startIndex = files.findIndex(f => f.path === startFile.path);
const endIndex = files.findIndex(f => f.path === endFile.path);
if (startIndex !== -1 && endIndex !== -1) {
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const rangeFiles = files.slice(start, end + 1);
setSelectedFiles(rangeFiles);
}
}, []);
const selectAll = useCallback((files: FileItem[]) => {
setSelectedFiles([...files]);
}, []);
const clearSelection = useCallback(() => {
setSelectedFiles([]);
}, []);
const toggleSelection = useCallback((file: FileItem) => {
setSelectedFiles(prev => {
const isSelected = prev.some(f => f.path === file.path);
if (isSelected) {
return prev.filter(f => f.path !== file.path);
} else {
return [...prev, file];
}
});
}, []);
const isSelected = useCallback((file: FileItem) => {
return selectedFiles.some(f => f.path === file.path);
}, [selectedFiles]);
const getSelectedCount = useCallback(() => {
return selectedFiles.length;
}, [selectedFiles]);
return {
selectedFiles,
selectFile,
selectRange,
selectAll,
clearSelection,
toggleSelection,
isSelected,
getSelectedCount,
setSelectedFiles
};
}