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 { 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); 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; 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[]; } 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; 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>; } 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, hostClickBehavior = 'connect', focusedHostId, onFocusHost, focusedGroupPath, onFocusGroup, 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); const isGroupFocused = hostClickBehavior === 'select' && focusedGroupPath === 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; 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} 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 )} labelActions={( )} /> {/* Child Groups */} {childNodes.map((child) => ( ))} {/* Hosts in this group */} {sortedHosts.map((host) => ( { onFocusHost?.(hostId); if (hostId) onFocusGroup?.(null); }} groupConfigs={groupConfigs} groupDefaultsByPath={groupDefaultsByPath} /> ))}
); }; 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; hostClickBehavior?: HostClickBehavior; focusedHostId?: string | null; onFocusHost?: (hostId: string | null) => 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, hostClickBehavior = 'connect', focusedHostId, onFocusHost, 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 isMultiSelected = Boolean(isMultiSelectMode && selectedHostIds?.has(host.id)); const isFocusSelected = !isMultiSelectMode && hostClickBehavior === 'select' && focusedHostId === host.id; const isSelected = isMultiSelected || isFocusSelected; return ( e.dataTransfer.setData("host-id", host.id)} onClick={() => { const action = resolveHostActivateAction({ behavior: hostClickBehavior, 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 ? (
{ e.stopPropagation(); toggleHostSelection?.(host.id); }}> {isMultiSelected ? ( ) : ( )}
) : (
)} 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} /> ); }; 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, hostClickBehavior, focusedHostId, onFocusHost, focusedGroupPath, onFocusGroup, 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) => ( { onFocusHost?.(hostId); if (hostId) onFocusGroup?.(null); }} groupConfigs={groupConfigs} groupDefaultsByPath={groupDefaultsByPath} /> ))} {/* Empty state */} {ungroupedHosts.length === 0 && groupTree.length === 0 && (

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

)}
); };