import { ChevronRight, Folder, FolderOpen, Plus, Server, Settings2 } from 'lucide-react'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useI18n } from '../../application/i18n/I18nProvider'; import { hostTreeInlineGroupEditStore, useHostTreeInlineGroupEdit, } from '../../application/state/hostTreeInlineGroupEditStore'; import { useHostTreeInlineHostEdit } from '../../application/state/hostTreeInlineHostEditStore'; import { useVaultHostTreeActions } from '../../application/state/vaultHostTreeActionsStore'; import { TERMINAL_HOST_TREE_DEFAULT_WIDTH, TERMINAL_HOST_TREE_MAX_WIDTH, TERMINAL_HOST_TREE_MIN_WIDTH, terminalHostTreeStore, useTerminalHostTreeOpen, } from '../../application/state/terminalHostTreeStore'; import { TERMINAL_HOST_TREE_ANIMATION_EASING, TERMINAL_HOST_TREE_ANIMATION_MS, } from '../../application/state/terminalHostTreeAnimation'; import { scheduleChromeLayoutAnimation } from '../../application/state/useActiveChromeTheme'; import { terminalLayoutSuppressStore } from '../../application/state/terminalLayoutSuppressStore'; import { useStoredNumber } from '../../application/state/useStoredNumber'; import { useTreeExpandedState } from '../../application/state/useTreeExpandedState'; import { applyHostLabelRename } from '../../domain/host'; import { ensureAncestorPathsExpanded } from '../../domain/hostGroupPathMutations'; import { buildHostGroupTree, collectGroupTreePaths } from '../../domain/hostGroupTree'; import { flattenHostGroupTree, hostTreeFlatRowContainsHost, hostTreeFlatRowKey, type HostTreeFlatRow, } from '../../domain/hostGroupTreeFlat'; import { STORAGE_KEY_TERMINAL_HOST_TREE_WIDTH, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED, } from '../../infrastructure/config/storageKeys'; import { themeFingerprint } from '../../application/state/useActiveChromeTheme'; import { buildHostTreeThemeFromTerminalTheme } from '../../infrastructure/theme/terminalAppearanceTokens'; import { cn } from '../../lib/utils'; import { matchesHostSearchQuery, matchesSearchQuery } from '../../lib/searchMatcher'; import type { GroupConfig, GroupNode, Host, TerminalTheme } from '../../types'; import { HostTreeGroupContextMenuContent, HostTreeHostContextMenuContent } from '../host/HostTreeContextMenus'; import { HostTreeGroupInlineRenameInput } from '../host/HostTreeGroupInlineRenameInput'; import { LazyMessageResponse } from '../ai-elements/LazyMessageResponse'; import { DistroAvatar } from '../DistroAvatar'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from '../ui/context-menu'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '../ui/hover-card'; import { TREE_ROW_HEIGHT } from '../sftp/SftpPaneTreeNode'; import { FixedSizeVirtualList, type FixedSizeVirtualListHandle } from '../ui/FixedSizeVirtualList'; import { TerminalHostTreeToolbar, type HostTreeToolbarPanel, } from './TerminalHostTreeToolbar'; const SIDEBAR_MIN_WIDTH = TERMINAL_HOST_TREE_MIN_WIDTH; const SIDEBAR_DEFAULT_WIDTH = TERMINAL_HOST_TREE_DEFAULT_WIDTH; const SIDEBAR_MAX_WIDTH = TERMINAL_HOST_TREE_MAX_WIDTH; const HOST_TREE_DRAG_HOST_ID = 'host-id'; const HOST_TREE_DRAG_GROUP_PATH = 'group-path'; let activeHostTreeDropIndicator: HTMLElement | null = null; const clearHostTreeDropIndicators = (_root: ParentNode | null) => { activeHostTreeDropIndicator?.removeAttribute('data-vault-drop-position'); activeHostTreeDropIndicator = null; }; const markHostTreeDropIndicator = ( target: HTMLElement, clientY: number, ) => { const rect = target.getBoundingClientRect(); const position = clientY < rect.top + rect.height / 2 ? 'before' : 'after'; if (target.dataset.vaultDropPosition === position) return; clearHostTreeDropIndicators(target.ownerDocument); target.dataset.vaultDropPosition = position; activeHostTreeDropIndicator = target; }; const getHostTreeDropZone = ( target: HTMLElement, clientY: number, ): 'before' | 'inside' | 'after' => { const rect = target.getBoundingClientRect(); const offset = clientY - rect.top; const edgeSize = Math.max(6, Math.min(12, rect.height * 0.28)); if (offset <= edgeSize) return 'before'; if (offset >= rect.height - edgeSize) return 'after'; return 'inside'; }; const hasDragType = (dataTransfer: DataTransfer, type: string) => Array.from(dataTransfer.types).includes(type); type HostTreeDropTarget = | { kind: 'root' } | { kind: 'group'; path: string }; export function resolveTerminalHostTreeDragCapabilities(input: { kind: HostTreeFlatRow['kind']; canReorder: boolean; isInlineEditing: boolean; }): { draggable: boolean; canAcceptDrop: boolean } { return { draggable: !input.isInlineEditing && (input.kind === 'host' || input.canReorder), canAcceptDrop: input.canReorder, }; } interface TerminalHostTreeSidebarProps { enabled?: boolean; surfaceVisible?: boolean; hosts: Host[]; customGroups: string[]; groupConfigs?: GroupConfig[]; resolvedPreviewTheme: TerminalTheme; activeHostId?: string | null; onConnect: (host: Host) => void; onNewHost?: (defaultGroup?: string) => void; onEditHost?: (host: Host) => void; onCreateLocalTerminal?: () => void; } type HostTreeTheme = { termBg: string; termFg: string; mutedFg: string; separator: string; rowHoverBg: string; rowActiveBg: string; rowDropBg: string; folderFg: string; }; export function isTerminalHostTreeSidebarVisible( isOpen: boolean, enabled = true, surfaceVisible = true, ): boolean { return surfaceVisible && enabled && isOpen; } export function getTerminalHostTreeSidebarShellStyle( isVisible: boolean, layoutWidth: number, shellTransition: string, ): React.CSSProperties { return { width: layoutWidth, transition: shellTransition, pointerEvents: isVisible ? 'auto' : 'none', }; } export function getTerminalHostTreeSidebarPanelStyle({ isVisible, displayWidth, panelTransition, theme, }: { isVisible: boolean; displayWidth: number; panelTransition: string; theme: HostTreeTheme; }): React.CSSProperties { return { width: displayWidth, opacity: 1, transition: panelTransition, backgroundColor: theme.termBg, color: theme.termFg, borderRight: isVisible ? `1px solid ${theme.separator}` : '1px solid transparent', }; } export function getTerminalHostTreeLayoutTargetWidth(isVisible: boolean, displayWidth: number): number { return isVisible ? displayWidth : 0; } export function getTerminalHostTreeHiddenSurfaceShellWidth( isOpen: boolean, enabled: boolean, displayWidth: number, ): number { return getTerminalHostTreeLayoutTargetWidth( isTerminalHostTreeSidebarVisible(isOpen, enabled, true), displayWidth, ); } export function getTerminalHostTreeInitialLayoutWidth(): number { return 0; } export const applyTerminalHostTreeHostRename = applyHostLabelRename; export function shouldShowTerminalHostHoverCard( hoveredHostId: string | null, editingHostId: string | null, ): boolean { return Boolean(hoveredHostId) && hoveredHostId !== editingHostId; } export function getTerminalHostTreeMeasuredLayoutWidth( element: Pick | null, fallbackWidth: number, ): number { const measuredWidth = element?.getBoundingClientRect().width; return typeof measuredWidth === 'number' && Number.isFinite(measuredWidth) ? Math.max(0, measuredWidth) : Math.max(0, fallbackWidth); } function hostMatchesSearch(host: Host, search: string): boolean { return matchesHostSearchQuery(search, host) || matchesSearchQuery(search, host.username, host.notes); } function filterGroupNode( node: GroupNode, search: string, preservePaths?: ReadonlySet, ): GroupNode | null { const matchingHosts = search ? node.hosts.filter((host) => hostMatchesSearch(host, search)) : node.hosts; const childNodes = Object.values(node.children) .map((child) => filterGroupNode(child as GroupNode, search, preservePaths)) .filter((child): child is GroupNode => child !== null); if (!search) return node; if (preservePaths?.has(node.path) || matchingHosts.length > 0 || childNodes.length > 0) { return { ...node, hosts: matchingHosts, children: Object.fromEntries(childNodes.map((child) => [child.name, child])), }; } return null; } function pruneEmptyGroupNode( node: GroupNode, preservePaths?: ReadonlySet, ): GroupNode | null { const childNodes = Object.values(node.children) .map((child) => pruneEmptyGroupNode(child as GroupNode, preservePaths)) .filter((child): child is GroupNode => child !== null); if (preservePaths?.has(node.path) || node.hosts.length > 0 || childNodes.length > 0) { return { ...node, children: Object.fromEntries(childNodes.map((child) => [child.name, child])), }; } return null; } const TerminalHostTreeHostHoverCard: React.FC<{ host: Host }> = ({ host }) => { const { t } = useI18n(); const protocol = host.protocol || 'ssh'; const port = host.port ?? 22; const username = host.username?.trim(); const notes = host.notes?.trim(); const rows = [ [t('terminal.layer.hostTree.details.host'), host.hostname], [t('terminal.layer.hostTree.details.user'), username], [t('terminal.layer.hostTree.details.port'), String(port)], [t('terminal.layer.hostTree.details.protocol'), protocol.toUpperCase()], [t('terminal.layer.hostTree.details.group'), host.group], [t('terminal.layer.hostTree.details.tags'), host.tags?.join(', ')], ].filter((entry): entry is [string, string] => Boolean(entry[1])); return (
{host.label}
{rows.map(([label, value]) => (
{label} {value}
))}
{notes && (
{t('hostDetails.notes.label')}
{notes}
)}
); }; type HostTreeFlatRowProps = { row: HostTreeFlatRow; activeHostId?: string | null; expandedPaths: Set; searchActive: boolean; canReorder: boolean; isDragOver: boolean; isInlineEditing: boolean; inlineEditInitialName?: string; onConnect: (host: Host) => void; onNewHost?: (defaultGroup?: string) => void; onEditHost?: (host: Host) => void; onTogglePath: (path: string) => void; onDragOverTarget: (target: HostTreeDropTarget) => void; onClearDragOverTarget: () => void; onDragLeaveRow: (event: React.DragEvent) => void; onDropToParent: (targetParent: string | null, dataTransfer: DataTransfer) => void; onDropToRow: (row: HostTreeFlatRow, event: React.DragEvent) => boolean; theme: HostTreeTheme; menuActions: ReturnType; }; const HostTreeFlatRowItem = memo(({ row, activeHostId, expandedPaths, searchActive, canReorder, isDragOver, isInlineEditing, inlineEditInitialName, onConnect, onNewHost, onEditHost, onTogglePath, onDragOverTarget, onClearDragOverTarget, onDragLeaveRow, onDropToParent, onDropToRow, theme, menuActions, }) => { const { t } = useI18n(); const dragCapabilities = resolveTerminalHostTreeDragCapabilities({ kind: row.kind, canReorder, isInlineEditing, }); if (row.kind === 'host') { const isActive = activeHostId === row.host.id; const hostDropParent = row.host.group || null; const canShowHoverCard = shouldShowTerminalHostHoverCard( row.host.id, isInlineEditing ? row.host.id : null, ); const rowBody = (
{ if (!dragCapabilities.draggable) return; event.dataTransfer.setData(HOST_TREE_DRAG_HOST_ID, row.host.id); // copyMove: tree reorder uses move; focus-sidebar append uses copy. // Filtered trees disable reorder, so allow copy-only for append. event.dataTransfer.effectAllowed = canReorder ? 'copyMove' : 'copy'; }} onDragOver={(event) => { if (!dragCapabilities.canAcceptDrop) return; event.preventDefault(); event.stopPropagation(); if (hasDragType(event.dataTransfer, HOST_TREE_DRAG_HOST_ID)) { markHostTreeDropIndicator(event.currentTarget, event.clientY); onClearDragOverTarget(); } else { clearHostTreeDropIndicators(event.currentTarget.ownerDocument); onDragOverTarget(hostDropParent ? { kind: 'group', path: hostDropParent } : { kind: 'root' }); } }} onDragLeave={onDragLeaveRow} onDrop={(event) => { if (!dragCapabilities.canAcceptDrop) return; event.preventDefault(); event.stopPropagation(); clearHostTreeDropIndicators(event.currentTarget.ownerDocument); if (onDropToRow(row, event)) return; onDropToParent(hostDropParent, event.dataTransfer); }} onDragEnd={(event) => clearHostTreeDropIndicators(event.currentTarget.ownerDocument)} onMouseEnter={(event) => { if (!isActive && !isDragOver) event.currentTarget.style.backgroundColor = theme.rowHoverBg; }} onMouseLeave={(event) => { if (!isActive && !isDragOver) event.currentTarget.style.backgroundColor = ''; }} onDoubleClick={() => { if (!isInlineEditing) onConnect(row.host); }} onKeyDown={(event) => { if (isInlineEditing) return; if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onConnect(row.host); } }} > {isInlineEditing && menuActions && inlineEditInitialName ? ( ) : ( {row.host.label} )} {row.host.protocol && row.host.protocol !== 'ssh' && ( {row.host.protocol} )}
); if (!menuActions && !onEditHost) return rowBody; return ( {rowBody} {menuActions ? ( ) : ( onConnect(row.host)}> {t('vault.hosts.connect')} onEditHost?.(row.host)}> {t('terminal.layer.hostTree.editHost')} )} {canShowHoverCard && } ); } const { node, depth } = row; const isExpanded = searchActive || expandedPaths.has(node.path); const hasChildren = Object.keys(node.children).length > 0; const hasHosts = node.hosts.length > 0; const isManaged = menuActions?.managedGroupPaths?.has(node.path) ?? false; const rowBody = (
{ if (!dragCapabilities.draggable) return; event.dataTransfer.setData(HOST_TREE_DRAG_GROUP_PATH, node.path); event.dataTransfer.effectAllowed = 'move'; }} onDragOver={(event) => { if (!dragCapabilities.canAcceptDrop) return; event.preventDefault(); event.stopPropagation(); const isDraggingGroup = hasDragType(event.dataTransfer, HOST_TREE_DRAG_GROUP_PATH); const zone = getHostTreeDropZone(event.currentTarget, event.clientY); if (isDraggingGroup && zone !== 'inside') { onClearDragOverTarget(); markHostTreeDropIndicator(event.currentTarget, event.clientY); return; } clearHostTreeDropIndicators(event.currentTarget.ownerDocument); onDragOverTarget({ kind: 'group', path: node.path }); }} onDragLeave={onDragLeaveRow} onDrop={(event) => { if (!dragCapabilities.canAcceptDrop) return; event.preventDefault(); event.stopPropagation(); clearHostTreeDropIndicators(event.currentTarget.ownerDocument); const isDraggingGroup = hasDragType(event.dataTransfer, HOST_TREE_DRAG_GROUP_PATH); const zone = getHostTreeDropZone(event.currentTarget, event.clientY); if (isDraggingGroup && zone !== 'inside' && onDropToRow(row, event)) return; onDropToParent(node.path, event.dataTransfer); }} onDragEnd={(event) => clearHostTreeDropIndicators(event.currentTarget.ownerDocument)} onMouseEnter={(event) => { if (!isDragOver) event.currentTarget.style.backgroundColor = theme.rowHoverBg; }} onMouseLeave={(event) => { if (!isDragOver) event.currentTarget.style.backgroundColor = ''; }} onClick={() => { if (isInlineEditing) return; onTogglePath(node.path); }} onKeyDown={(event) => { if (isInlineEditing) return; if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onTogglePath(node.path); } }} > {(hasChildren || hasHosts) && ( )} {isExpanded ? : } {isInlineEditing && menuActions && inlineEditInitialName ? ( ) : ( {node.name} )}
); if (!menuActions && !onNewHost) return rowBody; return ( {rowBody} {menuActions ? ( onNewHost?.(groupPath)} onNewGroup={menuActions.onNewGroup} onRenameGroup={menuActions.onRenameGroup} onDeleteGroup={menuActions.onDeleteGroup} onUnmanageGroup={menuActions.onUnmanageGroup} /> ) : ( onNewHost?.(node.path)}> {t('terminal.layer.hostTree.newHostInGroup')} )} ); }, (prev, next) => { if (prev.row !== next.row) return false; if (prev.expandedPaths !== next.expandedPaths) return false; if (prev.searchActive !== next.searchActive) return false; if (prev.canReorder !== next.canReorder) return false; if (prev.isDragOver !== next.isDragOver) return false; if (prev.isInlineEditing !== next.isInlineEditing) return false; if (prev.inlineEditInitialName !== next.inlineEditInitialName) return false; if (prev.theme !== next.theme) return false; if (prev.menuActions !== next.menuActions) return false; if (prev.onDragOverTarget !== next.onDragOverTarget) return false; if (prev.onClearDragOverTarget !== next.onClearDragOverTarget) return false; if (prev.onDragLeaveRow !== next.onDragLeaveRow) return false; if (prev.onDropToParent !== next.onDropToParent) return false; if (prev.onDropToRow !== next.onDropToRow) return false; if (prev.onTogglePath !== next.onTogglePath) return false; if (prev.onConnect !== next.onConnect) return false; if (prev.activeHostId === next.activeHostId) return true; if (prev.row.kind === 'host') { return prev.row.host.id !== prev.activeHostId && prev.row.host.id !== next.activeHostId; } const affectsRow = hostTreeFlatRowContainsHost(prev.row, prev.activeHostId) || hostTreeFlatRowContainsHost(prev.row, next.activeHostId); return !affectsRow; }); HostTreeFlatRowItem.displayName = 'HostTreeFlatRowItem'; const TerminalHostTreeSidebarInner: React.FC = ({ enabled = true, surfaceVisible = true, hosts, customGroups, groupConfigs = [], resolvedPreviewTheme, activeHostId, onConnect, onNewHost, onEditHost, onCreateLocalTerminal, }) => { const { t } = useI18n(); const isOpen = useTerminalHostTreeOpen(); const isVisible = isTerminalHostTreeSidebarVisible(isOpen, enabled, surfaceVisible); const [search, setSearch] = useState(''); const [selectedTags, setSelectedTags] = useState([]); const [expandedPanel, setExpandedPanel] = useState(null); const [isResizing, setIsResizing] = useState(false); const [resizePreviewWidth, setResizePreviewWidth] = useState(null); const [dragOverTarget, setDragOverTarget] = useState(null); const [rootContextMenuOpen, setRootContextMenuOpen] = useState(false); const rootContextMenuAllowedRef = useRef(false); const shellRef = useRef(null); const [sidebarWidth, setSidebarWidth, persistSidebarWidth] = useStoredNumber( STORAGE_KEY_TERMINAL_HOST_TREE_WIDTH, SIDEBAR_DEFAULT_WIDTH, { min: SIDEBAR_MIN_WIDTH, max: SIDEBAR_MAX_WIDTH }, ); const { expandedPaths, togglePath, ensurePathExpanded, expandAll, collapseAll } = useTreeExpandedState( STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED, ); const menuActions = useVaultHostTreeActions(); const inlineEdit = useHostTreeInlineGroupEdit(); const inlineHostEdit = useHostTreeInlineHostEdit(); const listRef = useRef(null); const theme = useMemo( () => buildHostTreeThemeFromTerminalTheme(resolvedPreviewTheme), [resolvedPreviewTheme], ); const allTags = useMemo(() => { const tags = new Set(); for (const host of hosts) { for (const tag of host.tags ?? []) { tags.add(tag); } } return Array.from(tags).sort((a, b) => a.localeCompare(b)); }, [hosts]); const tagFilteredHosts = useMemo(() => { if (selectedTags.length === 0) return hosts; return hosts.filter((host) => selectedTags.some((tag) => host.tags?.includes(tag))); }, [hosts, selectedTags]); const { groupTree, ungroupedHosts } = useMemo( () => buildHostGroupTree(tagFilteredHosts, customGroups, groupConfigs), [tagFilteredHosts, customGroups, groupConfigs], ); const searchTerm = search.trim(); const searchActive = searchTerm.length > 0; const tagsActive = selectedTags.length > 0; const treeExpandAll = searchActive || tagsActive; const preservePaths = useMemo(() => { if (!inlineEdit?.groupPath) return undefined; return new Set([inlineEdit.groupPath]); }, [inlineEdit?.groupPath]); const filteredTree = useMemo(() => { let tree = groupTree; if (tagsActive) { tree = tree .map((node) => pruneEmptyGroupNode(node, preservePaths)) .filter((node): node is GroupNode => node !== null); } if (!searchActive) return tree; return tree .map((node) => filterGroupNode(node, searchTerm, preservePaths)) .filter((node): node is GroupNode => node !== null); }, [groupTree, preservePaths, searchActive, searchTerm, tagsActive]); const filteredUngrouped = useMemo(() => { if (!searchActive) return ungroupedHosts; return ungroupedHosts.filter((host) => hostMatchesSearch(host, searchTerm)); }, [searchActive, searchTerm, ungroupedHosts]); const flatRows = useMemo(() => { return flattenHostGroupTree({ groupNodes: filteredTree, ungroupedHosts: filteredUngrouped, expandedPaths, searchActive: treeExpandAll, }); }, [expandedPaths, filteredTree, filteredUngrouped, treeExpandAll]); const canReorder = Boolean(menuActions) && !searchActive && !tagsActive; const handleNewRootGroup = useCallback(() => { if (!menuActions) return; setSearch(''); setSelectedTags([]); setExpandedPanel(null); menuActions.onNewGroup(); }, [menuActions]); useEffect(() => { if (!inlineEdit?.isNew || !inlineEdit.groupPath) return; const parentPath = inlineEdit.groupPath.split('/').filter(Boolean).slice(0, -1).join('/'); if (!parentPath) return; ensureAncestorPathsExpanded(parentPath, ensurePathExpanded); }, [ensurePathExpanded, inlineEdit?.groupPath, inlineEdit?.isNew]); const handleCreateLocalTerminal = useCallback(() => { onCreateLocalTerminal?.(); }, [onCreateLocalTerminal]); const allGroupPaths = useMemo(() => collectGroupTreePaths(groupTree), [groupTree]); const handleExpandAll = useCallback(() => { expandAll(allGroupPaths); }, [allGroupPaths, expandAll]); const handleCollapseAll = useCallback(() => { collapseAll(); }, [collapseAll]); const handleCollapse = useCallback(() => { terminalHostTreeStore.setIsOpen(false); }, []); const clearDragOver = useCallback(() => { setDragOverTarget(null); }, []); const handleDropToParent = useCallback((targetParent: string | null, dataTransfer: DataTransfer) => { if (!menuActions) return; const hostId = dataTransfer.getData(HOST_TREE_DRAG_HOST_ID); const groupPath = dataTransfer.getData(HOST_TREE_DRAG_GROUP_PATH); if (hostId) menuActions.moveHostToGroup(hostId, targetParent); if (groupPath) menuActions.moveGroup(groupPath, targetParent); clearDragOver(); }, [clearDragOver, menuActions]); const handleDropToRow = useCallback((row: HostTreeFlatRow, event: React.DragEvent) => { if (!menuActions) return false; const rect = event.currentTarget.getBoundingClientRect(); const position = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'; if (row.kind === 'host') { const hostId = event.dataTransfer.getData(HOST_TREE_DRAG_HOST_ID); if (hostId && hostId !== row.host.id) { menuActions.reorderHost(hostId, row.host.id, position); clearDragOver(); return true; } return false; } const groupPath = event.dataTransfer.getData(HOST_TREE_DRAG_GROUP_PATH); if (groupPath && groupPath !== row.node.path) { const handled = menuActions.reorderGroup(groupPath, row.node.path, position); if (handled) { clearDragOver(); return true; } } return false; }, [clearDragOver, menuActions]); const handleDragOverTarget = useCallback((target: HostTreeDropTarget) => { setDragOverTarget(target); }, []); const handleDragLeaveRow = useCallback((event: React.DragEvent) => { const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { return; } clearHostTreeDropIndicators(event.currentTarget.ownerDocument); clearDragOver(); }, [clearDragOver]); const handleRootDragOver = useCallback((event: React.DragEvent) => { if (!canReorder) return; event.preventDefault(); setDragOverTarget({ kind: 'root' }); }, [canReorder]); const handleRootDragLeave = useCallback((event: React.DragEvent) => { const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { return; } clearHostTreeDropIndicators(event.currentTarget.ownerDocument); clearDragOver(); }, [clearDragOver]); const handleRootDrop = useCallback((event: React.DragEvent) => { if (!canReorder) return; event.preventDefault(); clearHostTreeDropIndicators(event.currentTarget.ownerDocument); handleDropToParent(null, event.dataTransfer); }, [canReorder, handleDropToParent]); const handleListPointerDownCapture = useCallback((event: React.PointerEvent) => { if (!menuActions) return; const editingGroupPath = inlineEdit?.groupPath; const editingHostId = inlineHostEdit?.hostId; if (!editingGroupPath && !editingHostId) 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="terminal-host-tree-sidebar-row"]'); if (!row) return; if (editingGroupPath && row.getAttribute('data-group-path') === editingGroupPath) return; if (editingHostId && row.getAttribute('data-host-id') === editingHostId) return; if (editingGroupPath) menuActions.cancelInlineGroupEdit(); if (editingHostId) menuActions.cancelInlineHostEdit(); }, [inlineEdit?.groupPath, inlineHostEdit?.hostId, menuActions]); const handleRootContextMenuCapture = useCallback((event: React.MouseEvent) => { const target = event.target; rootContextMenuAllowedRef.current = target instanceof Element && !target.closest('[data-row-type]'); }, []); const handleRootContextMenuOpenChange = useCallback((open: boolean) => { setRootContextMenuOpen(open && rootContextMenuAllowedRef.current); if (!open) rootContextMenuAllowedRef.current = false; }, []); useEffect(() => { if (!inlineEdit?.shouldScrollIntoView || !inlineEdit.isNew) return; const index = flatRows.findIndex( (row) => row.kind === 'group' && row.node.path === inlineEdit.groupPath, ); if (index < 0) return; const frame = requestAnimationFrame(() => { listRef.current?.scrollToIndex(index, 'center'); hostTreeInlineGroupEditStore.markScrollHandled(); }); return () => cancelAnimationFrame(frame); }, [expandedPaths, flatRows, inlineEdit]); const isRowDragOver = useCallback((row: HostTreeFlatRow) => { if (!dragOverTarget) return false; if (dragOverTarget.kind === 'root') { return row.kind === 'host' && !row.host.group; } if (row.kind === 'group') { return dragOverTarget.path === row.node.path; } return Boolean(row.host.group && row.host.group === dragOverTarget.path); }, [dragOverTarget]); const renderFlatRow = useCallback((row: HostTreeFlatRow) => ( ), [ activeHostId, canReorder, clearDragOver, expandedPaths, inlineEdit, inlineHostEdit, handleDragLeaveRow, handleDragOverTarget, handleDropToParent, handleDropToRow, isRowDragOver, menuActions, onConnect, onNewHost, onEditHost, treeExpandAll, theme, togglePath, ]); const shellTransition = isResizing || !surfaceVisible ? 'none' : `width ${TERMINAL_HOST_TREE_ANIMATION_MS}ms ${TERMINAL_HOST_TREE_ANIMATION_EASING}`; const panelTransition = isResizing ? 'none' : `border-color ${TERMINAL_HOST_TREE_ANIMATION_MS}ms ease-out`; const handleResizeStart = useCallback((event: React.MouseEvent) => { if (!isVisible) return; event.preventDefault(); setIsResizing(true); terminalLayoutSuppressStore.begin(); const startX = event.clientX; const startWidth = sidebarWidth; let rafId: number | null = null; const onMouseMove = (moveEvent: MouseEvent) => { const next = Math.max( SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, startWidth + moveEvent.clientX - startX), ); if (rafId !== null) return; rafId = requestAnimationFrame(() => { rafId = null; setResizePreviewWidth(next); }); }; const onMouseUp = (upEvent: MouseEvent) => { if (rafId !== null) cancelAnimationFrame(rafId); const next = Math.max( SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, startWidth + upEvent.clientX - startX), ); setSidebarWidth(next); persistSidebarWidth(next); setResizePreviewWidth(null); setIsResizing(false); terminalLayoutSuppressStore.end(); window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); }; window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); }, [isVisible, persistSidebarWidth, setSidebarWidth, sidebarWidth]); const displayWidth = resizePreviewWidth ?? sidebarWidth; const canExpandCollapse = allGroupPaths.length > 0 && !searchActive && !tagsActive; const targetLayoutWidth = getTerminalHostTreeLayoutTargetWidth(isVisible, displayWidth); const hiddenSurfaceShellWidth = getTerminalHostTreeHiddenSurfaceShellWidth(isOpen, enabled, displayWidth); const [shellWidth, setShellWidth] = useState(getTerminalHostTreeInitialLayoutWidth); const cancelSyncLayoutWidthRef = useRef<(() => void) | null>(null); const prevIsVisibleRef = useRef(isVisible); const syncLayoutWidthFromShell = useCallback((fallbackWidth = targetLayoutWidth) => { terminalHostTreeStore.setLayoutWidth( getTerminalHostTreeMeasuredLayoutWidth(shellRef.current, fallbackWidth), ); }, [targetLayoutWidth]); useEffect(() => { const el = shellRef.current; if (!el) return; const ro = new ResizeObserver(() => { if (isResizing) return; syncLayoutWidthFromShell(); }); ro.observe(el); syncLayoutWidthFromShell(); return () => ro.disconnect(); }, [isResizing, syncLayoutWidthFromShell]); useEffect(() => { if (prevIsVisibleRef.current === isVisible) return; prevIsVisibleRef.current = isVisible; const el = shellRef.current; if (!el) return; terminalLayoutSuppressStore.begin(); let ended = false; const finish = () => { if (ended) return; ended = true; terminalLayoutSuppressStore.end(); }; const onTransitionEnd = (event: TransitionEvent) => { if (event.target !== el || event.propertyName !== 'width') return; syncLayoutWidthFromShell(); finish(); }; el.addEventListener('transitionend', onTransitionEnd); const timer = window.setTimeout(() => { syncLayoutWidthFromShell(); finish(); }, TERMINAL_HOST_TREE_ANIMATION_MS + 80); return () => { el.removeEventListener('transitionend', onTransitionEnd); window.clearTimeout(timer); finish(); }; }, [isVisible, syncLayoutWidthFromShell]); useEffect(() => { const el = shellRef.current; if (!el) return; const onTransitionEnd = (event: TransitionEvent) => { if (event.target !== el || event.propertyName !== 'width' || isResizing) return; syncLayoutWidthFromShell(); }; el.addEventListener('transitionend', onTransitionEnd); return () => el.removeEventListener('transitionend', onTransitionEnd); }, [isResizing, syncLayoutWidthFromShell]); useEffect(() => { cancelSyncLayoutWidthRef.current?.(); cancelSyncLayoutWidthRef.current = null; if (isResizing) { setShellWidth(targetLayoutWidth); terminalHostTreeStore.setLayoutWidth(targetLayoutWidth); return; } if (!surfaceVisible) { setShellWidth(hiddenSurfaceShellWidth); terminalHostTreeStore.setLayoutWidth(0); return; } syncLayoutWidthFromShell(); cancelSyncLayoutWidthRef.current = scheduleChromeLayoutAnimation(() => { cancelSyncLayoutWidthRef.current = null; setShellWidth(targetLayoutWidth); }); return () => { cancelSyncLayoutWidthRef.current?.(); cancelSyncLayoutWidthRef.current = null; }; }, [hiddenSurfaceShellWidth, isResizing, surfaceVisible, syncLayoutWidthFromShell, targetLayoutWidth]); return (
{isVisible && (
)} onNewHost?.()} canNewHost={Boolean(onNewHost)} onNewRootGroup={handleNewRootGroup} canNewGroup={Boolean(menuActions)} onCreateLocalTerminal={handleCreateLocalTerminal} canCreateLocalTerminal={Boolean(onCreateLocalTerminal)} onExpandAll={handleExpandAll} onCollapseAll={handleCollapseAll} canExpandCollapse={canExpandCollapse} onCollapse={handleCollapse} />
{flatRows.length === 0 ? (
{t('terminal.layer.hostTree.empty')}
) : ( ref={listRef} items={flatRows} itemHeight={TREE_ROW_HEIGHT} className="[scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden" contentClassName="py-0" getItemKey={hostTreeFlatRowKey} renderItem={renderFlatRow} /> )}
onNewHost?.()}> {t('terminal.layer.hostTree.newHost')}
); }; export const TerminalHostTreeSidebar = memo( TerminalHostTreeSidebarInner, (prev, next) => ( prev.hosts === next.hosts && prev.enabled === next.enabled && prev.surfaceVisible === next.surfaceVisible && prev.customGroups === next.customGroups && prev.activeHostId === next.activeHostId && themeFingerprint(prev.resolvedPreviewTheme) === themeFingerprint(next.resolvedPreviewTheme) && prev.onConnect === next.onConnect && prev.onNewHost === next.onNewHost && prev.onEditHost === next.onEditHost && prev.onCreateLocalTerminal === next.onCreateLocalTerminal ), ); TerminalHostTreeSidebar.displayName = 'TerminalHostTreeSidebar';