/* eslint-disable react-refresh/only-export-components */ import { useState, useEffect, useRef, useLayoutEffect, type MouseEvent, } from "react"; import { useTranslation } from "react-i18next"; import { useVirtualizer } from "@tanstack/react-virtual"; import { Box, Boxes, Check, ChevronDown, ChevronRight, Copy, CopyPlus, Cpu, FolderOpen, FolderSearch, Key, KeyRound, Layers, // --- tmux-monitor --- Link, Loader2, MemoryStick, MessagesSquare, Monitor, MoreHorizontal, MousePointerClick, Network, Pencil, Pin, Server, Share2, Terminal, Trash2, Users, Zap, } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/dropdown-menu"; import { toast } from "sonner"; import { bulkUpdateSSHHosts, createSSHHost, deleteSSHHost, getHostPassword, renameFolder, updateFolderMetadata, deleteAllHostsInFolder, wakeOnLan, } from "@/main-axios"; import type { Host, HostFolder, TabType } from "@/types/ui-types"; import type { SSHHostData } from "@/types/index"; import { FolderIconEl } from "@/components/folder-style"; import { resolveHostTabType } from "@/lib/host-connection-tabs"; import { copyToClipboard } from "@/lib/clipboard"; import { canDeleteHost, canEditHost, canShareHost, } from "@/sidebar/host-permissions"; import { FolderMetadataDialog } from "./FolderMetadataDialog"; import { HostShareModal } from "@/sidebar/HostShareModal"; import { useStatusColorScheme, getStatusClasses, } from "@/hooks/use-status-color-scheme"; import { useHostStatus, useServerStatus, useServerStatusMeta, } from "@/lib/ServerStatusContext"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/tooltip"; export function isFolder(item: Host | HostFolder): item is HostFolder { return "children" in item; } function statusCheckEnabled(host: Host): boolean { return host.statsConfig?.statusCheckEnabled !== false; } function buildStatusTooltip(host: Host, online: boolean): string { const statusLabel = online ? "Online" : "Offline"; if (!statusCheckEnabled(host)) return "Monitoring disabled"; const protocols: string[] = []; if (host.enableSsh) protocols.push("SSH"); if (host.enableRdp) protocols.push("RDP"); if (host.enableVnc) protocols.push("VNC"); if (host.enableTelnet) protocols.push("Telnet"); if (protocols.length === 0) return statusLabel; return `${protocols.join(", ")}: ${statusLabel}`; } function getSshActions( host: Host, ): { type: TabType; icon: typeof Terminal; label: string }[] { const metricsEnabled = host.enableSsh && host.statsConfig?.metricsEnabled !== false; return [ host.enableSsh && host.enableTerminal && { type: "terminal" as TabType, icon: Terminal, label: "Terminal", }, host.enableSsh && host.enableFileManager && { type: "files" as TabType, icon: FolderSearch, label: "Files", }, host.enableSsh && host.enableDocker && { type: "docker" as TabType, icon: Box, label: "Docker", }, host.enableSsh && host.enableTunnel && { type: "tunnel" as TabType, icon: Network, label: "Tunnel", }, metricsEnabled && { type: "host-metrics" as TabType, icon: Server, label: "Host Metrics", }, // --- tmux-monitor --- opt-in per host, off by default host.enableSsh && host.enableTerminal && host.enableTmuxMonitor && { type: "tmux_monitor" as TabType, icon: Layers, label: "Tmux Monitor", }, ].filter(Boolean) as { type: TabType; icon: typeof Terminal; label: string; }[]; } function hostMatchesQuery(host: Host, query: string) { return ( host.name.toLowerCase().includes(query) || host.ip.toLowerCase().includes(query) || host.username.toLowerCase().includes(query) || host.tags?.some((t) => t.toLowerCase().includes(query)) ); } function folderHasMatch(folder: HostFolder, query: string): boolean { for (const child of folder.children) { if (isFolder(child)) { if (folderHasMatch(child, query)) return true; } else { if (hostMatchesQuery(child, query)) return true; } } return false; } type VirtualRow = { item: Host | HostFolder; depth: number }; function collectVisibleRows( children: (Host | HostFolder)[], query: string, openSet: Set, out: VirtualRow[] = [], depth = 0, ): VirtualRow[] { for (const child of children) { if (isFolder(child)) { const visible = query ? folderHasMatch(child, query) : true; if (!visible) continue; out.push({ item: child, depth }); const childOpen = query ? true : openSet.has(child.path ?? child.name); if (childOpen) collectVisibleRows(child.children, query, openSet, out, depth + 1); } else { if (!query || hostMatchesQuery(child, query)) out.push({ item: child, depth }); } } return out; } function collectAllHosts(children: (Host | HostFolder)[]): Host[] { const out: Host[] = []; for (const child of children) { if (isFolder(child)) { out.push(...collectAllHosts(child.children)); } else { out.push(child); } } return out; } // Open/close state and folder assignment are both keyed by the full " / " path, // so two folders that share a leaf name don't collapse together. Synthetic group // headers (group-by views) are excluded from the assignable-folder list. function collectAllFolderPaths(children: (Host | HostFolder)[]): string[] { const paths = new Set(); for (const child of children) { if (isFolder(child)) { const path = child.path ?? child.name; if (!path.startsWith("__group__:")) paths.add(path); for (const p of collectAllFolderPaths(child.children)) paths.add(p); } } return Array.from(paths).sort((a, b) => a.localeCompare(b)); } async function writeClipboardText(value: string): Promise { await copyToClipboard(value); } function canCopyHostPassword(host: Host): boolean { return ( host.authType === "password" || host.authType === "credential" || !!host.hasPassword || !!host.password ); } function canCopyHostSudoPassword(host: Host): boolean { return ( !!host.hasSudoPassword || !!host.sudoPassword || !!host.terminalConfig?.sudoPassword ); } function folderHostCount(folder: HostFolder): { total: number; online: number; } { let total = 0, online = 0; for (const child of folder.children) { if (isFolder(child)) { const c = folderHostCount(child); total += c.total; online += c.online; } else { total++; if (child.online) online++; } } return { total, online }; } export function HostItem({ host, onOpenTab, onEditHost: onEditHostProp, onShareHost: onShareHostProp, onProxmoxDiscover, onDelete, onDuplicate, query = "", stripeIndex = 0, selectionMode = false, selected = false, onToggleSelect, isMenuOpen = false, onMenuOpenChange, isTrayOpen = false, onTrayOpenChange, onDragStart, onDragEnd, depth = 0, }: { host: Host; onOpenTab: (type: TabType) => void; onEditHost?: () => void; onShareHost?: () => void; onDelete: () => void; onDuplicate: () => void; query?: string; stripeIndex?: number; selectionMode?: boolean; selected?: boolean; onToggleSelect?: () => void; isMenuOpen?: boolean; onMenuOpenChange?: (open: boolean) => void; isTrayOpen?: boolean; onTrayOpenChange?: (open: boolean) => void; onProxmoxDiscover?: () => void; onDragStart?: () => void; onDragEnd?: () => void; /** Nesting level when rendered in a flattened virtual list. */ depth?: number; }) { const { t } = useTranslation(); // Shared hosts expose actions matching the recipient's permission level. const onEditHost = canEditHost(host) ? onEditHostProp : undefined; const onShareHost = canShareHost(host) ? onShareHostProp : undefined; const allowDelete = canDeleteHost(host); const metricsEnabled = host.enableSsh && host.statsConfig?.metricsEnabled !== false; const [trayOnClick, setTrayOnClick] = useState( () => localStorage.getItem("hostTrayOnClick") !== "false", ); const [showHostTags, setShowHostTags] = useState(() => { const v = localStorage.getItem("showHostTags"); return v !== null ? v === "true" : true; }); const [compactHostView, setCompactHostView] = useState( () => localStorage.getItem("compactHostView") === "true", ); const statusScheme = useStatusColorScheme(); const { initialLoadComplete } = useServerStatusMeta(); const statusCheckOn = statusCheckEnabled(host); const statusLoading = !initialLoadComplete && statusCheckOn; // Per-host subscription — status polls only re-render rows that flipped. const liveStatus = useHostStatus(Number(host.id), statusCheckOn); const isOnline = liveStatus != null ? liveStatus === "online" : host.online; const isTouchOnly = typeof window !== "undefined" && window.matchMedia("(hover: none)").matches; const shouldUseClickTray = trayOnClick || isTouchOnly; const showPasswordCopy = !host.isShared && canCopyHostPassword(host); const showSudoPasswordCopy = !host.isShared && canCopyHostSudoPassword(host); async function handleCopyPassword( e: MouseEvent, field: "password" | "sudoPassword", ) { e.stopPropagation(); const password = await getHostPassword(Number(host.id), field); if (!password) { toast.error(t("nav.failedToCopyPassword")); return; } try { await writeClipboardText(password); toast.success(t("nav.passwordCopied")); } catch { toast.error(t("nav.failedToCopyPassword")); } } useEffect(() => { const handler = () => setTrayOnClick(localStorage.getItem("hostTrayOnClick") !== "false"); window.addEventListener("storage", handler); window.addEventListener("hostTrayOnClickChanged", handler); return () => { window.removeEventListener("storage", handler); window.removeEventListener("hostTrayOnClickChanged", handler); }; }, []); useEffect(() => { const handler = () => { const v = localStorage.getItem("showHostTags"); setShowHostTags(v !== null ? v === "true" : true); }; window.addEventListener("storage", handler); window.addEventListener("showHostTagsChanged", handler); return () => { window.removeEventListener("storage", handler); window.removeEventListener("showHostTagsChanged", handler); }; }, []); useEffect(() => { const handler = () => setCompactHostView(localStorage.getItem("compactHostView") === "true"); window.addEventListener("storage", handler); window.addEventListener("compactHostViewChanged", handler); return () => { window.removeEventListener("storage", handler); window.removeEventListener("compactHostViewChanged", handler); }; }, []); if (query && !hostMatchesQuery(host, query)) return null; const depthStyle = depth > 0 ? ({ paddingLeft: depth * 12 } as const) : undefined; if (compactHostView) { return (
{ e.dataTransfer.effectAllowed = "move"; onDragStart?.(); }} onDragEnd={() => onDragEnd?.()} style={depthStyle} className={`group relative flex items-stretch cursor-pointer select-none transition-colors hover:bg-muted/40 ${ selected ? "bg-accent-brand/5" : stripeIndex % 2 === 1 ? "bg-muted/20" : "" } ${isMenuOpen ? "bg-muted/40" : ""}`} onClick={(e) => { if (selectionMode) { onToggleSelect?.(); return; } if (isTouchOnly) { e.stopPropagation(); const actionCount = getSshActions(host).length; const otherProtocols = [ host.enableRdp, host.enableVnc, host.enableTelnet, ].filter(Boolean).length; if (actionCount + otherProtocols <= 1) { if (host.enableSsh) onOpenTab("terminal"); else if (host.enableRdp) onOpenTab("rdp"); else if (host.enableVnc) onOpenTab("vnc"); else if (host.enableTelnet) onOpenTab("telnet"); else onOpenTab("terminal"); } else { onTrayOpenChange?.(!isTrayOpen); } return; } if (host.enableSsh) onOpenTab("terminal"); else if (host.enableRdp) onOpenTab("rdp"); else if (host.enableVnc) onOpenTab("vnc"); else if (host.enableTelnet) onOpenTab("telnet"); else onOpenTab("terminal"); }} >
{selectionMode && (
{selected && }
)} {buildStatusTooltip(host, isOnline)} {host.name} {host.isShared && ( {t("hosts.sharing.sharedBadge")} {t("hosts.sharing.sharedBadgeTooltip", { owner: host.ownerUsername || "?", level: t( `hosts.sharing.levels.${host.permissionLevel ?? "connect"}.label`, ), })} )} {!selectionMode && shouldUseClickTray && ( )} {!selectionMode && !shouldUseClickTray && ( {host.ip} )} {selectionMode && ( {host.ip} )}
{/* Click-tray mode: always-visible action buttons */} {shouldUseClickTray && !selectionMode && (
{getSshActions(host).map(({ type, icon: Icon, label }) => ( ))} {host.enableSsh && (host.enableRdp || host.enableVnc || host.enableTelnet) && getSshActions(host).length > 0 && (
)} {host.enableRdp && ( )} {host.enableVnc && ( )} {host.enableTelnet && ( )} {host.macAddress && ( )}
{showPasswordCopy && ( )} {showSudoPasswordCopy && ( )} {onEditHost && ( )} {onShareHost && ( )} {host.enableProxmox && onProxmoxDiscover && ( )} { e.stopPropagation(); writeClipboardText(`${host.username}@${host.ip}`); toast.success(t("hosts.copiedToClipboard")); }} > {t("hosts.copyAddress")} {showPasswordCopy && ( handleCopyPassword(e, "password")} > {t("nav.copyPassword")} )} {showSudoPasswordCopy && ( handleCopyPassword(e, "sudoPassword")} > {t("nav.copySudoPassword")} )} {allowDelete && ( <> { e.stopPropagation(); onDuplicate(); }} > {t("hosts.cloneHostAction")} { e.stopPropagation(); onDelete(); }} > {t("common.delete")} )}
)} {/* Hover tray (non-click-tray mode) */} {!shouldUseClickTray && !selectionMode && (
{getSshActions(host).map(({ type, icon: Icon, label }) => ( ))} {host.enableSsh && (host.enableRdp || host.enableVnc || host.enableTelnet) && getSshActions(host).length > 0 && (
)} {host.enableRdp && ( )} {host.enableVnc && ( )} {host.enableTelnet && ( )} {host.macAddress && ( )}
{showPasswordCopy && ( )} {showSudoPasswordCopy && ( )} {onEditHost && ( )} {onShareHost && ( )} {host.enableProxmox && onProxmoxDiscover && ( )} { e.stopPropagation(); writeClipboardText(`${host.username}@${host.ip}`); toast.success(t("hosts.copiedToClipboard")); }} > {t("hosts.copyAddress")} {showPasswordCopy && ( handleCopyPassword(e, "password")} > {t("nav.copyPassword")} )} {showSudoPasswordCopy && ( handleCopyPassword(e, "sudoPassword")} > {t("nav.copySudoPassword")} )} {allowDelete && ( <> { e.stopPropagation(); onDuplicate(); }} > {t("hosts.cloneHostAction")} { e.stopPropagation(); onDelete(); }} > {t("common.delete")} )}
)}
); } return (
{ e.dataTransfer.effectAllowed = "move"; onDragStart?.(); }} onDragEnd={() => onDragEnd?.()} style={depthStyle} className={`group relative flex items-stretch cursor-pointer select-none transition-colors hover:bg-muted/40 ${ selected ? "bg-accent-brand/5" : stripeIndex % 2 === 1 ? "bg-muted/20" : "" } ${isMenuOpen ? "bg-muted/40" : ""}`} onClick={(e) => { if (selectionMode) { onToggleSelect?.(); return; } const launchDefault = () => { if (host.enableSsh) onOpenTab("terminal"); else if (host.enableRdp) onOpenTab("rdp"); else if (host.enableVnc) onOpenTab("vnc"); else if (host.enableTelnet) onOpenTab("telnet"); else onOpenTab("terminal"); }; // On touch devices, open the action tray so the per-protocol buttons are // reachable. If the host only exposes a single action, just launch it. if (isTouchOnly) { e.stopPropagation(); const actionCount = getSshActions(host).length; const otherProtocols = [ host.enableRdp, host.enableVnc, host.enableTelnet, ].filter(Boolean).length; if (actionCount + otherProtocols <= 1) { launchDefault(); } else { onTrayOpenChange?.(!isTrayOpen); } return; } launchDefault(); }} > {/* Status stripe */}
{/* Name row */}
{selectionMode && (
{selected && }
)} {buildStatusTooltip(host, isOnline)} {host.name} {host.pin && ( )} {host.isShared && ( {t("hosts.sharing.sharedBadge")} {t("hosts.sharing.sharedBadgeTooltip", { owner: host.ownerUsername || "?", level: t( `hosts.sharing.levels.${host.permissionLevel ?? "connect"}.label`, ), })} )} {!selectionMode && shouldUseClickTray && ( )}
{/* Address — always visible */} {host.username}@{host.ip} {/* Tag pills */} {showHostTags && host.tags && host.tags.length > 0 && (
{host.tags.slice(0, 4).map((tag) => ( {tag} ))} {host.tags.length > 4 && ( +{host.tags.length - 4} )}
)} {/* Connection buttons — always visible in click-tray mode, inside hover tray otherwise */} {shouldUseClickTray && !selectionMode && (
{getSshActions(host).map(({ type, icon: Icon, label }) => ( ))} {host.enableSsh && (host.enableRdp || host.enableVnc || host.enableTelnet) && getSshActions(host).length > 0 && (
)} {host.enableRdp && ( )} {host.enableVnc && ( )} {host.enableTelnet && ( )} {host.macAddress && ( )}
)} {/* Action tray — slides open on hover (default) or via chevron in click-tray mode */}
{isOnline && ((host.cpu != null && host.cpu > 0) || (host.ram != null && host.ram > 0)) && (
{host.cpu != null && host.cpu > 0 && (
80 ? "bg-red-400" : host.cpu > 50 ? "bg-yellow-400" : "bg-accent-brand"}`} style={{ width: `${host.cpu}%` }} />
{host.cpu}%
)} {host.ram != null && host.ram > 0 && (
80 ? "bg-red-400" : host.ram > 60 ? "bg-yellow-400" : "bg-accent-brand/60"}`} style={{ width: `${host.ram}%` }} />
{host.ram}%
)}
)}
{/* Connection buttons — only shown here in hover mode */} {!shouldUseClickTray && (
{getSshActions(host).map(({ type, icon: Icon, label }) => ( ))} {host.enableSsh && (host.enableRdp || host.enableVnc || host.enableTelnet) && getSshActions(host).length > 0 && (
)} {host.enableRdp && ( )} {host.enableVnc && ( )} {host.enableTelnet && ( )} {host.macAddress && ( )}
)} {/* Management buttons row */}
{showPasswordCopy && ( )} {showSudoPasswordCopy && ( )} {onEditHost && ( )} {onShareHost && ( )} {host.enableProxmox && onProxmoxDiscover && ( )} { e.stopPropagation(); writeClipboardText(`${host.username}@${host.ip}`); toast.success(t("hosts.copiedToClipboard")); }} > {t("hosts.copyAddress")} {showPasswordCopy && ( handleCopyPassword(e, "password")} > {t("nav.copyPassword")} )} {showSudoPasswordCopy && ( handleCopyPassword(e, "sudoPassword")} > {t("nav.copySudoPassword")} )} {t("hosts.copyLink")} {host.enableSsh && host.enableTerminal && ( { e.stopPropagation(); writeClipboardText( `${window.location.origin}?view=terminal&hostId=${host.id}`, ); toast.success(t("hosts.terminalUrlCopied")); }} > {t("hosts.copyTerminalUrlAction")} )} {host.enableSsh && host.enableFileManager && ( { e.stopPropagation(); writeClipboardText( `${window.location.origin}?view=file-manager&hostId=${host.id}`, ); toast.success(t("hosts.fileManagerUrlCopied")); }} > {t("hosts.copyFileManagerUrlAction")} )} {host.enableSsh && host.enableTunnel && ( { e.stopPropagation(); writeClipboardText( `${window.location.origin}?view=tunnel&hostId=${host.id}`, ); toast.success(t("hosts.tunnelUrlCopied")); }} > {t("hosts.copyTunnelUrlAction")} )} {host.enableSsh && host.enableDocker && ( { e.stopPropagation(); writeClipboardText( `${window.location.origin}?view=docker&hostId=${host.id}`, ); toast.success(t("hosts.dockerUrlCopied")); }} > {t("hosts.copyDockerUrlAction")} )} {host.enableSsh && metricsEnabled && ( { e.stopPropagation(); writeClipboardText( `${window.location.origin}?view=host-metrics&hostId=${host.id}`, ); toast.success(t("hosts.hostMetricsUrlCopied")); }} > {t("hosts.copyHostMetricsUrlAction")} )} {host.enableSsh && host.enableTerminal && host.enableTmuxMonitor && ( { e.stopPropagation(); writeClipboardText( `${window.location.origin}?view=tmux_monitor&hostId=${host.id}`, ); toast.success(t("hosts.tmuxMonitorUrlCopied")); }} > {t("hosts.copyTmuxMonitorUrlAction")} )} {host.enableRdp && ( { e.stopPropagation(); writeClipboardText( `${window.location.origin}?view=rdp&hostId=${host.id}`, ); toast.success(t("hosts.rdpUrlCopied")); }} > {t("hosts.copyRdpUrlAction")} )} {host.enableVnc && ( { e.stopPropagation(); writeClipboardText( `${window.location.origin}?view=vnc&hostId=${host.id}`, ); toast.success(t("hosts.vncUrlCopied")); }} > {t("hosts.copyVncUrlAction")} )} {host.enableTelnet && ( { e.stopPropagation(); writeClipboardText( `${window.location.origin}?view=telnet&hostId=${host.id}`, ); toast.success(t("hosts.telnetUrlCopied")); }} > {t("hosts.copyTelnetUrlAction")} )} {allowDelete && ( <> { e.stopPropagation(); onDuplicate(); }} > {t("hosts.cloneHostAction")} { e.stopPropagation(); onDelete(); }} > {t("common.delete")} )}
); } export function FolderItem({ folder, depth = 0, onOpenTab, onEditHost, onShareHost, onDeleteHost, onDuplicateHost, onProxmoxDiscover, query = "", stripeMap, openFolders, onToggleFolder, selectionMode, selectedHostIds, onToggleSelect, openMenuHostId, onMenuOpenChange, openTrayHostId, onTrayOpenChange, onManageFolder, onDeleteFolder, onOpenAllSessions, onShareFolder, onMoveHostsToFolder, draggedHostIds, onDragHostStart, onDragEnd, /** When true, only render the folder header (children come from the virtual list). */ flat = false, stripeIndex: stripeIndexProp, }: { folder: HostFolder; depth?: number; onOpenTab: (host: Host, type: TabType) => void; onEditHost?: (host: Host) => void; onShareHost?: (host: Host) => void; onDeleteHost: (host: Host) => void; onDuplicateHost: (host: Host) => void; onProxmoxDiscover?: (host: Host) => void; query?: string; stripeMap?: Map; openFolders: Set; onToggleFolder: (name: string) => void; selectionMode: boolean; selectedHostIds: Set; onToggleSelect: (id: string) => void; openMenuHostId: string | null; onMenuOpenChange: (hostId: string | null) => void; openTrayHostId: string | null; onTrayOpenChange: (hostId: string | null) => void; onManageFolder: (folder: HostFolder) => void; onDeleteFolder: (folder: HostFolder) => void; onOpenAllSessions: (folder: HostFolder) => void; onShareFolder?: (folder: HostFolder) => void; onMoveHostsToFolder: (hostIds: string[], targetPath: string) => void; draggedHostIds: string[] | null; onDragHostStart: (hostId: string) => void; onDragEnd: () => void; flat?: boolean; stripeIndex?: number; }) { const { t } = useTranslation(); const { getStatus, initialLoadComplete } = useServerStatus(); const { total } = folderHostCount(folder); const online = initialLoadComplete ? collectAllHosts(folder.children).filter( (h) => statusCheckEnabled(h) && getStatus(Number(h.id)) === "online", ).length : folderHostCount(folder).online; const [dragOver, setDragOver] = useState(false); if (query && !folderHasMatch(folder, query)) return null; const folderPath = folder.path ?? folder.name; const isOpen = query ? true : openFolders.has(folderPath); const stripeIndex = stripeIndexProp ?? stripeMap?.get(folder) ?? 0; // Synthetic group headers (group-by tag/status/etc.) are not real folders, so // they can't be edited, deleted, or used as drop targets. const isGroup = folderPath.startsWith("__group__:"); return (
0 ? { paddingLeft: depth * 12 } : undefined} onDragOver={(e) => { if (draggedHostIds && !isGroup) { e.preventDefault(); e.stopPropagation(); setDragOver(true); } }} onDragLeave={() => setDragOver(false)} onDrop={(e) => { if (draggedHostIds && !isGroup) { e.preventDefault(); e.stopPropagation(); setDragOver(false); onMoveHostsToFolder(draggedHostIds, folderPath); } }} > {!flat && isOpen && (
{folder.children.map((child, i) => isFolder(child) ? ( ) : ( onOpenTab(child, t)} onEditHost={onEditHost ? () => onEditHost(child) : undefined} onShareHost={onShareHost ? () => onShareHost(child) : undefined} onProxmoxDiscover={ onProxmoxDiscover ? () => onProxmoxDiscover(child) : undefined } onDelete={() => onDeleteHost(child)} onDuplicate={() => onDuplicateHost(child)} query={query} stripeIndex={stripeMap?.get(child) ?? 0} selectionMode={selectionMode} selected={selectedHostIds.has(child.id)} onToggleSelect={() => onToggleSelect(child.id)} isMenuOpen={openMenuHostId === child.id} onMenuOpenChange={(open) => onMenuOpenChange(open ? child.id : null) } isTrayOpen={openTrayHostId === child.id} onTrayOpenChange={(open) => onTrayOpenChange(open ? child.id : null) } onDragStart={() => onDragHostStart(child.id)} onDragEnd={onDragEnd} /> ), )}
)}
); } export function SidebarTree({ children, onOpenTab, onEditHost, onShareHost, onProxmoxDiscover, query = "", selectionMode, onToggleSelectionMode, loading = false, }: { children: (Host | HostFolder)[]; onOpenTab: (host: Host, type: TabType) => void; onEditHost: (host: Host) => void; onShareHost?: (host: Host) => void; onProxmoxDiscover?: (host: Host) => void; query?: string; selectionMode: boolean; onToggleSelectionMode: () => void; loading?: boolean; }) { const { t } = useTranslation(); const [openFolders, setOpenFolders] = useState>(() => { try { const saved = localStorage.getItem("hostOpenFolders"); return saved ? new Set(JSON.parse(saved)) : new Set(); } catch { return new Set(); } }); const [selectedHostIds, setSelectedHostIds] = useState>( new Set(), ); const [openMenuHostId, setOpenMenuHostId] = useState(null); const [openTrayHostId, setOpenTrayHostId] = useState(null); const [confirmDialog, setConfirmDialog] = useState<{ message: string; onConfirm: () => Promise | void; } | null>(null); const [draggedHostIds, setDraggedHostIds] = useState(null); const [rootDragOver, setRootDragOver] = useState(false); const [folderDialog, setFolderDialog] = useState<{ mode: "create" | "edit"; folder?: HostFolder; } | null>(null); const [shareFolderTarget, setShareFolderTarget] = useState( null, ); const [compactHostView, setCompactHostView] = useState( () => localStorage.getItem("compactHostView") === "true", ); const [trayOnClick, setTrayOnClick] = useState( () => localStorage.getItem("hostTrayOnClick") !== "false", ); useEffect(() => { const handler = () => setCompactHostView(localStorage.getItem("compactHostView") === "true"); window.addEventListener("storage", handler); window.addEventListener("compactHostViewChanged", handler); return () => { window.removeEventListener("storage", handler); window.removeEventListener("compactHostViewChanged", handler); }; }, []); useEffect(() => { const handler = () => setTrayOnClick(localStorage.getItem("hostTrayOnClick") !== "false"); window.addEventListener("storage", handler); window.addEventListener("hostTrayOnClickChanged", handler); return () => { window.removeEventListener("storage", handler); window.removeEventListener("hostTrayOnClickChanged", handler); }; }, []); function handleDragHostStart(hostId: string) { // When the dragged host is part of an active selection, move the whole set. if (selectionMode && selectedHostIds.has(hostId)) { setDraggedHostIds([...selectedHostIds]); } else { setDraggedHostIds([hostId]); } } async function handleMoveHostsToFolder( hostIds: string[], targetPath: string, ) { setDraggedHostIds(null); try { await bulkUpdateSSHHosts(hostIds.map(Number), { folder: targetPath }); window.dispatchEvent(new CustomEvent("termix:hosts-changed")); toast.success( t("hosts.movedToFolder", { count: hostIds.length, folder: targetPath || t("hosts.folderPickerNone"), }), ); } catch { toast.error(t("hosts.failedToMoveHosts")); } } function handleManageFolder(folder: HostFolder) { setFolderDialog({ mode: "edit", folder }); } function handleOpenAllSessions(folder: HostFolder) { const hosts = collectAllHosts(folder.children); for (const host of hosts) { const type = resolveHostTabType(host); onOpenTab(host, type); } } async function handleSaveFolderMetadata(value: { name: string; color: string; icon: string; credentialId: number | null; }) { const existing = folderDialog?.folder; try { if (existing) { const oldPath = existing.path ?? existing.name; const parent = oldPath.includes(" / ") ? oldPath.slice(0, oldPath.lastIndexOf(" / ")) : ""; const newPath = parent ? `${parent} / ${value.name}` : value.name; if (newPath !== oldPath) { await renameFolder(oldPath, newPath); } await updateFolderMetadata( newPath, value.color, value.icon, value.credentialId, ); } else { await updateFolderMetadata( value.name, value.color, value.icon, value.credentialId, ); } window.dispatchEvent(new CustomEvent("termix:hosts-changed")); toast.success(t("hosts.folderSaved")); } catch { toast.error(t("hosts.failedToSaveFolder")); } } function handleDeleteFolder(folder: HostFolder) { const folderPath = folder.path ?? folder.name; const { total } = folderHostCount(folder); setConfirmDialog({ message: t("hosts.deleteFolderConfirm", { name: folder.name, count: total, }), onConfirm: async () => { try { await deleteAllHostsInFolder(folderPath); window.dispatchEvent(new CustomEvent("termix:hosts-changed")); toast.success(t("hosts.folderDeleted", { name: folder.name })); } catch { toast.error(t("hosts.failedToDeleteFolder")); } }, }); } useEffect(() => { const openCreate = () => setFolderDialog({ mode: "create" }); const expandAll = () => { const next = new Set(collectAllFolderPaths(children)); persistOpenFolders(next); setOpenFolders(next); }; const collapseAll = () => { const next = new Set(); persistOpenFolders(next); setOpenFolders(next); }; window.addEventListener("hosts:create-folder", openCreate); window.addEventListener("hosts:expand-all", expandAll); window.addEventListener("hosts:collapse-all", collapseAll); return () => { window.removeEventListener("hosts:create-folder", openCreate); window.removeEventListener("hosts:expand-all", expandAll); window.removeEventListener("hosts:collapse-all", collapseAll); }; }, [children]); function persistOpenFolders(next: Set) { try { localStorage.setItem("hostOpenFolders", JSON.stringify([...next])); } catch { // ignore quota/serialization failures } } function toggleFolder(name: string) { setOpenFolders((prev) => { const next = new Set(prev); if (next.has(name)) { next.delete(name); } else { next.add(name); } persistOpenFolders(next); return next; }); } function toggleSelect(id: string) { setSelectedHostIds((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); } function handleDeleteHost(host: Host) { setConfirmDialog({ message: t("hosts.deleteHostConfirm", { name: host.name }), onConfirm: async () => { try { await deleteSSHHost(Number(host.id)); window.dispatchEvent(new CustomEvent("termix:hosts-changed")); toast.success(t("hosts.deletedCount", { count: 1 })); } catch { toast.error(t("hosts.failedToDeleteCount", { count: 1 })); } }, }); } async function handleDuplicateHost(host: Host) { try { const duplicateHost: SSHHostData = { name: `${host.name} (copy)`, ip: host.ip, port: host.port, username: host.username, folder: host.folder, tags: host.tags ?? [], pin: host.pin ?? false, notes: host.notes, macAddress: host.macAddress, // Key material is never sent to the frontend, so a cloned key-auth // host would have authType "key" with no key — unusable. Reset to // password so the clone is in a connectable (editable) state. authType: host.authType === "key" ? "password" : host.authType, password: host.authType === "key" ? null : (host.password ?? null), key: null, keyPassword: null, keyType: null, credentialId: host.credentialId ? Number(host.credentialId) : null, overrideCredentialUsername: host.overrideCredentialUsername ?? false, enableSsh: host.enableSsh, enableRdp: host.enableRdp, enableVnc: host.enableVnc, enableTelnet: host.enableTelnet, enableTerminal: host.enableTerminal, enableTunnel: host.enableTunnel, enableFileManager: host.enableFileManager, enableDocker: host.enableDocker, sshPort: host.sshPort, rdpPort: host.rdpPort, vncPort: host.vncPort, telnetPort: host.telnetPort, rdpUser: host.rdpUser ?? null, rdpPassword: host.rdpPassword ?? null, rdpDomain: host.domain ?? null, rdpSecurity: host.security ?? null, rdpIgnoreCert: host.ignoreCert ?? false, vncAuthType: host.vncAuthType ?? null, vncCredentialId: host.vncCredentialId ?? null, vncPassword: host.vncPassword ?? null, vncUser: host.vncUser ?? null, telnetUser: host.telnetUser ?? null, telnetPassword: host.telnetPassword ?? null, defaultPath: host.defaultPath ?? "/", forceKeyboardInteractive: host.forceKeyboardInteractive ?? false, useSocks5: host.useSocks5, socks5Host: host.socks5Host ?? null, socks5Port: host.socks5Port ?? null, socks5Username: host.socks5Username ?? null, socks5Password: host.socks5Password ?? null, socks5ProxyChain: host.socks5ProxyChain ?? null, jumpHosts: (host.jumpHosts ?? []).map((j) => ({ hostId: Number(j.hostId), })), portKnockSequence: host.portKnockSequence ?? [], tunnelConnections: host.serverTunnels ?? [], quickActions: (host.quickActions ?? []).map((a) => ({ name: a.name, snippetId: Number(a.snippetId), })), statsConfig: host.statsConfig, guacamoleConfig: host.guacamoleConfig ?? null, terminalConfig: host.terminalConfig ?? null, }; await createSSHHost(duplicateHost); window.dispatchEvent(new CustomEvent("termix:hosts-changed")); toast.success(t("hosts.duplicatedHost", { name: host.name })); } catch { toast.error(t("hosts.failedToDuplicateHost")); } } const allHosts = collectAllHosts(children); const allFolderPaths = collectAllFolderPaths(children); const visibleRows = collectVisibleRows(children, query, openFolders); const parentRef = useRef(null); const isTouchOnly = typeof window !== "undefined" && window.matchMedia("(hover: none)").matches; const clickTrayActive = trayOnClick || isTouchOnly; const virtualizer = useVirtualizer({ count: visibleRows.length, getScrollElement: () => parentRef.current, estimateSize: (index) => { const row = visibleRows[index]; if (!row) return 36; if (isFolder(row.item)) return 36; const isOpen = openTrayHostId === row.item.id; if (compactHostView) { // Compact rows are a single line; the tray adds a second wrapped row. return isOpen ? 88 : 32; } // Default rows show name + address (+ optional tags), taller to start with. // Click-tray mode also keeps the connection buttons visible even when closed. const base = clickTrayActive ? 76 : 56; return isOpen ? base + 96 : base; }, overscan: 12, getItemKey: (index) => { const row = visibleRows[index]; if (!row) return index; return isFolder(row.item) ? `folder:${row.item.path ?? row.item.name}` : `host:${row.item.id}`; }, }); // Remeasure when the tree shape changes (rows added/removed/reordered), so // stale cached sizes from before don't leak onto different rows. Tray // open/close is intentionally excluded — `measureElement`'s ResizeObserver // already tracks that live via the CSS transition, and force-resetting the // cache here would snap rows back to the rough estimate mid-animation and // cause visible jitter. useLayoutEffect(() => { virtualizer.measure(); }, [ virtualizer, openFolders, query, visibleRows.length, compactHostView, trayOnClick, ]); if (loading) { return (
{[28, 20, 24, 20, 28, 20].map((w, i) => (
))}
{t("hosts.loadingHosts")}
); } return (
{ if (draggedHostIds) { e.preventDefault(); setRootDragOver(true); } }} onDragLeave={(e) => { if (e.currentTarget === e.target) setRootDragOver(false); }} onDrop={(e) => { if (draggedHostIds) { e.preventDefault(); setRootDragOver(false); handleMoveHostsToFolder(draggedHostIds, ""); } }} > {visibleRows.length === 0 ? (
{query ? t("hosts.noHostsMatchSearch") : t("hosts.noHostsYet")}
) : (
{virtualizer.getVirtualItems().map((vItem) => { const row = visibleRows[vItem.index]; if (!row) return null; const { item, depth } = row; return (
{isFolder(item) ? ( setShareFolderTarget(folder.path ?? folder.name) } onMoveHostsToFolder={handleMoveHostsToFolder} draggedHostIds={draggedHostIds} onDragHostStart={handleDragHostStart} onDragEnd={() => setDraggedHostIds(null)} stripeIndex={vItem.index} /> ) : ( onOpenTab(item, type)} onEditHost={() => onEditHost(item)} onShareHost={ onShareHost ? () => onShareHost(item) : undefined } onProxmoxDiscover={ onProxmoxDiscover ? () => onProxmoxDiscover(item) : undefined } onDelete={() => handleDeleteHost(item)} onDuplicate={() => handleDuplicateHost(item)} query={query} stripeIndex={vItem.index} selectionMode={selectionMode} selected={selectedHostIds.has(item.id)} onToggleSelect={() => toggleSelect(item.id)} isMenuOpen={openMenuHostId === item.id} onMenuOpenChange={(open) => setOpenMenuHostId(open ? item.id : null) } isTrayOpen={openTrayHostId === item.id} onTrayOpenChange={(open) => setOpenTrayHostId(open ? item.id : null) } onDragStart={() => handleDragHostStart(item.id)} onDragEnd={() => setDraggedHostIds(null)} /> )}
); })}
)}
{/* Floating selection bar */} {selectionMode && (
{t("hosts.nSelected", { count: selectedHostIds.size })}
{[ { labelKey: "hosts.enableTerminalFeature", field: "enableTerminal", value: true, icon: Terminal, }, { labelKey: "hosts.disableTerminalFeature", field: "enableTerminal", value: false, icon: Terminal, }, { labelKey: "hosts.enableFilesFeature", field: "enableFileManager", value: true, icon: FolderSearch, }, { labelKey: "hosts.disableFilesFeature", field: "enableFileManager", value: false, icon: FolderSearch, }, { labelKey: "hosts.enableTunnelsFeature", field: "enableTunnel", value: true, icon: Network, }, { labelKey: "hosts.disableTunnelsFeature", field: "enableTunnel", value: false, icon: Network, }, { labelKey: "hosts.enableDockerFeature", field: "enableDocker", value: true, icon: Box, }, { labelKey: "hosts.disableDockerFeature", field: "enableDocker", value: false, icon: Box, }, { labelKey: "hosts.enableProxmoxFeature", field: "enableProxmox", value: true, icon: Boxes, }, { labelKey: "hosts.disableProxmoxFeature", field: "enableProxmox", value: false, icon: Boxes, }, ].map(({ labelKey, field, value, icon: Icon }) => ( { const ids = Array.from(selectedHostIds).map(Number); try { await bulkUpdateSSHHosts(ids, { [field]: value }); window.dispatchEvent( new CustomEvent("termix:hosts-changed"), ); toast.success( t("hosts.updatedCount", { count: ids.length }), ); } catch { toast.error(t("hosts.bulkUpdateFailed")); } }} > {t(labelKey)} ))} { const ids = Array.from(selectedHostIds).map(Number); try { await bulkUpdateSSHHosts(ids, { folder: "" }); window.dispatchEvent( new CustomEvent("termix:hosts-changed"), ); toast.success(t("hosts.movedToRoot")); } catch { toast.error(t("hosts.failedToMoveHosts")); } }} > {t("hosts.noFolderOption")} {allFolderPaths.map((f) => ( { const ids = Array.from(selectedHostIds).map(Number); try { await bulkUpdateSSHHosts(ids, { folder: f }); window.dispatchEvent( new CustomEvent("termix:hosts-changed"), ); toast.success(t("hosts.movedToFolder", { folder: f })); } catch { toast.error(t("hosts.failedToMoveHosts")); } }} > {f} ))}
)} {/* Confirm dialog */} {confirmDialog && (

{confirmDialog.message}

)} !v && setFolderDialog(null)} onSubmit={handleSaveFolderMetadata} /> setShareFolderTarget(null)} host={null} folder={shareFolderTarget} />
); }