import React, { useState, useEffect, useRef, useCallback, useMemo, } from "react"; import { cn } from "@/lib/utils.ts"; import { Star, Clock, Bookmark, File, Folder, Trash2, ChevronDown, Check, } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { SSHHost } from "@/types"; import { getRecentFiles, getPinnedFiles, getFolderShortcuts, listSSHFiles, removeRecentFile, removePinnedFile, removeFolderShortcut, type DiskFilesystem, } from "@/main-axios.ts"; import { toast } from "sonner"; import FolderTree from "@/components/folder.tsx"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, } from "@/components/dropdown-menu.tsx"; // ─── Interfaces ──────────────────────────────────────────────────────────────── interface RecentFileData { id: number; name: string; path: string; lastOpened?: string; [key: string]: unknown; } interface PinnedFileData { id: number; name: string; path: string; [key: string]: unknown; } interface ShortcutData { id: number; name: string; path: string; [key: string]: unknown; } interface DirectoryItemData { name: string; path: string; type: string; [key: string]: unknown; } export interface SidebarItem { id: string; name: string; path: string; type: "recent" | "pinned" | "shortcut" | "folder"; lastAccessed?: string; isExpanded?: boolean; children?: SidebarItem[]; } interface FileManagerSidebarProps { currentHost: SSHHost; currentPath: string; onPathChange: (path: string) => void; onFileOpen?: (file: SidebarItem) => void; /** Full file-manager context menu (same as main grid). */ onItemContextMenu?: (event: React.MouseEvent, item: SidebarItem) => void; sshSessionId?: string; refreshTrigger?: number; onOpenTrash?: () => void; diskInfo?: { usedHuman: string; totalHuman: string; percent: number; mount?: string | null; filesystems?: DiskFilesystem[]; }; } // ─── Storage meter ───────────────────────────────────────────────────────────── function StorageMeter({ storage, filesystems, selectedMount, onSelectMount, }: { storage: { percent: number; usedHuman: string; totalHuman: string }; filesystems: DiskFilesystem[]; selectedMount: string | null; onSelectMount: (mount: string) => void; }) { const { t } = useTranslation(); const hasPicker = filesystems.length > 1; return (
{hasPicker ? ( {selectedMount ?? t("fileManager.disk")} {filesystems.map((fs) => ( onSelectMount(fs.mount)} className="gap-3" > {fs.mount} {fs.usedHuman && fs.totalHuman ? `${fs.usedHuman}/${fs.totalHuman}` : "N/A"} {fs.percent != null ? ` · ${fs.percent}%` : ""} ))} ) : ( {selectedMount ?? t("fileManager.disk")} )} {storage.percent}% {t("fileManager.used")}
{storage.usedHuman} {t("fileManager.of")} {storage.totalHuman}{" "} {t("fileManager.used").toLowerCase()}
); } // ─── Component ───────────────────────────────────────────────────────────────── export function FileManagerSidebar({ currentHost, currentPath, onPathChange, onFileOpen, onItemContextMenu, sshSessionId, refreshTrigger, onOpenTrash, diskInfo, }: FileManagerSidebarProps) { const { t } = useTranslation(); // ── Quick access state (API-backed) ────────────────────────────────────────── const [recentItems, setRecentItems] = useState([]); const [pinnedItems, setPinnedItems] = useState([]); const [shortcuts, setShortcuts] = useState([]); // ── Directory tree state ────────────────────────────────────────────────────── const [directoryTree, setDirectoryTree] = useState([]); // ── Storage state ───────────────────────────────────────────────────────────── const [selectedMount, setSelectedMount] = useState(null); /** * Tracks which folder paths have already been lazy-loaded so we don't * re-fetch on every re-selection / collapse-reopen. */ const loadedFoldersRef = useRef>(new Set(["/"])); // ── Context menu state ──────────────────────────────────────────────────────── const [contextMenu, setContextMenu] = useState<{ x: number; y: number; isVisible: boolean; item: SidebarItem | null; }>({ x: 0, y: 0, isVisible: false, item: null, }); // ─── Effects ────────────────────────────────────────────────────────────────── // ─── API: Quick access ──────────────────────────────────────────────────────── const loadQuickAccessData = useCallback(async () => { if (!currentHost?.id) return; try { const recentData = await getRecentFiles(currentHost.id); const recentItems = (recentData as unknown as RecentFileData[]) .slice(0, 5) .map((item: RecentFileData) => ({ id: `recent-${item.id}`, name: item.name, path: item.path, type: "recent" as const, lastAccessed: item.lastOpened, })); setRecentItems(recentItems); const pinnedData = await getPinnedFiles(currentHost.id); const pinnedItems = (pinnedData as unknown as PinnedFileData[]).map( (item: PinnedFileData) => ({ id: `pinned-${item.id}`, name: item.name, path: item.path, type: "pinned" as const, }), ); setPinnedItems(pinnedItems); const shortcutData = await getFolderShortcuts(currentHost.id); const shortcutItems = (shortcutData as unknown as ShortcutData[]).map( (item: ShortcutData) => ({ id: `shortcut-${item.id}`, name: item.name, path: item.path, type: "shortcut" as const, }), ); setShortcuts(shortcutItems); } catch (error) { console.error("Failed to load quick access data:", error); setRecentItems([]); setPinnedItems([]); setShortcuts([]); } }, [currentHost?.id]); // ─── API: Directory tree ────────────────────────────────────────────────────── const loadDirectoryTree = useCallback( async (attempt = 0) => { if (!sshSessionId) return; try { const response = await listSSHFiles(sshSessionId, "/"); const rootFiles = (response.files || []) as DirectoryItemData[]; const rootFolders = rootFiles .filter((item: DirectoryItemData) => item.type === "directory") .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), ); const rootTreeItems = rootFolders.map((folder: DirectoryItemData) => ({ id: `folder-${folder.name}`, name: folder.name, path: folder.path, type: "folder" as const, isExpanded: false, children: [], })); setDirectoryTree([ { id: "root", name: "/", path: "/", type: "folder" as const, isExpanded: true, children: rootTreeItems, }, ]); } catch (error: unknown) { const status = (error as { status?: number })?.status || (error as { response?: { status?: number } })?.response?.status; if (status === 409 && attempt < 3) { // Another request was already listing "/" — retry after a short delay setTimeout(() => loadDirectoryTree(attempt + 1), 600); return; } console.error("Failed to load directory tree:", error); setDirectoryTree([ { id: "root", name: "/", path: "/", type: "folder" as const, isExpanded: false, children: [], }, ]); } }, [sshSessionId], ); /** * Lazily fetches subdirectory contents and patches them into the tree state. * Called the first time a folder is expanded via FolderTree's onSelect. */ const loadSubdirectory = useCallback( async (folderId: string, folderPath: string): Promise => { if (!sshSessionId) return false; try { const subResponse = await listSSHFiles(sshSessionId, folderPath); const subFiles = (subResponse.files || []) as DirectoryItemData[]; const subFolders = subFiles .filter((item: DirectoryItemData) => item.type === "directory") .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), ); const subTreeItems = subFolders.map((folder: DirectoryItemData) => ({ id: `folder-${folder.path.replace(/\//g, "-")}`, name: folder.name, path: folder.path, type: "folder" as const, isExpanded: false, children: [], })); setDirectoryTree((prevTree) => { const updateChildren = (items: SidebarItem[]): SidebarItem[] => items.map((item) => { if (item.id === folderId) { return { ...item, children: subTreeItems }; } if (item.children) { return { ...item, children: updateChildren(item.children) }; } return item; }); return updateChildren(prevTree); }); loadedFoldersRef.current.add(folderPath); return true; } catch (error: unknown) { const status = (error as { status?: number })?.status || (error as { response?: { status?: number } })?.response?.status; if (status === 409) { // Another request was listing this path — retry after the lock clears setTimeout(() => void loadSubdirectory(folderId, folderPath), 600); return false; } loadedFoldersRef.current.delete(folderPath); console.error("Failed to load subdirectory:", error); return false; } }, [sshSessionId], ); useEffect(() => { loadQuickAccessData(); }, [loadQuickAccessData, refreshTrigger]); useEffect(() => { if (sshSessionId) { loadedFoldersRef.current = new Set(["/"]); loadDirectoryTree(); } }, [loadDirectoryTree, sshSessionId]); // When currentPath changes externally (grid navigation), ensure the parent // directory is loaded in the tree so the selection highlight can appear. useEffect(() => { if (!sshSessionId || currentPath === "/") return; const parentPath = currentPath.substring(0, currentPath.lastIndexOf("/")) || "/"; const findByPath = (items: SidebarItem[]): SidebarItem | null => { for (const item of items) { if (item.path === parentPath) return item; if (item.children) { const found = findByPath(item.children); if (found) return found; } } return null; }; const parent = findByPath(directoryTree); if (parent && !loadedFoldersRef.current.has(parent.path)) { void loadSubdirectory(parent.id, parent.path); } }, [currentPath, directoryTree, loadSubdirectory, sshSessionId]); // ─── Quick-access mutation handlers ────────────────────────────────────────── const handleRemoveRecentFile = async (item: SidebarItem) => { if (!currentHost?.id) return; try { await removeRecentFile(currentHost.id, item.path); loadQuickAccessData(); toast.success( t("fileManager.removedFromRecentFiles", { name: item.name }), ); } catch (error) { console.error("Failed to remove recent file:", error); toast.error(t("fileManager.removeFailed")); } }; const handleUnpinFile = async (item: SidebarItem) => { if (!currentHost?.id) return; try { await removePinnedFile(currentHost.id, item.path); loadQuickAccessData(); toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name })); } catch (error) { console.error("Failed to unpin file:", error); toast.error(t("fileManager.unpinFailed")); } }; const handleRemoveShortcut = async (item: SidebarItem) => { if (!currentHost?.id) return; try { await removeFolderShortcut(currentHost.id, item.path); loadQuickAccessData(); toast.success(t("fileManager.removedShortcut", { name: item.name })); } catch (error) { console.error("Failed to remove shortcut:", error); toast.error(t("fileManager.removeShortcutFailed")); } }; const handleClearAllRecent = async () => { if (!currentHost?.id || recentItems.length === 0) return; try { await Promise.all( recentItems.map((item) => removeRecentFile(currentHost.id, item.path)), ); loadQuickAccessData(); toast.success(t("fileManager.clearedAllRecentFiles")); } catch (error) { console.error("Failed to clear recent files:", error); toast.error(t("fileManager.clearFailed")); } }; // ─── Quick-access item click ────────────────────────────────────────────────── const handleQuickAccessClick = (item: SidebarItem) => { if (item.type === "recent" || item.type === "pinned") { if (onFileOpen) { onFileOpen(item); } else { const directory = item.path.substring(0, item.path.lastIndexOf("/")) || "/"; onPathChange(directory); } } else if (item.type === "shortcut") { onPathChange(item.path); } }; // ─── FolderTree directory selection (onSelect callback) ────────────────────── /** * Called by FolderTree whenever the user selects (clicks) a tree item. * We navigate to the folder and lazily load children on first visit. */ const handleDirectorySelect = useCallback( async (id: string) => { // Walk the tree to find the item by id const findItem = (items: SidebarItem[]): SidebarItem | null => { for (const item of items) { if (item.id === id) return item; if (item.children) { const found = findItem(item.children); if (found) return found; } } return null; }; const item = findItem(directoryTree); if (!item) return; // Navigate to path onPathChange(item.path); // Lazy-load children the first time this folder is expanded if ( sshSessionId && item.path !== "/" && !loadedFoldersRef.current.has(item.path) ) { await loadSubdirectory(id, item.path); } }, [directoryTree, onPathChange, sshSessionId, loadSubdirectory], ); // ─── Context menu ───────────────────────────────────────────────────────────── const findTreeItemById = useCallback( (items: SidebarItem[], id: string): SidebarItem | null => { for (const item of items) { if (item.id === id) return item; if (item.children) { const found = findTreeItemById(item.children, id); if (found) return found; } } return null; }, [], ); const handleItemContextMenu = (e: React.MouseEvent, item: SidebarItem) => { e.preventDefault(); e.stopPropagation(); if (onItemContextMenu) { onItemContextMenu(e, item); return; } setContextMenu({ x: e.clientX, y: e.clientY, isVisible: true, item }); }; const handleTreeContextMenu = (e: React.MouseEvent) => { const target = e.target as HTMLElement; const row = target.closest("[data-id]"); if (!row) return; const id = row.getAttribute("data-id"); if (!id) return; const item = findTreeItemById(directoryTree, id); if (!item || item.type !== "folder") return; handleItemContextMenu(e, item); }; const closeContextMenu = () => { setContextMenu((prev) => ({ ...prev, isVisible: false, item: null })); }; useEffect(() => { if (!contextMenu.isVisible || onItemContextMenu) return; const handleClickOutside = (event: MouseEvent) => { const target = event.target as Element; const menuElement = document.querySelector("[data-sidebar-context-menu]"); if (!menuElement?.contains(target)) closeContextMenu(); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") closeContextMenu(); }; const timeoutId = setTimeout(() => { document.addEventListener("mousedown", handleClickOutside); document.addEventListener("keydown", handleKeyDown); }, 50); return () => { clearTimeout(timeoutId); document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("keydown", handleKeyDown); }; }, [contextMenu.isVisible, onItemContextMenu]); // ─── Derive selected tree node + ancestors from currentPath ────────────────── const { selectedTreeId, ancestorIds } = useMemo(() => { if (currentPath === "/") return { selectedTreeId: "root", ancestorIds: new Set() }; const ancestors: string[] = []; const findByPath = ( items: SidebarItem[], path: string[], ): string | null => { for (const item of items) { if (item.path === currentPath) { ancestors.push(...path, "root"); return item.id; } if (item.children) { const found = findByPath(item.children, [...path, item.id]); if (found) return found; } } return null; }; const id = findByPath(directoryTree, []); return { selectedTreeId: id, ancestorIds: new Set(ancestors) }; }, [currentPath, directoryTree]); // ─── Render helpers ─────────────────────────────────────────────────────────── /** * Recursively renders directory tree items using FolderTree.Item + Content. * * FolderTree.Item detects "has children" via React.Children.count > 0. * By always wrapping children in (even when the * children array is empty), every directory shows the expand chevron. * FolderTree.Content internally shows nothing when its own children are * absent, so an unloaded folder simply expands to an empty state while * the async fetch fills it in. */ const renderFolderTreeItem = (item: SidebarItem): React.ReactNode => ( {item.children?.map((child) => renderFolderTreeItem(child))} ); const renderQuickAccessItem = (item: SidebarItem, icon: React.ReactNode) => { const dirPath = item.type === "shortcut" ? item.path : item.path.substring(0, item.path.lastIndexOf("/")) || "/"; const isActive = currentPath === dirPath; return ( ); }; const renderSection = ( title: string, items: SidebarItem[], renderItem: (item: SidebarItem) => React.ReactNode, ) => { if (items.length === 0) return null; return (
{title}
{items.map((item) => renderItem(item))}
); }; const hasQuickAccessItems = recentItems.length > 0 || pinnedItems.length > 0 || shortcuts.length > 0; // ─── Render ─────────────────────────────────────────────────────────────────── const storageFilesystems = diskInfo?.filesystems ?? []; const activeStorageMount = selectedMount ?? diskInfo?.mount ?? null; const selectedFs = selectedMount !== null ? (storageFilesystems.find((fs) => fs.mount === selectedMount) ?? null) : null; const storage = selectedFs && selectedFs.percent !== null ? { percent: selectedFs.percent, usedHuman: selectedFs.usedHuman ?? "N/A", totalHuman: selectedFs.totalHuman ?? "N/A", } : diskInfo && diskInfo.percent !== null ? { percent: diskInfo.percent, usedHuman: diskInfo.usedHuman, totalHuman: diskInfo.totalHuman, } : null; return ( <>
{onOpenTrash && ( )} {/* ── Recent files ──────────────────────────────────────── */} {renderSection(t("fileManager.recent"), recentItems, (item) => renderQuickAccessItem( item, , ), )} {/* ── Pinned files ───────────────────────────────────────── */} {renderSection(t("fileManager.pinned"), pinnedItems, (item) => renderQuickAccessItem( item, , ), )} {/* ── Folder shortcuts ───────────────────────────────────── */} {renderSection(t("fileManager.folderShortcuts"), shortcuts, (item) => renderQuickAccessItem( item, , ), )} {/* ── Directory tree ─────────────────────────────────────── */}
{t("fileManager.directories")}
handleDirectorySelect(id)} className="bg-transparent border-0 rounded-none shadow-none" > {directoryTree.map((item) => renderFolderTreeItem(item))}
{/* ── Storage — mobile only (inside scroll) ──────────────── */} {storage && (
{t("fileManager.storage")}
)}
{/* ── Storage — desktop only (bottom of sidebar) ──────────── */} {storage && (
{t("fileManager.storage")}
)}
{/* ── Context menu (fallback when parent does not supply onItemContextMenu) */} {!onItemContextMenu && contextMenu.isVisible && contextMenu.item && ( <>
{contextMenu.item.type === "recent" && ( <> {recentItems.length > 1 && ( <>
)} )} {contextMenu.item.type === "pinned" && ( )} {contextMenu.item.type === "shortcut" && ( )}
)} ); }