import { useConfirm } from "@/components/surface/surface-scope"; import { useHostSpeedSearch } from "./hooks/useHostSpeedSearch"; import { useCallback, useEffect, useMemo, useRef, useLayoutEffect, useState, } from "react"; import { updatePluginHostSettings } from "@/api/plugins-api"; import { resetHostToDefaults } from "@/api/host-defaults-api"; import { PluginIcon } from "@/lib/plugin-icon"; import { pluginName } from "@/lib/plugin-i18n"; import { usePluginHostSections } from "@/settings/HostPluginSections"; import { useTranslation } from "react-i18next"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useAreaPreferences } from "@/contexts/UiPreferencesContext"; import { ChevronDown, Download, FolderOpen, Loader2, Plus, RotateCcw, Server, Terminal, } from "lucide-react"; import { Button } from "@/components/button"; import { EmptyState } from "@/components/empty-state"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/dropdown-menu"; import { toast } from "sonner"; import { bulkUpdateSSHHosts, createSSHHost, deleteSSHHost, renameFolder, updateFolderMetadata, deleteAllHostsInFolder, reorderSSHHosts, reorderFolders, } from "@/main-axios"; import { planReorder, renumberSiblings } from "@/sidebar/reorder-utils"; import type { Host, HostFolder, TabType } from "@/types/ui-types"; import type { SSHHostData } from "@/types/index"; import type { HostDensity, HostClickBehavior, HostRowFields, HostTrayTrigger, } from "@/types/host-sidebar-preferences"; import { resolveHostTabType } from "@/lib/host-connection-tabs"; import { canEditHost } from "@/sidebar/host-permissions"; import { FolderMetadataDialog } from "@/sidebar/FolderMetadataDialog"; import { HostShareModal } from "@/sidebar/HostShareModal"; import { HostItem } from "./HostItem/HostItem"; import { FolderItem, folderHostCount } from "./FolderItem/FolderItem"; import { isFolder, collectVisibleRows, collectAllHosts, collectAllFolderPaths, hostExpandKey, hostMatchesQuery, buildReorderRows, collectOrderableRows, rowKey, rowKind, ROOT_PARENT, } from "./visible-rows"; import { RESOURCE_ROW_EXTRA, rendersResourceRow } from "./row-metrics"; import { ReorderIndicator } from "@/sidebar/ReorderIndicator"; import { useSidebarSelection } from "./hooks/useSidebarSelection"; import { useSidebarDragState } from "./hooks/useSidebarDragState"; export function SidebarTree({ children, onOpenTab: openTab, onEditHost, onShareHost, query: externalQuery = "", selectionMode, onToggleSelectionMode, loading = false, onExportSelected, arrangeLocked = true, density = "comfortable", trayTrigger = "hover", showTags = true, openOnDoubleClick = false, showFolderPaths = true, hostClickBehavior = "newTab", rowFields, }: { children: (Host | HostFolder)[]; onOpenTab: ( host: Host, type: TabType, options?: { data?: Record; label?: string; forceNewTab?: boolean; }, ) => void; onEditHost: (host: Host) => void; onShareHost?: (host: Host) => void; query?: string; selectionMode: boolean; onToggleSelectionMode: () => void; loading?: boolean; onExportSelected?: (hostIds: string[]) => void; /** When true, drag-to-rearrange is off entirely. Toggled from the panel header. */ arrangeLocked?: boolean; density?: HostDensity; trayTrigger?: HostTrayTrigger; showTags?: boolean; openOnDoubleClick?: boolean; /** When false, nested folders hide the parent-path breadcrumb before their name. */ showFolderPaths?: boolean; hostClickBehavior?: HostClickBehavior; rowFields?: HostRowFields; }) { const { t } = useTranslation(); const rootRef = useRef(null); // Focus left on the clicked row sends the next keys to the host speed // search instead of the session that just opened. const onOpenTab: typeof openTab = (...args) => { const active = document.activeElement; if (active instanceof HTMLElement && rootRef.current?.contains(active)) { active.blur(); } openTab(...args); }; const hostSettingPlugins = usePluginHostSections(); const hostSwitchPlugins = hostSettingPlugins.filter( (plugin) => !!plugin.contributes?.settings?.host?.enableKey, ); // Knobs with no other owner come straight from the interface preset; the // ones above still come from the host sidebar preferences blob. const { showResourceBars, showStatusStripes, rowActions } = useAreaPreferences("hostList"); const [openFolders, setOpenFolders] = useState>(() => { try { const saved = localStorage.getItem("hostOpenFolders"); return saved ? new Set(JSON.parse(saved)) : new Set(); } catch { return new Set(); } }); // Sub-host parents default to expanded (unlike folders, which default // collapsed) -- a host reparented under another shouldn't seem to vanish // just because its new parent row starts closed. This tracks the opposite: // parents the user has explicitly collapsed. const [closedHostParents, setClosedHostParents] = useState>( () => { try { const saved = localStorage.getItem("hostClosedParents"); return saved ? new Set(JSON.parse(saved)) : new Set(); } catch { return new Set(); } }, ); function toggleHostParent(key: string) { setClosedHostParents((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); persistClosedHostParents(next); return next; }); } const { selectedHostIds, setSelectedHostIds, openMenuHostId, setOpenMenuHostId, openTrayHostId, setOpenTrayHostId, hoveredHostId, setHoveredHostId, toggleSelect, toggleSelectMany, } = useSidebarSelection(); // Selection mode can be toggled off from outside this component (e.g. the // topbar button in HostsPanel), which has no way to reach into this // component's own selectedHostIds state -- clear it here instead so stale // selections don't stay visually highlighted after leaving selection mode. useEffect(() => { if (!selectionMode) setSelectedHostIds(new Set()); }, [selectionMode, setSelectedHostIds]); const confirm = useConfirm(); const setConfirmDialog = ( request: { message: string; onConfirm: () => Promise | void } | null, ) => { if (!request) return; void confirm({ title: request.message }).then((ok) => { if (ok) void request.onConfirm(); }); }; const { draggedHostIds, setDraggedHostIds, rootDragOver, setRootDragOver } = useSidebarDragState(); const [folderDialog, setFolderDialog] = useState<{ mode: "create" | "edit"; folder?: HostFolder; } | null>(null); const [shareFolderTarget, setShareFolderTarget] = useState( null, ); // Tracks the single item being dragged in manual sort mode, separate from // draggedHostIds (which drives multi-select folder-assignment drops). const [draggedReorderKey, setDraggedReorderKey] = useState( null, ); // Tracks which single row is currently the drop target during a manual // reorder drag, lifted here (rather than local state per row) so only one // row can ever show the drop-indicator bar at a time -- per-row local // state could get stuck showing a stale bar when the pointer jumped // directly from one virtualized row to another without a clean // dragleave firing on the row being left. const [reorderHoverKey, setReorderHoverKey] = useState(null); const [reorderHoverEdge, setReorderHoverEdge] = useState< "before" | "after" | null >(null); // Gated on the lock alone. Unlocking also switches the panel to manual // sort, but that write lands separately -- requiring it here meant the // unlock did nothing until the sort state caught up. const arrangeMode = !arrangeLocked; const hostsById = useMemo(() => { const map = new Map(); for (const host of collectAllHosts(children)) map.set(host.id, host); return map; }, [children]); 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); // A selection can mix owned hosts with shared ones the recipient may not // edit; moving those would fail server-side and take the whole batch down. const movableIds = hostIds.filter((id) => { const host = hostsById.get(id); return !host || canEditHost(host); }); if (movableIds.length === 0) return; // Folders only exist visibly via an sshFolders metadata row or by having // hosts in them (see buildHostTree in AppShell.tsx). A folder that was // never explicitly created and loses its last host here would otherwise // vanish with no trace the moment this move lands -- persist it first so // it stays visible-but-empty until the user explicitly deletes it. const sourceFolders = new Set( movableIds .map((id) => hostsById.get(id)?.folder) .filter((f): f is string => !!f && f !== targetPath), ); const emptiedFolders = [...sourceFolders].filter((path) => { const remaining = collectAllHosts(children).filter( (h) => h.folder === path && !movableIds.includes(h.id), ); return remaining.length === 0; }); try { await bulkUpdateSSHHosts(movableIds.map(Number), { folder: targetPath }); await Promise.all( emptiedFolders.map((path) => updateFolderMetadata(path).catch(() => { /* best-effort; worst case the folder disappears as before */ }), ), ); window.dispatchEvent(new CustomEvent("termix:hosts-changed")); toast.success( t("hosts.movedToFolder", { count: movableIds.length, folder: targetPath || t("hosts.folderPickerNone"), }), ); } catch { toast.error(t("hosts.failedToMoveHosts")); } } function isDescendantOfDragged(candidateId: string, draggedIds: string[]) { let current: string | null | undefined = candidateId; const visited = new Set(); while (current) { if (draggedIds.includes(current)) return true; if (visited.has(current)) return false; visited.add(current); current = hostsById.get(current)?.parentHostId; } return false; } async function handleMoveHostsToParent(hostIds: string[], parentId: string) { setDraggedHostIds(null); const movableIds = hostIds.filter((id) => { const host = hostsById.get(id); return !host || canEditHost(host); }); if (movableIds.length === 0) return; if (movableIds.includes(parentId)) return; // A host can't become its own descendant's child -- guard client-side so // the drop just silently no-ops rather than round-tripping to the // backend's own cycle rejection. if (isDescendantOfDragged(parentId, movableIds)) { toast.error(t("hosts.cannotNestUnderDescendant")); return; } try { await bulkUpdateSSHHosts(movableIds.map(Number), { parentHostId: Number(parentId), }); window.dispatchEvent(new CustomEvent("termix:hosts-changed")); const parentHost = hostsById.get(parentId); toast.success( t("hosts.movedToParent", { count: movableIds.length, parent: parentHost?.name ?? parentId, }), ); } catch { toast.error(t("hosts.failedToMoveHosts")); } } /** * Resolves a drop into position and (when the drop crossed into another * folder) a folder move, then writes both. Siblings are scoped to the drop * target's own folder rather than every row of the same type -- comparing * against unrelated neighbours in other folders produced sort orders that * put the row nowhere near where it was dropped. */ async function handleReorderDrop( targetKey: string, position: "before" | "after", ) { const draggedKey = draggedReorderKey; setDraggedReorderKey(null); setReorderHoverKey(null); setReorderHoverEdge(null); if (!draggedKey) return; const [draggedType] = draggedKey.split(":", 2); const [targetType] = targetKey.split(":", 2); // A host dropped on a folder header has no neighbour to position // against, so it lands at the end of that folder. It still needs an // explicit sortOrder: a null one sorts last in manual mode and then // falls back to alphabetical, losing the placement entirely. if (draggedType === "host" && targetType === "folder") { const draggedId = draggedKey.slice("host:".length); const folderPath = targetKey.slice("folder:".length); if (folderPath.startsWith("__group__:")) return; const host = hostsById.get(draggedId); if (host && !canEditHost(host)) { toast.error(t("hosts.failedToMoveHosts")); return; } const destination = collectAllHosts(children).filter( (h) => h.folder === folderPath && h.id !== draggedId, ); const ordered = [ ...destination.filter((h) => h.sortOrder != null), ...destination.filter((h) => h.sortOrder == null), ]; try { await bulkUpdateSSHHosts([Number(draggedId)], { folder: folderPath, parentHostId: null, }); await reorderSSHHosts( renumberSiblings([...ordered, { id: draggedId }]).map((r) => ({ id: Number(r.id), sortOrder: r.sortOrder, })), ); window.dispatchEvent(new CustomEvent("termix:hosts-changed")); } catch { toast.error(t("hosts.failedToMoveHosts")); } return; } if (draggedType !== targetType) return; // Built from the whole tree, not visibleRows: a collapsed destination // folder contributes no visible rows, so planning off the rendered list // compared against a partial sibling group and only landed correctly on // a second drop. const rows = buildReorderRows(collectOrderableRows(children)).filter( (r) => rowKind(r.key) === draggedType, ); const plan = planReorder(rows, draggedKey, targetKey, position); if (!plan) return; const draggedId = draggedKey.slice(draggedKey.indexOf(":") + 1); try { if (draggedType === "host") { // A cross-folder drop has to move the host as well as position it, // otherwise it snaps back to its old folder on the next refresh. if (plan.movedTo !== null) { const host = hostsById.get(draggedId); if (host && !canEditHost(host)) { toast.error(t("hosts.failedToMoveHosts")); return; } // movedTo is a parent KEY ("folder:Homelab" / "host:11" / the root // sentinel), not a folder path -- writing it raw created folders // literally named "folder:Homelab". if (plan.movedTo === ROOT_PARENT) { await bulkUpdateSSHHosts([Number(draggedId)], { folder: "", parentHostId: null, }); } else if (plan.movedTo.startsWith("folder:")) { await bulkUpdateSSHHosts([Number(draggedId)], { folder: plan.movedTo.slice("folder:".length), parentHostId: null, }); } else { await bulkUpdateSSHHosts([Number(draggedId)], { folder: "", parentHostId: Number(plan.movedTo.slice("host:".length)), }); } } await reorderSSHHosts( plan.positions.map((pos) => ({ id: Number(pos.key.slice(pos.key.indexOf(":") + 1)), sortOrder: pos.sortOrder, })), ); } else { await reorderFolders( plan.positions.map((pos) => ({ name: pos.key.slice(pos.key.indexOf(":") + 1), sortOrder: pos.sortOrder, })), ); } window.dispatchEvent(new CustomEvent("termix:hosts-changed")); } catch { toast.error(t("hosts.failedToReorder")); } } 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); if (type) onOpenTab(host, type); } } async function handleSaveFolderMetadata(value: { name: string; color: string; icon: string; credentialId: number | null; localOnly: boolean; }) { 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, value.localOnly, ); } else { await updateFolderMetadata( value.name, value.color, value.icon, value.credentialId, value.localOnly, ); } 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); persistClosedHostParents(new Set()); setClosedHostParents(new Set()); }; const collapseAll = () => { const next = new Set(); persistOpenFolders(next); setOpenFolders(next); const closedHosts = new Set( collectAllHosts(children) .filter((h) => h.childHosts && h.childHosts.length > 0) .map((h) => hostExpandKey(h)), ); persistClosedHostParents(closedHosts); setClosedHostParents(closedHosts); }; 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 persistClosedHostParents(next: Set) { try { localStorage.setItem("hostClosedParents", 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 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, parentHostId: host.parentHostId ? Number(host.parentHostId) : null, tags: host.tags ?? [], pin: host.pin ?? false, notes: host.notes, // Key material is never sent to the frontend, so a cloned key-auth // host would have authType "key" with no key, which is 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, sshPort: host.sshPort, // Saved passwords never reach the browser, so a copy starts without them. protocolAuth: Object.fromEntries( Object.entries(host.protocolAuth ?? {}).map(([protocol, login]) => [ protocol, { authType: login.authType, credentialId: login.credentialId ?? null, username: login.username ?? null, fields: login.fields ?? {}, }, ]), ), 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 ?? [], statusCheckEnabled: host.statusCheckEnabled, statusCheckInterval: host.statusCheckInterval, sshOptions: host.sshOptions ?? null, }; const created = await createSSHHost(duplicateHost); // Plugin host settings live outside the host row. Secrets come back // redacted, and sending the marker back is a no-op, so they stay unset. for (const [pluginId, fields] of Object.entries( host.pluginSettings ?? {}, )) { if (!fields || Object.keys(fields).length === 0) continue; await updatePluginHostSettings( pluginId, Number(created.id), fields, ).catch(() => {}); } window.dispatchEvent(new CustomEvent("termix:hosts-changed")); toast.success(t("hosts.duplicatedHost", { name: host.name })); } catch { toast.error(t("hosts.failedToDuplicateHost")); } } const speedSearch = useHostSpeedSearch(); const query = speedSearch.open ? speedSearch.text.trim().toLowerCase() : externalQuery; const allHosts = collectAllHosts(children); const allFolderPaths = collectAllFolderPaths(children); const visibleRows = collectVisibleRows( children, query, openFolders, [], 0, closedHostParents, ); const parentRef = useRef(null); const matchingRows = visibleRows .map((row, index) => ({ row, index })) .filter( ({ row }) => !isFolder(row.item) && hostMatchesQuery(row.item, query), ); const activeMatch = matchingRows[Math.min(speedSearch.index, matchingRows.length - 1)]; const isTouchOnly = typeof window !== "undefined" && window.matchMedia("(hover: none)").matches; const alwaysShowActions = trayTrigger === "always"; const actionsOnly = trayTrigger === "actionsOnly"; const clickTrayActive = !alwaysShowActions && !actionsOnly && (trayTrigger === "click" || isTouchOnly); const isCompactDensity = density === "compact"; // Fixed, exactly-computed row heights rather than estimate-then-measure: // the redesigned rows have a small, known set of shapes (folder header, // host closed, host with its tray open, in each density), so there is // nothing left to discover at layout time. estimateSize doubled as the // real size unconditionally used to cause visible gaps between rows -- // ResizeObserver-based remeasuring raced the tray's open/close transition // and could lock in a stale height for a row that happened to get measured // mid-animation. A fixed lookup has no such window: every row's height is // decided before the virtualizer ever asks. // Re-measured live after fixing two padding bugs: (1) the comfortable- // density row's top/bottom padding asymmetry (was pt-2.5 pb-2, now the // symmetric py-2), and (2) redundant stacked top padding between the // always-visible connection-buttons row and the management-buttons row // (the tray wrapper's own pt-1.5 plus the management row's separate // pt-1 mt-0.5 compounded into a much larger gap there than between any // other pair of rows in the card). // Click mode adds a size-5 expand-actions chevron to the name row, which is // taller than anything hover mode puts there -- the base heights were // measured in hover mode, so a click-mode row renders past its slot and its // status stripe runs into the row below. Most visible down an indented // sub-host group, where rows stack with no folder header between them. // Measured off the rendered row (49.5 in a 45 slot). actionsOnly renders the // same chevron but its own constants were measured with it, so it is not // included here. const CLICK_CHEVRON_EXTRA = clickTrayActive ? 4.5 : 0; const HOST_ROW_HEIGHT = (isCompactDensity ? 27.5 : 45) + CLICK_CHEVRON_EXTRA; const FOLDER_ROW_HEIGHT = 31.5; // "always" mode permanently renders the connection-buttons row plus the // management row -- measured directly rather than derived, since it has its // own fixed shape. The resource bars are NOT included: they only render for // an online host with CPU/RAM, so rowHeight adds RESOURCE_ROW_EXTRA per row. // Carries the click-mode chevron so the open row lands at its measured // 104.75 rather than always mode's 100.25. The term cancels out of // OPEN_TRAY_EXTRA below, leaving HOST_ROW_HEIGHT to supply it once. const ALWAYS_ROW_HEIGHT = (isCompactDensity ? 73.75 : 83) + CLICK_CHEVRON_EXTRA; const OPEN_TRAY_EXTRA = ALWAYS_ROW_HEIGHT - HOST_ROW_HEIGHT; // "actionsOnly" permanently renders just the connection-buttons row above // the tray; the management row/resource bars stay collapsed until toggled. const ACTIONS_ONLY_ROW_HEIGHT = isCompactDensity ? 50.25 : 75.75; // Opening the management row from actionsOnly's closed state (which // already includes the connection row). Excludes the resource bars for the // same reason as ALWAYS_ROW_HEIGHT -- rowHeight adds them per row. const ACTIONS_ONLY_OPEN_ROW_HEIGHT = isCompactDensity ? 79.25 : 87.5; // Tag pills are a separate flex row. Comfortable density adds the row plus // its gap; compact density pulls it upward by 2px but still needs a slot. const TAG_ROW_EXTRA = isCompactDensity ? 12.5 : 18.5; // The username@ip line plus its gap. const ADDRESS_ROW_SAVED = rowFields && !rowFields.showAddress ? 14.5 : 0; // showResourceBars:false simply drops RESOURCE_ROW_EXTRA from every row. const rowHeight = useCallback( (index: number) => { const row = visibleRows[index]; if (!row) return FOLDER_ROW_HEIGHT; if (isFolder(row.item)) return FOLDER_ROW_HEIGHT; if (isCompactDensity) return 29; const tagRowExtra = showTags && row.item.tags?.length ? TAG_ROW_EXTRA : 0; // The resource bars only render for an online host that reported CPU/RAM. // Reserving their height unconditionally left a gap under every offline // row. measureElement corrects any drift from live status this estimate // can't see. const resourceExtra = rendersResourceRow( row.item, showResourceBars, isCompactDensity, ) ? RESOURCE_ROW_EXTRA : 0; const tagExtra = tagRowExtra - ADDRESS_ROW_SAVED; if (alwaysShowActions) return ALWAYS_ROW_HEIGHT + tagExtra + resourceExtra; const toggledOpen = (openTrayHostId === row.item.id || openMenuHostId === row.item.id) && (clickTrayActive || actionsOnly); // In hover mode the tray expands on pointer-over, so the row needs the // taller height reserved or it overlaps the row below it. const hoverOpen = !clickTrayActive && !actionsOnly && !selectionMode && (hoveredHostId === row.item.id || openMenuHostId === row.item.id); const isOpen = toggledOpen || hoverOpen; // The bars live inside the tray, so they only take space once it opens. const openResourceExtra = isOpen ? resourceExtra : 0; if (actionsOnly) { return ( (isOpen ? ACTIONS_ONLY_OPEN_ROW_HEIGHT : ACTIONS_ONLY_ROW_HEIGHT) + tagExtra + openResourceExtra ); } return ( (isOpen ? HOST_ROW_HEIGHT + OPEN_TRAY_EXTRA : HOST_ROW_HEIGHT) + tagExtra + openResourceExtra ); }, [ visibleRows, openTrayHostId, openMenuHostId, hoveredHostId, selectionMode, clickTrayActive, alwaysShowActions, actionsOnly, showTags, HOST_ROW_HEIGHT, CLICK_CHEVRON_EXTRA, OPEN_TRAY_EXTRA, ALWAYS_ROW_HEIGHT, ACTIONS_ONLY_ROW_HEIGHT, ACTIONS_ONLY_OPEN_ROW_HEIGHT, TAG_ROW_EXTRA, ADDRESS_ROW_SAVED, showResourceBars, isCompactDensity, ], ); const virtualizer = useVirtualizer({ count: visibleRows.length, getScrollElement: () => parentRef.current, estimateSize: rowHeight, overscan: 12, // The library's default rounds every measurement to a whole pixel. Rows // here land on fractions (a 63.84px row rounds to 64), and the rounded-up // size becomes the slot pitch -- leaving a visible sliver under every // single row, which stacks into the gaps the list is judged by. Keep the // sub-pixel size so slots sit flush against the row above. measureElement: (element, entry) => { const box = entry?.borderBoxSize?.[0]; return box ? box.blockSize : element.getBoundingClientRect().height; }, getItemKey: (index) => { const row = visibleRows[index]; if (!row) return index; // Group-by views (e.g. by tags) can list the same host under several // group folders, so parentPath is folded in to keep each occurrence's // key unique -- a shared key across rows made the virtualizer collide // their measured positions and render them stacked on top of each other. return isFolder(row.item) ? `folder:${row.item.path ?? row.item.name}` : `${row.parentPath}>host:${row.item.id}`; }, }); // One indicator for the whole tree, placed off the virtualizer's slot // geometry rather than by the hovered row. Row-drawn bars disagreed about // where the seam was whenever the hovered row's tray expanded it, so two // candidate lines appeared and flickered with the pointer. const reorderIndicatorTop = (() => { if (!arrangeMode || !reorderHoverKey || !reorderHoverEdge) return null; const index = visibleRows.findIndex( (row) => rowKey(row.item) === reorderHoverKey, ); if (index === -1) return null; const slot = virtualizer .getVirtualItems() .find((vItem) => vItem.index === index); if (!slot) return null; return reorderHoverEdge === "before" ? slot.start : slot.start + slot.size; })(); // rowHeight is only an estimate: a row's real height also depends on live // state the tree can't cheaply know per row (an offline host drops the // resource bars "always" mode reserves space for). measureElement corrects // it from the DOM, keyed by getItemKey rather than index so a deleted row's // size is never reused for whatever shifts into its slot. // // measure() wipes the ENTIRE measurement cache, so it may only run when // every row's shape changes at once -- a density/trigger/tag switch. Hover // and tray state are deliberately absent: they change one row, which // re-renders and re-measures itself through the observer anyway, whereas // calling measure() for them threw all 5000 rows back to their estimates // and made the list visibly jump apart on every pointer move. useLayoutEffect(() => { virtualizer.measure(); // Rebuild the cleared size cache before measuring the mounted rows again. virtualizer.getTotalSize(); // Unchanged rows will not trigger ResizeObserver after their cache is cleared. parentRef.current ?.querySelectorAll("[data-index]") .forEach((element) => virtualizer.measureElement(element)); }, [ virtualizer, density, trayTrigger, showTags, showResourceBars, ADDRESS_ROW_SAVED, ]); useEffect(() => { if (speedSearch.open) speedSearch.inputRef.current?.focus(); }, [speedSearch.open, speedSearch.inputRef]); const activeMatchIndex = activeMatch?.index; useEffect(() => { if (speedSearch.open && activeMatchIndex !== undefined) virtualizer.scrollToIndex(activeMatchIndex, { align: "auto" }); }, [speedSearch.open, activeMatchIndex, query, virtualizer]); if (loading) { return (
{[28, 20, 24, 20, 28, 20].map((w, i) => (
))}
{t("hosts.loadingHosts")}
); } return (
{ if (selectionMode || arrangeMode) return; speedSearch.onKeyDown( event, matchingRows.length, (index) => { const host = matchingRows[index]?.row.item; if (!host || isFolder(host)) return; const type = resolveHostTabType(host); if (type) onOpenTab(host, type, { forceNewTab: hostClickBehavior === "newTab", }); }, () => parentRef.current?.focus(), ); }} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) speedSearch.close(); }} > {speedSearch.open && (
speedSearch.change(event.target.value)} className="min-w-0 flex-1 bg-transparent text-sm outline-none" /> {matchingRows.length ? Math.min(speedSearch.index + 1, matchingRows.length) : 0}{" "} / {matchingRows.length}
)}
{ if (arrangeMode && draggedHostIds && e.currentTarget === e.target) { e.preventDefault(); setRootDragOver(true); } }} onDragLeave={(e) => { if (e.currentTarget === e.target) setRootDragOver(false); }} onDragEnd={() => setRootDragOver(false)} onDrop={(e) => { setRootDragOver(false); if (arrangeMode && draggedHostIds && e.currentTarget === e.target) { e.preventDefault(); handleMoveHostsToFolder(draggedHostIds, ""); } }} > {visibleRows.length === 0 ? ( window.dispatchEvent( new CustomEvent("host-manager:add-host"), ) } > {t("hosts.addHost")} ) } /> ) : (
{reorderIndicatorTop !== null && ( )} {virtualizer.getVirtualItems().map((vItem) => { const row = visibleRows[vItem.index]; if (!row) return null; const { item, depth } = row; return (
{isFolder(item) ? ( toggleSelectMany( collectAllHosts(f.children).map((h) => h.id), ) } openMenuHostId={openMenuHostId} onMenuOpenChange={setOpenMenuHostId} openTrayHostId={openTrayHostId} onTrayOpenChange={setOpenTrayHostId} onManageFolder={handleManageFolder} onDeleteFolder={handleDeleteFolder} onOpenAllSessions={handleOpenAllSessions} onShareFolder={(folder) => setShareFolderTarget(folder.path ?? folder.name) } onMoveHostsToFolder={handleMoveHostsToFolder} draggedHostIds={draggedHostIds} onDragHostStart={handleDragHostStart} onDragEnd={() => { setDraggedHostIds(null); setDraggedReorderKey(null); setReorderHoverKey(null); setReorderHoverEdge(null); }} stripeIndex={vItem.index} density={density} trayTrigger={trayTrigger} showTags={showTags} openOnDoubleClick={openOnDoubleClick} showFolderPaths={showFolderPaths} hostClickBehavior={hostClickBehavior} arrangeMode={arrangeMode} isDragging={ draggedReorderKey === `folder:${item.path ?? item.name}` } onReorderDrop={handleReorderDrop} onFolderDragStart={(path) => setDraggedReorderKey(`folder:${path}`) } onFolderDragEnd={() => { setDraggedReorderKey(null); setReorderHoverKey(null); setReorderHoverEdge(null); }} isReorderHovered={ reorderHoverKey === `folder:${item.path ?? item.name}` } reorderHoverEdge={reorderHoverEdge} onReorderHoverChange={(edge) => { const key = `folder:${item.path ?? item.name}`; setReorderHoverKey(edge ? key : null); setReorderHoverEdge(edge); }} /> ) : ( onOpenTab(item, type, options) } onEditHost={() => onEditHost(item)} onShareHost={ onShareHost ? () => onShareHost(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) } isHovered={hoveredHostId === item.id} onHoverChange={(hovered) => setHoveredHostId((prev) => hovered ? item.id : prev === item.id ? null : prev, ) } onDragStart={() => { // Both paths arm at once: the drop target decides // whether this becomes a reorder or a move/nest. setDraggedReorderKey(`host:${item.id}`); handleDragHostStart(item.id); }} onDragEnd={() => { setDraggedHostIds(null); setDraggedReorderKey(null); setReorderHoverKey(null); setReorderHoverEdge(null); }} density={density} trayTrigger={trayTrigger} showTags={showTags} openOnDoubleClick={openOnDoubleClick} hostClickBehavior={hostClickBehavior} showResourceBars={showResourceBars} showStatusStripes={showStatusStripes} rowFields={rowFields} rowActions={rowActions} arrangeMode={arrangeMode} isDragging={draggedReorderKey === `host:${item.id}`} onReorderDrop={(position) => handleReorderDrop(`host:${item.id}`, position) } isReorderHovered={reorderHoverKey === `host:${item.id}`} reorderHoverEdge={reorderHoverEdge} onReorderHoverChange={(edge) => { setReorderHoverKey(edge ? `host:${item.id}` : null); setReorderHoverEdge(edge); }} isExpanded={!closedHostParents.has(hostExpandKey(item))} onToggleExpand={ item.childHosts && item.childHosts.length > 0 ? () => toggleHostParent(hostExpandKey(item)) : undefined } draggedHostIds={draggedHostIds} onDropChildHosts={(ids) => handleMoveHostsToParent(ids, item.id) } /> )}
); })}
)}
{/* Floating selection bar */} {selectionMode && (
{t("hosts.nSelected", { count: selectedHostIds.size })}
{hostSwitchPlugins.flatMap((plugin) => [true, false].map((value) => ( { const ids = Array.from(selectedHostIds).map(Number); try { await bulkUpdateSSHHosts(ids, { pluginEnable: { [plugin.id]: value }, }); window.dispatchEvent( new CustomEvent("termix:hosts-changed"), ); toast.success( t("hosts.updatedCount", { count: ids.length }), ); } catch { toast.error(t("hosts.bulkUpdateFailed")); } }} > {t( value ? "hosts.enablePluginFeature" : "hosts.disablePluginFeature", { name: pluginName(t, plugin) }, )} )), )} {[ { id: "all", label: t("hostDefaults.resetAll") }, { id: "core", label: t("hostDefaults.resetCore") }, ...hostSettingPlugins.map((plugin) => ({ id: plugin.id, label: pluginName(t, plugin), })), ].map((option) => ( { const ids = Array.from(selectedHostIds).map(Number); try { await resetHostToDefaults( ids, option.id === "all" ? { all: true } : { namespaces: [option.id] }, ); window.dispatchEvent( new CustomEvent("termix:hosts-changed"), ); toast.success( t("hostDefaults.resetDone", { count: ids.length }), ); } catch { toast.error(t("hosts.bulkUpdateFailed")); } }} > {option.label} ))} { 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} ))}
)} !v && setFolderDialog(null)} onSubmit={handleSaveFolderMetadata} /> setShareFolderTarget(null)} host={null} folder={shareFolderTarget} />
); }