Files
Termix/src/ui/sidebar/tree/SidebarTree.tsx
T

1493 lines
58 KiB
TypeScript

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 { 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,
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<string, unknown>;
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 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<Set<string>>(() => {
try {
const saved = localStorage.getItem("hostOpenFolders");
return saved ? new Set<string>(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<Set<string>>(
() => {
try {
const saved = localStorage.getItem("hostClosedParents");
return saved ? new Set<string>(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> | 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<string | null>(
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<string | null>(
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<string | null>(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<string, Host>();
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<string>();
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<string>();
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<string>) {
try {
localStorage.setItem("hostOpenFolders", JSON.stringify([...next]));
} catch {
// ignore quota/serialization failures
}
}
function persistClosedHostParents(next: Set<string>) {
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<HTMLDivElement>(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<HTMLElement>("[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 (
<div className="relative flex flex-col flex-1 min-h-0">
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2 space-y-1.5">
{[28, 20, 24, 20, 28, 20].map((w, i) => (
<div
key={i}
className={`flex items-center gap-2 px-3 py-2 ${i % 2 === 1 ? "ml-4" : ""}`}
>
<div className="size-3 rounded-sm bg-muted/50 animate-pulse shrink-0" />
<div
className="h-3 rounded bg-muted/50 animate-pulse"
style={{ width: `${w * 3}px` }}
/>
</div>
))}
<div className="flex items-center justify-center gap-2 pt-4 text-muted-foreground/40">
<Loader2 className="size-3.5 animate-spin" />
<span className="text-xs">{t("hosts.loadingHosts")}</span>
</div>
</div>
</div>
);
}
return (
<div
className="relative flex flex-col flex-1 min-h-0"
onKeyDown={(event) => {
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 && (
<div className="flex items-center gap-2 border-b border-border bg-popover px-3 py-2">
<input
ref={speedSearch.inputRef}
type="search"
aria-label={t("hosts.speedSearch")}
placeholder={t("hosts.speedSearch")}
value={speedSearch.text}
onChange={(event) => speedSearch.change(event.target.value)}
className="min-w-0 flex-1 bg-transparent text-sm outline-none"
/>
<span role="status" className="text-xs text-muted-foreground">
{matchingRows.length
? Math.min(speedSearch.index + 1, matchingRows.length)
: 0}{" "}
/ {matchingRows.length}
</span>
</div>
)}
<div
ref={parentRef}
tabIndex={0}
aria-label={t("hosts.speedSearchList")}
className={`flex-1 min-h-0 overflow-y-auto ${rootDragOver ? "ring-1 ring-inset ring-accent-brand/50" : ""}`}
// Only the container's own empty space is a root drop target. Without
// the target check this fired for every child row the pointer crossed
// (dragover bubbles), and dragleave never cleared it because leaving a
// child never satisfies currentTarget === target -- so the ring stuck
// around for the rest of the session.
onDragOver={(e) => {
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 ? (
<EmptyState
className="py-12"
icon={Server}
title={
query ? t("hosts.noHostsMatchSearch") : t("hosts.noHostsYet")
}
hint={query ? undefined : t("hosts.noHostsYetHint")}
action={
query ? undefined : (
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() =>
window.dispatchEvent(
new CustomEvent("host-manager:add-host"),
)
}
>
<Plus className="size-3" />
{t("hosts.addHost")}
</Button>
)
}
/>
) : (
<div
className="relative w-full"
style={{ height: virtualizer.getTotalSize() }}
>
{reorderIndicatorTop !== null && (
<ReorderIndicator top={reorderIndicatorTop} />
)}
{virtualizer.getVirtualItems().map((vItem) => {
const row = visibleRows[vItem.index];
if (!row) return null;
const { item, depth } = row;
return (
<div
key={vItem.key}
data-index={vItem.index}
ref={virtualizer.measureElement}
data-speed-search-active={
(speedSearch.open && activeMatch?.index === vItem.index) ||
undefined
}
className={`absolute top-0 left-0 w-full ${speedSearch.open && activeMatch?.index === vItem.index ? "ring-1 ring-inset ring-accent-brand bg-accent/30" : ""}`}
style={{
transform: `translateY(${vItem.start}px)`,
}}
>
{isFolder(item) ? (
<FolderItem
folder={item}
depth={depth}
flat
onOpenTab={onOpenTab}
onEditHost={onEditHost}
onShareHost={onShareHost}
onDeleteHost={handleDeleteHost}
onDuplicateHost={handleDuplicateHost}
query={query}
openFolders={openFolders}
onToggleFolder={toggleFolder}
selectionMode={selectionMode}
selectedHostIds={selectedHostIds}
onToggleSelect={toggleSelect}
onToggleSelectFolder={(f) =>
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);
}}
/>
) : (
<HostItem
host={item}
depth={depth}
onOpenTab={(type, options) =>
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)
}
/>
)}
</div>
);
})}
</div>
)}
</div>
{/* Floating selection bar */}
{selectionMode && (
<div className="motion-panel-enter absolute bottom-4 inset-x-3 z-50">
<div className="bg-popover border border-border shadow-xl px-2.5 py-2 flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-semibold tabular-nums shrink-0">
{t("hosts.nSelected", { count: selectedHostIds.size })}
</span>
<div className="w-px h-4 bg-border mx-0.5" />
<button
className="text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-1 hover:bg-muted rounded transition-colors"
onClick={() => {
if (selectedHostIds.size === allHosts.length)
setSelectedHostIds(new Set());
else setSelectedHostIds(new Set(allHosts.map((h) => h.id)));
}}
>
{selectedHostIds.size === allHosts.length
? t("hosts.deselectAll")
: t("hosts.selectAll")}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-1 hover:bg-muted rounded transition-colors flex items-center gap-1 disabled:opacity-40"
disabled={selectedHostIds.size === 0}
>
{t("hosts.featuresMenu")} <ChevronDown className="size-2.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="text-xs">
{hostSwitchPlugins.flatMap((plugin) =>
[true, false].map((value) => (
<DropdownMenuItem
key={`${plugin.id}:${value}`}
onClick={async () => {
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"));
}
}}
>
<PluginIcon
name={plugin.icon}
className="size-3.5 mr-2"
/>
{t(
value
? "hosts.enablePluginFeature"
: "hosts.disablePluginFeature",
{ name: plugin.name },
)}
</DropdownMenuItem>
)),
)}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-1 hover:bg-muted rounded transition-colors flex items-center gap-1 disabled:opacity-40"
disabled={selectedHostIds.size === 0}
>
{t("hostDefaults.resetMenu")}{" "}
<ChevronDown className="size-2.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="text-xs">
{[
{ id: "all", label: t("hostDefaults.resetAll") },
{ id: "core", label: t("hostDefaults.resetCore") },
...hostSettingPlugins.map((plugin) => ({
id: plugin.id,
label: plugin.name,
})),
].map((option) => (
<DropdownMenuItem
key={option.id}
onClick={async () => {
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"));
}
}}
>
<RotateCcw className="size-3.5 mr-2" />
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-1 hover:bg-muted rounded transition-colors flex items-center gap-1 disabled:opacity-40"
disabled={selectedHostIds.size === 0}
>
{t("hosts.moveMenu")} <ChevronDown className="size-2.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="text-xs">
<DropdownMenuItem
onClick={async () => {
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"));
}
}}
>
<FolderOpen className="size-3.5 mr-2" />
{t("hosts.noFolderOption")}
</DropdownMenuItem>
{allFolderPaths.map((f) => (
<DropdownMenuItem
key={f}
onClick={async () => {
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"));
}
}}
>
<FolderOpen className="size-3.5 mr-2" />
{f}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<button
className="text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-1 hover:bg-muted rounded transition-colors flex items-center gap-1 disabled:opacity-40"
disabled={selectedHostIds.size === 0}
onClick={() => {
onExportSelected?.(Array.from(selectedHostIds));
setSelectedHostIds(new Set());
onToggleSelectionMode();
}}
>
<Download className="size-3" />
{t("hosts.export.bulkButton")}
</button>
<button
className="text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-1 hover:bg-muted rounded transition-colors flex items-center gap-1 disabled:opacity-40"
disabled={selectedHostIds.size === 0}
onClick={() => {
const selectedHosts = allHosts.filter((h) =>
selectedHostIds.has(String(h.id)),
);
for (const host of selectedHosts) {
const type = resolveHostTabType(host);
if (type) onOpenTab(host, type);
}
setSelectedHostIds(new Set());
onToggleSelectionMode();
}}
>
<Terminal className="size-3" />
{t("hosts.connectSelected")}
</button>
<button
className="text-[10px] text-destructive hover:text-destructive px-1.5 py-1 hover:bg-destructive/10 rounded transition-colors disabled:opacity-40"
disabled={selectedHostIds.size === 0}
onClick={() => {
setConfirmDialog({
message: t("hosts.deleteHostsConfirm", {
count: selectedHostIds.size,
}),
onConfirm: async () => {
const ids = Array.from(selectedHostIds);
const results = await Promise.allSettled(
ids.map((id) => deleteSSHHost(Number(id))),
);
const succeeded = results.filter(
(r) => r.status === "fulfilled",
).length;
const failed = results.filter(
(r) => r.status === "rejected",
).length;
setSelectedHostIds(new Set());
window.dispatchEvent(
new CustomEvent("termix:hosts-changed"),
);
if (succeeded > 0)
toast.success(
t("hosts.deletedCount", { count: succeeded }),
);
if (failed > 0)
toast.error(
t("hosts.failedToDeleteCount", { count: failed }),
);
},
});
}}
>
{t("hosts.deleteSelected")}
</button>
<div className="flex-1" />
<button
className="text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-1 hover:bg-muted rounded transition-colors"
onClick={() => {
onToggleSelectionMode();
setSelectedHostIds(new Set());
}}
>
{t("hosts.cancelSelection")}
</button>
</div>
</div>
)}
<FolderMetadataDialog
open={folderDialog !== null}
mode={folderDialog?.mode ?? "create"}
initial={
folderDialog?.folder
? {
name: folderDialog.folder.name,
color: folderDialog.folder.color,
icon: folderDialog.folder.icon,
credentialId: folderDialog.folder.credentialId,
localOnly: folderDialog.folder.localOnly,
}
: undefined
}
existingPaths={allFolderPaths}
currentPath={
folderDialog?.folder
? (folderDialog.folder.path ?? folderDialog.folder.name)
: undefined
}
onOpenChange={(v) => !v && setFolderDialog(null)}
onSubmit={handleSaveFolderMetadata}
/>
<HostShareModal
open={shareFolderTarget !== null}
onClose={() => setShareFolderTarget(null)}
host={null}
folder={shareFolderTarget}
/>
</div>
);
}