Implement modern file manager with drag-and-drop interface
Major UI/UX improvements to replace clunky sidebar with modern grid layout: - Add FileManagerModern component with grid-based file browser - Implement drag-and-drop file upload with validation and progress - Add comprehensive context menu with file operations (copy/cut/paste/delete) - Create intelligent file selection system with multi-select support - Add modern toolbar with search, view switching, and file operations - Integrate seamless view switching between classic and modern interfaces - Support keyboard shortcuts and accessibility features - Add complete i18n support for all new interface elements Technical components: - FileManagerGrid: Grid layout with breadcrumb navigation - FileManagerContextMenu: Right-click context menu system - useFileSelection: Hook for managing file selection state - useDragAndDrop: Hook for handling drag-and-drop operations - View switching logic integrated into main FileManager component The modern interface is now the default while maintaining backward compatibility. Users can switch between modern and classic views seamlessly.
This commit is contained in:
@@ -3,10 +3,11 @@ import { FileManagerLeftSidebar } from "@/ui/Desktop/Apps/File Manager/FileManag
|
||||
import { FileManagerHomeView } from "@/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx";
|
||||
import { FileManagerFileEditor } from "@/ui/Desktop/Apps/File Manager/FileManagerFileEditor.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 { FIleManagerTopNavbar } from "@/ui/Desktop/Apps/File Manager/FIleManagerTopNavbar.tsx";
|
||||
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 { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -48,6 +49,7 @@ export function FileManager({
|
||||
|
||||
const [showOperations, setShowOperations] = useState(false);
|
||||
const [currentPath, setCurrentPath] = useState("/");
|
||||
const [useModernView, setUseModernView] = useState(true); // 默认使用现代视图
|
||||
|
||||
const [deletingItem, setDeletingItem] = useState<any | null>(null);
|
||||
|
||||
@@ -518,6 +520,15 @@ export function FileManager({
|
||||
};
|
||||
|
||||
if (!currentHost) {
|
||||
if (useModernView) {
|
||||
return (
|
||||
<FileManagerModern
|
||||
initialHost={initialHost}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden rounded-md">
|
||||
<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 (
|
||||
<div className="absolute inset-0 overflow-hidden rounded-md">
|
||||
<div className="absolute top-0 left-0 w-64 h-full z-[20]">
|
||||
@@ -577,6 +612,16 @@ export function FileManager({
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
variant="outline"
|
||||
onClick={() => setShowOperations(!showOperations)}
|
||||
|
||||
328
src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx
Normal file
328
src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
432
src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx
Normal file
432
src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
439
src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx
Normal file
439
src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
159
src/ui/Desktop/Apps/File Manager/hooks/useDragAndDrop.ts
Normal file
159
src/ui/Desktop/Apps/File Manager/hooks/useDragAndDrop.ts
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
82
src/ui/Desktop/Apps/File Manager/hooks/useFileSelection.ts
Normal file
82
src/ui/Desktop/Apps/File Manager/hooks/useFileSelection.ts
Normal file
@@ -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