mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
1499 lines
58 KiB
TypeScript
1499 lines
58 KiB
TypeScript
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,
|
|
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",
|
|
}: {
|
|
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;
|
|
}) {
|
|
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 [confirmDialog, setConfirmDialog] = useState<{
|
|
message: string;
|
|
onConfirm: () => Promise<void> | void;
|
|
} | null>(null);
|
|
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 — 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;
|
|
// 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 tagExtra = 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;
|
|
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,
|
|
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]);
|
|
|
|
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}
|
|
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,
|
|
plural: selectedHostIds.size !== 1 ? "s" : "",
|
|
}),
|
|
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>
|
|
)}
|
|
|
|
{/* Confirm dialog */}
|
|
{confirmDialog && (
|
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
|
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
|
|
<p className="text-sm text-foreground">{confirmDialog.message}</p>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={() => setConfirmDialog(null)}
|
|
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
|
|
>
|
|
{t("hosts.cancelBtn")}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
confirmDialog.onConfirm();
|
|
setConfirmDialog(null);
|
|
}}
|
|
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
|
|
>
|
|
{t("hosts.deleteConfirmBtn")}
|
|
</button>
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|