/* eslint-disable react-refresh/only-export-components */ import { actionOrder } from "@/sidebar/host-contributions"; import { showsInBar, useBarActions } from "@/sidebar/tree/host-bar-actions"; import { rem } from "@/lib/rem"; import { enabledHostProtocols } from "@/sidebar/host-protocols"; import { useEffect, useRef, useState, type MouseEvent } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Check, ChevronRight, Copy, CopyPlus, Cpu, GripVertical, Key, KeyRound, LayoutPanelLeft, Link, MemoryStick, MoreHorizontal, Pencil, Pin, Share2, SquarePlus, Terminal, Trash2, Users, } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/dropdown-menu"; import { toast } from "sonner"; import { getHostPassword } from "@/main-axios"; import type { Host, TabType } from "@/types/ui-types"; import { defaultHostRowFields, type HostDensity, type HostClickBehavior, type HostRowFields, type HostTrayTrigger, } from "@/types/host-sidebar-preferences"; import { copyToClipboard } from "@/lib/clipboard"; import { canDeleteHost, canEditHost, canOverrideHostAuth, canShareHost, authOverrideProtocols as listAuthOverrideProtocols, authProtocolLabel, } from "@/sidebar/host-permissions"; import { HostAuthOverrideModal } from "@/sidebar/HostAuthOverrideModal"; import type { AuthOverrideProtocol } from "@/types/auth-protocols"; import { useStatusColorScheme, getStatusClasses, } from "@/hooks/use-status-color-scheme"; import { useHostStatus } from "@/lib/ServerStatusContext"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/tooltip"; import { hostMatchesQuery } from "../visible-rows"; import { markTabSurfaceUsed, preloadTabSurface } from "@/shell/tabUtils"; import { getPreferredHostAction, recordHostActionPreference, } from "@/lib/local-adaptive-preferences"; import { defaultConnectAction, hostActionsFor, hostBadgesFor, hostMenuItemsFor, useHostActions, useHostBadges, useHostContextMenuItems, type HostActionDef, } from "@/sidebar/host-contributions"; import { shell } from "@/plugin-host/shell-bridge"; import type { TabShellCallbacks } from "@/shell/tab-registry"; import { openInSplit, useSplitTargets, type SplitOpenTarget, } from "@/shell/split/split-targets"; import { MAX_PANES } from "@/shell/split/split-tree"; export function statusCheckEnabled(host: Host): boolean { return host.statusCheckEnabled !== false; } export function buildStatusTooltip( host: Host, status: "online" | "offline" | "unknown", t: (key: string) => string = (k) => k, ): string { if (!statusCheckEnabled(host)) return t("hosts.status.monitoringDisabled"); const statusLabel = status === "online" ? t("hosts.status.online") : status === "offline" ? t("hosts.status.offline") : t("hosts.status.checking"); const protocols: string[] = []; if (host.enableSsh) protocols.push("SSH"); for (const protocol of enabledHostProtocols(host)) { protocols.push(t(protocol.titleKey)); } if (protocols.length === 0) return statusLabel; return `${protocols.join(", ")}: ${statusLabel}`; } 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; } /** * Per-density layout knobs. Every feature is available in both densities -- * only spacing/sizing and whether a couple of rows collapse to a single line * differ. Keeping this as one lookup (rather than two parallel JSX trees) * means a future style tweak only has to be made once. */ const DOUBLE_CLICK_WINDOW_MS = 250; const DEFAULT_ROW_FIELDS = defaultHostRowFields(); export function formatHostAddress( host: Pick & Partial>, fields: Pick, ): string { const protocol = host.enableSsh === false ? enabledHostProtocols(host)[0] : undefined; const username = host.enableSsh === false ? protocol ? host.protocolAuth?.[protocol.id]?.username : undefined : host.username; const user = fields.showUsername && username ? `${username}@` : ""; const port = fields.showPort && host.port ? `:${host.port}` : ""; return `${user}${host.ip}${port}`; } const HOST_ITEM_DENSITY_TOKENS = { comfortable: { rowPadding: "pl-[8.75px] pr-[7px] py-[7px]", nameTextSize: "text-[13px]", showAddressRow: true, showTagsRow: true, showResourceRow: true, }, compact: { rowPadding: "pl-[7px] pr-[5.25px] py-[5px]", nameTextSize: "text-[12px]", showAddressRow: false, showTagsRow: false, showResourceRow: false, }, } as const; export function HostItem({ host, onOpenTab, onEditHost: onEditHostProp, onShareHost: onShareHostProp, onDelete, onDuplicate, query = "", stripeIndex = 0, selectionMode = false, selected = false, onToggleSelect, isMenuOpen = false, onMenuOpenChange, isTrayOpen = false, onTrayOpenChange, isHovered = false, onHoverChange, onDragStart, onDragEnd, depth = 0, density = "comfortable", trayTrigger = "hover", showTags = true, openOnDoubleClick = false, hostClickBehavior = "newTab", showResourceBars = true, showStatusStripes = true, rowFields = DEFAULT_ROW_FIELDS, rowActions = "full", arrangeMode = false, isDragging = false, onReorderDrop, isReorderHovered = false, reorderHoverEdge = null, onReorderHoverChange, isExpanded = false, onToggleExpand, draggedHostIds = null, onDropChildHosts, }: { host: Host; onOpenTab: ( type: TabType, options?: { data?: Record; label?: string; forceNewTab?: boolean; }, ) => 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; isHovered?: boolean; onHoverChange?: (hovered: boolean) => void; onDragStart?: () => void; onDragEnd?: () => void; /** Nesting level when rendered in a flattened virtual list. */ depth?: number; density?: HostDensity; trayTrigger?: HostTrayTrigger; showTags?: boolean; /** Requires a double click to launch instead of a single click. */ openOnDoubleClick?: boolean; /** What a click does when the host already has an open tab. */ hostClickBehavior?: HostClickBehavior; /** Preset-driven: hides the CPU/RAM bars without changing density. */ showResourceBars?: boolean; /** Preset-driven: hides the per-row status color stripe. */ showStatusStripes?: boolean; rowFields?: HostRowFields; /** "essential" trims the row's management actions to the common few. */ rowActions?: "essential" | "full"; /** When true (rearranging unlocked), the row can be dragged: its edges * become reorder drop zones and its middle accepts a nest/move drop. */ arrangeMode?: boolean; /** True while this row is the one being dragged, for the ghost styling. */ isDragging?: boolean; onReorderDrop?: (position: "before" | "after") => void; /** Whether THIS row is the current reorder drop target -- lifted to a * single piece of state in the parent tree so only one row can ever show * the drop-indicator bar at a time, instead of each row tracking its own * hover state (which could get stuck showing a stale bar when dragleave * didn't fire cleanly, e.g. jumping directly from one virtualized row to * another). */ isReorderHovered?: boolean; reorderHoverEdge?: "before" | "after" | null; onReorderHoverChange?: (edge: "before" | "after" | null) => void; /** Whether this host's sub-host children are currently shown. */ isExpanded?: boolean; /** Present only when this host has sub-hosts nested under it. */ onToggleExpand?: () => void; /** ids of the host(s) currently being dragged, if any -- mirrors FolderItem's drop-target wiring. */ draggedHostIds?: string[] | null; /** Present when this row can accept a dragged host/selection to become its parent. */ onDropChildHosts?: (hostIds: string[]) => void; }) { 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 allHostActions = useHostActions(); const pluginActions = hostActionsFor(allHostActions, host); const barActions = useBarActions(); const badges = hostBadgesFor(useHostBadges(), host); const pluginMenuItems = hostMenuItemsFor(useHostContextMenuItems(), host); const splitTargets = useSplitTargets(); const statusScheme = useStatusColorScheme(); const statusCheckOn = statusCheckEnabled(host); // Per-host subscription, so a poll only re-renders rows that flipped. const availability = useHostStatus(Number(host.id), statusCheckOn) ?? "offline"; const statusLoading = availability === "unknown"; const isOnline = availability === "online"; const previousAvailability = useRef(availability); const [statusLocking, setStatusLocking] = useState(false); useEffect(() => { const justCameOnline = previousAvailability.current !== "online" && availability === "online"; previousAvailability.current = availability; if (!justCameOnline) return; setStatusLocking(true); const timeout = window.setTimeout(() => setStatusLocking(false), 400); return () => window.clearTimeout(timeout); }, [availability]); const isTouchOnly = typeof window !== "undefined" && window.matchMedia("(hover: none)").matches; const alwaysShowTray = trayTrigger === "always"; const actionsOnly = trayTrigger === "actionsOnly"; const shouldUseClickTray = !alwaysShowTray && !actionsOnly && (trayTrigger === "click" || isTouchOnly); const showPasswordCopy = !host.isShared && canCopyHostPassword(host); const showSudoPasswordCopy = !host.isShared && canCopyHostSudoPassword(host); const authOverrideProtocols = listAuthOverrideProtocols().filter((protocol) => canOverrideHostAuth(host, protocol), ); const [authOverrideProtocol, setAuthOverrideProtocol] = useState(null); const [parentDragOver, setParentDragOver] = useState(false); const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number; } | null>(null); // Density decides the base shape; the preset can only take rows away, never // add them, so a row's real height stays <= the virtualizer's fixed estimate. const densityTokens = HOST_ITEM_DENSITY_TOKENS[density]; const tokens = { ...densityTokens, showAddressRow: densityTokens.showAddressRow && rowFields.showAddress, showTagsRow: densityTokens.showTagsRow && showTags, showResourceRow: densityTokens.showResourceRow && showResourceBars, }; const isCompact = density === "compact"; const focusExistingTab = hostClickBehavior !== "newTab"; const doubleClickOpensNew = hostClickBehavior === "focusExistingDoubleClickNew"; // In double click mode a single click waits out the double click window, // so a double click doesn't also switch to (or open) a tab first. const singleClickTimer = useRef | null>(null); useEffect( () => () => { if (singleClickTimer.current) clearTimeout(singleClickTimer.current); }, [], ); const reorderEdge = isReorderHovered ? reorderHoverEdge : null; const canDrag = arrangeMode && !selectionMode && !isTouchOnly && canEditHost(host); // The middle band nests the dragged host under this one; the top/bottom // bands reorder. Both live on the same row, so the pointer's position // inside it picks the intent instead of a modifier key. const acceptsChildDrop = arrangeMode && !!onDropChildHosts && !!draggedHostIds && !draggedHostIds.includes(host.id); 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")); } } if (query && !hostMatchesQuery(host, query)) return null; const depthStyle = depth > 0 ? ({ paddingLeft: rem(depth * 12) } as const) : undefined; const trayButtonClass = `flex items-center justify-center ${isCompact ? "size-[19px]" : "size-[22.75px]"} text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors`; const availableActions: TabType[] = pluginActions.flatMap((action) => action.tabType ? [action.tabType] : [], ); // Empty when no running plugin can connect to this host. const defaultAction: TabType = defaultConnectAction(allHostActions, host)?.tabType ?? ""; const openHostTab = ( type: TabType, options?: { data?: Record; label?: string; forceNewTab?: boolean; }, ) => { if (!type) return; markTabSurfaceUsed(type); recordHostActionPreference(host.id, type); onOpenTab(type, { ...options, forceNewTab: options?.forceNewTab ?? !focusExistingTab, }); }; // A plugin action opening a tab goes through this row, so it gets the same // focus-existing and preference handling as a core one. const rowShell: TabShellCallbacks = { ...shell, openTab: (_host, type, options) => openHostTab(type, options), }; const runPluginAction = (action: HostActionDef) => { if (action.run) action.run(host, rowShell); else if (action.tabType) openHostTab(action.tabType); }; /** Plugin actions in row order. */ const rowEntries = [ ...pluginActions.map((action) => { const items = action.items?.(host); return { key: action.id, order: actionOrder(action), icon: action.icon as typeof Terminal, label: action.label?.(host) ?? t(action.titleKey), tabType: action.tabType, tray: showsInBar(barActions, action), items: items && items.length > 1 ? items.map((item) => ({ id: item.id, label: item.label, run: () => item.run(host, rowShell), })) : undefined, run: () => items && items.length === 1 ? items[0].run(host, rowShell) : runPluginAction(action), }; }), ].sort((a, b) => a.order - b.order || a.key.localeCompare(b.key)); const trayEntries = rowEntries.filter((entry) => entry.tray); const connectionButtons = ( <> {trayEntries.map((entry, index) => { const Icon = entry.icon; // A thin rule between host tools and the other ways to connect. const separator = index > 0 && trayEntries[index - 1].order < 100 && entry.order >= 100 ? (
) : null; const button = entry.items ? ( {entry.items.map((item) => ( { e.stopPropagation(); item.run(); }} > {item.label} ))} ) : ( ); return separator ? [separator, button] : button; })} ); // "essential" only trims buttons that the overflow menu also offers, so no // action becomes unreachable. const essentialActions = rowActions === "essential"; const managementButtons = ( <> {!essentialActions && showPasswordCopy && ( )} {!essentialActions && showSudoPasswordCopy && ( )} {onEditHost && ( )} {onShareHost && ( )} { if (!open) setContextMenuPosition(null); onMenuOpenChange?.(open); }} > {contextMenuPosition ? ( createPortal( , document.body, ) ) : ( )} {t("common.connect")} {rowEntries.map((entry) => { const Icon = entry.icon; return entry.items ? ( {entry.label} {entry.items.map((item) => ( { e.stopPropagation(); item.run(); }} > {item.label} ))} ) : ( { e.stopPropagation(); entry.run(); }} > {entry.label} ); })} {focusExistingTab && ( { e.stopPropagation(); openHostTab(defaultAction, { forceNewTab: true }); }} > {t("hosts.openInNewTab")} )} {defaultAction && (splitTargets.canStartSplit || splitTargets.splits.length > 0) && ( {t("splitScreen.openInSplit")} {(() => { const openAt = (target: SplitOpenTarget) => openInSplit(target, () => openHostTab(defaultAction, { forceNewTab: true }), ); return ( <> {splitTargets.canStartSplit && ( { e.stopPropagation(); openAt({ kind: "newSplit" }); }} > {t("splitScreen.openInNewSplit")} )} {splitTargets.splits.map((split) => (
{split.label} {split.panes.map((pane) => ( { e.stopPropagation(); openAt({ kind: "pane", splitTabId: split.id, paneId: pane.id, }); }} > {pane.label ? t("splitScreen.paneItem", { index: pane.index, label: pane.label, }) : t("splitScreen.paneEmptyItem", { index: pane.index, })} ))} { e.stopPropagation(); openAt({ kind: "newPane", splitTabId: split.id, }); }} > {t("splitScreen.newPaneRight")}
))} ); })()}
)} {onEditHost && ( { e.stopPropagation(); onEditHost(); }} > {t("hosts.editHostAction")} )} {onShareHost && ( { e.stopPropagation(); onShareHost(); }} > {t("hosts.shareHost")} )} {pluginMenuItems.map((item) => { const Icon = item.icon; return ( { e.stopPropagation(); item.run(host, rowShell); }} > {Icon && } {t(item.titleKey)} ); })} { e.stopPropagation(); writeClipboardText( formatHostAddress(host, { showUsername: true, showPort: false, }), ); toast.success(t("hosts.copiedToClipboard")); }} > {t("hosts.copyAddress")} {authOverrideProtocols.map((protocol) => ( { e.stopPropagation(); setAuthOverrideProtocol(protocol); }} > {t("hosts.sharing.authOverrideActionProtocol", { protocol: authProtocolLabel(protocol, t), })} ))} {showPasswordCopy && ( handleCopyPassword(e, "password")} > {t("nav.copyPassword")} )} {showSudoPasswordCopy && ( handleCopyPassword(e, "sudoPassword")} > {t("nav.copySudoPassword")} )} {t("hosts.copyLink")} {pluginActions .filter((action) => action.copyUrlView) .map((action) => { const Icon = action.icon; return ( { e.stopPropagation(); writeClipboardText( `${window.location.origin}?view=${encodeURIComponent(action.copyUrlView!)}&hostId=${host.id}`, ); toast.success(t("hosts.copiedToClipboard")); }} > {t("hosts.copyViewUrlAction", { name: t(action.titleKey), })} ); })} {allowDelete && ( <> { e.stopPropagation(); onDuplicate(); }} > {t("hosts.cloneHostAction")} { e.stopPropagation(); onDelete(); }} > {t("common.delete")} )}
); const trayOpenState = isTrayOpen || (isMenuOpen && !contextMenuPosition); // Hover mode keeps the tray open from React state rather than group-hover so // the virtualizer can reserve the expanded height for this row. const hoverTrayOpen = !alwaysShowTray && !actionsOnly && !shouldUseClickTray && !selectionMode && (isHovered || (isMenuOpen && !contextMenuPosition)); // A collapsed tray must not earn the text column's gap-[3.5px]. Clipping to // max-h-0 leaves it a flex item, so every closed row measured ~3.5px taller // than its slot and the virtualizer spread the rows apart to match. The // negative margin cancels the gap while keeping the element in flow, so the // modes that animate open still have something to transition. const trayCollapsedClass = `max-h-0 opacity-0 ${isCompact ? "" : "-mt-[3.5px]"}`; const trayVisibilityClass = alwaysShowTray || actionsOnly ? `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[130px] opacity-100" : trayCollapsedClass}` : shouldUseClickTray ? `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayOpenState ? "max-h-[130px] opacity-100" : trayCollapsedClass}` : // No transition in hover mode: the row's height is set by the // virtualizer and snaps in a single frame, so animating the tray // against it leaves the open tray overflowing its shortened row for // the length of the animation. Both change together instead. `overflow-hidden ${hoverTrayOpen ? "max-h-[130px] opacity-100" : trayCollapsedClass}`; return (
{ if (!canDrag) return; e.dataTransfer.effectAllowed = "move"; // Without an explicit drag image the browser snapshots the whole row // including its open action tray, which drags a tall block around // and reads as the UI overlapping itself. Use the name row instead. const label = e.currentTarget.querySelector("[data-drag-label]"); if (label) { const rect = label.getBoundingClientRect(); e.dataTransfer.setDragImage( label, Math.min(e.clientX - rect.left, rect.width), rect.height / 2, ); } onDragStart?.(); }} onDragEnd={() => { onReorderHoverChange?.(null); setParentDragOver(false); onDragEnd?.(); }} onDragOver={(e) => { if (!arrangeMode) return; const rect = e.currentTarget.getBoundingClientRect(); const offset = e.clientY - rect.top; // Reordering is the common intent, so the edge bands get most of the // row: only the middle third nests. A 10px floor keeps the bands // hittable on a compact row, and measuring against the name row's // height (not the row's, which grows with an open tray) keeps the // split where the pointer expects it on a tall row. const NAME_ROW = 34; const effective = Math.min(rect.height, NAME_ROW); const band = Math.max(10, effective / 3); const wantsNest = acceptsChildDrop && offset > band && offset < rect.height - band; if (wantsNest) { e.preventDefault(); e.stopPropagation(); onReorderHoverChange?.(null); setParentDragOver(true); return; } if (onReorderDrop) { e.preventDefault(); e.stopPropagation(); setParentDragOver(false); onReorderHoverChange?.(offset < rect.height / 2 ? "before" : "after"); } }} onDragLeave={(e) => { if (e.currentTarget === e.target) { setParentDragOver(false); } }} onDrop={(e) => { if (!arrangeMode) return; if (parentDragOver && acceptsChildDrop && draggedHostIds) { e.preventDefault(); e.stopPropagation(); setParentDragOver(false); onDropChildHosts?.(draggedHostIds); return; } if (onReorderDrop && reorderEdge) { e.preventDefault(); e.stopPropagation(); onReorderDrop(reorderEdge); onReorderHoverChange?.(null); } }} style={depthStyle} onPointerEnter={() => { if (defaultAction) preloadTabSurface(defaultAction); const preferredAction = getPreferredHostAction( host.id, availableActions, defaultAction, ); if (preferredAction !== defaultAction) { preloadTabSurface(preferredAction); } }} onMouseEnter={() => onHoverChange?.(true)} onMouseLeave={() => onHoverChange?.(false)} onContextMenu={(event) => { if (selectionMode || arrangeMode) return; event.preventDefault(); event.stopPropagation(); setContextMenuPosition({ x: event.clientX, y: event.clientY }); onMenuOpenChange?.(true); }} className={`group relative flex items-stretch select-none transition-colors motion-interactive hover:bg-muted/50 ${ canDrag ? "cursor-grab active:cursor-grabbing" : "cursor-pointer" } ${ selected ? "bg-accent-brand/[0.07]" : stripeIndex % 2 === 1 ? "bg-muted/15" : "" } ${isMenuOpen ? "bg-muted/50" : ""} ${parentDragOver ? "ring-1 ring-inset ring-accent-brand bg-accent-brand/10" : ""} ${isDragging ? "opacity-40" : ""}`} onMouseDown={(e) => { // Middle-click always opens a new tab, matching browser tab behavior. if (e.button === 1 && !selectionMode && !isTouchOnly) { e.preventDefault(); openHostTab(defaultAction, { forceNewTab: true }); } }} onClick={(e) => { if (selectionMode) { onToggleSelect?.(); return; } // 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 otherProtocols = enabledHostProtocols(host).length; if (otherProtocols <= 1) { openHostTab(defaultAction); } else { onTrayOpenChange?.(!isTrayOpen); } return; } const forceNewTab = e.ctrlKey || e.metaKey; if (doubleClickOpensNew) { if (singleClickTimer.current) clearTimeout(singleClickTimer.current); singleClickTimer.current = null; if (e.detail > 1) return; if (forceNewTab) { openHostTab(defaultAction, { forceNewTab }); } else { singleClickTimer.current = setTimeout(() => { singleClickTimer.current = null; openHostTab(defaultAction); }, DOUBLE_CLICK_WINDOW_MS); } return; } if (openOnDoubleClick) return; openHostTab(defaultAction, forceNewTab ? { forceNewTab } : undefined); }} onDoubleClick={(e) => { if (selectionMode || isTouchOnly) return; if (doubleClickOpensNew) { e.stopPropagation(); if (singleClickTimer.current) clearTimeout(singleClickTimer.current); singleClickTimer.current = null; if (e.ctrlKey || e.metaKey) return; openHostTab(defaultAction, { forceNewTab: true }); return; } if (!openOnDoubleClick) return; e.stopPropagation(); const forceNewTab = e.ctrlKey || e.metaKey; openHostTab(defaultAction, forceNewTab ? { forceNewTab } : undefined); }} > {/* Status stripe */} {showStatusStripes && (
)} {canDrag && (
)}
{/* Name row */}
{onToggleExpand && ( )} {selectionMode && (
{selected && }
)} {host.name} {buildStatusTooltip(host, availability, t)} {rowFields.showPinIcon && host.pin && ( )} {rowFields.showBadges && badges.map((badge) => { const Badge = badge.component; return ; })} {rowFields.showSharedBadge && host.isShared && ( {t("hosts.sharing.sharedBadge")} {t("hosts.sharing.sharedBadgeTooltip", { owner: host.ownerUsername || "?", level: t( `hosts.sharing.levels.${host.permissionLevel ?? "connect"}.label`, ), })} )} {isCompact && showTags && host.tags?.slice(0, 2).map((tag) => ( {tag} ))} {isCompact && showTags && (host.tags?.length ?? 0) > 2 && ( +{host.tags!.length - 2} )} {isCompact && rowFields.showAddress && ( {formatHostAddress(host, { ...rowFields, showUsername: false })} )} {isCompact && !selectionMode && (
*]:shrink-0 ${alwaysShowTray || actionsOnly || trayOpenState || hoverTrayOpen ? "" : "hidden"}`} > {connectionButtons}
{managementButtons}
)} {!selectionMode && (shouldUseClickTray || actionsOnly) && ( )}
{tokens.showAddressRow && ( {formatHostAddress(host, rowFields)} )} {/* Tag pills */} {showTags && !isCompact && host.tags && host.tags.length > 0 && (
{host.tags.slice(0, isCompact ? 2 : 4).map((tag) => ( {tag} ))} {host.tags.length > (isCompact ? 2 : 4) && ( +{host.tags.length - (isCompact ? 2 : 4)} )}
)} {/* Connection buttons: permanent in "always"/"actionsOnly" modes, or shown once the chevron opens the tray in click mode */} {!isCompact && !selectionMode && (alwaysShowTray || actionsOnly || (shouldUseClickTray && isTrayOpen)) && (
{connectionButtons}
)} {/* Action tray, slides open on hover (default) or via chevron in click-tray mode */} {!isCompact && (
{tokens.showResourceRow && 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-warning" : "bg-accent-brand"}`} style={{ width: `${host.cpu}%` }} />
{host.cpu}%
)} {host.ram != null && host.ram > 0 && (
80 ? "bg-red-400" : host.ram > 60 ? "bg-warning" : "bg-accent-brand/60"}`} style={{ width: `${host.ram}%` }} />
{host.ram}%
)}
)}
{/* Connection buttons, only shown here when not already shown above */} {!alwaysShowTray && !actionsOnly && !shouldUseClickTray && (
{connectionButtons}
)} {/* Management buttons row */}
{managementButtons}
)} {authOverrideProtocol && ( { if (!open) setAuthOverrideProtocol(null); }} host={host} protocol={authOverrideProtocol} /> )}
); }