Files
Termix/src/ui/sidebar/tree/SidebarTree.tsx
T
66e8ce6df7 v26.10.2 (#1672)
* fix: keep concurrent ssh pool connects from overwriting each other (#1619)

* fix: stop auto-accepting changed jump host keys (#1620)

A jump hop whose key no longer matched the pinned fingerprint was
silently accepted and the pin overwritten. Background status probes
reconnect to jump hosts every cycle, so a MITM on that hop got the
credentials with no user interaction. Changed jump host keys now go
through the same path as any other host: prompt when there is a
terminal socket, otherwise refuse.

* fix(electron): validate external login URL scheme and callback port (#1621)

* fix(plugins): read artifact manifest with the same tar parser as the unpack (#1622)

The consent check read the first manifest.json with a hand-written tar
reader, while the unpack keeps the last one and also follows pax and
long-name headers. An archive with two manifests could pass the check
with one capability list and get granted another. Read the manifest
with node-tar, count only plain files, and refuse archives that hold
more than one manifest.json.

* fix: only trust the server's origin in the desktop login frame (#1624)

The desktop sign-in iframe accepted AUTH_SUCCESS and system browser requests from whatever page it had navigated to, and posted replies (including 2FA temp tokens) to any origin. Messages must now come from the server's origin and replies are posted to it.

The web UI also treated any framing page as the desktop app and posted the JWT to it. It now also requires the Termix-Desktop user agent.

* fix: check trusted proxy login against nginx's peer, not nginx (#1625)

The backend only sees the bundled nginx on loopback, so the allowlist
could only work by trusting 127.0.0.1, which let any client that reached
nginx log in as anyone by sending the identity headers. nginx now passes
its own TCP peer (before real_ip rewriting) in X-Termix-Proxy-Peer, and
loopback connections are judged by that address instead.

* fix: keep foreign keys on during user data import (#1626)

Import credentials before hosts and remap the file's credential ids to
the ones created here, so hosts and protocol logins no longer point at
another server's ids. With rows written in dependency order, the import
no longer turns off SQLite foreign keys on the shared connection. The
uploaded file is now removed on every exit path.

* fix: enforce unique usernames (#1628)

* fix: enforce unique usernames

Registration checked for a taken name, hashed the password, then
inserted, with nothing in the schema enforcing uniqueness. Concurrent
sign-ups could create two accounts with one name, after which login,
password reset, proxy login and admin lookups by username picked
between them arbitrarily.

Add a unique index on users.username for every dialect. Existing
duplicates are renamed (later rows get a -<id> suffix, the oldest keeps
the name) rather than deleted. Registration and admin user creation now
return the same 409 when the insert loses the race.

* fix: avoid username collisions during migration

* fix: don't auto-enable synced plugins signed only by a server-sent key (#1623)

* fix: don't auto-enable synced plugins signed only by a server-sent key

Registry keys a linked server sends still let its plugins load on the desktop, but no longer count as a signer for switching them on or copying their grants. Keys the desktop added itself keep their trust.

* fix: enforce local plugin trust across remote source labels

* fix: require positive local trust for synced plugin keys

* fix(desktop): harden external editor and session cookie IPC (#1627)

* fix(desktop): stop the renderer choosing the external editor binary or reading the session cookie

open-external-editor now launches only the editor saved from a file dialog
and refuses to hand executable file types to the OS opener. The unused
get-session-cookie and wait-session-cookie channels, which returned the
httpOnly jwt, are removed.

* fix: validate setting keys before editor authorization

* fix(sidebar): sort hosts by IP address numerically (#1629)

* fix(desktop): find spawn-helper in universal macOS builds (#1630)

The universal build ships app-arm64.asar and app-x64.asar instead of
app.asar, so node-pty's helper path rewrite never pointed at the
unpacked copy and every local terminal failed with posix_spawnp failed.

* fix(sync): send shared hosts with text columns in their stored form (#1631)

resolveHostById turns forceKeyboardInteractive into a boolean, and the
shared-host export passed it on as is. A desktop could not bind the boolean
into the text column, so every shared host failed to apply.

* fix: drop ssh_data.vault_profile_id so removing the vault plugin keeps hosts writable (#1633)

The vault plugin renames vault_profiles to p_vault_profiles, and SQLite
rewrites ssh_data's foreign key to follow it. Uninstalling the plugin then
dropped p_vault_profiles and left ssh_data pointing at a missing table, so
creating, cloning and deleting hosts failed with "no such table:
main.p_vault_profiles". Postgres and MySQL refused the uninstall instead.

The column was already copied into the vault plugin's host settings by 2.9,
so retire it on every engine. The SQLite boot drop also repairs databases
already in the broken state; MySQL drops the column's foreign key first.

* fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own (#1632)

* fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own

* fix(sync): switch off an untrusted mirrored plugin when the server updates it

* fix: require local consent for mirrored plugin activation

* docs: describe consent fields on the plugin state endpoint

* fix(i18n): keep Indonesian technical terms consistent (#1636)

Use the English terms Indonesian users know for tunnel, snippet, deploy,
file manager, remote desktop, jump host, passphrase, sidebar, toolbar,
endpoint, browser and SSH agent, instead of mixing literal translations
(terowongan, cuplikan, terapkan, bilah sisi, titik akhir, ...) with the
English words. Match the split shortcuts to the split screen menu.

Also fix the user data export description, which said secrets are
encrypted while the English says they are decrypted.

* fix(settings): leave user manage view when switching admin sections (#1649)

The admin panel stays mounted across Settings pages, so an open user
manage view kept replacing every other admin section until Back to users
was clicked. Reset it whenever the selected section changes.

* fix(sync): don't delete a local row when the server can't send its copy (#1650)

* fix(sync): don't delete a local row when the server can't send its copy

A push conflict on a live record whose server row failed to serialize
came back as row: null, deleted: false, and the desktop treated that as
a delete. The server now rejects that op as unavailable, the desktop
never deletes on a conflict without a row, and serialization failures
are logged.

* fix(sync): retry temporarily unavailable records

* fix: smooth host editor jumps to lower sections

* chore: add changelog entries for 26.10.2 fixes

* fix: stop building ssh2 native crypto in the docker image (#1644)

* chore: test that an update keeps a changed auth type (#1601)

* feat: option to hide the sidebar after opening a host (#1652)

* feat: sort the dashboard host status card (Plugin-Host-Metrics#6)

* fix: let the hover side menu overlay content instead of resizing it (Plugin-Remote-Desktop#2)

* fix: say the server is down and keep retrying instead of a database error (#1637)

* fix: show why a client tunnel failed to start (Plugin-Tunnels#2)

* fix(sdk): skip comment-only chunks when splitting migrations

* feat: setting to let the hover side menu push content again

* fix: use the vite proxy for plugin sockets on any dev port

* feat(sdk): let a plugin raise its public route rate limit

* chore: update translations

* chore: add the overview video to the readme

* chore: update the overview video and add a play button to its thumbnail

* feat: plugin builds bundle worker entries and import wasm and fonts as bytes

* feat: plugin packs include LICENSE and NOTICE

* chore: drop guacd from compose files and dev runner

* chore: update remote desktop origin comment for the built-in engine

* fix: show plugin names and descriptions in the UI language (#1658)

* fix: show plugin names and descriptions in the UI language

Read plugin.name and plugin.description from each plugin's own locale
namespace, falling back to the manifest, everywhere core shows a plugin's
name: the Plugins manager, onboarding picker, host editor tabs and
sections, host filters and bulk actions, beta updates, interface presets
and plugin crash/loading notices.

* docs(sdk): document plugin.name and plugin.description locale keys

---------

Co-authored-by: ZacharyZcR <zkl205305@gmail.com>

* fix(hosts): reject or infer a missing authType on host create (#1664)

Creating an SSH host through the API without authType hit the NOT NULL
auth_type column and came back as a 500. The create route now infers the
type from the credential, key or password in the body, falls back to the
host defaults, and otherwise answers 400. A non-string authType is a 400
on create and update.

* chore: changelog for merged plugin name and host auth type PRs

* fix: move macOS split pane navigation to Cmd+Option+Arrow

* fix: pick a free port for the desktop backend when 30001 is taken (#1663)

* feat: build the Windows desktop app for ARM64 (#1615)

* fix(sync): keep the changes cursor within the 32-bit seq column (#1665)

The desktop learned the server's entity types by asking for changes after
Number.MAX_SAFE_INTEGER. sync_records.seq is a 32-bit integer on Postgres,
which rejects that bound value, so every pass needing the probe failed with
22003 out of range.

The server now clamps the cursor to the largest seq the column holds and
answers 400 for a cursor or limit that is not a non-negative integer. The
desktop asks for a one-row page from its saved cursor instead.

* fix(audit): coalesce identical plugin routine audits so they cannot flush the log (#1667)

Successful plugin_as_user and plugin_service_* entries written by a plugin
with the same user, action, resource and details are recorded once per
10 minutes. Failures and every other action are still written each time.

* feat(plugins): publish host.created when a host is added (#1668)

Plugins that keep an inventory of hosts had no way to hear about a new
one short of polling. Core now publishes host.created with the same
{ hostId, userId } payload as host.updated wherever a host row is
created: the host create route, ctx.hosts.create, JSON and SSH config
bulk import, database file import, and sync writing a host it did not
have yet.

* fix: show the login for signed-out full-screen view links and return to them (#1669)

A ?view= link opened without a session spun forever: the gate rendered
the bare view when the session check failed, so the plugin runtime never
started and the view never registered. A regular browser now keeps the
link in sessionStorage, shows the normal login and opens the link again
after any login, including redirect logins. Only same-origin paths with
a view are restored. The desktop app, embedded sign-in frames and guest
views keep their current behavior.

* fix(desktop): apply linked server proxy headers and certificate exception to WebSockets (#1670)

* fix(desktop): send linked server proxy headers on WebSocket upgrades

isLinkedOrigin compared the request origin with the link's http(s)
origin, so the terminal WebSocket (wss://host/...) never matched and its
upgrade went out without the proxy headers and basic auth. Behind a
forward-auth proxy that upgrade was redirected to the login and the
terminal closed with "connection closed unexpectedly". ws and wss now
count as http and https for the comparison, the same mapping the
WebSocket handshake itself uses, with host and port still matched
exactly.

* fix(desktop): allow a linked server's self-signed certificate for its WebSockets

isInvalidCertificateAllowedForUrl compared the URL with a local
getOrigin copy, so wss://host never matched the link's https://host
origin and an allowed self-signed certificate did not cover the
terminal and tunnel sockets. It now uses linkedServer.isLinkedOrigin,
which maps ws and wss the same way as the proxy headers, and the local
copy is removed.

* feat(sdk): let plugins mint a short-lived user session and read a user's locale (#1666)

ctx.auth.issueUserToken(userId, { purpose, ttlSeconds }) gives background
work a session for calling Termix's own API as an existing user. It needs
users:impersonate, defaults to 300s and is capped at 900s, is stored as a
normal session row marked as plugin-issued, and every issue is audited.
Such sessions are refused on routes that change the user's password,
second factors, API keys or linked logins.

ctx.users.getLocale(userId) reads the user's UI language under users:read.

SDK 1.0.11, plugin API 1.3.0.

* chore: changelog for merged sync, audit, event and desktop PRs

* feat: add shell.rightEdge slot so plugin panels can push the page over

* chore: pin bundled plugins to the latest releases

* chore: lint, format, and bump version to 26.10.2

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Ferdina Kusumah <19812460+FerdinaKusumah@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zkl205305@gmail.com>
Co-authored-by: Lukas <80832688+0skater0@users.noreply.github.com>
2026-10-18 00:34:50 -05:00

1505 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 { 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<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 rootRef = useRef<HTMLDivElement>(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<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
ref={rootRef}
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: pluginName(t, plugin) },
)}
</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: pluginName(t, plugin),
})),
].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>
);
}