import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode, } from 'react'; import { Search, AlertCircle } from 'lucide-react'; import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import { CommandDialog, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command'; import { DialogDescription, DialogTitle } from '@/components/ui/dialog'; import { useNodes, type Node } from '@/context/NodeContext'; import { isInputFocused } from '@/lib/keyboard-guards'; import { cn } from '@/lib/utils'; import { useCrossNodeStackSearch, type StackHit, type StackStatus, } from '@/hooks/useCrossNodeStackSearch'; import type { TopBarNavItem } from './TopBar'; const MAX_STACK_HITS = 50; const statusDot: Record = { running: 'bg-success', exited: 'bg-muted-foreground', unknown: 'bg-muted-foreground/60', partial: 'bg-warning', }; interface PaletteState { open: boolean; setOpen: (open: boolean) => void; toggle: () => void; } const PaletteContext = createContext(null); export function GlobalCommandPaletteProvider({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false); useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { // Let cmdk's own Ctrl+K handling take precedence when focus is already inside a palette const target = e.target as HTMLElement | null; if (target?.closest('[cmdk-root]')) return; // Don't hijack Cmd/Ctrl+K while the user is typing in a field or the code // editor: stealing focus mid-edit is surprising, and on macOS Ctrl+K is the // native delete-to-end-of-line. Search still opens elsewhere, or via the icon. if (isInputFocused()) return; e.preventDefault(); setOpen(prev => !prev); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, []); const value = useMemo( () => ({ open, setOpen, toggle: () => setOpen(prev => !prev) }), [open], ); return {children}; } // eslint-disable-next-line react-refresh/only-export-components export function usePaletteState(): PaletteState { const ctx = useContext(PaletteContext); if (!ctx) throw new Error('usePaletteState must be used within GlobalCommandPaletteProvider'); return ctx; } export function GlobalCommandPaletteTrigger() { const { setOpen } = usePaletteState(); return ( ); } interface GlobalCommandPaletteProps { navItems: TopBarNavItem[]; onNavigate: (value: string) => void; onSelectStack: (node: Node, filename: string) => void; } export function GlobalCommandPalette({ navItems, onNavigate, onSelectStack }: GlobalCommandPaletteProps) { const { open, setOpen } = usePaletteState(); const { nodes, activeNode, setActiveNode } = useNodes(); const [query, setQuery] = useState(''); const { hits: remoteHits, failedNodes, loading: stacksLoading } = useCrossNodeStackSearch({ query, enabled: open, }); const stackHits = useMemo(() => remoteHits.slice(0, MAX_STACK_HITS), [remoteHits]); const handleOpenChange = useCallback((next: boolean) => { setOpen(next); if (!next) setQuery(''); }, [setOpen]); const handleSelectNav = useCallback((value: string) => { handleOpenChange(false); onNavigate(value); }, [handleOpenChange, onNavigate]); const handleSelectNode = useCallback((node: Node) => { handleOpenChange(false); setActiveNode(node); }, [handleOpenChange, setActiveNode]); const handleSelectStack = useCallback((hit: StackHit) => { const node = nodes.find(n => n.id === hit.nodeId); if (!node) return; handleOpenChange(false); onSelectStack(node, hit.file); }, [handleOpenChange, nodes, onSelectStack]); // cmdk's built-in filter is disabled (shouldFilter={false}) so the palette // owns matching for every group. This keeps stack order deterministic (node // order, then the 50-row cap) instead of being re-sorted by cmdk's scorer, // and makes matching a plain case-insensitive substring across all groups. const q = query.trim().toLowerCase(); const visiblePages = useMemo( () => (q ? navItems.filter(i => i.label.toLowerCase().includes(q) || i.value.toLowerCase().includes(q)) : navItems), [navItems, q], ); const visibleNodes = useMemo( () => (q ? nodes.filter(n => n.name.toLowerCase().includes(q)) : nodes), [nodes, q], ); const hasResults = visiblePages.length > 0 || visibleNodes.length > 0 || stackHits.length > 0; // Suppress the empty state when a node failed (unless still loading): the // "N nodes unreachable" line below already explains why results are missing. const showEmptyState = !hasResults && (stacksLoading || failedNodes.length === 0); return ( Search Jump to a page, node, or stack { if (e.key === 'Escape') { e.preventDefault(); handleOpenChange(false); } }} /> {showEmptyState && (
{stacksLoading ? 'Searching...' : 'No results.'}
)} {visiblePages.length > 0 && ( {visiblePages.map(({ value, label, icon: Icon }) => ( handleSelectNav(value)} > {label} ))} )} {visibleNodes.length > 0 && ( {visibleNodes.map(node => { const isActive = node.id === activeNode?.id; const offline = node.status === 'offline'; return ( handleSelectNode(node)} disabled={offline} > {node.name} {isActive && ( Active )} ); })} )} {stackHits.length > 0 && ( {stackHits.map(hit => ( handleSelectStack(hit)} > {hit.file} {hit.nodeName} ))} {remoteHits.length > MAX_STACK_HITS && (
Showing first {MAX_STACK_HITS} of {remoteHits.length}
)}
)} {failedNodes.length > 0 && (
`${f.nodeName}: ${f.reason}`).join('\n')} > {failedNodes.length} {failedNodes.length === 1 ? 'node' : 'nodes'} unreachable, stack results may be incomplete
)}
); }