import { Check, CheckSquare, ChevronRight, LayoutGrid, MinusSquare, Plus, Search, Square, } from 'lucide-react'; import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { cn } from '../lib/utils'; import { matchesHostSearchQuery, matchesSearchQuery } from '../lib/searchMatcher'; import { useI18n } from '../application/i18n/I18nProvider'; import { collectSelectableHostIdsInGroup, getGroupSelectionState, toggleIdsInSelection, } from '../domain/selectHostSelection'; import { Host, ProxyProfile, SSHKey } from '../types'; import { ManagedSource } from '../domain/models'; import { DistroAvatar } from './DistroAvatar'; import HostDetailsPanel from './HostDetailsPanel'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { SortDropdown, SortMode } from './ui/sort-dropdown'; import { TagFilterDropdown } from './ui/tag-filter-dropdown'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from './ui/tooltip'; import { VariableSizeVirtualList, type VariableSizeVirtualListHandle, } from './ui/VariableSizeVirtualList'; import { clampListIndex, stepListIndex } from './ui/virtualListMath'; const SELECT_HOST_SECTION_HEIGHT = 28; const SELECT_HOST_ROW_HEIGHT = 48; export interface SelectHostPanelContentProps { hosts: Host[]; customGroups?: string[]; selectedHostIds: string[]; multiSelect?: boolean; onSelect: (host: Host) => void; /** Preferred multi-select path for host/group toggles (selection resolves to host ids). */ onSelectionChange?: (selectedHostIds: string[]) => void; onConfirm: () => void; onNewHost?: () => void; availableKeys?: SSHKey[]; identities?: import('../domain/models').Identity[]; proxyProfiles?: ProxyProfile[]; managedSources?: ManagedSource[]; onSaveHost?: (host: Host) => void; onCreateGroup?: (groupPath: string) => void; onNewHostPanelOpenChange?: (open: boolean) => void; className?: string; } type SelectHostListRow = | { kind: 'section'; key: string; title: string } | { kind: 'group'; key: string; path: string; name: string; count: number } | { kind: 'host'; key: string; host: Host }; type SelectHostNavigableRow = Extract; /** Shared host-picker body used by aside panel and dialog variants. */ export const SelectHostPanelContent: React.FC = ({ hosts, customGroups = [], selectedHostIds, multiSelect = false, onSelect, onSelectionChange, onConfirm, onNewHost, availableKeys = [], identities = [], proxyProfiles = [], managedSources = [], onSaveHost, onCreateGroup, onNewHostPanelOpenChange, className, }) => { const { t } = useI18n(); const [searchQuery, setSearchQuery] = useState(''); const [currentPath, setCurrentPath] = useState(null); const [sortMode, setSortMode] = useState('az'); const [selectedTags, setSelectedTags] = useState([]); const [showNewHostPanel, setShowNewHostPanel] = useState(false); const [activeNavIndex, setActiveNavIndex] = useState(0); const listRef = useRef(null); const listboxId = useId(); // Index-based IDs stay unique even when group paths only differ by chars that // would collide under a sanitize-to-_ mapping (e.g. "Prod East" vs "Prod_East"). const optionDomId = useCallback((navIndex: number) => ( `${listboxId}-opt-${navIndex}` ), [listboxId]); useEffect(() => { onNewHostPanelOpenChange?.(showNewHostPanel); }, [onNewHostPanelOpenChange, showNewHostPanel]); const selectableHosts = useMemo( () => hosts.filter((host) => host.protocol !== 'serial'), [hosts], ); const selectedHostIdSet = useMemo( () => new Set(selectedHostIds), [selectedHostIds], ); const allTags = useMemo(() => { const tagSet = new Set(); selectableHosts.forEach((host) => { host.tags?.forEach((tag) => tagSet.add(tag)); }); return Array.from(tagSet).sort(); }, [selectableHosts]); const allGroupPaths = useMemo(() => { const pathSet = new Set(); selectableHosts.forEach((host) => { if (host.group) { const parts = host.group.split('/'); for (let i = 1; i <= parts.length; i += 1) { pathSet.add(parts.slice(0, i).join('/')); } } }); customGroups.forEach((group) => pathSet.add(group)); return Array.from(pathSet).sort(); }, [selectableHosts, customGroups]); const groupHostCounts = useMemo(() => { const counts = new Map(); selectableHosts.forEach((host) => { if (!host.group) return; const parts = host.group.split('/'); for (let i = 1; i <= parts.length; i += 1) { const path = parts.slice(0, i).join('/'); counts.set(path, (counts.get(path) ?? 0) + 1); } }); return counts; }, [selectableHosts]); const groupsWithCounts = useMemo(() => { const prefix = currentPath ? `${currentPath}/` : ''; const groups: { path: string; name: string; count: number }[] = []; const seen = new Set(); allGroupPaths.forEach((path) => { if (currentPath === null) { const topLevel = path.split('/')[0]; if (!seen.has(topLevel)) { seen.add(topLevel); groups.push({ path: topLevel, name: topLevel, count: groupHostCounts.get(topLevel) ?? 0 }); } } else if (path.startsWith(prefix) && path !== currentPath) { const rest = path.slice(prefix.length); const nextLevel = rest.split('/')[0]; const fullPath = `${prefix}${nextLevel}`; if (!seen.has(fullPath)) { seen.add(fullPath); groups.push({ path: fullPath, name: nextLevel, count: groupHostCounts.get(fullPath) ?? 0 }); } } }); return groups; }, [allGroupPaths, currentPath, groupHostCounts]); const filteredHosts = useMemo(() => { let result = selectableHosts; if (currentPath) { result = result.filter( (host) => host.group === currentPath || host.group?.startsWith(`${currentPath}/`), ); } if (searchQuery) { result = result.filter( (host) => matchesHostSearchQuery(searchQuery, host) || matchesSearchQuery(searchQuery, host.username, host.notes), ); } if (selectedTags.length > 0) { result = result.filter( (host) => host.tags && selectedTags.some((tag) => host.tags.includes(tag)), ); } result = [...result].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.id.localeCompare(a.id); case 'oldest': return a.id.localeCompare(b.id); default: return 0; } }); return result; }, [selectableHosts, currentPath, searchQuery, selectedTags, sortMode]); const breadcrumbs = useMemo(() => { if (!currentPath) return []; const parts = currentPath.split('/'); return parts.map((part, index) => ({ name: part, path: parts.slice(0, index + 1).join('/'), })); }, [currentPath]); const groupHostIdsByPath = useMemo(() => { if (!multiSelect) return new Map(); const map = new Map(); for (const group of groupsWithCounts) { map.set(group.path, collectSelectableHostIdsInGroup(selectableHosts, group.path)); } return map; }, [multiSelect, groupsWithCounts, selectableHosts]); const listRows = useMemo(() => { const rows: SelectHostListRow[] = []; if (groupsWithCounts.length > 0) { rows.push({ kind: 'section', key: 'section:groups', title: t('vault.groups.title'), }); for (const group of groupsWithCounts) { rows.push({ kind: 'group', key: `group:${group.path}`, path: group.path, name: group.name, count: group.count, }); } } if (filteredHosts.length > 0) { rows.push({ kind: 'section', key: 'section:hosts', title: t('vault.nav.hosts'), }); for (const host of filteredHosts) { rows.push({ kind: 'host', key: `host:${host.id}`, host, }); } } return rows; }, [filteredHosts, groupsWithCounts, t]); const applySelectionChange = useCallback((nextSelectedHostIds: string[]) => { if (onSelectionChange) { onSelectionChange(nextSelectedHostIds); return; } // Fallback for callers that only implement per-host toggle: sync by flipping diffs. const prev = new Set(selectedHostIds); const next = new Set(nextSelectedHostIds); for (const host of selectableHosts) { const wasSelected = prev.has(host.id); const isSelected = next.has(host.id); if (wasSelected !== isSelected) onSelect(host); } }, [onSelect, onSelectionChange, selectableHosts, selectedHostIds]); const handleHostClick = useCallback((host: Host) => { if (multiSelect && onSelectionChange) { onSelectionChange(toggleIdsInSelection(selectedHostIds, [host.id])); return; } onSelect(host); }, [multiSelect, onSelect, onSelectionChange, selectedHostIds]); const handleGroupToggle = useCallback((groupPath: string) => { const groupHostIds = groupHostIdsByPath.get(groupPath) ?? collectSelectableHostIdsInGroup(selectableHosts, groupPath); if (groupHostIds.length === 0) return; applySelectionChange(toggleIdsInSelection(selectedHostIds, groupHostIds)); }, [applySelectionChange, groupHostIdsByPath, selectableHosts, selectedHostIds]); // Navigable rows (groups + hosts) for listbox keyboard model under virtualization. const navigable = useMemo(() => { const entries: { key: string; listIndex: number; row: SelectHostNavigableRow }[] = []; listRows.forEach((row, listIndex) => { if (row.kind === 'group' || row.kind === 'host') { entries.push({ key: row.key, listIndex, row }); } }); return entries; }, [listRows]); // O(1) key → nav index for virtual row renders (avoid findIndex per visible row). const navigableIndexByKey = useMemo(() => { const map = new Map(); navigable.forEach((entry, index) => { map.set(entry.key, index); }); return map; }, [navigable]); const clampedNavIndex = clampListIndex(activeNavIndex, navigable.length); const activeNavEntry = navigable[clampedNavIndex]; const activeNavKey = activeNavEntry?.key ?? null; const activeDescendantId = activeNavEntry ? optionDomId(clampedNavIndex) : undefined; // Opening a group / changing filters must start at the first entry; otherwise a // deep prior cursor scrolls the new list partway down and hides its top rows. useEffect(() => { setActiveNavIndex(0); }, [currentPath, searchQuery, selectedTags, sortMode]); // Inventory-only shrinkage keeps the prior cursor when still in range. useEffect(() => { setActiveNavIndex((prev) => clampListIndex(prev, navigable.length)); }, [navigable.length]); useEffect(() => { const entry = navigable[clampListIndex(activeNavIndex, navigable.length)]; if (!entry) return; listRef.current?.scrollToIndex(entry.listIndex, 'auto'); }, [activeNavIndex, navigable]); const handleListKeyDown = useCallback((event: React.KeyboardEvent) => { if (navigable.length === 0) return; if (event.key === 'ArrowDown') { event.preventDefault(); setActiveNavIndex((prev) => stepListIndex(prev, navigable.length, 1)); return; } if (event.key === 'ArrowUp') { event.preventDefault(); setActiveNavIndex((prev) => stepListIndex(prev, navigable.length, -1)); return; } if (event.key === 'Home') { event.preventDefault(); setActiveNavIndex(0); return; } if (event.key === 'End') { event.preventDefault(); setActiveNavIndex(Math.max(0, navigable.length - 1)); return; } if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); const entry = navigable[clampListIndex(activeNavIndex, navigable.length)]; if (!entry) return; if (entry.row.kind === 'group') { if (multiSelect && event.key === ' ') { handleGroupToggle(entry.row.path); return; } setCurrentPath(entry.row.path); return; } if (entry.row.kind === 'host') { handleHostClick(entry.row.host); } } }, [activeNavIndex, handleGroupToggle, handleHostClick, multiSelect, navigable]); const renderSelectionIcon = (state: 'none' | 'partial' | 'all') => { if (state === 'all') return ; if (state === 'partial') return ; return ; }; const getRowHeight = useCallback((row: SelectHostListRow) => ( row.kind === 'section' ? SELECT_HOST_SECTION_HEIGHT : SELECT_HOST_ROW_HEIGHT ), []); const renderRow = useCallback((row: SelectHostListRow) => { if (row.kind === 'section') { return (

{row.title}

); } const navIndex = navigableIndexByKey.get(row.key) ?? -1; const isActive = activeNavKey === row.key; if (row.kind === 'group') { const groupHostIds = groupHostIdsByPath.get(row.path) ?? []; const groupState = multiSelect ? getGroupSelectionState(selectedHostIdSet, groupHostIds) : 'none'; const canToggleGroup = multiSelect && groupHostIds.length > 0; return (
= 0 ? optionDomId(navIndex) : undefined} role="option" aria-selected={multiSelect ? groupState === 'all' : false} data-active={isActive ? 'true' : undefined} className={cn( 'flex h-full min-h-0 items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors', isActive ? 'bg-primary/10 ring-1 ring-primary/40' : 'hover:bg-muted/70', )} onClick={() => { if (navIndex >= 0) setActiveNavIndex(navIndex); setCurrentPath(row.path); }} > {multiSelect ? ( ) : null}
{row.name}
{t('vault.groups.hostsCount', { count: row.count })}
); } const host = row.host; const isSelected = selectedHostIdSet.has(host.id); const connectionStr = `${host.username}@${host.hostname}:${host.port || 22}`; return (
= 0 ? optionDomId(navIndex) : undefined} role="option" aria-selected={isSelected} data-host-id={host.id} data-active={isActive ? 'true' : undefined} aria-label={t('selectHost.toggleHost', { name: host.label })} className={cn( 'flex h-full min-h-0 cursor-pointer items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors', isSelected ? 'bg-muted' : isActive ? 'bg-primary/10' : 'hover:bg-muted/70', // Keep keyboard cursor visible even when the host is already selected. isActive && 'ring-1 ring-primary/40', )} onClick={() => { if (navIndex >= 0) setActiveNavIndex(navIndex); handleHostClick(host); }} > {multiSelect ? ( {renderSelectionIcon(isSelected ? 'all' : 'none')} ) : null}
{host.label}

{host.label}

{connectionStr}

{connectionStr}

{!multiSelect && isSelected ? ( ) : null}
); }, [ activeNavKey, groupHostIdsByPath, handleGroupToggle, handleHostClick, multiSelect, navigableIndexByKey, optionDomId, selectedHostIdSet, t, ]); return (
{(onNewHost || onSaveHost) ? ( ) : null}
setSearchQuery(event.target.value)} />
{currentPath ? (
{breadcrumbs.map((crumb, index) => ( ))}
) : null} {listRows.length === 0 ? (

{t('selectHost.noHostsFound')}

) : (
ref={listRef} items={listRows} getItemHeight={getRowHeight} className="h-full" overscan={8} getItemKey={(row) => row.key} renderItem={renderRow} />
)}
{showNewHostPanel && onSaveHost ? ( { onSaveHost(host); setShowNewHostPanel(false); }} onCancel={() => setShowNewHostPanel(false)} onCreateGroup={onCreateGroup} /> ) : null}
); };