import React, { useEffect, useState, useRef, forwardRef, useImperativeHandle, } from "react"; import { Folder, File, ArrowUp, Pin, MoreVertical, Trash2, Edit3, } from "lucide-react"; import { ScrollArea } from "@/components/ui/scroll-area.tsx"; import { cn } from "@/lib/utils.ts"; import { Input } from "@/components/ui/input.tsx"; import { Button } from "@/components/ui/button.tsx"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { listSSHFiles, renameSSHItem, deleteSSHItem, getFileManagerPinned, addFileManagerPinned, removeFileManagerPinned, getSSHStatus, connectSSH, } from "@/ui/main-axios.ts"; import type { SSHHost } from "../../../types/index.js"; const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( { onOpenFile, tabs, host, onOperationComplete, onPathChange, onDeleteItem, }: { onSelectView?: (view: string) => void; onOpenFile: (file: any) => void; tabs: any[]; host: SSHHost; onOperationComplete?: () => void; onError?: (error: string) => void; onSuccess?: (message: string) => void; onPathChange?: (path: string) => void; onDeleteItem?: (item: any) => void; }, ref, ) { const { t } = useTranslation(); const [currentPath, setCurrentPath] = useState("/"); const [files, setFiles] = useState([]); const pathInputRef = useRef(null); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [fileSearch, setFileSearch] = useState(""); const [debouncedFileSearch, setDebouncedFileSearch] = useState(""); useEffect(() => { const handler = setTimeout(() => setDebouncedSearch(search), 200); return () => clearTimeout(handler); }, [search]); useEffect(() => { const handler = setTimeout(() => setDebouncedSearch(fileSearch), 200); return () => clearTimeout(handler); }, [fileSearch]); const [sshSessionId, setSshSessionId] = useState(null); const [filesLoading, setFilesLoading] = useState(false); const [connectingSSH, setConnectingSSH] = useState(false); const [connectionCache, setConnectionCache] = useState< Record< string, { sessionId: string; timestamp: number; } > >({}); const [fetchingFiles, setFetchingFiles] = useState(false); const [contextMenu, setContextMenu] = useState<{ visible: boolean; x: number; y: number; item: any; }>({ visible: false, x: 0, y: 0, item: null, }); const [renamingItem, setRenamingItem] = useState<{ item: any; newName: string; } | null>(null); useEffect(() => { const nextPath = host?.defaultPath || "/"; setCurrentPath(nextPath); onPathChange?.(nextPath); (async () => { await connectToSSH(host); })(); }, [host?.id]); async function connectToSSH(server: SSHHost): Promise { const sessionId = server.id.toString(); const cached = connectionCache[sessionId]; if (cached && Date.now() - cached.timestamp < 30000) { setSshSessionId(cached.sessionId); return cached.sessionId; } if (connectingSSH) { return null; } setConnectingSSH(true); try { if (!server.password && !server.key) { toast.error(t("common.noAuthCredentials")); return null; } const connectionConfig = { hostId: server.id, ip: server.ip, port: server.port, username: server.username, password: server.password, sshKey: server.key, keyPassword: server.keyPassword, authType: server.authType, credentialId: server.credentialId, userId: server.userId, }; await connectSSH(sessionId, connectionConfig); setSshSessionId(sessionId); setConnectionCache((prev) => ({ ...prev, [sessionId]: { sessionId, timestamp: Date.now() }, })); return sessionId; } catch (err: any) { toast.error( err?.response?.data?.error || t("fileManager.failedToConnectSSH"), ); setSshSessionId(null); return null; } finally { setConnectingSSH(false); } } async function fetchFiles() { if (fetchingFiles) { return; } setFetchingFiles(true); setFiles([]); setFilesLoading(true); try { let pinnedFiles: any[] = []; try { if (host) { pinnedFiles = await getFileManagerPinned(host.id); } } catch (err) {} if (host && sshSessionId) { let res: any[] = []; try { const status = await getSSHStatus(sshSessionId); if (!status.connected) { const newSessionId = await connectToSSH(host); if (newSessionId) { setSshSessionId(newSessionId); res = await listSSHFiles(newSessionId, currentPath); } else { throw new Error(t("fileManager.failedToReconnectSSH")); } } else { res = await listSSHFiles(sshSessionId, currentPath); } } catch (sessionErr) { const newSessionId = await connectToSSH(host); if (newSessionId) { setSshSessionId(newSessionId); res = await listSSHFiles(newSessionId, currentPath); } else { throw sessionErr; } } const processedFiles = (res || []).map((f: any) => { const filePath = currentPath + (currentPath.endsWith("/") ? "" : "/") + f.name; const isPinned = pinnedFiles.some( (pinned) => pinned.path === filePath, ); return { ...f, path: filePath, isPinned, isSSH: true, sshSessionId: sshSessionId, }; }); setFiles(processedFiles); } } catch (err: any) { setFiles([]); toast.error( err?.response?.data?.error || err?.message || t("fileManager.failedToListFiles"), ); } finally { setFilesLoading(false); setFetchingFiles(false); } } useEffect(() => { if (host && sshSessionId && !connectingSSH && !fetchingFiles) { const timeoutId = setTimeout(() => { fetchFiles(); }, 100); return () => clearTimeout(timeoutId); } }, [currentPath, host, sshSessionId]); useImperativeHandle(ref, () => ({ openFolder: async (_server: SSHHost, path: string) => { if (connectingSSH || fetchingFiles) { return; } if (currentPath === path) { setTimeout(() => fetchFiles(), 100); return; } setFetchingFiles(false); setFilesLoading(false); setFiles([]); setCurrentPath(path); onPathChange?.(path); if (!sshSessionId) { const sessionId = await connectToSSH(host); if (sessionId) setSshSessionId(sessionId); } }, fetchFiles: () => { if (host && sshSessionId) { fetchFiles(); } }, getCurrentPath: () => currentPath, })); useEffect(() => { if (pathInputRef.current) { pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth; } }, [currentPath]); const filteredFiles = files.filter((file) => { const q = debouncedFileSearch.trim().toLowerCase(); if (!q) return true; return file.name.toLowerCase().includes(q); }); const handleContextMenu = (e: React.MouseEvent, item: any) => { e.preventDefault(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const menuWidth = 160; const menuHeight = 80; let x = e.clientX; let y = e.clientY; if (x + menuWidth > viewportWidth) { x = e.clientX - menuWidth; } if (y + menuHeight > viewportHeight) { y = e.clientY - menuHeight; } if (x < 0) { x = 0; } if (y < 0) { y = 0; } setContextMenu({ visible: true, x, y, item, }); }; const closeContextMenu = () => { setContextMenu({ visible: false, x: 0, y: 0, item: null }); }; const handleRename = async (item: any, newName: string) => { if (!sshSessionId || !newName.trim() || newName === item.name) { setRenamingItem(null); return; } try { await renameSSHItem(sshSessionId, item.path, newName.trim()); toast.success( `${item.type === "directory" ? t("common.folder") : t("common.file")} ${t("common.renamedSuccessfully")}`, ); setRenamingItem(null); if (onOperationComplete) { onOperationComplete(); } else { fetchFiles(); } } catch (error: any) { toast.error( error?.response?.data?.error || t("fileManager.failedToRenameItem"), ); } }; const startRename = (item: any) => { setRenamingItem({ item, newName: item.name }); closeContextMenu(); }; const startDelete = (item: any) => { onDeleteItem?.(item); closeContextMenu(); }; useEffect(() => { const handleClickOutside = () => closeContextMenu(); document.addEventListener("click", handleClickOutside); return () => document.removeEventListener("click", handleClickOutside); }, []); const handlePathChange = (newPath: string) => { setCurrentPath(newPath); onPathChange?.(newPath); }; return (
{host && (
handlePathChange(e.target.value)} className="flex-1 bg-dark-bg border-2 border-dark-border-hover text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-dark-border-light" />
setFileSearch(e.target.value)} />
{connectingSSH || filesLoading ? (
{t("common.loading")}
) : filteredFiles.length === 0 ? (
{t("fileManager.noFilesOrFoldersFound")}
) : (
{filteredFiles.map((item: any) => { const isOpen = (tabs || []).some( (t: any) => t.id === item.path, ); const isRenaming = renamingItem?.item?.path === item.path; const isDeleting = false; return (
!isOpen && handleContextMenu(e, item) } > {isRenaming ? (
{item.type === "directory" ? ( ) : ( )} setRenamingItem((prev) => prev ? { ...prev, newName: e.target.value, } : null, ) } className="flex-1 h-6 text-sm bg-dark-bg-button border border-dark-border-hover text-white" autoFocus onKeyDown={(e) => { if (e.key === "Enter") { handleRename( item, renamingItem.newName, ); } else if (e.key === "Escape") { setRenamingItem(null); } }} onBlur={() => handleRename(item, renamingItem.newName) } />
) : ( <>
!isOpen && (item.type === "directory" ? handlePathChange(item.path) : onOpenFile({ name: item.name, path: item.path, isSSH: item.isSSH, sshSessionId: item.sshSessionId, })) } > {item.type === "directory" ? ( ) : ( )} {item.name}
{item.type === "file" && ( )} {!isOpen && ( )}
)}
); })}
)}
)}
{contextMenu.visible && contextMenu.item && (
)}
); }); export { FileManagerLeftSidebar };