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; 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; allGroupPaths: string[]; autoExpandGroupsKey?: string; onTogglePath: (path: string) => void; onExpandAll: (paths: string[]) => void; onCollapseAll: () => void; }) { const [temporaryExpandedPaths, setTemporaryExpandedPaths] = useState | null>(null); const lastAutoExpandGroupsKeyRef = useRef(undefined); const lastAutoExpandGroupPathsRef = useRef>(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; 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; cancelInlineGroupEdit?: () => void; managedGroupPaths?: Set; onUnmanageGroup?: (groupPath: string) => void; isMultiSelectMode?: boolean; selectedHostIds?: Set; selectedGroupPaths?: Set; 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; autoExpandGroupsKey?: string; } interface TreeNodeProps { node: GroupNode; depth: number; sortMode: HostTreeSortMode; expandedPaths: Set; 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; cancelInlineGroupEdit?: () => void; managedGroupPaths?: Set; onUnmanageGroup?: (groupPath: string) => void; isMultiSelectMode?: boolean; selectedHostIds?: Set; selectedGroupPaths?: Set; 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>; activeTreeItemKey: string | null; initialTreeItemKey: string | null; onActiveTreeItemChange: (key: string) => void; renderDescendants?: boolean; treePosInSet?: number; treeSetSize?: number; } const TreeNode: React.FC = ({ 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(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 (
{/* Group Node */} { if (isInlineEditing) return; if (isMultiSelectMode) { toggleGroupSelection?.(node.path); return; } if (hostClickBehavior === 'select' && focusedGroupPath !== node.path) { onFocusGroup?.(node.path); onFocusHost?.(null); return; } onToggle(node.path); }} > 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 && ( Managed )} icon={isMultiSelectMode ? isGroupMultiSelected ? : : undefined} labelActions={!isMultiSelectMode && ( )} /> {renderDescendants && {/* Child Groups */} {childNodes.map((child) => ( ))} {/* Hosts in this group */} {sortedHosts.map((host) => ( { onFocusHost?.(hostId); if (hostId) onFocusGroup?.(null); }} groupConfigs={groupConfigs} groupDefaultsByPath={groupDefaultsByPath} activeTreeItemKey={activeTreeItemKey} initialTreeItemKey={initialTreeItemKey} onActiveTreeItemChange={onActiveTreeItemChange} /> ))} }
); }; 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; toggleHostSelection?: (hostId: string) => void; hostClickBehavior?: HostClickBehavior; focusedHostId?: string | null; onFocusHost?: (hostId: string | null) => void; groupConfigs: GroupConfig[]; groupDefaultsByPath: ReadonlyMap>; activeTreeItemKey: string | null; initialTreeItemKey: string | null; onActiveTreeItemChange: (key: string) => void; treePosInSet?: number; treeSetSize?: number; } export const getHostTreeDisplayDetails = ( host: Host, groupConfigs: GroupConfig[] = [], groupDefaultsByPath?: ReadonlyMap>, ) => { 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 = ({ 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 ( 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 ? ( ) : (
)} icon={( )} content={(
{host.label}
{displayUsername}@{host.hostname}:{displayPort}
)} actions={(displayProtocol && displayProtocol !== 'ssh') || tags.length > 0 ? (
{displayProtocol && displayProtocol !== 'ssh' && ( {displayProtocol.toUpperCase()} )} {tags.length > 0 && ( {tags.slice(0, 2).join(', ')} {tags.length > 2 && '...'} )}
) : undefined} /> ); }; const HostTreeViewInner: React.FC = ({ 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) => { 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(allGroupPaths as string[]); for (const host of hosts) { if (host.group) { paths.add(host.group); } } const defaultsByPath = new Map>(); 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(null); const pendingTreeFocusKeyRef = useRef(null); const [scrollMargin, setScrollMargin] = useState(0); const [activeTreeItemKey, setActiveTreeItemKey] = useState(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('[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) => { if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) { return; } const target = event.target instanceof Element ? event.target.closest('[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 (
{/* Expand/Collapse controls */} {groupTree.length > 0 && (
)}
{virtualizer.getVirtualItems().map((virtualRow) => { const item = visibleTreeItems[virtualRow.index]; if (!item) return null; return (
{item.kind === 'group' ? ( ) : ( { onFocusHost?.(hostId); if (hostId) onFocusGroup?.(null); }} groupConfigs={groupConfigs} groupDefaultsByPath={groupDefaultsByPath} activeTreeItemKey={activeTreeItemKey} initialTreeItemKey={initialTreeItemKey} onActiveTreeItemChange={setActiveTreeItemKey} treePosInSet={item.posInSet} treeSetSize={item.setSize} /> )}
); })}
{/* Empty state */} {ungroupedHosts.length === 0 && groupTree.length === 0 && (

{t("vault.hosts.empty")}

)}
); }; 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';