Implement Executable File Detection & Terminal Integration + i18n Improvements #252
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user