"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, Menu, Users, Share2, MailPlus, Paperclip, ExternalLink, } from "lucide-react"; import { useIsDesktop } from "@/hooks/use-media-query"; 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 { Avatar } from "@/components/ui/avatar"; import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils"; import type { FileResource } from "@/stores/file-store"; import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog"; import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import type { FileNodeRights } from "@/lib/jmap/types"; 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; } export interface AccountFolderEntry { accountId: string; label: string; email: string; avatarColor: string; } 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; /** Pro shell only: all connected accounts surfaced as top-level folders at the root. */ accountFolders?: AccountFolderEntry[]; onSelectAccount?: (accountId: string) => void; /** Pro shell only: when true, the root is a pure account picker - hide the file toolbar and don't render a regular listing. */ accountPickerMode?: boolean; /** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after Home. */ accountLabel?: string | null; /** JMAP client for the browsing account, used by the share dialog to list principals. */ client?: IJMAPClient | null; /** Files account id of the browsing account; used to exclude self from the share picker. */ ownAccountId?: string | null; /** True when the server supports JMAP Sharing (principals); gates the Share action. */ sharingEnabled?: boolean; /** Add/update/remove a principal's share on a node. Set null rights to revoke. */ onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise; /** Send selected files as email attachments - opens the composer with files pre-attached. */ onSendAsAttachment?: (names: string[]) => void; } 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); } const OFFICE_EXTENSIONS = new Set([ "docx", "xlsx", "pptx", "odt", "ods", "odp", "doc", "xls", "ppt", ]); function isOfficeFile(name: string): boolean { const ext = name.split(".").pop()?.toLowerCase() || ""; return OFFICE_EXTENSIONS.has(ext); } function isPreviewable(name: string): boolean { return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name); } 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"); } // Small inline indicator: a node shared out by the user (shareWith has entries) // or a node shared *with* the user by another principal (isShared). function ShareBadge({ resource, t }: { resource: FileResource; t: (key: string) => string }) { const sharedOut = !!resource.shareWith && Object.keys(resource.shareWith).length > 0; if (resource.isShared) { return ( {t("shared_with_me")} ); } if (sharedOut) { return ( {t("shared")} ); } return null; } 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, accountFolders, onSelectAccount, accountPickerMode, accountLabel, client, ownAccountId, sharingEnabled, onShare, onSendAsAttachment, }: FileBrowserProps) { const t = useTranslations("files"); const [showNewFolder, setShowNewFolder] = useState(false); const [renameTarget, setRenameTarget] = useState(null); const [shareTargetId, setShareTargetId] = useState(null); const [isDraggingOver, setIsDraggingOver] = useState(false); // The share dialog is bound to a node id (not name) so its shareWith stays // live after a share refresh re-derives the resource list. const shareTarget = shareTargetId ? resources.find(r => r.id === shareTargetId) ?? null : null; // A node is shareable when the server supports JMAP Sharing, the viewer owns // it (not a shared-with-me node), and holds the mayShare right (owned nodes // report full rights; treat missing myRights as allowed). const canShare = useCallback((r: FileResource | null | undefined): boolean => !!(sharingEnabled && onShare && client && r && !r.isShared && (r.myRights?.mayShare ?? true)), [sharingEnabled, onShare, client]); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null); const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null); // Radial menu state const [radialMenuOpen, setRadialMenuOpen] = useState(false); const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 }); const [radialMenuResourceName, setRadialMenuResourceName] = useState(null); const closeRadialMenu = useCallback(() => { setRadialMenuOpen(false); }, []); const radialMenuItems = useMemo(() => { if (!radialMenuResourceName) return []; const name = radialMenuResourceName; const resource = resources.find((r) => r.name === name); const items: RadialMenuItem[] = []; items.push({ id: "rename", icon: , label: t("rename"), onClick: () => { setRenameTarget(name); }, }); items.push({ id: "delete", icon: , label: t("delete"), onClick: () => { onDelete(name); }, destructive: true, }); if (resource && !resource.isDirectory) { items.push({ id: "download", icon: , label: t("download"), onClick: () => { onDownload(name); }, }); } if (canShare(resource)) { items.push({ id: "share", icon: , label: t("share"), onClick: () => { if (resource?.id) setShareTargetId(resource.id); }, }); } if (resource && !resource.isDirectory) { items.push({ id: "send-as-attachment", icon: , label: t("send_as_attachment"), onClick: () => {}, }); } return items; }, [radialMenuResourceName, resources, t, onDelete, onDownload, canShare]); 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); // Pane-aware: in a Pro split pane (or a narrow window) the folder tree // sidebar collapses into a burger-toggled overlay so it doesn't crowd the // file list. const isDesktopPane = useIsDesktop(); const isNarrow = !isDesktopPane; const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false); useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]); // 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. In Pro mode an account is mounted "between" // Home and the account's filesystem - surfaced as a non-clickable label // (clicking the actual account again would be a no-op; Home detaches it). const breadcrumbs: { name: string; path: string; isAccount?: boolean }[] = 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('/'), })), ]; if (accountLabel) { breadcrumbs.splice(1, 0, { name: accountLabel, path: '', isAccount: true }); } const handleNavigateUp = useCallback(() => { if (currentPath === '/') return; const segments = currentPath.split('/').filter(Boolean); segments.pop(); const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/'); // Pro shell: going up to root from a subfolder must land on the // account's filesystem root, not detach back to the account picker. // Home click (breadcrumb) still detaches. if (parentPath === '/' && accountLabel) { onNavigate('/', '__account_root__'); return; } onNavigate(parentPath); }, [currentPath, onNavigate, accountLabel]); 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 }); setRadialMenuPos({ x: e.clientX, y: e.clientY }); setRadialMenuResourceName(name); setRadialMenuOpen(true); }; // 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 */}
{isNarrow && folderLayout === "sidebar" && ( )} {/* Breadcrumbs */} {/* Action buttons */}
{selectedResources.size > 0 && (() => { const fileNames = [...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory); const hasFiles = fileNames.length > 0; const showBatch = selectedResources.size > 1; if (!showBatch && !hasFiles) return null; return ( <> {showBatch && ( <> )} {hasFiles && onSendAsAttachment && ( )} {!showBatch && hasFiles && fileNames.length === 1 && isOfficeFile(fileNames[0]) && ( )} ); })()} {clipboard && ( )} {!accountPickerMode && ( )} {!accountPickerMode && ( <> )}
{/* 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 */}
{/* Narrow-pane backdrop for the overlay folder tree */} {folderLayout === "sidebar" && isNarrow && narrowSidebarOpen && (
setNarrowSidebarOpen(false)} /> )} {/* Folder tree sidebar (when layout is sidebar) */} {folderLayout === "sidebar" && ( isNarrow ? (
{ // Auto-close when the user taps a folder name. Chevrons stay // open so they can expand/collapse without dismissing. const target = e.target as HTMLElement; const btn = target.closest('button'); if (btn && !btn.querySelector('svg.lucide-chevron-right, svg.lucide-chevron-down')) { setNarrowSidebarOpen(false); } }} >
) : ( <> { 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")}
) : accountPickerMode && accountFolders && accountFolders.length > 0 && onSelectAccount ? ( /* ======= ACCOUNT PICKER (Pro mode root) ======= */
{accountFolders.map((acc) => ( ))}
) : accountPickerMode ? (

{t("no_accounts")}

) : 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)}
)} {/* Radial Action Menu */} {/* Context menu */} {contextMenu && (
e.stopPropagation()} > {!resources.find(r => r.name === contextMenu.name)?.isDirectory && isPreviewable(contextMenu.name) && ( )} {!resources.find(r => r.name === contextMenu.name)?.isDirectory && ( )} {!resources.find(r => r.name === contextMenu.name)?.isDirectory && isOfficeFile(contextMenu.name) && ( )} {clipboard && ( )} {!resources.find(r => r.name === contextMenu.name)?.isDirectory && ( )} {canShare(resources.find(r => r.name === contextMenu.name)) && ( )}
)} {/* 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)} /> )} {/* Share dialog */} {shareTarget && client && onShare && ( onShare(shareTarget.id, principalId, rights as FileNodeRights | null)} onClose={() => setShareTargetId(null)} /> )}
); }