import React, { useState, useEffect, useRef, useCallback, useMemo, } from "react"; import { cn } from "@/lib/utils.ts"; import { Star, Clock, Bookmark, File, Folder } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { SSHHost } from "@/types"; import { getRecentFiles, getPinnedFiles, getFolderShortcuts, listSSHFiles, removeRecentFile, removePinnedFile, removeFolderShortcut, } from "@/main-axios.ts"; import { toast } from "sonner"; import FolderTree from "@/components/folder.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; sshSessionId?: string; refreshTrigger?: number; diskInfo?: { usedHuman: string; totalHuman: string; percent: number }; } // ─── Component ───────────────────────────────────────────────────────────────── export function FileManagerSidebar({ currentHost, currentPath, onPathChange, onFileOpen, sshSessionId, refreshTrigger, 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([]); /** * 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 ────────────────────────────────────────────────────────────────── useEffect(() => { loadQuickAccessData(); }, [currentHost, refreshTrigger]); useEffect(() => { if (sshSessionId) { loadedFoldersRef.current = new Set(["/"]); 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)) { loadedFoldersRef.current.add(parent.path); loadSubdirectory(parent.id, parent.path); } }, [currentPath, sshSessionId]); // ─── API: Quick access ──────────────────────────────────────────────────────── const loadQuickAccessData = async () => { if (!currentHost?.id) return; try { const recentData = await getRecentFiles(currentHost.id); const recentItems = (recentData 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 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 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([]); } }; // ─── API: Directory tree ────────────────────────────────────────────────────── const loadDirectoryTree = 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", ); 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: [], }, ]); } }; /** * 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) => { if (!sshSessionId) return; try { const subResponse = await listSSHFiles(sshSessionId, folderPath); const subFiles = (subResponse.files || []) as DirectoryItemData[]; const subFolders = subFiles.filter( (item: DirectoryItemData) => item.type === "directory", ); 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); }); } 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(() => loadSubdirectory(folderId, folderPath), 600); return; } console.error("Failed to load subdirectory:", error); } }, [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) ) { loadedFoldersRef.current.add(item.path); await loadSubdirectory(id, item.path); } }, [directoryTree, onPathChange, sshSessionId, loadSubdirectory], ); // ─── Context menu ───────────────────────────────────────────────────────────── const handleContextMenu = (e: React.MouseEvent, item: SidebarItem) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, isVisible: true, item }); }; const closeContextMenu = () => { setContextMenu((prev) => ({ ...prev, isVisible: false, item: null })); }; useEffect(() => { if (!contextMenu.isVisible) 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]); // ─── 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 storageUsedPct = diskInfo?.percent ?? null; return ( <>
{/* ── 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) ──────────────── */} {diskInfo && storageUsedPct !== null && (
{t("fileManager.storage")}
{t("fileManager.disk")} {storageUsedPct}% {t("fileManager.used")}
{diskInfo.usedHuman} {t("fileManager.of")}{" "} {diskInfo.totalHuman} {t("fileManager.used").toLowerCase()}
)}
{/* ── Storage — desktop only (bottom of sidebar) ──────────── */} {diskInfo && storageUsedPct !== null && (
{t("fileManager.storage")} {storageUsedPct}% {t("fileManager.used")}
{diskInfo.usedHuman} {t("fileManager.of")} {diskInfo.totalHuman}{" "} {t("fileManager.used").toLowerCase()}
)}
{/* ── Context menu ─────────────────────────────────────────────── */} {contextMenu.isVisible && contextMenu.item && ( <>
{contextMenu.item.type === "recent" && ( <> {recentItems.length > 1 && ( <>
)} )} {contextMenu.item.type === "pinned" && ( )} {contextMenu.item.type === "shortcut" && ( )}
)} ); }