import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Box, Check, ChevronDown, ChevronRight, Copy, CopyPlus, Cpu, FolderOpen, FolderSearch, Link, Loader2, MemoryStick, Monitor, MoreHorizontal, Network, Pencil, Pin, Server, Share2, Terminal, Trash2, } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/dropdown-menu"; import { toast } from "sonner"; import { bulkUpdateSSHHosts, createSSHHost, deleteSSHHost } from "@/main-axios"; import type { Host, HostFolder, TabType } from "@/types/ui-types"; export function isFolder(item: Host | HostFolder): item is HostFolder { return "children" in item; } 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: "stats" as TabType, icon: Server, label: "Stats", }, ].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.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; } function collectAllFolders(children: (Host | HostFolder)[]): string[] { const names = new Set(); for (const child of children) { if (isFolder(child)) { names.add(child.name); for (const f of collectAllFolders(child.children)) names.add(f); } } return Array.from(names).sort(); } 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, onShareHost, onDelete, onDuplicate, query = "", stripeIndex = 0, selectionMode = false, selected = false, onToggleSelect, isMenuOpen = false, onMenuOpenChange, }: { 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; }) { const { t } = useTranslation(); const metricsEnabled = host.enableSsh && host.statsConfig?.metricsEnabled !== false; if (query && !hostMatchesQuery(host, query)) return null; return (
{ if (selectionMode) { onToggleSelect?.(); 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"); }} > {/* Status stripe */}
{/* Name row */}
{selectionMode && (
{selected && }
)} {host.name} {host.pin && ( )}
{/* Address — only visible on hover or while menu is open */} {host.username}@{host.ip} {/* Tag pills */} {host.tags && host.tags.length > 0 && (
{host.tags.slice(0, 4).map((tag) => ( {tag} ))} {host.tags.length > 4 && ( +{host.tags.length - 4} )}
)} {/* Action tray — slides open on CSS hover or while menu is open */}
{host.online && ((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 — wrap naturally to a second line */}
{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 && ( )}
{/* Separator + management buttons row — always fixed position */}
{onEditHost && ( )} {onShareHost && ( )} { e.stopPropagation(); navigator.clipboard.writeText( `${host.username}@${host.ip}`, ); toast.success(t("hosts.copiedToClipboard")); }} > {t("hosts.copyAddress")} {t("hosts.copyLink")} {host.enableSsh && host.enableTerminal && ( { e.stopPropagation(); navigator.clipboard.writeText( `${window.location.origin}?view=terminal&hostId=${host.id}`, ); toast.success(t("hosts.terminalUrlCopied")); }} > {t("hosts.copyTerminalUrlAction")} )} {host.enableSsh && host.enableFileManager && ( { e.stopPropagation(); navigator.clipboard.writeText( `${window.location.origin}?view=file-manager&hostId=${host.id}`, ); toast.success(t("hosts.fileManagerUrlCopied")); }} > {t("hosts.copyFileManagerUrlAction")} )} {host.enableSsh && host.enableTunnel && ( { e.stopPropagation(); navigator.clipboard.writeText( `${window.location.origin}?view=tunnel&hostId=${host.id}`, ); toast.success(t("hosts.tunnelUrlCopied")); }} > {t("hosts.copyTunnelUrlAction")} )} {host.enableSsh && host.enableDocker && ( { e.stopPropagation(); navigator.clipboard.writeText( `${window.location.origin}?view=docker&hostId=${host.id}`, ); toast.success(t("hosts.dockerUrlCopied")); }} > {t("hosts.copyDockerUrlAction")} )} {host.enableSsh && metricsEnabled && ( { e.stopPropagation(); navigator.clipboard.writeText( `${window.location.origin}?view=server-stats&hostId=${host.id}`, ); toast.success(t("hosts.serverStatsUrlCopied")); }} > {t("hosts.copyServerStatsUrlAction")} )} {host.enableRdp && ( { e.stopPropagation(); navigator.clipboard.writeText( `${window.location.origin}?view=rdp&hostId=${host.id}`, ); toast.success(t("hosts.rdpUrlCopied")); }} > {t("hosts.copyRdpUrlAction")} )} {host.enableVnc && ( { e.stopPropagation(); navigator.clipboard.writeText( `${window.location.origin}?view=vnc&hostId=${host.id}`, ); toast.success(t("hosts.vncUrlCopied")); }} > {t("hosts.copyVncUrlAction")} )} {host.enableTelnet && ( { e.stopPropagation(); navigator.clipboard.writeText( `${window.location.origin}?view=telnet&hostId=${host.id}`, ); toast.success(t("hosts.telnetUrlCopied")); }} > {t("hosts.copyTelnetUrlAction")} )} { e.stopPropagation(); onDuplicate(); }} > {t("hosts.cloneHostAction")} { e.stopPropagation(); onDelete(); }} > {t("common.delete")}
); } export function FolderItem({ folder, depth = 0, onOpenTab, onEditHost, onShareHost, onDeleteHost, onDuplicateHost, query = "", stripeMap, openFolders, onToggleFolder, selectionMode, selectedHostIds, onToggleSelect, openMenuHostId, onMenuOpenChange, }: { 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; 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; }) { const { total, online } = folderHostCount(folder); if (query && !folderHasMatch(folder, query)) return null; const isOpen = query ? true : openFolders.has(folder.name); const stripeIndex = stripeMap.get(folder) ?? 0; return (
{isOpen && (
{folder.children.map((child, i) => isFolder(child) ? ( ) : ( onOpenTab(child, t)} onEditHost={onEditHost ? () => onEditHost(child) : undefined} onShareHost={onShareHost ? () => onShareHost(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) } /> ), )}
)}
); } export function SidebarTree({ children, onOpenTab, onEditHost, onShareHost, query = "", selectionMode, onToggleSelectionMode, loading = false, }: { children: (Host | HostFolder)[]; onOpenTab: (host: Host, type: TabType) => void; onEditHost: (host: Host) => void; onShareHost?: (host: Host) => void; query?: string; selectionMode: boolean; onToggleSelectionMode: () => void; loading?: boolean; }) { const { t } = useTranslation(); const [openFolders, setOpenFolders] = useState>(new Set()); const [selectedHostIds, setSelectedHostIds] = useState>( new Set(), ); const [openMenuHostId, setOpenMenuHostId] = useState(null); const [confirmDialog, setConfirmDialog] = useState<{ message: string; onConfirm: () => Promise | void; } | null>(null); function toggleFolder(name: string) { setOpenFolders((prev) => { const next = new Set(prev); next.has(name) ? next.delete(name) : next.add(name); return next; }); } function toggleSelect(id: string) { setSelectedHostIds((prev) => { const next = new Set(prev); next.has(id) ? next.delete(id) : 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 { id: _id, online: _online, cpu: _cpu, ram: _ram, lastAccess: _la, hasKey: _hk, hasKeyPassword: _hkp, ...rest } = host as any; await createSSHHost({ ...rest, name: `${host.name} (copy)`, key: undefined, }); 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 allFolders = collectAllFolders(children); const visibleRows = collectVisibleRows(children, query, openFolders); const stripeMap = new Map( visibleRows.map((r, i) => [r.item, i]), ); if (loading) { return (
{[28, 20, 24, 20, 28, 20].map((w, i) => (
))}
{t("hosts.loadingHosts")}
); } return (
{visibleRows.length === 0 ? (
{query ? t("hosts.noHostsMatchSearch") : t("hosts.noHostsYet")}
) : ( children.map((child, i) => isFolder(child) ? ( ) : ( onOpenTab(child, type)} onEditHost={() => onEditHost(child)} onShareHost={onShareHost ? () => onShareHost(child) : undefined} onDelete={() => handleDeleteHost(child)} onDuplicate={() => handleDuplicateHost(child)} query={query} stripeIndex={stripeMap.get(child) ?? 0} selectionMode={selectionMode} selected={selectedHostIds.has(child.id)} onToggleSelect={() => toggleSelect(child.id)} isMenuOpen={openMenuHostId === child.id} onMenuOpenChange={(open) => setOpenMenuHostId(open ? child.id : 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, }, ].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")} {allFolders.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}

)}
); }