Files
NetMesh/components/terminalLayer/TerminalHostTreeSidebar.tsx
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

1224 lines
44 KiB
TypeScript

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<HTMLElement, 'getBoundingClientRect'> | 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<string>,
): 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<string>,
): 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 (
<HoverCardContent
side="right"
align="start"
sideOffset={10}
className="w-72 p-3 text-xs"
>
<div className="flex min-w-0 items-center gap-2">
<DistroAvatar
host={host}
size="sm"
fallback={host.label.slice(0, 1).toUpperCase()}
/>
<div className="flex min-h-6 min-w-0 items-center">
<div className="truncate text-[15px] font-semibold leading-5">{host.label}</div>
</div>
</div>
<div className="mt-3 space-y-1.5">
{rows.map(([label, value]) => (
<div key={label} className="grid grid-cols-[82px_minmax(0,1fr)] gap-2">
<span className="text-muted-foreground">{label}</span>
<span className="min-w-0 truncate">{value}</span>
</div>
))}
</div>
{notes && (
<div className="mt-3 border-t border-border/60 pt-3">
<div className="mb-1 text-muted-foreground">{t('hostDetails.notes.label')}</div>
<LazyMessageResponse className="host-tree-notes-scroll max-h-[min(44vh,420px)] overflow-y-auto pr-2 text-xs leading-relaxed text-popover-foreground/90 [&_h1]:text-sm [&_h1]:mt-2 [&_h1]:mb-1 [&_h2]:text-sm [&_h2]:mt-2 [&_h2]:mb-1 [&_h3]:text-xs [&_h3]:mt-1.5 [&_h3]:mb-1 [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1">
{notes}
</LazyMessageResponse>
</div>
)}
</HoverCardContent>
);
};
type HostTreeFlatRowProps = {
row: HostTreeFlatRow;
activeHostId?: string | null;
expandedPaths: Set<string>;
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<HTMLDivElement>) => void;
onDropToParent: (targetParent: string | null, dataTransfer: DataTransfer) => void;
onDropToRow: (row: HostTreeFlatRow, event: React.DragEvent<HTMLDivElement>) => boolean;
theme: HostTreeTheme;
menuActions: ReturnType<typeof useVaultHostTreeActions>;
};
const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
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 = (
<div
role="button"
tabIndex={0}
data-section="terminal-host-tree-sidebar-row"
data-row-type="host"
data-host-id={row.host.id}
data-active={isActive ? 'true' : 'false'}
data-drag-over={isDragOver ? 'true' : 'false'}
className={cn(
'vault-drop-indicator-row flex min-w-0 items-center gap-1 px-2 cursor-pointer select-none text-sm',
)}
style={{
height: TREE_ROW_HEIGHT,
paddingLeft: row.depth * 16 + 8,
backgroundColor: isActive ? theme.rowActiveBg : (isDragOver ? theme.rowDropBg : undefined),
}}
// Host rows stay draggable under search/tag filters so focus-sidebar
// append can still receive host-id; in-tree reorder/drop stays disabled.
draggable={dragCapabilities.draggable}
onDragStart={(event) => {
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);
}
}}
>
<span className="flex h-5 w-4 shrink-0 items-center" />
<span className="flex h-5 shrink-0 items-center justify-center">
<DistroAvatar host={row.host} size="xs" fallback={row.host.label.slice(0, 1).toUpperCase()} />
</span>
{isInlineEditing && menuActions && inlineEditInitialName ? (
<HostTreeGroupInlineRenameInput
initialName={inlineEditInitialName}
onCommit={menuActions.commitInlineHostRename}
onCancel={menuActions.cancelInlineHostEdit}
className="flex-1 font-medium"
style={{ color: theme.termFg }}
/>
) : (
<span
className="flex min-w-0 flex-1 items-center truncate leading-5"
style={{ color: theme.termFg }}
>
{row.host.label}
</span>
)}
{row.host.protocol && row.host.protocol !== 'ssh' && (
<span className="flex shrink-0 items-center text-[10px] leading-4 uppercase opacity-70" style={{ color: theme.mutedFg }}>
{row.host.protocol}
</span>
)}
</div>
);
if (!menuActions && !onEditHost) return rowBody;
return (
<HoverCard openDelay={650} closeDelay={80}>
<ContextMenu>
<ContextMenuTrigger asChild>
<HoverCardTrigger asChild>
{rowBody}
</HoverCardTrigger>
</ContextMenuTrigger>
{menuActions ? (
<HostTreeHostContextMenuContent
host={row.host}
onConnect={onConnect}
onEditHost={onEditHost}
onRenameHost={menuActions.onRenameHost}
onDuplicateHost={menuActions.onDuplicateHost}
onCopyCredentials={menuActions.onCopyCredentials}
onCopyHostname={menuActions.onCopyHostname}
onDeleteHost={menuActions.onDeleteHost}
/>
) : (
<ContextMenuContent>
<ContextMenuItem onClick={() => onConnect(row.host)}>
<Server className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
<ContextMenuItem onClick={() => onEditHost?.(row.host)}>
<Settings2 className="mr-2 h-4 w-4" /> {t('terminal.layer.hostTree.editHost')}
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
{canShowHoverCard && <TerminalHostTreeHostHoverCard host={row.host} />}
</HoverCard>
);
}
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 = (
<div
role="button"
tabIndex={0}
data-section="terminal-host-tree-sidebar-row"
data-row-type="group"
data-group-path={node.path}
data-expanded={isExpanded ? 'true' : 'false'}
data-drag-over={isDragOver ? 'true' : 'false'}
className={cn(
'vault-drop-indicator-row flex min-w-0 items-center gap-1 px-2 cursor-pointer select-none text-sm font-medium',
)}
style={{
height: TREE_ROW_HEIGHT,
paddingLeft: depth * 16 + 8,
color: theme.termFg,
backgroundColor: isDragOver ? theme.rowDropBg : undefined,
}}
draggable={dragCapabilities.draggable}
onDragStart={(event) => {
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);
}
}}
>
<span className="flex h-5 w-4 shrink-0 items-center justify-center">
{(hasChildren || hasHosts) && (
<ChevronRight
size={14}
className={cn('transition-transform', isExpanded && 'rotate-90')}
style={{ color: theme.mutedFg }}
/>
)}
</span>
<span className="flex h-5 shrink-0 items-center">
{isExpanded
? <FolderOpen size={14} className="shrink-0" style={{ color: theme.folderFg }} />
: <Folder size={14} className="shrink-0" style={{ color: theme.folderFg }} />}
</span>
{isInlineEditing && menuActions && inlineEditInitialName ? (
<HostTreeGroupInlineRenameInput
initialName={inlineEditInitialName}
onCommit={menuActions.commitInlineGroupRename}
onCancel={menuActions.cancelInlineGroupEdit}
className="flex-1 font-medium"
style={{ color: theme.termFg }}
/>
) : (
<span className="flex min-w-0 flex-1 items-center truncate leading-5">{node.name}</span>
)}
</div>
);
if (!menuActions && !onNewHost) return rowBody;
return (
<ContextMenu>
<ContextMenuTrigger asChild>
{rowBody}
</ContextMenuTrigger>
{menuActions ? (
<HostTreeGroupContextMenuContent
groupPath={node.path}
isManaged={isManaged}
onNewHost={(groupPath) => onNewHost?.(groupPath)}
onNewGroup={menuActions.onNewGroup}
onRenameGroup={menuActions.onRenameGroup}
onDeleteGroup={menuActions.onDeleteGroup}
onUnmanageGroup={menuActions.onUnmanageGroup}
/>
) : (
<ContextMenuContent>
<ContextMenuItem onClick={() => onNewHost?.(node.path)}>
<Plus className="mr-2 h-4 w-4" /> {t('terminal.layer.hostTree.newHostInGroup')}
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
);
}, (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<TerminalHostTreeSidebarProps> = ({
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<string[]>([]);
const [expandedPanel, setExpandedPanel] = useState<HostTreeToolbarPanel>(null);
const [isResizing, setIsResizing] = useState(false);
const [resizePreviewWidth, setResizePreviewWidth] = useState<number | null>(null);
const [dragOverTarget, setDragOverTarget] = useState<HostTreeDropTarget | null>(null);
const [rootContextMenuOpen, setRootContextMenuOpen] = useState(false);
const rootContextMenuAllowedRef = useRef(false);
const shellRef = useRef<HTMLDivElement>(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<FixedSizeVirtualListHandle>(null);
const theme = useMemo(
() => buildHostTreeThemeFromTerminalTheme(resolvedPreviewTheme),
[resolvedPreviewTheme],
);
const allTags = useMemo(() => {
const tags = new Set<string>();
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (!canReorder) return;
event.preventDefault();
setDragOverTarget({ kind: 'root' });
}, [canReorder]);
const handleRootDragLeave = useCallback((event: React.DragEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (!canReorder) return;
event.preventDefault();
clearHostTreeDropIndicators(event.currentTarget.ownerDocument);
handleDropToParent(null, event.dataTransfer);
}, [canReorder, handleDropToParent]);
const handleListPointerDownCapture = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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) => (
<HostTreeFlatRowItem
row={row}
activeHostId={activeHostId}
expandedPaths={expandedPaths}
searchActive={treeExpandAll}
canReorder={canReorder}
isDragOver={isRowDragOver(row)}
isInlineEditing={
(row.kind === 'group' && inlineEdit?.groupPath === row.node.path)
|| (row.kind === 'host' && inlineHostEdit?.hostId === row.host.id)
}
inlineEditInitialName={
row.kind === 'group' && inlineEdit?.groupPath === row.node.path
? inlineEdit.initialName
: row.kind === 'host' && inlineHostEdit?.hostId === row.host.id
? inlineHostEdit.initialName
: undefined
}
onConnect={onConnect}
onNewHost={onNewHost}
onEditHost={onEditHost}
onTogglePath={togglePath}
onDragOverTarget={handleDragOverTarget}
onClearDragOverTarget={clearDragOver}
onDragLeaveRow={handleDragLeaveRow}
onDropToParent={handleDropToParent}
onDropToRow={handleDropToRow}
theme={theme}
menuActions={menuActions}
/>
), [
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 (
<div
ref={shellRef}
className="relative flex-shrink-0 h-full overflow-hidden"
style={getTerminalHostTreeSidebarShellStyle(isVisible, shellWidth, shellTransition)}
data-section="terminal-host-tree-sidebar-shell"
data-open={isVisible ? 'true' : 'false'}
data-enabled={enabled ? 'true' : 'false'}
>
<div
className="relative flex flex-col h-full"
style={getTerminalHostTreeSidebarPanelStyle({
isVisible,
displayWidth,
panelTransition,
theme,
})}
data-section="terminal-host-tree-sidebar"
>
{isVisible && (
<div
className="absolute top-0 right-[-3px] h-full w-2 cursor-ew-resize z-30"
onMouseDown={handleResizeStart}
/>
)}
<TerminalHostTreeToolbar
theme={theme}
expandedPanel={expandedPanel}
onExpandedPanelChange={setExpandedPanel}
search={search}
onSearchChange={setSearch}
allTags={allTags}
selectedTags={selectedTags}
onSelectedTagsChange={setSelectedTags}
onNewHost={() => onNewHost?.()}
canNewHost={Boolean(onNewHost)}
onNewRootGroup={handleNewRootGroup}
canNewGroup={Boolean(menuActions)}
onCreateLocalTerminal={handleCreateLocalTerminal}
canCreateLocalTerminal={Boolean(onCreateLocalTerminal)}
onExpandAll={handleExpandAll}
onCollapseAll={handleCollapseAll}
canExpandCollapse={canExpandCollapse}
onCollapse={handleCollapse}
/>
<ContextMenu open={rootContextMenuOpen} onOpenChange={handleRootContextMenuOpenChange}>
<ContextMenuTrigger asChild>
<div
className="flex-1 min-h-0 py-1"
data-section="terminal-host-tree-sidebar-content"
style={dragOverTarget?.kind === 'root' ? { backgroundColor: theme.rowDropBg } : undefined}
onContextMenuCapture={handleRootContextMenuCapture}
onPointerDownCapture={handleListPointerDownCapture}
onDragOver={handleRootDragOver}
onDragLeave={handleRootDragLeave}
onDrop={handleRootDrop}
>
{flatRows.length === 0 ? (
<div className="px-3 py-8 text-center text-xs" style={{ color: theme.mutedFg }}>
<Server size={24} className="mx-auto mb-2 opacity-50" />
{t('terminal.layer.hostTree.empty')}
</div>
) : (
<FixedSizeVirtualList<HostTreeFlatRow>
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}
/>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent data-section="terminal-host-tree-root-context">
<ContextMenuItem onClick={() => onNewHost?.()}>
<Plus className="mr-2 h-4 w-4" /> {t('terminal.layer.hostTree.newHost')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div>
</div>
);
};
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';