"use client"; import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; import { Folder, File, Upload, FolderPlus, Download, Trash2, Pencil, RefreshCw, Home, ChevronRight, MoreVertical, Search, ArrowUp, ArrowDown, X, LayoutGrid, LayoutList, Copy, Clipboard, Scissors, Info, Image as ImageIcon, FilePlus, CopyPlus, FileText, FileAudio, FileVideo, AlertCircle, Star, Clock, FolderUp, FileArchive, FileSpreadsheet, Presentation, FileCode, Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn, formatFileSize } from "@/lib/utils"; import { NewFolderDialog } from "@/components/files/new-folder-dialog"; import { RenameDialog } from "@/components/files/rename-dialog"; import { FileUploadArea } from "@/components/files/file-upload-area"; import { loadFilesSettings } from "@/components/files/files-settings-dialog"; import type { FolderLayout } from "@/components/files/files-settings-dialog"; import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils"; import type { FileResource } from "@/stores/file-store"; type SortKey = "name" | "size" | "modified"; type SortDir = "asc" | "desc"; type ViewMode = "list" | "grid"; interface ClipboardState { mode: "cut" | "copy"; ids: string[]; names: string[]; sourceParentId: string | null; } interface FileBrowserProps { currentPath: string; resources: FileResource[]; isLoading: boolean; error: string | null; selectedResources: Set; uploadProgress: { name: string; loaded: number; total: number; current: number; totalFiles: number } | null; clipboard: ClipboardState | null; onNavigate: (path: string, resourceId?: string | null) => void; onCreateFolder: (name: string) => Promise; onUploadFiles: (files: File[]) => Promise; onUploadFolder: (files: File[]) => Promise; onCancelUpload: () => void; onDelete: (name: string) => Promise; onBatchDelete: (names: string[]) => Promise; onRename: (oldName: string, newName: string) => Promise; onDownload: (name: string) => Promise; onBatchDownload: (names: string[]) => Promise; onRefresh: () => Promise; onSelectResource: (name: string | null) => void; onToggleSelect: (name: string) => void; onSelectAll: () => void; onClearSelection: () => void; onSetSelection: (names: Set) => void; onCut: (names: string[]) => void; onCopy: (names: string[]) => void; onPaste: () => Promise; onMoveToFolder: (names: string[], targetFolder: string) => Promise; onMoveToParent: (names: string[]) => Promise; onPreviewImage: (name: string) => void; onPreviewFile: (name: string) => void; onShowDetails: (name: string) => void; onCreateTextFile: (name: string) => Promise; onDuplicate: (name: string) => Promise; getImageUrl: (name: string) => Promise; listPath: (path: string) => Promise; listByParentId: (parentId: string | null) => Promise; favorites: string[]; recentFiles: { name: string; id: string; timestamp: number }[]; onToggleFavorite: (path: string) => void; showDetails: boolean; onToggleDetails: () => void; detailResource: FileResource | null; } const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]); function isImageFile(name: string): boolean { const ext = name.split(".").pop()?.toLowerCase() || ""; return IMAGE_EXTENSIONS.has(ext); } const TEXT_EXTENSIONS = new Set([ "txt", "md", "markdown", "json", "xml", "html", "htm", "css", "js", "ts", "jsx", "tsx", "py", "rb", "java", "c", "cpp", "h", "hpp", "go", "rs", "sh", "bash", "zsh", "yaml", "yml", "toml", "ini", "cfg", "conf", "env", "log", "csv", "sql", "graphql", "vue", "svelte", "astro", "php", "pl", "swift", "kt", "scala", "r", "lua", "vim", ]); function isTextFile(name: string): boolean { const ext = name.split(".").pop()?.toLowerCase() || ""; const baseName = name.toLowerCase(); return TEXT_EXTENSIONS.has(ext) || ["dockerfile", "makefile", "readme", "license", "changelog"].includes(baseName); } const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "flac", "aac", "m4a", "wma", "opus"]); function isAudioFile(name: string): boolean { const ext = name.split(".").pop()?.toLowerCase() || ""; return AUDIO_EXTENSIONS.has(ext); } const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "ogv", "mov", "avi", "mkv", "m4v"]); function isVideoFile(name: string): boolean { const ext = name.split(".").pop()?.toLowerCase() || ""; return VIDEO_EXTENSIONS.has(ext); } const PDF_EXTENSIONS = new Set(["pdf"]); function isPdfFile(name: string): boolean { const ext = name.split(".").pop()?.toLowerCase() || ""; return PDF_EXTENSIONS.has(ext); } const VECTOR_EXTENSIONS = new Set(["svg", "ai", "eps", "ps", "sketch", "fig", "xd", "gvdesign"]); function isVectorFile(name: string): boolean { const ext = name.split(".").pop()?.toLowerCase() || ""; return VECTOR_EXTENSIONS.has(ext); } const THREE_D_EXTENSIONS = new Set([ "obj", "fbx", "gltf", "glb", "stl", "3mf", "step", "stp", "iges", "igs", "blend", "3ds", "dae", "usdz", "usd", "usda", "usdc", "ply", "wrl", "c4d", "max", "ma", "mb", "dwg", "dxf", ]); function is3DFile(name: string): boolean { const ext = name.split(".").pop()?.toLowerCase() || ""; return THREE_D_EXTENSIONS.has(ext); } const EXECUTABLE_EXTENSIONS = new Set([ "exe", "msi", "dmg", "app", "appimage", "deb", "rpm", "snap", "flatpak", "bat", "cmd", "com", "scr", "ps1", "apk", "ipa", "jar", "run", ]); function isExecutableFile(name: string): boolean { const ext = name.split(".").pop()?.toLowerCase() || ""; return EXECUTABLE_EXTENSIONS.has(ext); } const ARCHIVE_EXTENSIONS = new Set([ "zip", "rar", "7z", "tar", "gz", "bz2", "xz", "zst", "lz", "lzma", "tgz", "tbz2", "txz", "cab", "iso", "img", ]); function isArchiveFile(name: string): boolean { const ext = name.split(".").pop()?.toLowerCase() || ""; return ARCHIVE_EXTENSIONS.has(ext); } const SPREADSHEET_EXTENSIONS = new Set(["xls", "xlsx", "ods", "numbers", "tsv"]); function isSpreadsheetFile(name: string): boolean { const ext = name.split(".").pop()?.toLowerCase() || ""; return SPREADSHEET_EXTENSIONS.has(ext); } const PRESENTATION_EXTENSIONS = new Set(["ppt", "pptx", "odp", "key"]); function isPresentationFile(name: string): boolean { const ext = name.split(".").pop()?.toLowerCase() || ""; return PRESENTATION_EXTENSIONS.has(ext); } const FONT_EXTENSIONS = new Set(["ttf", "otf", "woff", "woff2", "eot"]); function isFontFile(name: string): boolean { const ext = name.split(".").pop()?.toLowerCase() || ""; return FONT_EXTENSIONS.has(ext); } const DATABASE_EXTENSIONS = new Set(["db", "sqlite", "sqlite3", "mdb", "accdb"]); function isDatabaseFile(name: string): boolean { const ext = name.split(".").pop()?.toLowerCase() || ""; return DATABASE_EXTENSIONS.has(ext); } function isPreviewable(name: string): boolean { return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name); } const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB function getFileIconByName(name: string, size: "sm" | "lg") { const cls = size === "sm" ? "w-5 h-5" : "w-10 h-10"; if (isVectorFile(name)) return ; if (is3DFile(name)) return ; if (isImageFile(name)) return ; if (isAudioFile(name)) return ; if (isVideoFile(name)) return ; if (isArchiveFile(name)) return ; if (isExecutableFile(name)) return ; if (isSpreadsheetFile(name)) return ; if (isPresentationFile(name)) return ; if (isFontFile(name)) return ; if (isDatabaseFile(name)) return ; if (isPdfFile(name)) return ; if (isTextFile(name)) return ; return ; } function getFileIcon(resource: FileResource) { if (resource.isDirectory) { return ; } return getFileIconByName(resource.name, "sm"); } function getGridIcon(resource: FileResource) { if (resource.isDirectory) { return ; } return getFileIconByName(resource.name, "lg"); } function Thumbnail({ name, getImageUrl: fetchUrl, size = "sm" }: { name: string; getImageUrl: (n: string) => Promise; size?: "sm" | "lg"; }) { const [src, setSrc] = useState(null); const [failed, setFailed] = useState(false); useEffect(() => { let cancelled = false; fetchUrl(name).then(url => { if (!cancelled) setSrc(url); }).catch(() => { if (!cancelled) setFailed(true); }); return () => { cancelled = true; }; }, [name, fetchUrl]); if (failed || !src) { return size === "sm" ? : ; } const cls = size === "sm" ? "w-5 h-5 rounded object-cover" : "w-10 h-10 rounded object-cover"; return {name}; } function formatDate(dateString: string): string { if (!dateString) return ""; try { return new Date(dateString).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } catch { return dateString; } } function SkeletonRow() { return (
); } export function FileBrowser({ currentPath, resources, isLoading, error, selectedResources, uploadProgress, onNavigate, onCreateFolder, onUploadFiles, onUploadFolder, onCancelUpload, onDelete, onBatchDelete, onRename, onDownload, onBatchDownload, onRefresh, onSelectResource, onToggleSelect, onSelectAll, onClearSelection, onSetSelection, onCut, onCopy, onPaste, onMoveToFolder, onMoveToParent, onPreviewImage, onPreviewFile, onShowDetails, onCreateTextFile, onDuplicate, getImageUrl, listPath, listByParentId, favorites, recentFiles, onToggleFavorite, showDetails, onToggleDetails, detailResource, clipboard, }: FileBrowserProps) { const t = useTranslations("files"); const [showNewFolder, setShowNewFolder] = useState(false); const [renameTarget, setRenameTarget] = useState(null); const [isDraggingOver, setIsDraggingOver] = useState(false); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null); const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null); const [showNewTextFile, setShowNewTextFile] = useState(false); const [isUploading, setIsUploading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [showSearch, setShowSearch] = useState(false); const [sortKey, setSortKey] = useState("name"); const [sortDir, setSortDir] = useState("asc"); const [viewMode, setViewMode] = useState(() => { if (typeof window !== "undefined") { return (localStorage.getItem("files-view-mode") as ViewMode) || "list"; } return "list"; }); const [showThumbnails, setShowThumbnails] = useState(() => loadFilesSettings().showThumbnails); const [folderLayout, setFolderLayout] = useState(() => loadFilesSettings().folderLayout); const [sidebarWidth, setSidebarWidth] = useState(() => { if (typeof window !== "undefined") { const saved = localStorage.getItem("files-sidebar-width"); if (saved) return Math.max(180, Math.min(400, Number(saved))); } return 256; }); const [isResizing, setIsResizing] = useState(false); const dragStartWidth = useRef(256); const [dragTarget, setDragTarget] = useState(null); // Sync showThumbnails and folderLayout when settings change useEffect(() => { const reloadSettings = () => { const s = loadFilesSettings(); setShowThumbnails(s.showThumbnails); setFolderLayout(s.folderLayout); }; const handleStorage = (e: StorageEvent) => { if (e.key === "files-settings") reloadSettings(); }; // StorageEvent fires cross-tab, custom event fires same-tab window.addEventListener("storage", handleStorage); window.addEventListener("files-settings-changed", reloadSettings); return () => { window.removeEventListener("storage", handleStorage); window.removeEventListener("files-settings-changed", reloadSettings); }; }, []); const [breadcrumbDropdown, setBreadcrumbDropdown] = useState<{ path: string; folders: FileResource[]; x: number; y: number; } | null>(null); const [marquee, setMarquee] = useState<{ startX: number; startY: number; currentX: number; currentY: number; } | null>(null); const marqueeRef = useRef<{ additive: boolean; initialSelection: Set; } | null>(null); const fileInputRef = useRef(null); const folderInputRef = useRef(null); const searchInputRef = useRef(null); const containerRef = useRef(null); const scrollAreaRef = useRef(null); // Reset search when navigating useEffect(() => { setSearchQuery(""); }, [currentPath]); // Focus search input when shown useEffect(() => { if (showSearch) searchInputRef.current?.focus(); }, [showSearch]); // Persist view mode const handleViewModeChange = useCallback((mode: ViewMode) => { setViewMode(mode); localStorage.setItem("files-view-mode", mode); }, []); // Filter and sort resources const displayResources = useMemo(() => { let filtered = resources; // In sidebar mode, folders are shown in the sidebar tree — hide them from the main list if (folderLayout === "sidebar") { filtered = filtered.filter(r => !r.isDirectory); } if (searchQuery) { const q = searchQuery.toLowerCase(); filtered = filtered.filter(r => r.name.toLowerCase().includes(q)); } const sorted = [...filtered].sort((a, b) => { // Directories always first if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; let cmp = 0; switch (sortKey) { case "name": cmp = a.name.localeCompare(b.name); break; case "size": cmp = a.contentLength - b.contentLength; break; case "modified": cmp = new Date(a.lastModified || 0).getTime() - new Date(b.lastModified || 0).getTime(); break; } return sortDir === "asc" ? cmp : -cmp; }); return sorted; }, [resources, searchQuery, sortKey, sortDir, folderLayout]); // Build breadcrumb segments const breadcrumbs = currentPath === '/' ? [{ name: t("breadcrumb_root"), path: '/' }] : [ { name: t("breadcrumb_root"), path: '/' }, ...currentPath.split('/').filter(Boolean).map((segment, i, arr) => ({ name: segment, path: '/' + arr.slice(0, i + 1).join('/'), })), ]; const handleNavigateUp = () => { if (currentPath === '/') return; const segments = currentPath.split('/').filter(Boolean); segments.pop(); const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/'); onNavigate(parentPath, null); }; const handleResourceClick = (resource: FileResource, e: React.MouseEvent) => { if (resource.isDirectory) { // Ctrl/Cmd+click on directories also toggles selection if (e.ctrlKey || e.metaKey) { onToggleSelect(resource.name); return; } const newPath = currentPath === '/' ? `/${resource.name}` : `${currentPath}/${resource.name}`; onNavigate(newPath, resource.id); } else { if (e.ctrlKey || e.metaKey) { onToggleSelect(resource.name); } else if (e.shiftKey && resources.length > 0) { // Shift+click range select handleShiftSelect(resource.name); } else { onSelectResource(resource.name === [...selectedResources][0] && selectedResources.size === 1 ? null : resource.name); } } }; const handleShiftSelect = (targetName: string) => { const lastSelected = [...selectedResources].pop(); if (!lastSelected) { onToggleSelect(targetName); return; } const names = displayResources.map(r => r.name); const startIdx = names.indexOf(lastSelected); const endIdx = names.indexOf(targetName); if (startIdx === -1 || endIdx === -1) return; const from = Math.min(startIdx, endIdx); const to = Math.max(startIdx, endIdx); for (let i = from; i <= to; i++) { if (!selectedResources.has(names[i])) { onToggleSelect(names[i]); } } }; // Marquee (rubber-band) selection const handleMarqueeMouseDown = useCallback((e: React.MouseEvent) => { if (e.button !== 0) return; const target = e.target as HTMLElement; if (target.closest('[data-resource]') || target.closest('input') || target.closest('button') || target.closest('thead')) return; const scrollArea = scrollAreaRef.current; if (!scrollArea) return; const rect = scrollArea.getBoundingClientRect(); const x = e.clientX - rect.left + scrollArea.scrollLeft; const y = e.clientY - rect.top + scrollArea.scrollTop; const additive = e.ctrlKey || e.metaKey; marqueeRef.current = { additive, initialSelection: additive ? new Set(selectedResources) : new Set(), }; setMarquee({ startX: x, startY: y, currentX: x, currentY: y }); if (!additive) { onClearSelection(); } e.preventDefault(); }, [selectedResources, onClearSelection]); useEffect(() => { if (!marquee) return; const handleMouseMove = (e: MouseEvent) => { const scrollArea = scrollAreaRef.current; if (!scrollArea) return; const rect = scrollArea.getBoundingClientRect(); const x = e.clientX - rect.left + scrollArea.scrollLeft; const y = e.clientY - rect.top + scrollArea.scrollTop; setMarquee(prev => prev ? { ...prev, currentX: x, currentY: y } : null); // Calculate marquee rect const info = marqueeRef.current; if (!info) return; const mx = Math.min(marquee.startX, x); const my = Math.min(marquee.startY, y); const mw = Math.abs(x - marquee.startX); const mh = Math.abs(y - marquee.startY); // Find intersecting items const elements = scrollArea.querySelectorAll('[data-resource]'); const containerRect = scrollArea.getBoundingClientRect(); const newSelection = new Set(info.initialSelection); elements.forEach(el => { const name = el.getAttribute('data-resource'); if (!name) return; const elRect = el.getBoundingClientRect(); const elX = elRect.left - containerRect.left + scrollArea.scrollLeft; const elY = elRect.top - containerRect.top + scrollArea.scrollTop; if (mx < elX + elRect.width && mx + mw > elX && my < elY + elRect.height && my + mh > elY) { newSelection.add(name); } }); onSetSelection(newSelection); }; const handleMouseUp = () => { setMarquee(null); marqueeRef.current = null; }; window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); return () => { window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; }, [marquee, onSetSelection]); const handleResourceDoubleClick = (resource: FileResource) => { if (resource.isDirectory) { const newPath = currentPath === '/' ? `/${resource.name}` : `${currentPath}/${resource.name}`; onNavigate(newPath, resource.id); } else if (isPreviewable(resource.name)) { if (isImageFile(resource.name)) { onPreviewImage(resource.name); } else { onPreviewFile(resource.name); } } else { onDownload(resource.name); } }; const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); // Only show upload overlay for external file drags, not internal resource drags if (e.dataTransfer.types.includes("Files")) { setIsDraggingOver(true); } }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDraggingOver(false); }, []); const handleDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDraggingOver(false); setIsUploading(true); try { const { files, hasDirectories } = await getDroppedFilesAndFolders(e.dataTransfer); if (files.length > 0) { if (hasDirectories) { await onUploadFolder(files); } else { await onUploadFiles(files); } } } finally { setIsUploading(false); } }, [onUploadFiles, onUploadFolder]); const handleFileInputChange = async (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); if (files.length > 0) { setIsUploading(true); try { await onUploadFiles(files); } finally { setIsUploading(false); } } if (fileInputRef.current) { fileInputRef.current.value = ''; } }; const handleFolderInputChange = async (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); if (files.length > 0) { setIsUploading(true); try { await onUploadFolder(files); } finally { setIsUploading(false); } } if (folderInputRef.current) { folderInputRef.current.value = ''; } }; const contextMenuRef = useRef(null); const handleContextMenu = (e: React.MouseEvent, name: string) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, name }); }; // Adjust context menu position to stay within viewport useEffect(() => { if (contextMenu && contextMenuRef.current) { const menu = contextMenuRef.current; const rect = menu.getBoundingClientRect(); let { x, y } = contextMenu; let adjusted = false; if (x + rect.width > window.innerWidth) { x = window.innerWidth - rect.width - 8; adjusted = true; } if (y + rect.height > window.innerHeight) { y = window.innerHeight - rect.height - 8; adjusted = true; } if (adjusted) { setContextMenu({ ...contextMenu, x, y }); } } }, [contextMenu]); const handleContainerClick = () => { if (contextMenu) setContextMenu(null); if (emptyContextMenu) setEmptyContextMenu(null); if (breadcrumbDropdown) setBreadcrumbDropdown(null); }; const handleBreadcrumbRightClick = async (e: React.MouseEvent, crumbPath: string) => { e.preventDefault(); e.stopPropagation(); const parentPath = crumbPath === '/' ? '/' : '/' + crumbPath.split('/').filter(Boolean).slice(0, -1).join('/') || '/'; try { const items = await listPath(parentPath === '/' ? '/' : parentPath); const folders = items.filter(r => r.isDirectory); setBreadcrumbDropdown({ path: parentPath, folders, x: e.clientX, y: e.clientY }); } catch { // ignore } }; const handleSortClick = (key: SortKey) => { if (sortKey === key) { setSortDir(d => d === "asc" ? "desc" : "asc"); } else { setSortKey(key); setSortDir("asc"); } }; const SortIndicator = ({ column }: { column: SortKey }) => { if (sortKey !== column) return null; return sortDir === "asc" ? : ; }; // Keyboard shortcuts useEffect(() => { const handler = (e: KeyboardEvent) => { // Don't capture when typing in inputs or dialogs const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return; if (showNewFolder || renameTarget) return; // Ctrl+F / Cmd+F: toggle search if ((e.ctrlKey || e.metaKey) && e.key === 'f') { e.preventDefault(); setShowSearch(v => !v); return; } // Ctrl+A / Cmd+A: select all if ((e.ctrlKey || e.metaKey) && e.key === 'a') { e.preventDefault(); onSelectAll(); return; } // Ctrl+C / Cmd+C: copy selected if ((e.ctrlKey || e.metaKey) && e.key === 'c') { if (selectedResources.size > 0) { e.preventDefault(); onCopy([...selectedResources]); } return; } // Ctrl+X / Cmd+X: cut selected if ((e.ctrlKey || e.metaKey) && e.key === 'x') { if (selectedResources.size > 0) { e.preventDefault(); onCut([...selectedResources]); } return; } // Ctrl+V / Cmd+V: paste if ((e.ctrlKey || e.metaKey) && e.key === 'v') { if (clipboard) { e.preventDefault(); onPaste(); } return; } // Escape: clear selection, close search if (e.key === 'Escape') { if (showSearch) { setShowSearch(false); setSearchQuery(""); } else if (selectedResources.size > 0) onClearSelection(); return; } // Delete: delete selected if (e.key === 'Delete') { if (selectedResources.size === 1) { onDelete([...selectedResources][0]); } else if (selectedResources.size > 1) { onBatchDelete([...selectedResources]); } return; } // F2: rename selected (single) if (e.key === 'F2' && selectedResources.size === 1) { setRenameTarget([...selectedResources][0]); return; } // Enter: open selected directory or download selected file if (e.key === 'Enter' && selectedResources.size === 1) { const name = [...selectedResources][0]; const resource = resources.find(r => r.name === name); if (resource?.isDirectory) { const newPath = currentPath === '/' ? `/${resource.name}` : `${currentPath}/${resource.name}`; onNavigate(newPath, resource.id); } else if (resource) { onDownload(resource.name); } return; } // Backspace: navigate up if (e.key === 'Backspace' && currentPath !== '/') { handleNavigateUp(); return; } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [selectedResources, resources, currentPath, showSearch, showNewFolder, renameTarget, onDelete, onBatchDelete, onSelectAll, onClearSelection, onNavigate, onDownload, onCut, onCopy, onPaste, clipboard, handleNavigateUp]); const allSelected = resources.length > 0 && selectedResources.size === resources.length; const someSelected = selectedResources.size > 0 && !allSelected; return (
{/* Toolbar */}
{/* Breadcrumbs */} {/* Action buttons */}
{selectedResources.size > 1 && ( <> )} {clipboard && ( )}
{/* Search bar */} {showSearch && (
setSearchQuery(e.target.value)} placeholder={t("search_placeholder")} className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" onKeyDown={(e) => { if (e.key === 'Escape') { setShowSearch(false); setSearchQuery(""); } }} /> {searchQuery && ( )}
)} {/* Hidden file input */} {/* Hidden folder input */} )} /> {/* Error display with retry */} {error && (
{error}
)} {/* Drag overlay */} {isDraggingOver && (

{t("drop_files_here")}

)} {/* Upload progress */} {uploadProgress && (
{t("uploading")} {uploadProgress.name} {uploadProgress.totalFiles > 1 && ( ({uploadProgress.current}/{uploadProgress.totalFiles}) )} {uploadProgress.total > 0 ? `${Math.round((uploadProgress.loaded / uploadProgress.total) * 100)}%` : "…"}
0 ? `${(uploadProgress.loaded / uploadProgress.total) * 100}%` : '0%' }} />
)} {/* File list */}
{/* Folder tree sidebar (when layout is sidebar) */} {folderLayout === "sidebar" && ( <> { dragStartWidth.current = sidebarWidth; setIsResizing(true); }} onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} onResizeEnd={() => { setIsResizing(false); localStorage.setItem("files-sidebar-width", String(sidebarWidth)); }} onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("files-sidebar-width", "256"); }} /> )} {/* Favorites & Recent sidebar (when layout is inline) */} {folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && (
{favorites.length > 0 && (

{t("favorites")}

{favorites.map((fav) => ( ))}
)} {recentFiles.length > 0 && (

{t("recent")}

{recentFiles.slice(0, 10).map((recent) => ( ))}
)}
)}
{isLoading && resources.length === 0 ? (
{t("name")}
{t("size")} {t("modified")}
) : resources.length === 0 && !searchQuery && currentPath === '/' ? ( { setIsUploading(true); try { await onUploadFiles(files); } finally { setIsUploading(false); } }} onUploadFolder={async (files: File[]) => { setIsUploading(true); try { await onUploadFolder(files); } finally { setIsUploading(false); } }} onCreateFolder={() => setShowNewFolder(true)} onCreateTextFile={() => setShowNewTextFile(true)} /> ) : viewMode === "grid" ? ( /* ======= GRID VIEW ======= */
{currentPath !== '/' && !searchQuery && (
{ if (e.dataTransfer.types.includes("application/x-file-names")) { e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDragTarget('..'); } }} onDragLeave={() => setDragTarget(null)} onDrop={async (e) => { e.preventDefault(); setDragTarget(null); const raw = e.dataTransfer.getData("application/x-file-names"); if (!raw) return; const names: string[] = JSON.parse(raw); await onMoveToParent(names); }} > ..
)} {displayResources.length === 0 && searchQuery ? (

{t("no_results")}

) : (
{ if ((e.target as HTMLElement).closest('[data-resource]')) return; e.preventDefault(); setEmptyContextMenu({ x: e.clientX, y: e.clientY }); }} > {displayResources.map((resource) => (
{ const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name]; e.dataTransfer.setData("application/x-file-names", JSON.stringify(names)); e.dataTransfer.effectAllowed = "move"; }} onDragOver={(e) => { if (resource.isDirectory) { e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDragTarget(resource.name); } }} onDragLeave={() => setDragTarget(null)} onDrop={async (e) => { e.preventDefault(); setDragTarget(null); if (!resource.isDirectory) return; const raw = e.dataTransfer.getData("application/x-file-names"); if (!raw) return; const names: string[] = JSON.parse(raw); if (names.includes(resource.name)) return; await onMoveToFolder(names, resource.name); }} className={cn( "flex flex-col items-center gap-2 p-3 rounded-lg cursor-pointer transition-colors relative group", selectedResources.has(resource.name) ? "bg-primary/10 ring-1 ring-primary/30" : dragTarget === resource.name ? "bg-primary/5 ring-1 ring-primary/40" : "hover:bg-muted/50", clipboard?.mode === "cut" && clipboard.names.includes(resource.name) && "opacity-50" )} onClick={(e) => handleResourceClick(resource, e)} onDoubleClick={() => handleResourceDoubleClick(resource)} onContextMenu={(e) => handleContextMenu(e, resource.name)} > onToggleSelect(resource.name)} className="w-3.5 h-3.5 rounded border-border accent-primary cursor-pointer absolute top-2 left-2 opacity-0 group-hover:opacity-100 data-[checked=true]:opacity-100" data-checked={selectedResources.has(resource.name)} onClick={(e) => e.stopPropagation()} /> {showThumbnails && isImageFile(resource.name) ? : getGridIcon(resource)} {resource.name}
))}
)}
) : ( /* ======= LIST VIEW ======= */ { if ((e.target as HTMLElement).closest('tr[data-resource]')) return; e.preventDefault(); setEmptyContextMenu({ x: e.clientX, y: e.clientY }); }} > {currentPath !== '/' && !searchQuery && ( { if (e.dataTransfer.types.includes("application/x-file-names")) { e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDragTarget('..'); } }} onDragLeave={() => setDragTarget(null)} onDrop={async (e) => { e.preventDefault(); setDragTarget(null); const raw = e.dataTransfer.getData("application/x-file-names"); if (!raw) return; const names: string[] = JSON.parse(raw); await onMoveToParent(names); }} > )} {displayResources.length === 0 && searchQuery ? ( ) : displayResources.map((resource) => ( { const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name]; e.dataTransfer.setData("application/x-file-names", JSON.stringify(names)); e.dataTransfer.effectAllowed = "move"; }} onDragOver={(e) => { if (resource.isDirectory) { e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDragTarget(resource.name); } }} onDragLeave={() => setDragTarget(null)} onDrop={async (e) => { e.preventDefault(); setDragTarget(null); if (!resource.isDirectory) return; const raw = e.dataTransfer.getData("application/x-file-names"); if (!raw) return; const names: string[] = JSON.parse(raw); if (names.includes(resource.name)) return; await onMoveToFolder(names, resource.name); }} className={cn( "border-b border-border cursor-pointer transition-colors", selectedResources.has(resource.name) ? "bg-primary/10" : dragTarget === resource.name ? "bg-primary/5 ring-1 ring-primary/40" : "hover:bg-muted/50", clipboard?.mode === "cut" && clipboard.names.includes(resource.name) && "opacity-50" )} onClick={(e) => handleResourceClick(resource, e)} onDoubleClick={() => handleResourceDoubleClick(resource)} onContextMenu={(e) => handleContextMenu(e, resource.name)} > ))}
{ if (el) el.indeterminate = someSelected; }} onChange={() => allSelected ? onClearSelection() : onSelectAll()} className="w-4 h-4 rounded border-border accent-primary cursor-pointer" onClick={(e) => e.stopPropagation()} />
..
{t("no_results")}
onToggleSelect(resource.name)} className="w-4 h-4 rounded border-border accent-primary cursor-pointer shrink-0" onClick={(e) => e.stopPropagation()} /> {showThumbnails && isImageFile(resource.name) ? : getFileIcon(resource)} {resource.name}
{resource.isDirectory ? "—" : formatFileSize(resource.contentLength)} {formatDate(resource.lastModified)}
)} {/* Context menu */} {contextMenu && (
e.stopPropagation()} > {!resources.find(r => r.name === contextMenu.name)?.isDirectory && isPreviewable(contextMenu.name) && ( )} {!resources.find(r => r.name === contextMenu.name)?.isDirectory && ( )} {clipboard && ( )} {!resources.find(r => r.name === contextMenu.name)?.isDirectory && ( )}
)} {/* Empty-area context menu */} {emptyContextMenu && (
e.stopPropagation()} > {clipboard && ( <>
)}
)} {/* Breadcrumb dropdown */} {breadcrumbDropdown && (
e.stopPropagation()} > {breadcrumbDropdown.folders.length === 0 ? (

{t("no_results")}

) : ( breadcrumbDropdown.folders.map((folder) => { const folderPath = breadcrumbDropdown.path === '/' ? `/${folder.name}` : `${breadcrumbDropdown.path}/${folder.name}`; return ( ); }) )}
)} {/* Marquee selection rectangle */} {marquee && (
)}
{/* Details sidebar */} {showDetails && detailResource && (

{t("details")}

{detailResource.isDirectory ? : getFileIconByName(detailResource.name, "lg")}

{detailResource.name}

{t("type")}
{detailResource.isDirectory ? t("folder") : (detailResource.contentType || t("file"))}
{!detailResource.isDirectory && (
{t("size")}
{formatFileSize(detailResource.contentLength)}
)} {detailResource.lastModified && (
{t("modified")}
{formatDate(detailResource.lastModified)}
)}
{t("path")}
{currentPath === "/" ? `/${detailResource.name}` : `${currentPath}/${detailResource.name}`}
)}
{/* New folder dialog */} {showNewFolder && ( { await onCreateFolder(name); setShowNewFolder(false); }} onCancel={() => setShowNewFolder(false)} /> )} {/* New text file dialog */} {showNewTextFile && ( { await onCreateTextFile(name); setShowNewTextFile(false); }} onCancel={() => setShowNewTextFile(false)} /> )} {/* Rename dialog */} {renameTarget && ( { await onRename(renameTarget, newName); setRenameTarget(null); }} onCancel={() => setRenameTarget(null)} /> )}
); }