import React, { useState } from "react"; import { ChevronUp, User2, HardDrive, Menu, ChevronRight, RotateCcw, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { isElectron, logoutUser } from "@/ui/main-axios.ts"; import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarInset, SidebarHeader, } from "@/components/ui/sidebar.tsx"; import { Separator } from "@/components/ui/separator.tsx"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@radix-ui/react-dropdown-menu"; import { Input } from "@/components/ui/input.tsx"; import { Button } from "@/components/ui/button.tsx"; import { FolderCard } from "@/ui/desktop/navigation/hosts/FolderCard.tsx"; import { getSSHHosts, getSSHFolders } from "@/ui/main-axios.ts"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import type { SSHFolder } from "@/types/index.ts"; interface SSHHost { id: number; name: string; ip: string; port: number; username: string; folder: string; tags: string[]; pin: boolean; authType: string; password?: string; key?: string; keyPassword?: string; keyType?: string; enableTerminal: boolean; enableTunnel: boolean; enableFileManager: boolean; defaultPath: string; tunnelConnections: unknown[]; createdAt: string; updatedAt: string; } interface SidebarProps { disabled?: boolean; isAdmin?: boolean; username?: string | null; children?: React.ReactNode; onLogout?: () => void; } async function handleLogout() { try { await logoutUser(); if (isElectron()) { localStorage.removeItem("jwt"); } window.location.reload(); } catch (error) { console.error("Logout failed:", error); window.location.reload(); } } export function LeftSidebar({ disabled, isAdmin, username, children, onLogout, }: SidebarProps): React.ReactElement { const { t } = useTranslation(); const [isSidebarOpen, setIsSidebarOpen] = useState(() => { const saved = localStorage.getItem("leftSidebarOpen"); return saved !== null ? JSON.parse(saved) : true; }); const { tabs: tabList, addTab, setCurrentTab, allSplitScreenTab, updateHostConfig, } = useTabs() as { tabs: Array<{ id: number; type: string; [key: string]: unknown }>; addTab: (tab: { type: string; [key: string]: unknown }) => number; setCurrentTab: (id: number) => void; allSplitScreenTab: number[]; updateHostConfig: (id: number, config: unknown) => void; }; const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0; const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); const openSshManagerTab = () => { if (isSplitScreenActive) return; if (sshManagerTab) { setCurrentTab(sshManagerTab.id); return; } const id = addTab({ type: "ssh_manager", title: "Host Manager" }); setCurrentTab(id); }; const adminTab = tabList.find((t) => t.type === "admin"); const openAdminTab = () => { if (isSplitScreenActive) return; if (adminTab) { setCurrentTab(adminTab.id); return; } const id = addTab({ type: "admin" }); setCurrentTab(id); }; const userProfileTab = tabList.find((t) => t.type === "user_profile"); const openUserProfileTab = () => { if (isSplitScreenActive) return; if (userProfileTab) { setCurrentTab(userProfileTab.id); return; } const id = addTab({ type: "user_profile" }); setCurrentTab(id); }; const [hosts, setHosts] = useState([]); const [hostsLoading] = useState(false); const [hostsError, setHostsError] = useState(null); const prevHostsRef = React.useRef([]); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [folderMetadata, setFolderMetadata] = useState>( new Map(), ); const fetchFolderMetadata = React.useCallback(async () => { try { const folders = await getSSHFolders(); const metadataMap = new Map(); folders.forEach((folder) => { metadataMap.set(folder.name, folder); }); setFolderMetadata(metadataMap); } catch (error) { console.error("Failed to fetch folder metadata:", error); } }, []); const fetchHosts = React.useCallback(async () => { try { const newHosts = await getSSHHosts(); const prevHosts = prevHostsRef.current; const existingHostsMap = new Map(prevHosts.map((h) => [h.id, h])); const newHostsMap = new Map(newHosts.map((h) => [h.id, h])); let hasChanges = false; if (newHosts.length !== prevHosts.length) { hasChanges = true; } else { for (const [id, newHost] of newHostsMap) { const existingHost = existingHostsMap.get(id); if (!existingHost) { hasChanges = true; break; } if ( newHost.name !== existingHost.name || newHost.folder !== existingHost.folder || newHost.ip !== existingHost.ip || newHost.port !== existingHost.port || newHost.username !== existingHost.username || newHost.pin !== existingHost.pin || newHost.enableTerminal !== existingHost.enableTerminal || newHost.enableTunnel !== existingHost.enableTunnel || newHost.enableFileManager !== existingHost.enableFileManager || newHost.authType !== existingHost.authType || newHost.password !== existingHost.password || newHost.key !== existingHost.key || newHost.keyPassword !== existingHost.keyPassword || newHost.keyType !== existingHost.keyType || newHost.defaultPath !== existingHost.defaultPath || JSON.stringify(newHost.tags) !== JSON.stringify(existingHost.tags) || JSON.stringify(newHost.tunnelConnections) !== JSON.stringify(existingHost.tunnelConnections) ) { hasChanges = true; break; } } } if (hasChanges) { setTimeout(() => { setHosts(newHosts); prevHostsRef.current = newHosts; newHosts.forEach((newHost) => { updateHostConfig(newHost.id, newHost); }); }, 50); } } catch { setHostsError(t("leftSidebar.failedToLoadHosts")); } }, [updateHostConfig]); React.useEffect(() => { fetchHosts(); fetchFolderMetadata(); const interval = setInterval(() => { fetchHosts(); fetchFolderMetadata(); }, 300000); return () => clearInterval(interval); }, [fetchHosts, fetchFolderMetadata]); React.useEffect(() => { const handleHostsChanged = () => { fetchHosts(); fetchFolderMetadata(); }; const handleCredentialsChanged = () => { fetchHosts(); }; window.addEventListener( "ssh-hosts:changed", handleHostsChanged as EventListener, ); window.addEventListener( "credentials:changed", handleCredentialsChanged as EventListener, ); return () => { window.removeEventListener( "ssh-hosts:changed", handleHostsChanged as EventListener, ); window.removeEventListener( "credentials:changed", handleCredentialsChanged as EventListener, ); }; }, [fetchHosts, fetchFolderMetadata]); React.useEffect(() => { const handler = setTimeout(() => setDebouncedSearch(search), 200); return () => clearTimeout(handler); }, [search]); React.useEffect(() => { localStorage.setItem("leftSidebarOpen", JSON.stringify(isSidebarOpen)); }, [isSidebarOpen]); const [sidebarWidth, setSidebarWidth] = useState(() => { const saved = localStorage.getItem("leftSidebarWidth"); return saved !== null ? parseInt(saved, 10) : 250; }); const [isResizing, setIsResizing] = useState(false); const startXRef = React.useRef(null); const startWidthRef = React.useRef(sidebarWidth); React.useEffect(() => { localStorage.setItem("leftSidebarWidth", String(sidebarWidth)); }, [sidebarWidth]); const handleMouseDown = (e: React.MouseEvent) => { e.preventDefault(); setIsResizing(true); startXRef.current = e.clientX; startWidthRef.current = sidebarWidth; }; React.useEffect(() => { if (!isResizing) return; const handleMouseMove = (e: MouseEvent) => { if (startXRef.current == null) return; const dx = e.clientX - startXRef.current; const newWidth = Math.round(startWidthRef.current + dx); const minWidth = 200; const maxWidth = Math.round(window.innerWidth * 0.5); if (newWidth >= minWidth && newWidth <= maxWidth) { setSidebarWidth(newWidth); } else if (newWidth < minWidth) { setSidebarWidth(minWidth); } else if (newWidth > maxWidth) { setSidebarWidth(maxWidth); } }; const handleMouseUp = () => { setIsResizing(false); startXRef.current = null; }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); document.body.style.cursor = ""; document.body.style.userSelect = ""; }; }, [isResizing]); const filteredHosts = React.useMemo(() => { if (!debouncedSearch.trim()) return hosts; const q = debouncedSearch.trim().toLowerCase(); return hosts.filter((h) => { const searchableText = [ h.name || "", h.username, h.ip, h.folder || "", ...(h.tags || []), h.authType, h.defaultPath || "", ] .join(" ") .toLowerCase(); return searchableText.includes(q); }); }, [hosts, debouncedSearch]); const hostsByFolder = React.useMemo(() => { const map: Record = {}; filteredHosts.forEach((h) => { const folder = h.folder && h.folder.trim() ? h.folder : t("leftSidebar.noFolder"); if (!map[folder]) map[folder] = []; map[folder].push(h); }); return map; }, [filteredHosts]); const sortedFolders = React.useMemo(() => { const folders = Object.keys(hostsByFolder); folders.sort((a, b) => { if (a === t("leftSidebar.noFolder")) return -1; if (b === t("leftSidebar.noFolder")) return 1; return a.localeCompare(b); }); return folders; }, [hostsByFolder]); const getSortedHosts = React.useCallback((arr: SSHHost[]) => { const pinned = arr .filter((h) => h.pin) .sort((a, b) => (a.name || "").localeCompare(b.name || "")); const rest = arr .filter((h) => !h.pin) .sort((a, b) => (a.name || "").localeCompare(b.name || "")); return [...pinned, ...rest]; }, []); return (
Termix
setSearch(e.target.value)} placeholder={t("placeholders.searchHostsAny")} className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md" autoComplete="off" />
{hostsError && (
{t("leftSidebar.failedToLoadHosts")}
)} {hostsLoading && (
{t("hosts.loadingHosts")}
)} {sortedFolders.map((folder, idx) => { const metadata = folderMetadata.get(folder); return ( ); })}
{username ? username : t("common.logout")} { openUserProfileTab(); }} > {t("profile.title")} {isAdmin && ( { if (isAdmin) openAdminTab(); }} > {t("admin.title")} )} {t("common.logout")} {isSidebarOpen && (
{ if (!isResizing) { e.currentTarget.style.backgroundColor = "var(--dark-border-hover)"; } }} onMouseLeave={(e) => { if (!isResizing) { e.currentTarget.style.backgroundColor = "transparent"; } }} title="Drag to resize sidebar" /> )} {children}
{!isSidebarOpen && (
setIsSidebarOpen(true)} className="fixed top-0 left-0 w-[10px] h-full cursor-pointer flex items-center justify-center rounded-tr-md rounded-br-md" style={{ zIndex: 9999, backgroundColor: "#18181b", border: "2px solid #27272a", borderLeft: "none", }} >
)}
); }