import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Search, Loader2, Network, HardDrive, Box, Database, ArrowLeftRight, ArrowUpDown, Router, Wifi, Monitor, Container, FolderTree, Folder, X } from 'lucide-react'; import { useTopologyStore } from '../store/topologyStore'; import { NodeType } from '../types'; interface Command { id: string; label: string; description?: string; icon: React.ReactNode; category: 'action' | 'filter' | 'view' | 'orientation'; action: () => void; } const nodeTypeLabels: Record = { gateway: 'Gateway', vlan: 'VLAN', wifi: 'WiFi', host_physical: 'Physical Host', host_vm: 'VM Host', host_container: 'Container Host', vm_lxc: 'LXC Container', vm_qemu: 'QEMU VM', systemd_service: 'Systemd Service', service: 'Service', volume: 'Volume', mount: 'Mount', path: 'Path', }; const nodeTypeIcons: Record = { gateway: , vlan: , wifi: , host_physical: , host_vm: , host_container: , vm_lxc: , vm_qemu: , systemd_service: , service: , volume: , mount: , path: }; function fuzzyMatch(pattern: string, text: string): boolean { const patternLower = pattern.toLowerCase(); const textLower = text.toLowerCase(); let patternIdx = 0; for (let i = 0; i < textLower.length && patternIdx < patternLower.length; i++) { if (textLower[i] === patternLower[patternIdx]) { patternIdx++; } } return patternIdx === patternLower.length; } interface CommandPaletteProps { onRefresh?: () => Promise; } export default function CommandPalette({ onRefresh }: CommandPaletteProps) { const { commandPaletteOpen, toggleCommandPalette, typeFilters, toggleTypeFilter, setViewMode, setOrientation } = useTopologyStore(); const [search, setSearch] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); const listRef = useRef(null); const commands = useMemo(() => { const cmds: Command[] = [ { id: 'refresh', label: 'Refresh Discovery', description: 'Reload topology data from hosts', icon: , category: 'action', action: async () => { if (onRefresh) { await onRefresh(); } toggleCommandPalette(); } }, { id: 'view-full', label: 'Set View: Full', description: 'Show complete topology', icon: , category: 'view', action: () => { setViewMode('full'); toggleCommandPalette(); } }, { id: 'view-network', label: 'Set View: Network', description: 'Show network infrastructure', icon: , category: 'view', action: () => { setViewMode('network'); toggleCommandPalette(); } }, { id: 'view-host', label: 'Set View: Hosts', description: 'Show hosts and containers', icon: , category: 'view', action: () => { setViewMode('host'); toggleCommandPalette(); } }, { id: 'view-service', label: 'Set View: Services', description: 'Show services and volumes', icon: , category: 'view', action: () => { setViewMode('service'); toggleCommandPalette(); } }, { id: 'view-filesystem', label: 'Set View: Files', description: 'Show filesystem hierarchy', icon: , category: 'view', action: () => { setViewMode('filesystem'); toggleCommandPalette(); } }, { id: 'orientation-lr', label: 'Set Orientation: Left to Right', icon: , category: 'orientation', action: () => { setOrientation('LR'); toggleCommandPalette(); } }, { id: 'orientation-tb', label: 'Set Orientation: Top to Bottom', icon: , category: 'orientation', action: () => { setOrientation('TB'); toggleCommandPalette(); } } ]; const nodeTypes: NodeType[] = [ 'gateway', 'vlan', 'wifi', 'host_physical', 'host_vm', 'host_container', 'service', 'volume', 'mount', 'path' ]; nodeTypes.forEach((type) => { const isActive = typeFilters.includes(type); cmds.push({ id: `filter-${type}`, label: `${isActive ? 'Disable' : 'Enable'} Filter: ${nodeTypeLabels[type]}`, description: `${isActive ? 'Hide' : 'Show'} ${nodeTypeLabels[type]} nodes`, icon: nodeTypeIcons[type], category: 'filter', action: () => { toggleTypeFilter(type); toggleCommandPalette(); } }); }); return cmds; }, [typeFilters, onRefresh, setViewMode, setOrientation, toggleTypeFilter, toggleCommandPalette]); const filteredCommands = useMemo(() => { if (!search.trim()) return commands; return commands.filter(cmd => fuzzyMatch(search, cmd.label)); }, [commands, search]); useEffect(() => { setSelectedIndex(0); }, [filteredCommands.length]); useEffect(() => { if (commandPaletteOpen) { setSearch(''); setSelectedIndex(0); setTimeout(() => inputRef.current?.focus(), 50); } }, [commandPaletteOpen]); useEffect(() => { if (listRef.current) { const selectedEl = listRef.current.children[selectedIndex] as HTMLElement; if (selectedEl) { selectedEl.scrollIntoView({ block: 'nearest' }); } } }, [selectedIndex]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { switch (e.key) { case 'ArrowDown': e.preventDefault(); setSelectedIndex(prev => Math.min(prev + 1, filteredCommands.length - 1)); break; case 'ArrowUp': e.preventDefault(); setSelectedIndex(prev => Math.max(prev - 1, 0)); break; case 'Enter': e.preventDefault(); if (filteredCommands[selectedIndex]) { filteredCommands[selectedIndex].action(); } break; case 'Escape': e.preventDefault(); toggleCommandPalette(); break; } }, [filteredCommands, selectedIndex, toggleCommandPalette]); const handleBackdropClick = useCallback((e: React.MouseEvent) => { if (e.target === e.currentTarget) { toggleCommandPalette(); } }, [toggleCommandPalette]); if (!commandPaletteOpen) return null; const categoryLabels: Record = { action: 'Actions', filter: 'Filters', view: 'View Mode', orientation: 'Orientation' }; const groupedCommands = filteredCommands.reduce((acc, cmd) => { if (!acc[cmd.category]) { acc[cmd.category] = []; } acc[cmd.category].push(cmd); return acc; }, {} as Record); let globalIndex = 0; return (
setSearch(e.target.value)} onKeyDown={handleKeyDown} className="flex-1 bg-transparent text-white placeholder-slate-400 outline-none text-base" />
{filteredCommands.length === 0 ? (
No commands found
) : ( Object.entries(groupedCommands).map(([category, cmds]) => (
{categoryLabels[category as Command['category']]}
{cmds.map((cmd) => { const currentIndex = globalIndex++; const isSelected = currentIndex === selectedIndex; return ( ); })}
)) )}
↑↓ Navigate Enter Select Esc Close
); }