Files
NetMesh/components/HostTreeView.tsx
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

1315 lines
47 KiB
TypeScript

import { CheckSquare, Edit2, FileSymlink, Server, Square, Expand, Minimize2 } from 'lucide-react';
import { useVirtualizer } from '@tanstack/react-virtual';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import {
hostTreeInlineGroupEditStore,
useHostTreeInlineGroupEdit,
} from '../application/state/hostTreeInlineGroupEditStore';
import { useVaultHostTreeActions } from '../application/state/vaultHostTreeActionsStore';
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
import { resolveTelnetPort, resolveTelnetUsername, sanitizeHost } from '../domain/host';
import {
resolveHostActivateAction,
type HostClickBehavior,
} from '../domain/hostClickBehavior';
import { sortByVaultOrder } from '../domain/vaultOrder';
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
import { GroupConfig, GroupNode, Host } from '../types';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import { HostTreeGroupContextMenuContent, HostTreeHostContextMenuContent } from './host/HostTreeContextMenus';
import { ContextMenu, ContextMenuTrigger } from './ui/context-menu';
import { DistroAvatar } from './DistroAvatar';
import { HostNotesIndicator } from './host/HostNotesIndicator';
import { Button } from './ui/button';
import { VaultTreeGroupRow, VaultTreeItemRow } from './vault/VaultTreeRow';
const getTreeGroupDropIntent = (
element: HTMLElement,
clientY: number,
): 'before' | 'inside' | 'after' => {
const rect = element.getBoundingClientRect();
const edgeSize = Math.max(8, Math.min(14, rect.height * 0.28));
if (clientY <= rect.top + edgeSize) return 'before';
if (clientY >= rect.bottom - edgeSize) return 'after';
return 'inside';
};
const hasDragType = (dataTransfer: DataTransfer, type: string) =>
Array.from(dataTransfer.types).includes(type);
type HostTreeSortMode = 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group';
export type VisibleHostTreeItem =
| {
kind: 'group';
key: string;
depth: number;
node: GroupNode;
posInSet: number;
setSize: number;
}
| {
kind: 'host';
key: string;
depth: number;
host: Host;
posInSet: number;
setSize: number;
};
const sortHostTreeGroups = (
nodes: GroupNode[],
sortMode: HostTreeSortMode,
groupConfigs: GroupConfig[],
): GroupNode[] => {
const originalIndex = new Map(nodes.map((node, index) => [node.path, index]));
const orderByPath = new Map(
groupConfigs
.filter((config) => typeof config.order === 'number' && Number.isFinite(config.order))
.map((config) => [config.path, config.order as number]),
);
return [...nodes].sort((a, b) => {
if (sortMode === 'za') return b.name.localeCompare(a.name);
if (sortMode === 'manual') {
const orderA = orderByPath.get(a.path);
const orderB = orderByPath.get(b.path);
if (orderA !== undefined && orderB !== undefined && orderA !== orderB) return orderA - orderB;
if (orderA !== undefined) return -1;
if (orderB !== undefined) return 1;
return (originalIndex.get(a.path) ?? 0) - (originalIndex.get(b.path) ?? 0);
}
return a.name.localeCompare(b.name);
});
};
const sortHostTreeHosts = (hosts: Host[], sortMode: HostTreeSortMode): Host[] => {
const sorted = [...hosts].sort((a, b) => {
if (sortMode === 'za') return b.label.localeCompare(a.label);
if (sortMode === 'newest') return (b.createdAt || 0) - (a.createdAt || 0);
if (sortMode === 'oldest') return (a.createdAt || 0) - (b.createdAt || 0);
if (sortMode === 'manual') return 0;
return a.label.localeCompare(b.label);
});
return sortMode === 'manual' ? sortByVaultOrder(sorted) : sorted;
};
export function buildVisibleHostTreeItems({
groupTree,
ungroupedHosts,
expandedPaths,
sortMode,
groupConfigs,
}: {
groupTree: GroupNode[];
ungroupedHosts: Host[];
expandedPaths: Set<string>;
sortMode: HostTreeSortMode;
groupConfigs: GroupConfig[];
}): VisibleHostTreeItem[] {
const items: VisibleHostTreeItem[] = [];
const visitGroups = (nodes: GroupNode[], depth: number) => {
const sortedGroups = sortHostTreeGroups(nodes, sortMode, groupConfigs);
// Root-level ungrouped hosts are siblings of top-level groups.
const rootUngrouped = depth === 0 ? sortHostTreeHosts(ungroupedHosts, sortMode) : [];
const siblingSetSize = sortedGroups.length + rootUngrouped.length;
sortedGroups.forEach((node, groupIndex) => {
items.push({
kind: 'group',
key: `group:${node.path}`,
depth,
node,
posInSet: groupIndex + 1,
setSize: siblingSetSize,
});
if (!expandedPaths.has(node.path)) return;
const childGroups = sortHostTreeGroups(
Object.values(node.children ?? {}) as GroupNode[],
sortMode,
groupConfigs,
);
const childHosts = sortHostTreeHosts(node.hosts, sortMode);
const childSetSize = childGroups.length + childHosts.length;
// Recurse for nested groups first so expanded descendants stay contiguous
// under each child group, then emit this node's direct host siblings.
visitChildGroups(childGroups, depth + 1, childSetSize);
childHosts.forEach((host, hostIndex) => {
items.push({
kind: 'host',
key: `host:${host.id}`,
depth: depth + 1,
host,
posInSet: childGroups.length + hostIndex + 1,
setSize: childSetSize,
});
});
});
if (depth === 0) {
rootUngrouped.forEach((host, hostIndex) => {
items.push({
kind: 'host',
key: `host:${host.id}`,
depth: 0,
host,
posInSet: sortedGroups.length + hostIndex + 1,
setSize: siblingSetSize,
});
});
}
};
const visitChildGroups = (
sortedGroups: GroupNode[],
depth: number,
siblingSetSize: number,
) => {
sortedGroups.forEach((node, groupIndex) => {
items.push({
kind: 'group',
key: `group:${node.path}`,
depth,
node,
posInSet: groupIndex + 1,
setSize: siblingSetSize,
});
if (!expandedPaths.has(node.path)) return;
const childGroups = sortHostTreeGroups(
Object.values(node.children ?? {}) as GroupNode[],
sortMode,
groupConfigs,
);
const childHosts = sortHostTreeHosts(node.hosts, sortMode);
const childSetSize = childGroups.length + childHosts.length;
visitChildGroups(childGroups, depth + 1, childSetSize);
childHosts.forEach((host, hostIndex) => {
items.push({
kind: 'host',
key: `host:${host.id}`,
depth: depth + 1,
host,
posInSet: childGroups.length + hostIndex + 1,
setSize: childSetSize,
});
});
});
};
visitGroups(groupTree, 0);
return items;
}
export function useHostTreeExpandedPaths({
persistentExpandedPaths,
allGroupPaths,
autoExpandGroupsKey,
onTogglePath,
onExpandAll,
onCollapseAll,
}: {
persistentExpandedPaths: Set<string>;
allGroupPaths: string[];
autoExpandGroupsKey?: string;
onTogglePath: (path: string) => void;
onExpandAll: (paths: string[]) => void;
onCollapseAll: () => void;
}) {
const [temporaryExpandedPaths, setTemporaryExpandedPaths] = useState<Set<string> | null>(null);
const lastAutoExpandGroupsKeyRef = useRef<string | undefined>(undefined);
const lastAutoExpandGroupPathsRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!autoExpandGroupsKey) {
lastAutoExpandGroupsKeyRef.current = undefined;
lastAutoExpandGroupPathsRef.current = new Set();
setTemporaryExpandedPaths(null);
return;
}
const nextGroupPaths = new Set(allGroupPaths);
if (lastAutoExpandGroupsKeyRef.current !== autoExpandGroupsKey) {
lastAutoExpandGroupsKeyRef.current = autoExpandGroupsKey;
lastAutoExpandGroupPathsRef.current = nextGroupPaths;
setTemporaryExpandedPaths(nextGroupPaths);
return;
}
const newlyVisiblePaths = allGroupPaths.filter(
(path) => !lastAutoExpandGroupPathsRef.current.has(path),
);
lastAutoExpandGroupPathsRef.current = nextGroupPaths;
if (newlyVisiblePaths.length === 0) return;
setTemporaryExpandedPaths((current) => new Set([
...(current ?? []),
...newlyVisiblePaths,
]));
}, [allGroupPaths, autoExpandGroupsKey]);
const togglePath = useCallback((path: string) => {
if (temporaryExpandedPaths === null) {
onTogglePath(path);
return;
}
setTemporaryExpandedPaths((current) => {
const next = new Set(current ?? []);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
}, [onTogglePath, temporaryExpandedPaths]);
const expandAll = useCallback((paths: string[]) => {
if (temporaryExpandedPaths === null) onExpandAll(paths);
else setTemporaryExpandedPaths(new Set(paths));
}, [onExpandAll, temporaryExpandedPaths]);
const collapseAll = useCallback(() => {
if (temporaryExpandedPaths === null) onCollapseAll();
else setTemporaryExpandedPaths(new Set());
}, [onCollapseAll, temporaryExpandedPaths]);
return {
expandedPaths: temporaryExpandedPaths ?? persistentExpandedPaths,
togglePath,
expandAll,
collapseAll,
};
}
interface HostTreeViewProps {
groupTree: GroupNode[];
hosts: Host[];
sortMode?: HostTreeSortMode;
expandedPaths?: Set<string>;
onTogglePath?: (path: string) => void;
onExpandAll?: (paths: string[]) => void;
onCollapseAll?: () => void;
onConnect: (host: Host) => void;
onEditHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onCopyHostname?: (host: Host) => void;
onNewGroup: (parentPath?: string) => void;
onRenameGroup: (groupPath: string) => void;
onEditGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
cancelInlineGroupEdit?: () => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
selectedGroupPaths?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
toggleGroupSelection?: (groupPath: string) => void;
hostClickBehavior?: HostClickBehavior;
focusedHostId?: string | null;
onFocusHost?: (hostId: string | null) => void;
focusedGroupPath?: string | null;
onFocusGroup?: (groupPath: string | null) => void;
getDropTargetClasses?: (target: string) => string;
setDragOverDropTarget?: (target: string | null) => void;
groupConfigs?: GroupConfig[];
scrollRef?: React.RefObject<HTMLDivElement | null>;
autoExpandGroupsKey?: string;
}
interface TreeNodeProps {
node: GroupNode;
depth: number;
sortMode: HostTreeSortMode;
expandedPaths: Set<string>;
onToggle: (path: string) => void;
onConnect: (host: Host) => void;
onEditHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onCopyHostname?: (host: Host) => void;
onNewGroup: (parentPath?: string) => void;
onRenameGroup: (groupPath: string) => void;
onEditGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
cancelInlineGroupEdit?: () => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
selectedGroupPaths?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
toggleGroupSelection?: (groupPath: string) => void;
hostClickBehavior?: HostClickBehavior;
focusedHostId?: string | null;
onFocusHost?: (hostId: string | null) => void;
focusedGroupPath?: string | null;
onFocusGroup?: (groupPath: string | null) => void;
getDropTargetClasses?: (target: string) => string;
setDragOverDropTarget?: (target: string | null) => void;
groupConfigs: GroupConfig[];
groupDefaultsByPath: ReadonlyMap<string, Partial<GroupConfig>>;
activeTreeItemKey: string | null;
initialTreeItemKey: string | null;
onActiveTreeItemChange: (key: string) => void;
renderDescendants?: boolean;
treePosInSet?: number;
treeSetSize?: number;
}
const TreeNode: React.FC<TreeNodeProps> = ({
node,
depth,
sortMode,
expandedPaths,
onToggle,
onConnect,
onEditHost,
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
onCopyHostname,
onNewGroup,
onRenameGroup,
onEditGroup,
onDeleteGroup,
moveHostToGroup,
moveGroup,
managedGroupPaths,
onUnmanageGroup,
commitInlineGroupRename,
cancelInlineGroupEdit,
isMultiSelectMode,
selectedHostIds,
selectedGroupPaths,
toggleHostSelection,
toggleGroupSelection,
hostClickBehavior = 'connect',
focusedHostId,
onFocusHost,
focusedGroupPath,
onFocusGroup,
getDropTargetClasses,
setDragOverDropTarget,
groupConfigs,
groupDefaultsByPath,
activeTreeItemKey,
initialTreeItemKey,
onActiveTreeItemChange,
renderDescendants = true,
treePosInSet,
treeSetSize,
}) => {
const inlineEdit = useHostTreeInlineGroupEdit();
const vaultTreeActions = useVaultHostTreeActions();
const commitRename = commitInlineGroupRename ?? vaultTreeActions?.commitInlineGroupRename;
const cancelRename = cancelInlineGroupEdit ?? vaultTreeActions?.cancelInlineGroupEdit;
const isInlineEditing = inlineEdit?.groupPath === node.path;
const groupRowRef = useRef<HTMLDivElement>(null);
const isExpanded = expandedPaths.has(node.path);
const isGroupFocused = hostClickBehavior === 'select' && focusedGroupPath === node.path;
const isGroupMultiSelected = Boolean(isMultiSelectMode && selectedGroupPaths?.has(node.path));
useEffect(() => {
if (!isInlineEditing || !inlineEdit?.shouldScrollIntoView) return;
const frame = requestAnimationFrame(() => {
groupRowRef.current?.scrollIntoView({ block: 'nearest' });
hostTreeInlineGroupEditStore.markScrollHandled();
});
return () => cancelAnimationFrame(frame);
}, [inlineEdit?.groupPath, inlineEdit?.shouldScrollIntoView, isInlineEditing]);
const hasChildren = node.children && Object.keys(node.children).length > 0;
const isManaged = managedGroupPaths?.has(node.path) ?? false;
const hostsCountInNode = node.totalHostCount ?? node.hosts.length;
const childNodes = useMemo(() => sortHostTreeGroups(
Object.values(node.children ?? {}) as GroupNode[],
sortMode,
groupConfigs,
), [groupConfigs, node.children, sortMode]);
const sortedHosts = useMemo(
() => sortHostTreeHosts(node.hosts, sortMode),
[node.hosts, sortMode],
);
return (
<div>
{/* Group Node */}
<Collapsible
open={isExpanded}
onOpenChange={() => {
if (isInlineEditing) return;
if (isMultiSelectMode) {
toggleGroupSelection?.(node.path);
return;
}
if (hostClickBehavior === 'select' && focusedGroupPath !== node.path) {
onFocusGroup?.(node.path);
onFocusHost?.(null);
return;
}
onToggle(node.path);
}}
>
<ContextMenu>
<ContextMenuTrigger>
<CollapsibleTrigger asChild>
<VaultTreeGroupRow
rowRef={groupRowRef}
name={node.name}
depth={depth}
expanded={isExpanded}
selected={isGroupFocused || isGroupMultiSelected}
hasChildren={hasChildren || node.hosts.length > 0}
count={hostsCountInNode}
editing={isInlineEditing}
editingInitialName={inlineEdit?.initialName}
onRenameCommit={commitRename}
onRenameCancel={cancelRename}
className={getDropTargetClasses?.(node.path)}
data-section="host-tree-row"
data-row-type="group"
data-group-path={node.path}
data-tree-item-key={`group:${node.path}`}
data-tree-depth={depth}
role="treeitem"
aria-level={depth + 1}
aria-posinset={treePosInSet}
aria-setsize={treeSetSize}
aria-selected={isMultiSelectMode ? isGroupMultiSelected : isGroupFocused}
aria-expanded={hasChildren || node.hosts.length > 0 ? isExpanded : undefined}
tabIndex={activeTreeItemKey === `group:${node.path}`
|| (!activeTreeItemKey && initialTreeItemKey === `group:${node.path}`)
? 0
: -1}
onFocus={() => onActiveTreeItemChange(`group:${node.path}`)}
draggable={!isInlineEditing && !isMultiSelectMode}
onKeyDown={(event) => {
if (!isMultiSelectMode) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
toggleGroupSelection?.(node.path);
}}
onDragStart={(e) => {
if (isInlineEditing) return;
e.dataTransfer.setData("group-path", node.path);
}}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
if (hasDragType(e.dataTransfer, "group-path")) {
const intent = getTreeGroupDropIntent(e.currentTarget, e.clientY);
if (intent !== "inside") {
setDragOverDropTarget?.(null);
return;
}
}
setDragOverDropTarget?.(node.path);
}}
onDragLeave={(e) => {
const nextTarget = e.relatedTarget;
if (nextTarget instanceof Node && e.currentTarget.contains(nextTarget)) {
return;
}
setDragOverDropTarget?.(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget?.(null);
const hostId = e.dataTransfer.getData("host-id");
const groupPath = e.dataTransfer.getData("group-path");
if (hostId) moveHostToGroup(hostId, node.path);
if (groupPath && getTreeGroupDropIntent(e.currentTarget, e.clientY) === "inside") {
moveGroup(groupPath, node.path);
}
}}
meta={isManaged && (
<span className="mr-1.5 inline-flex shrink-0 items-center gap-1 rounded bg-primary/15 px-1.5 py-0.5 text-[10px] font-medium text-primary">
<FileSymlink size={10} />
Managed
</span>
)}
icon={isMultiSelectMode
? isGroupMultiSelected
? <CheckSquare size={16} />
: <Square size={16} />
: undefined}
labelActions={!isMultiSelectMode && (
<button
aria-label={`Edit ${node.name}`}
tabIndex={-1}
data-host-tree-group-edit-button={node.path}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 transition-colors hover:bg-secondary/80 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onEditGroup(node.path);
}}
>
<Edit2 size={12} />
</button>
)}
/>
</CollapsibleTrigger>
</ContextMenuTrigger>
<HostTreeGroupContextMenuContent
groupPath={node.path}
isManaged={isManaged}
onNewGroup={onNewGroup}
onRenameGroup={onRenameGroup}
onDeleteGroup={onDeleteGroup}
onUnmanageGroup={onUnmanageGroup}
/>
</ContextMenu>
{renderDescendants && <CollapsibleContent role="group">
{/* Child Groups */}
{childNodes.map((child) => (
<TreeNode
key={child.path}
node={child}
depth={depth + 1}
sortMode={sortMode}
expandedPaths={expandedPaths}
onToggle={onToggle}
onConnect={onConnect}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
onCopyHostname={onCopyHostname}
onNewGroup={onNewGroup}
onRenameGroup={onRenameGroup}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
commitInlineGroupRename={commitInlineGroupRename}
cancelInlineGroupEdit={cancelInlineGroupEdit}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
selectedGroupPaths={selectedGroupPaths}
toggleHostSelection={toggleHostSelection}
toggleGroupSelection={toggleGroupSelection}
hostClickBehavior={hostClickBehavior}
focusedHostId={focusedHostId}
onFocusHost={onFocusHost}
focusedGroupPath={focusedGroupPath}
onFocusGroup={onFocusGroup}
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
groupConfigs={groupConfigs}
groupDefaultsByPath={groupDefaultsByPath}
activeTreeItemKey={activeTreeItemKey}
initialTreeItemKey={initialTreeItemKey}
onActiveTreeItemChange={onActiveTreeItemChange}
/>
))}
{/* Hosts in this group */}
{sortedHosts.map((host) => (
<HostTreeItem
key={host.id}
host={host}
depth={depth + 1}
onConnect={onConnect}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
onCopyHostname={onCopyHostname}
moveHostToGroup={moveHostToGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
hostClickBehavior={hostClickBehavior}
focusedHostId={focusedHostId}
onFocusHost={(hostId) => {
onFocusHost?.(hostId);
if (hostId) onFocusGroup?.(null);
}}
groupConfigs={groupConfigs}
groupDefaultsByPath={groupDefaultsByPath}
activeTreeItemKey={activeTreeItemKey}
initialTreeItemKey={initialTreeItemKey}
onActiveTreeItemChange={onActiveTreeItemChange}
/>
))}
</CollapsibleContent>}
</Collapsible>
</div>
);
};
interface HostTreeItemProps {
host: Host;
depth: number;
onConnect: (host: Host) => void;
onEditHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onCopyHostname?: (host: Host) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
hostClickBehavior?: HostClickBehavior;
focusedHostId?: string | null;
onFocusHost?: (hostId: string | null) => void;
groupConfigs: GroupConfig[];
groupDefaultsByPath: ReadonlyMap<string, Partial<GroupConfig>>;
activeTreeItemKey: string | null;
initialTreeItemKey: string | null;
onActiveTreeItemChange: (key: string) => void;
treePosInSet?: number;
treeSetSize?: number;
}
export const getHostTreeDisplayDetails = (
host: Host,
groupConfigs: GroupConfig[] = [],
groupDefaultsByPath?: ReadonlyMap<string, Partial<GroupConfig>>,
) => {
const displayHost = host.group
? applyGroupDefaults(host, groupDefaultsByPath?.get(host.group) ?? resolveGroupDefaults(host.group, groupConfigs))
: host;
const isTelnet = displayHost.protocol === 'telnet';
return {
protocol: displayHost.protocol,
username: isTelnet
? (resolveTelnetUsername(displayHost) || '')
: (displayHost.username?.trim() || ''),
port: isTelnet
? resolveTelnetPort(displayHost)
: (displayHost.port ?? 22),
};
};
const HostTreeItem: React.FC<HostTreeItemProps> = ({
host,
depth,
onConnect,
onEditHost,
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
onCopyHostname,
moveHostToGroup: _moveHostToGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
hostClickBehavior = 'connect',
focusedHostId,
onFocusHost,
groupConfigs,
treePosInSet,
treeSetSize,
groupDefaultsByPath,
activeTreeItemKey,
initialTreeItemKey,
onActiveTreeItemChange,
}) => {
const safeHost = sanitizeHost(host);
const tags = host.tags || [];
const displayDetails = useMemo(
() => getHostTreeDisplayDetails(host, groupConfigs, groupDefaultsByPath),
[groupConfigs, groupDefaultsByPath, host],
);
const displayProtocol = displayDetails.protocol;
const displayUsername = displayDetails.username;
const displayPort = displayDetails.port;
const isMultiSelected = Boolean(isMultiSelectMode && selectedHostIds?.has(host.id));
const isFocusSelected = !isMultiSelectMode && hostClickBehavior === 'select' && focusedHostId === host.id;
const isSelected = isMultiSelected || isFocusSelected;
const normalizedHostClickBehavior: HostClickBehavior = hostClickBehavior === 'select'
? 'select'
: 'connect';
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<VaultTreeItemRow
label={host.label}
depth={depth}
selected={Boolean(isSelected)}
className="h-10 rounded-md py-1 pr-2 text-[13px]"
data-section="host-tree-row"
data-row-type="host"
data-host-id={host.id}
data-tree-item-key={`host:${host.id}`}
data-tree-depth={depth}
role="treeitem"
aria-level={depth + 1}
aria-posinset={treePosInSet}
aria-setsize={treeSetSize}
aria-selected={Boolean(isSelected)}
tabIndex={activeTreeItemKey === `host:${host.id}`
|| (!activeTreeItemKey && initialTreeItemKey === `host:${host.id}`)
? 0
: -1}
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
draggable={!isMultiSelectMode}
onDragStart={(e) => {
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("host-id", host.id);
}}
onClick={() => {
const action = resolveHostActivateAction({
behavior: normalizedHostClickBehavior,
isMultiSelectMode: Boolean(isMultiSelectMode),
focusedHostId,
hostId: host.id,
});
if (action === 'toggle-multi') {
toggleHostSelection?.(host.id);
return;
}
if (action === 'select') {
onFocusHost?.(host.id);
return;
}
onConnect(safeHost);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
const action = resolveHostActivateAction({
behavior: normalizedHostClickBehavior,
isMultiSelectMode: Boolean(isMultiSelectMode),
focusedHostId,
hostId: host.id,
});
if (action === 'toggle-multi') {
toggleHostSelection?.(host.id);
return;
}
if (action === 'select') {
onFocusHost?.(host.id);
return;
}
onConnect(safeHost);
}}
leading={isMultiSelectMode ? (
<div
className="mr-2 flex h-5 w-4 flex-shrink-0 items-center justify-center"
aria-hidden="true"
>
{isMultiSelected ? (
<CheckSquare size={15} className="text-primary" />
) : (
<Square size={15} className="text-muted-foreground" />
)}
</div>
) : (
<div className="mr-2 h-4 w-4 flex-shrink-0" />
)}
icon={(
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="tree" />
)}
content={(
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5 font-medium leading-5">
<span className="min-w-0 truncate">{host.label}</span>
<button
aria-label={`Edit ${host.label}`}
tabIndex={-1}
data-host-tree-host-edit-button={host.id}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded transition-colors opacity-0 hover:bg-secondary/80 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onEditHost(host);
}}
>
<Edit2 size={12} />
</button>
<HostNotesIndicator notes={host.notes} />
</div>
<div className="truncate text-[11px] leading-4 text-muted-foreground">
{displayUsername}@{host.hostname}:{displayPort}
</div>
</div>
)}
actions={(displayProtocol && displayProtocol !== 'ssh') || tags.length > 0 ? (
<div className="ml-2 flex shrink-0 items-center gap-1.5 opacity-0 transition-opacity group-hover:opacity-100">
{displayProtocol && displayProtocol !== 'ssh' && (
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] leading-none text-primary">
{displayProtocol.toUpperCase()}
</span>
)}
{tags.length > 0 && (
<span className="text-[10px] opacity-60">
{tags.slice(0, 2).join(', ')}
{tags.length > 2 && '...'}
</span>
)}
</div>
) : undefined}
/>
</ContextMenuTrigger>
<HostTreeHostContextMenuContent
host={host}
onConnect={onConnect}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onCopyHostname={onCopyHostname}
onCopyCredentials={onCopyCredentials}
onDeleteHost={onDeleteHost}
/>
</ContextMenu>
);
};
const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
groupTree,
hosts,
sortMode = 'az',
expandedPaths: externalExpandedPaths,
onTogglePath: externalOnTogglePath,
onExpandAll: externalOnExpandAll,
onCollapseAll: externalOnCollapseAll,
onConnect,
onEditHost,
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
onCopyHostname,
onNewGroup,
onRenameGroup,
onEditGroup,
onDeleteGroup,
moveHostToGroup,
moveGroup,
managedGroupPaths,
onUnmanageGroup,
commitInlineGroupRename,
cancelInlineGroupEdit,
isMultiSelectMode,
selectedHostIds,
selectedGroupPaths,
toggleHostSelection,
toggleGroupSelection,
hostClickBehavior,
focusedHostId,
onFocusHost,
focusedGroupPath,
onFocusGroup,
getDropTargetClasses,
setDragOverDropTarget,
groupConfigs = [],
scrollRef,
autoExpandGroupsKey,
}) => {
const { t } = useI18n();
const treeSortMode = sortMode as HostTreeSortMode;
const inlineEdit = useHostTreeInlineGroupEdit();
const vaultTreeActions = useVaultHostTreeActions();
const cancelRename = cancelInlineGroupEdit ?? vaultTreeActions?.cancelInlineGroupEdit;
const handleTreePointerDownCapture = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!inlineEdit?.groupPath || !cancelRename) return;
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest('[data-inline-group-edit="true"]')) return;
const row = target.closest('[data-section="host-tree-row"]');
if (!row) return;
if (row.getAttribute('data-group-path') === inlineEdit.groupPath) return;
cancelRename();
}, [cancelRename, inlineEdit?.groupPath]);
// Use external state if provided, otherwise use local persistent state
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
const persistentTogglePath = externalOnTogglePath || localTreeState.togglePath;
const persistentExpandAll = externalOnExpandAll || localTreeState.expandAll;
const persistentCollapseAll = externalOnCollapseAll || localTreeState.collapseAll;
// Get all possible group paths for expand/collapse all functionality
const getAllGroupPaths = (nodes: GroupNode[]): string[] => {
const paths: string[] = [];
const traverse = (nodeList: GroupNode[]) => {
nodeList.forEach(node => {
paths.push(node.path);
if (node.children) {
traverse(Object.values(node.children) as GroupNode[]);
}
});
};
traverse(nodes);
return paths;
};
const allGroupPaths = useMemo(() => getAllGroupPaths(groupTree), [groupTree]);
const persistentExpandedPaths = externalExpandedPaths || localTreeState.expandedPaths;
const { expandedPaths, togglePath, expandAll, collapseAll } = useHostTreeExpandedPaths({
persistentExpandedPaths,
allGroupPaths,
autoExpandGroupsKey,
onTogglePath: persistentTogglePath,
onExpandAll: persistentExpandAll,
onCollapseAll: persistentCollapseAll,
});
const groupDefaultsByPath = useMemo(() => {
const paths = new Set<string>(allGroupPaths as string[]);
for (const host of hosts) {
if (host.group) {
paths.add(host.group);
}
}
const defaultsByPath = new Map<string, Partial<GroupConfig>>();
for (const path of paths) {
defaultsByPath.set(path, resolveGroupDefaults(path, groupConfigs));
}
return defaultsByPath;
}, [allGroupPaths, groupConfigs, hosts]);
const handleExpandAll = () => {
expandAll(allGroupPaths);
};
const handleCollapseAll = () => {
collapseAll();
};
const ungroupedHosts = useMemo(
() => hosts.filter((host) => !host.group || host.group === ''),
[hosts],
);
const visibleTreeItems = useMemo(() => buildVisibleHostTreeItems({
groupTree,
ungroupedHosts,
expandedPaths,
sortMode: treeSortMode,
groupConfigs,
}), [expandedPaths, groupConfigs, groupTree, treeSortMode, ungroupedHosts]);
const treeRef = useRef<HTMLDivElement>(null);
const pendingTreeFocusKeyRef = useRef<string | null>(null);
const [scrollMargin, setScrollMargin] = useState(0);
const [activeTreeItemKey, setActiveTreeItemKey] = useState<string | null>(null);
const initialTreeItemKey = visibleTreeItems[0]?.key ?? null;
const treeItemIndexByKey = useMemo(() => new Map(
visibleTreeItems.map((item, index) => [item.key, index]),
), [visibleTreeItems]);
const virtualizer = useVirtualizer({
count: visibleTreeItems.length,
getScrollElement: () => scrollRef?.current ?? null,
estimateSize: () => 40,
getItemKey: (index) => visibleTreeItems[index]?.key ?? index,
overscan: 10,
scrollMargin,
initialRect: typeof window === 'undefined'
? { width: 1280, height: 800 }
: undefined,
});
React.useLayoutEffect(() => {
const root = treeRef.current;
const scrollElement = scrollRef?.current;
if (!root || !scrollElement) return;
const measure = () => {
const nextMargin = root.getBoundingClientRect().top
- scrollElement.getBoundingClientRect().top
+ scrollElement.scrollTop;
setScrollMargin((current) => current === nextMargin ? current : nextMargin);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(root);
observer.observe(scrollElement);
return () => observer.disconnect();
}, [scrollRef, visibleTreeItems.length]);
React.useLayoutEffect(() => {
virtualizer.measure();
}, [virtualizer, visibleTreeItems.length]);
const focusRenderedTreeItem = useCallback((key: string) => {
const target = [...(treeRef.current?.querySelectorAll<HTMLElement>('[data-tree-item-key]') ?? [])]
.find((item) => item.dataset.treeItemKey === key);
if (!target) return false;
target.focus();
return true;
}, []);
React.useLayoutEffect(() => {
const key = pendingTreeFocusKeyRef.current;
if (!key || !focusRenderedTreeItem(key)) return;
pendingTreeFocusKeyRef.current = null;
});
useEffect(() => {
if (!activeTreeItemKey) return;
if (!treeItemIndexByKey.has(activeTreeItemKey)) {
setActiveTreeItemKey(null);
}
}, [activeTreeItemKey, treeItemIndexByKey]);
const focusTreeItem = useCallback((index: number) => {
const item = visibleTreeItems[index];
if (!item) return;
const key = item.key;
pendingTreeFocusKeyRef.current = key;
setActiveTreeItemKey(key);
virtualizer.scrollToIndex(index, { align: 'auto' });
queueMicrotask(() => {
if (focusRenderedTreeItem(key)) pendingTreeFocusKeyRef.current = null;
});
}, [focusRenderedTreeItem, virtualizer, visibleTreeItems]);
const handleTreeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
return;
}
const target = event.target instanceof Element
? event.target.closest<HTMLElement>('[role="treeitem"]')
: null;
if (!target || !treeRef.current?.contains(target)) return;
const key = target.dataset.treeItemKey;
const index = key ? treeItemIndexByKey.get(key) : undefined;
if (index === undefined) return;
const currentItem = visibleTreeItems[index];
const depth = currentItem.depth;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') {
event.preventDefault();
const nextIndex = event.key === 'Home'
? 0
: event.key === 'End'
? visibleTreeItems.length - 1
: Math.max(0, Math.min(
visibleTreeItems.length - 1,
index + (event.key === 'ArrowDown' ? 1 : -1),
));
focusTreeItem(nextIndex);
return;
}
const isGroup = target.dataset.rowType === 'group';
const groupPath = target.dataset.groupPath;
const isExpandable = target.hasAttribute('aria-expanded');
const isExpanded = target.getAttribute('aria-expanded') === 'true';
if (event.key === 'ArrowRight') {
if (!isGroup || !isExpandable) return;
event.preventDefault();
if (!isExpanded && groupPath) {
togglePath(groupPath);
return;
}
const child = visibleTreeItems[index + 1];
if (child && child.depth > depth) {
focusTreeItem(index + 1);
}
return;
}
event.preventDefault();
if (isGroup && isExpandable && isExpanded && groupPath) {
togglePath(groupPath);
return;
}
for (let parentIndex = index - 1; parentIndex >= 0; parentIndex -= 1) {
const parent = visibleTreeItems[parentIndex];
if (parent.kind === 'group' && parent.depth < depth) {
focusTreeItem(parentIndex);
return;
}
}
}, [focusTreeItem, togglePath, treeItemIndexByKey, visibleTreeItems]);
return (
<div className="space-y-1" onPointerDownCapture={handleTreePointerDownCapture}>
{/* Expand/Collapse controls */}
{groupTree.length > 0 && (
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/30">
<Button
variant="ghost"
size="sm"
onClick={handleExpandAll}
className="h-7 px-2 text-xs"
>
<Expand size={12} className="mr-1" />
{t("vault.tree.expandAll")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleCollapseAll}
className="h-7 px-2 text-xs"
>
<Minimize2 size={12} className="mr-1" />
{t("vault.tree.collapseAll")}
</Button>
</div>
)}
<div
ref={treeRef}
className="relative min-w-0"
style={{ height: virtualizer.getTotalSize() }}
data-vault-virtual-tree="true"
role="tree"
aria-label={t("vault.nav.hosts")}
aria-multiselectable={isMultiSelectMode || undefined}
onKeyDownCapture={handleTreeKeyDown}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = visibleTreeItems[virtualRow.index];
if (!item) return null;
return (
<div
key={item.key}
data-vault-virtual-tree-row={virtualRow.index}
className="absolute left-0 top-0 w-full"
style={{
height: virtualRow.size,
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
}}
>
{item.kind === 'group' ? (
<TreeNode
node={item.node}
depth={item.depth}
sortMode={sortMode}
expandedPaths={expandedPaths}
onToggle={togglePath}
onConnect={onConnect}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
onCopyHostname={onCopyHostname}
onNewGroup={onNewGroup}
onRenameGroup={onRenameGroup}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
commitInlineGroupRename={commitInlineGroupRename}
cancelInlineGroupEdit={cancelInlineGroupEdit}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
selectedGroupPaths={selectedGroupPaths}
toggleHostSelection={toggleHostSelection}
toggleGroupSelection={toggleGroupSelection}
hostClickBehavior={hostClickBehavior}
focusedHostId={focusedHostId}
onFocusHost={onFocusHost}
focusedGroupPath={focusedGroupPath}
onFocusGroup={onFocusGroup}
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
groupConfigs={groupConfigs}
groupDefaultsByPath={groupDefaultsByPath}
activeTreeItemKey={activeTreeItemKey}
initialTreeItemKey={initialTreeItemKey}
onActiveTreeItemChange={setActiveTreeItemKey}
renderDescendants={false}
treePosInSet={item.posInSet}
treeSetSize={item.setSize}
/>
) : (
<HostTreeItem
host={item.host}
depth={item.depth}
onConnect={onConnect}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
onCopyHostname={onCopyHostname}
moveHostToGroup={moveHostToGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
hostClickBehavior={hostClickBehavior}
focusedHostId={focusedHostId}
onFocusHost={(hostId) => {
onFocusHost?.(hostId);
if (hostId) onFocusGroup?.(null);
}}
groupConfigs={groupConfigs}
groupDefaultsByPath={groupDefaultsByPath}
activeTreeItemKey={activeTreeItemKey}
initialTreeItemKey={initialTreeItemKey}
onActiveTreeItemChange={setActiveTreeItemKey}
treePosInSet={item.posInSet}
treeSetSize={item.setSize}
/>
)}
</div>
);
})}
</div>
{/* Empty state */}
{ungroupedHosts.length === 0 && groupTree.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<Server size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-sm">{t("vault.hosts.empty")}</p>
</div>
)}
</div>
);
};
function hostTreeViewAreEqual(prev: HostTreeViewProps, next: HostTreeViewProps): boolean {
return prev.groupTree === next.groupTree
&& prev.hosts === next.hosts
&& prev.sortMode === next.sortMode
&& prev.expandedPaths === next.expandedPaths
&& prev.onTogglePath === next.onTogglePath
&& prev.onExpandAll === next.onExpandAll
&& prev.onCollapseAll === next.onCollapseAll
&& prev.onConnect === next.onConnect
&& prev.onEditHost === next.onEditHost
&& prev.onDuplicateHost === next.onDuplicateHost
&& prev.onDeleteHost === next.onDeleteHost
&& prev.onCopyCredentials === next.onCopyCredentials
&& prev.onCopyHostname === next.onCopyHostname
&& prev.onNewGroup === next.onNewGroup
&& prev.onRenameGroup === next.onRenameGroup
&& prev.onEditGroup === next.onEditGroup
&& prev.onDeleteGroup === next.onDeleteGroup
&& prev.moveHostToGroup === next.moveHostToGroup
&& prev.moveGroup === next.moveGroup
&& prev.commitInlineGroupRename === next.commitInlineGroupRename
&& prev.cancelInlineGroupEdit === next.cancelInlineGroupEdit
&& prev.managedGroupPaths === next.managedGroupPaths
&& prev.onUnmanageGroup === next.onUnmanageGroup
&& prev.isMultiSelectMode === next.isMultiSelectMode
&& prev.selectedHostIds === next.selectedHostIds
&& prev.selectedGroupPaths === next.selectedGroupPaths
&& prev.toggleHostSelection === next.toggleHostSelection
&& prev.toggleGroupSelection === next.toggleGroupSelection
&& prev.hostClickBehavior === next.hostClickBehavior
&& prev.focusedHostId === next.focusedHostId
&& prev.onFocusHost === next.onFocusHost
&& prev.focusedGroupPath === next.focusedGroupPath
&& prev.onFocusGroup === next.onFocusGroup
&& prev.getDropTargetClasses === next.getDropTargetClasses
&& prev.setDragOverDropTarget === next.setDragOverDropTarget
&& prev.groupConfigs === next.groupConfigs
&& prev.scrollRef === next.scrollRef
&& prev.autoExpandGroupsKey === next.autoExpandGroupsKey;
}
export const HostTreeView = memo(HostTreeViewInner, hostTreeViewAreEqual);
HostTreeView.displayName = 'HostTreeView';