diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 853bc4c6..4741d731 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -627,6 +627,33 @@ "folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully", "failedToCreateFolder": "Failed to create folder", "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", "itemRenamedSuccessfully": "{{type}} renamed successfully", "failedToRenameItem": "Failed to rename item", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 0562d509..3c0ff7bb 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -642,6 +642,33 @@ "folderCreatedSuccessfully": "文件夹 \"{{name}}\" 创建成功", "failedToCreateFolder": "创建文件夹失败", "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": "删除项目失败", "itemRenamedSuccessfully": "{{type}}重命名成功", "failedToRenameItem": "重命名项目失败", diff --git a/src/ui/Desktop/Apps/File Manager/FileManager.tsx b/src/ui/Desktop/Apps/File Manager/FileManager.tsx index c6c1b8c8..9d8d013a 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManager.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManager.tsx @@ -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(null); @@ -518,6 +520,15 @@ export function FileManager({ }; if (!currentHost) { + if (useModernView) { + return ( + + ); + } + return (
@@ -547,6 +558,30 @@ export function FileManager({ ); } + // 如果使用现代视图且有主机连接,显示现代文件管理器 + if (useModernView && currentHost) { + return ( +
+ {/* 视图切换按钮 */} +
+ +
+ +
+ ); + } + return (
@@ -577,6 +612,16 @@ export function FileManager({ />
+ {/* 添加现代视图切换按钮 */} + +
+ ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx new file mode 100644 index 00000000..c53e743a --- /dev/null +++ b/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx @@ -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 ; + } + + const ext = fileName.split('.').pop()?.toLowerCase(); + const iconClass = "w-8 h-8"; + + switch (ext) { + case 'txt': + case 'md': + case 'readme': + return ; + case 'png': + case 'jpg': + case 'jpeg': + case 'gif': + case 'bmp': + case 'svg': + return ; + case 'mp4': + case 'avi': + case 'mkv': + case 'mov': + return ; + case 'mp3': + case 'wav': + case 'flac': + case 'ogg': + return ; + case 'zip': + case 'tar': + case 'gz': + case 'rar': + case '7z': + return ; + 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 ; + case 'json': + case 'xml': + case 'yaml': + case 'yml': + case 'toml': + case 'ini': + case 'conf': + case 'config': + return ; + default: + return ; + } +}; + +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(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 ( +
+
+
+

{t("common.loading")}

+
+
+ ); + } + + return ( +
+ {/* 工具栏和路径导航 */} +
+ {/* 导航按钮 */} +
+ + + + +
+ + {/* 面包屑导航 */} +
+ + {pathParts.map((part, index) => ( + + / + + + ))} +
+
+ + {/* 主文件网格 */} +
onContextMenu?.(e)} + tabIndex={0} + > + {isDragging && ( +
+
+ +

+ {t("fileManager.dragFilesToUpload")} +

+
+
+ )} + + {files.length === 0 ? ( +
+
+ +

{t("fileManager.emptyFolder")}

+
+
+ ) : ( +
+ {files.map((file) => { + const isSelected = selectedFiles.some(f => f.path === file.path); + + return ( +
handleFileClick(file, e)} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu?.(e, file); + }} + > +
+ {/* 文件图标 */} +
+ {getFileIcon(file.name, file.type === 'directory')} +
+ + {/* 文件名 */} +
+

+ {file.name} +

+ {file.size && file.type === 'file' && ( +

+ {formatFileSize(file.size)} +

+ )} +
+
+
+ ); + })} +
+ )} +
+ + {/* 状态栏 */} +
+
+ + {files.length} {t("fileManager.itemCount", { count: files.length })} + + {selectedFiles.length > 0 && ( + + {t("fileManager.selectedCount", { count: selectedFiles.length })} + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx new file mode 100644 index 00000000..85eaebbf --- /dev/null +++ b/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx @@ -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(initialHost || null); + const [currentPath, setCurrentPath] = useState("/"); + const [files, setFiles] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [sshSessionId, setSshSessionId] = useState(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 ( +
+
+

+ {t("fileManager.selectHostToStart")} +

+
+
+ ); + } + + return ( +
+ {/* 工具栏 */} +
+
+
+

+ {currentHost.name} +

+ + {currentHost.ip}:{currentHost.port} + +
+ +
+ {/* 搜索 */} +
+ + setSearchQuery(e.target.value)} + className="pl-8 w-48 h-9 bg-dark-bg-button border-dark-border" + /> +
+ + {/* 视图切换 */} +
+ + +
+ + {/* 操作按钮 */} + + + + + +
+
+
+ + {/* 主内容区域 */} +
+ loadDirectory(currentPath)} + onUpload={handleFilesDropped} + onContextMenu={handleContextMenu} + /> + + {/* 右键菜单 */} + 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} + /> +
+
+ ); +} \ No newline at end of file diff --git a/src/ui/Desktop/Apps/File Manager/hooks/useDragAndDrop.ts b/src/ui/Desktop/Apps/File Manager/hooks/useDragAndDrop.ts new file mode 100644 index 00000000..773b3566 --- /dev/null +++ b/src/ui/Desktop/Apps/File Manager/hooks/useDragAndDrop.ts @@ -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({ + 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 + }; +} \ No newline at end of file diff --git a/src/ui/Desktop/Apps/File Manager/hooks/useFileSelection.ts b/src/ui/Desktop/Apps/File Manager/hooks/useFileSelection.ts new file mode 100644 index 00000000..2cf0eeda --- /dev/null +++ b/src/ui/Desktop/Apps/File Manager/hooks/useFileSelection.ts @@ -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([]); + + 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 + }; +} \ No newline at end of file