import { CheckSquare, Edit2, FileSymlink, Server, Square, Expand, Minimize2 } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useRef } 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 { 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); interface HostTreeViewProps { groupTree: GroupNode[]; hosts: Host[]; sortMode?: 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group'; 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; 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) => void; cancelInlineGroupEdit?: () => void; managedGroupPaths?: Set; onUnmanageGroup?: (groupPath: string) => void; isMultiSelectMode?: boolean; selectedHostIds?: Set; toggleHostSelection?: (hostId: string) => void; getDropTargetClasses?: (target: string) => string; setDragOverDropTarget?: (target: string | null) => void; groupConfigs?: GroupConfig[]; } interface TreeNodeProps { node: GroupNode; depth: number; sortMode: 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group'; 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; 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) => void; cancelInlineGroupEdit?: () => void; managedGroupPaths?: Set; onUnmanageGroup?: (groupPath: string) => void; isMultiSelectMode?: boolean; selectedHostIds?: Set; toggleHostSelection?: (hostId: string) => void; getDropTargetClasses?: (target: string) => string; setDragOverDropTarget?: (target: string | null) => void; groupConfigs: GroupConfig[]; groupDefaultsByPath: ReadonlyMap>; } const TreeNode: React.FC = ({ node, depth, sortMode, expandedPaths, onToggle, onConnect, onEditHost, onDuplicateHost, onDeleteHost, onCopyCredentials, onNewGroup, onRenameGroup, onEditGroup, onDeleteGroup, moveHostToGroup, moveGroup, managedGroupPaths, onUnmanageGroup, commitInlineGroupRename, cancelInlineGroupEdit, isMultiSelectMode, selectedHostIds, toggleHostSelection, getDropTargetClasses, setDragOverDropTarget, groupConfigs, groupDefaultsByPath, }) => { 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); 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(() => { if (!node.children) return []; const nodes = Object.values(node.children) as unknown as GroupNode[]; const originalIndex = new Map(nodes.map((child, index) => [child.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) => { switch (sortMode) { case 'za': return b.name.localeCompare(a.name); case 'manual': { const orderA = orderByPath.get(a.path); const orderB = orderByPath.get(b.path); const hasOrderA = typeof orderA === 'number' && Number.isFinite(orderA); const hasOrderB = typeof orderB === 'number' && Number.isFinite(orderB); if (hasOrderA && hasOrderB && orderA !== orderB) return orderA - orderB; if (hasOrderA) return -1; if (hasOrderB) return 1; return (originalIndex.get(a.path) ?? 0) - (originalIndex.get(b.path) ?? 0); } case 'newest': case 'oldest': // For groups, fall back to name sorting since groups don't have creation dates return a.name.localeCompare(b.name); case 'az': default: return a.name.localeCompare(b.name); } }); }, [groupConfigs, node.children, sortMode]); const sortedHosts = useMemo(() => { const sorted = [...node.hosts].sort((a, b) => { switch (sortMode) { case 'az': return a.label.localeCompare(b.label); case 'za': return b.label.localeCompare(a.label); case 'newest': return (b.createdAt || 0) - (a.createdAt || 0); case 'oldest': return (a.createdAt || 0) - (b.createdAt || 0); case 'manual': return 0; default: return a.label.localeCompare(b.label); } }); if (sortMode === 'manual') return sortByVaultOrder(sorted); return sorted; }, [node.hosts, sortMode]); return (
{/* Group Node */} { if (isInlineEditing) 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} draggable={!isInlineEditing} 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 )} actions={( )} /> {/* Child Groups */} {childNodes.map((child) => ( ))} {/* Hosts in this group */} {sortedHosts.map((host) => ( ))}
); }; 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; moveHostToGroup: (hostId: string, groupPath: string | null) => void; isMultiSelectMode?: boolean; selectedHostIds?: Set; toggleHostSelection?: (hostId: string) => void; groupConfigs: GroupConfig[]; groupDefaultsByPath: ReadonlyMap>; } 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, moveHostToGroup: _moveHostToGroup, isMultiSelectMode, selectedHostIds, toggleHostSelection, groupConfigs, groupDefaultsByPath, }) => { 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 isSelected = isMultiSelectMode && selectedHostIds?.has(host.id); return ( e.dataTransfer.setData("host-id", host.id)} onClick={() => { if (isMultiSelectMode && toggleHostSelection) { toggleHostSelection(host.id); } else { onConnect(safeHost); } }} leading={isMultiSelectMode ? (
{ e.stopPropagation(); toggleHostSelection?.(host.id); }}> {isSelected ? ( ) : ( )}
) : (
)} icon={( )} content={(
{host.label}
{displayUsername}@{host.hostname}:{displayPort}
)} actions={(
{displayProtocol && displayProtocol !== 'ssh' && ( {displayProtocol.toUpperCase()} )} {tags.length > 0 && ( {tags.slice(0, 2).join(', ')} {tags.length > 2 && '...'} )}
)} /> ); }; export const HostTreeView: React.FC = ({ groupTree, hosts, sortMode = 'az', expandedPaths: externalExpandedPaths, onTogglePath: externalOnTogglePath, onExpandAll: externalOnExpandAll, onCollapseAll: externalOnCollapseAll, onConnect, onEditHost, onDuplicateHost, onDeleteHost, onCopyCredentials, onNewGroup, onRenameGroup, onEditGroup, onDeleteGroup, moveHostToGroup, moveGroup, managedGroupPaths, onUnmanageGroup, commitInlineGroupRename, cancelInlineGroupEdit, isMultiSelectMode, selectedHostIds, toggleHostSelection, getDropTargetClasses, setDragOverDropTarget, groupConfigs = [], }) => { const { t } = useI18n(); 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 expandedPaths = externalExpandedPaths || localTreeState.expandedPaths; const togglePath = externalOnTogglePath || localTreeState.togglePath; const expandAll = externalOnExpandAll || localTreeState.expandAll; const collapseAll = 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 groupDefaultsByPath = useMemo(() => { const paths = new Set(allGroupPaths); 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(); }; // Get ungrouped hosts (hosts without a group or with empty group) and sort them const ungroupedHosts = useMemo(() => { const hosts_without_group = hosts.filter(host => !host.group || host.group === ''); const sorted = hosts_without_group.sort((a, b) => { switch (sortMode) { case 'az': return a.label.localeCompare(b.label); case 'za': return b.label.localeCompare(a.label); case 'newest': return (b.createdAt || 0) - (a.createdAt || 0); case 'oldest': return (a.createdAt || 0) - (b.createdAt || 0); case 'manual': return 0; default: return a.label.localeCompare(b.label); } }); if (sortMode === 'manual') return sortByVaultOrder(sorted); return sorted; }, [hosts, sortMode]); // Sort group tree based on sort mode const sortedGroupTree = useMemo(() => { return [...groupTree].sort((a, b) => { switch (sortMode) { case 'za': return b.name.localeCompare(a.name); case 'manual': return 0; case 'newest': case 'oldest': // For groups, fall back to name sorting since groups don't have creation dates return a.name.localeCompare(b.name); case 'az': default: return a.name.localeCompare(b.name); } }); }, [groupTree, sortMode]); return (
{/* Expand/Collapse controls */} {groupTree.length > 0 && (
)} {/* Group tree */} {sortedGroupTree.map((node) => ( ))} {/* Ungrouped hosts at root level */} {ungroupedHosts.map((host) => ( ))} {/* Empty state */} {ungroupedHosts.length === 0 && groupTree.length === 0 && (

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

)}
); };