import { ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Expand, Minimize2 } from 'lucide-react'; import React, { useMemo } from 'react'; import { useI18n } from '../application/i18n/I18nProvider'; import { useTreeExpandedState } from '../application/state/useTreeExpandedState'; import { sanitizeHost } from '../domain/host'; import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys'; import { cn } from '../lib/utils'; import { GroupNode, Host } from '../types'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu'; import { DistroAvatar } from './DistroAvatar'; import { Button } from './ui/button'; interface HostTreeViewProps { groupTree: GroupNode[]; hosts: Host[]; sortMode?: '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; onNewHost: (groupPath?: string) => void; onNewGroup: (parentPath?: string) => void; onEditGroup: (groupPath: string) => void; onDeleteGroup: (groupPath: string) => void; moveHostToGroup: (hostId: string, groupPath: string | null) => void; moveGroup: (sourcePath: string, targetPath: string) => void; managedGroupPaths?: Set; onUnmanageGroup?: (groupPath: string) => void; } interface TreeNodeProps { node: GroupNode; depth: number; sortMode: '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; onNewHost: (groupPath?: string) => void; onNewGroup: (parentPath?: string) => void; onEditGroup: (groupPath: string) => void; onDeleteGroup: (groupPath: string) => void; moveHostToGroup: (hostId: string, groupPath: string | null) => void; moveGroup: (sourcePath: string, targetPath: string) => void; managedGroupPaths?: Set; onUnmanageGroup?: (groupPath: string) => void; } const TreeNode: React.FC = ({ node, depth, sortMode, expandedPaths, onToggle, onConnect, onEditHost, onDuplicateHost, onDeleteHost, onCopyCredentials, onNewHost, onNewGroup, onEditGroup, onDeleteGroup, moveHostToGroup, moveGroup, managedGroupPaths, onUnmanageGroup, }) => { const { t } = useI18n(); const isExpanded = expandedPaths.has(node.path); const hasChildren = node.children && Object.keys(node.children).length > 0; const paddingLeft = `${depth * 20 + 12}px`; const isManaged = managedGroupPaths?.has(node.path) ?? false; const childNodes = useMemo(() => { if (!node.children) return []; const nodes = Object.values(node.children) as unknown as GroupNode[]; return nodes.sort((a, b) => { switch (sortMode) { case 'za': return b.name.localeCompare(a.name); 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); } }); }, [node.children, sortMode]); const sortedHosts = useMemo(() => { return [...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); default: return a.label.localeCompare(b.label); } }); }, [node.hosts, sortMode]); return (
{/* Group Node */} onToggle(node.path)}>
e.dataTransfer.setData("group-path", node.path)} onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }} onDrop={(e) => { e.preventDefault(); e.stopPropagation(); const hostId = e.dataTransfer.getData("host-id"); const groupPath = e.dataTransfer.getData("group-path"); if (hostId) moveHostToGroup(hostId, node.path); if (groupPath) moveGroup(groupPath, node.path); }} >
{(hasChildren || node.hosts.length > 0) && (
)}
{isExpanded ? : }
{node.name} {isManaged && ( Managed )} {(node.hosts.length > 0 || hasChildren) && ( {node.hosts.length} )}
onNewHost(node.path)}> {t("vault.hosts.newHost")} onNewGroup(node.path)}> {t("vault.hosts.newGroup")} onEditGroup(node.path)}> {t("vault.groups.rename")} onDeleteGroup(node.path)} className="text-destructive focus:text-destructive" > {t("vault.groups.delete")} {isManaged && onUnmanageGroup && ( onUnmanageGroup(node.path)}> {t("vault.managedSource.unmanage")} )}
{/* 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; } const HostTreeItem: React.FC = ({ host, depth, onConnect, onEditHost, onDuplicateHost, onDeleteHost, onCopyCredentials, moveHostToGroup: _moveHostToGroup, }) => { const { t } = useI18n(); const paddingLeft = `${depth * 20 + 12}px`; const safeHost = sanitizeHost(host); const tags = host.tags || []; const isTelnet = host.protocol === 'telnet'; const displayUsername = isTelnet ? (host.telnetUsername?.trim() || host.username?.trim() || '') : (host.username?.trim() || ''); const displayPort = isTelnet ? (host.telnetPort ?? host.port ?? 23) : (host.port ?? 22); return (
e.dataTransfer.setData("host-id", host.id)} onClick={() => onConnect(safeHost)} >
{host.label}
{displayUsername}@{host.hostname}:{displayPort}
{host.protocol && host.protocol !== 'ssh' && ( {host.protocol.toUpperCase()} )} {tags.length > 0 && ( {tags.slice(0, 2).join(', ')} {tags.length > 2 && '...'} )}
onConnect(safeHost)}> {t("vault.hosts.connect")} onEditHost(host)}> {t("action.edit")} onDuplicateHost(host)}> {t("action.duplicate")} onCopyCredentials(host)}> {t("vault.hosts.copyCredentials")} onDeleteHost(host)} className="text-destructive focus:text-destructive" > {t("action.delete")} ); }; export const HostTreeView: React.FC = ({ groupTree, hosts, sortMode = 'az', expandedPaths: externalExpandedPaths, onTogglePath: externalOnTogglePath, onExpandAll: externalOnExpandAll, onCollapseAll: externalOnCollapseAll, onConnect, onEditHost, onDuplicateHost, onDeleteHost, onCopyCredentials, onNewHost, onNewGroup, onEditGroup, onDeleteGroup, moveHostToGroup, moveGroup, managedGroupPaths, onUnmanageGroup, }) => { const { t } = useI18n(); // 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 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 === ''); return 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); default: return a.label.localeCompare(b.label); } }); }, [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 '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")}

)}
); };