Files
Termix/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx
ZacharyZcR fee7af3c46 Fix file manager view mode toggle - add grid and list view support
- Added viewMode prop to FileManagerGrid component
- Implemented list view layout with detailed file information
- Updated icon sizing for different view modes (8px for grid, 6px for list)
- Added proper file metadata display in list view (size, permissions, modified date)
- Connected view mode state from FileManagerModern to FileManagerGrid
- Both grid and list view buttons now fully functional
2025-09-16 16:57:47 +08:00

574 lines
18 KiB
TypeScript

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,
RefreshCw,
ArrowUp
} 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;
viewMode?: 'grid' | 'list';
}
const getFileIcon = (fileName: string, isDirectory: boolean, viewMode: 'grid' | 'list' = 'grid') => {
const iconClass = viewMode === 'grid' ? "w-8 h-8" : "w-6 h-6";
if (isDirectory) {
return <Folder className={`${iconClass} text-blue-400`} />;
}
const ext = fileName.split('.').pop()?.toLowerCase();
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,
viewMode = 'grid'
}: 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 [navigationHistory, setNavigationHistory] = useState<string[]>([currentPath]);
const [historyIndex, setHistoryIndex] = useState(0);
// 更新导航历史
useEffect(() => {
const lastPath = navigationHistory[historyIndex];
if (currentPath !== lastPath) {
const newHistory = navigationHistory.slice(0, historyIndex + 1);
newHistory.push(currentPath);
setNavigationHistory(newHistory);
setHistoryIndex(newHistory.length - 1);
}
}, [currentPath]);
// 导航函数
const goBack = () => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
onPathChange(navigationHistory[newIndex]);
}
};
const goForward = () => {
if (historyIndex < navigationHistory.length - 1) {
const newIndex = historyIndex + 1;
setHistoryIndex(newIndex);
onPathChange(navigationHistory[newIndex]);
}
};
const goUp = () => {
const parts = currentPath.split('/').filter(Boolean);
if (parts.length > 0) {
parts.pop();
const parentPath = '/' + parts.join('/');
onPathChange(parentPath);
} else if (currentPath !== '/') {
onPathChange('/');
}
};
// 路径导航
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();
console.log('File clicked:', file.name, 'Current selected:', selectedFiles.length);
if (event.detail === 2) {
// 双击打开
console.log('Double click - opening file');
onFileOpen(file);
} else {
// 单击选择
const multiSelect = event.ctrlKey || event.metaKey;
const rangeSelect = event.shiftKey;
console.log('Single click - multiSelect:', multiSelect, 'rangeSelect:', rangeSelect);
if (rangeSelect && selectedFiles.length > 0) {
// 范围选择 (Shift+点击)
console.log('Range selection');
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);
console.log('Range selection result:', rangeFiles.length, 'files');
onSelectionChange(rangeFiles);
}
} else if (multiSelect) {
// 多选 (Ctrl+点击)
console.log('Multi selection');
const isSelected = selectedFiles.some(f => f.path === file.path);
if (isSelected) {
console.log('Removing from selection');
onSelectionChange(selectedFiles.filter(f => f.path !== file.path));
} else {
console.log('Adding to selection');
onSelectionChange([...selectedFiles, file]);
}
} else {
// 单选
console.log('Single selection - should select only:', file.name);
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();
console.log('Ctrl+A pressed - selecting all files:', files.length);
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={goBack}
disabled={historyIndex <= 0}
className={cn(
"p-1 rounded hover:bg-dark-hover",
historyIndex <= 0 && "opacity-50 cursor-not-allowed"
)}
title={t("common.back")}
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={goForward}
disabled={historyIndex >= navigationHistory.length - 1}
className={cn(
"p-1 rounded hover:bg-dark-hover",
historyIndex >= navigationHistory.length - 1 && "opacity-50 cursor-not-allowed"
)}
title={t("common.forward")}
>
<ChevronRight className="w-4 h-4" />
</button>
<button
onClick={goUp}
disabled={currentPath === '/'}
className={cn(
"p-1 rounded hover:bg-dark-hover",
currentPath === '/' && "opacity-50 cursor-not-allowed"
)}
title={t("fileManager.parentDirectory")}
>
<ArrowUp className="w-4 h-4" />
</button>
<button
onClick={onRefresh}
className="p-1 rounded hover:bg-dark-hover"
title={t("common.refresh")}
>
<RefreshCw 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>
) : viewMode === 'grid' ? (
<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);
// 详细调试路径比较
if (selectedFiles.length > 0) {
console.log(`\n=== File: ${file.name} ===`);
console.log(`File path: "${file.path}"`);
console.log(`Selected files:`, selectedFiles.map(f => `"${f.path}"`));
console.log(`Path comparison results:`, selectedFiles.map(f =>
`"${f.path}" === "${file.path}" -> ${f.path === file.path}`
));
console.log(`Final isSelected: ${isSelected}`);
}
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"
)}
title={`${file.name} - Selected: ${isSelected} - SelectedCount: ${selectedFiles.length}`}
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', viewMode)}
</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 className="space-y-1">
{files.map((file) => {
const isSelected = selectedFiles.some(f => f.path === file.path);
return (
<div
key={file.path}
className={cn(
"flex items-center gap-3 p-2 rounded cursor-pointer transition-all",
"hover:bg-dark-hover",
isSelected && "bg-blue-500/20"
)}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu?.(e, file);
}}
>
{/* 文件图标 */}
<div className="flex-shrink-0">
{getFileIcon(file.name, file.type === 'directory', viewMode)}
</div>
{/* 文件信息 */}
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate" title={file.name}>
{file.name}
</p>
{file.modified && (
<p className="text-xs text-muted-foreground">
{file.modified}
</p>
)}
</div>
{/* 文件大小 */}
<div className="flex-shrink-0 text-right">
{file.type === 'file' && file.size && (
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
</p>
)}
</div>
{/* 权限信息 */}
<div className="flex-shrink-0 text-right w-20">
{file.permissions && (
<p className="text-xs text-muted-foreground font-mono">
{file.permissions}
</p>
)}
</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>
);
}