'use client' import { useState, useEffect, useRef, useMemo, useCallback, Fragment } from 'react' import { Search } from 'lucide-react' import { useCommandPalette } from './command-palette-context' import { useCommandPaletteSearch } from './useCommandPaletteSearch' import { CommandGroup } from './CommandGroup' import { CommandResultItem } from './CommandResultItem' import type { CommandItem } from './useCommandPaletteSearch' export interface SearchResult { id: string type: string name: string detail: string path: string } export interface QuickAction { id: string icon: React.ElementType name: string shortcut?: string action: () => void } export interface NavigationItem { id: string icon: React.ElementType name: string path: string } export interface CommandPaletteProps { searchFn?: (query: string) => Promise quickActions?: QuickAction[] navigationItems?: NavigationItem[] recentItems?: SearchResult[] typeIcons?: Record typeColors?: Record placeholder?: string onNavigate: (path: string) => void } /** * Convert the heterogeneous props into a unified CommandItem[] for the hook. * Async search results are injected separately since they arrive after debounce. */ function buildCommands( results: SearchResult[], quickActions: QuickAction[], navigationItems: NavigationItem[], recentItems: SearchResult[], hasQuery: boolean, typeIcons: Record, typeColors: Record, ): CommandItem[] { const commands: CommandItem[] = [] // Search results (only when query is active - injected externally) for (const r of results) { commands.push({ id: r.id, icon: typeIcons[r.type], label: r.name, detail: r.detail, group: '__results__', onSelect: r.path, colorClass: typeColors[r.type] || 'bg-gray-100 text-gray-600', }) } // Quick actions (always shown) for (const a of quickActions) { commands.push({ id: a.id, icon: a.icon, label: a.name, shortcut: a.shortcut, group: 'Quick Actions', onSelect: () => a.action(), colorClass: 'bg-gray-100 text-gray-600', }) } // Recent items (only when no query) if (!hasQuery) { for (const r of recentItems.slice(0, 5)) { commands.push({ id: r.id, icon: typeIcons[r.type], label: r.name, detail: r.detail, group: 'Recent', onSelect: r.path, colorClass: 'bg-gray-100 text-gray-600', }) } } // Navigation items (only included when query is active - filtered by hook) if (hasQuery) { for (const n of navigationItems) { commands.push({ id: n.id, icon: n.icon, label: n.name, group: 'Go to', onSelect: n.path, colorClass: 'bg-gray-100 text-gray-600', }) } } return commands } export function CommandPalette({ searchFn, quickActions = [], navigationItems = [], recentItems = [], typeIcons = {}, typeColors = {}, placeholder = 'Search or type a command...', onNavigate, }: CommandPaletteProps) { const { isOpen, close } = useCommandPalette() const inputRef = useRef(null) const [asyncResults, setAsyncResults] = useState([]) const [isLoading, setIsLoading] = useState(false) // Track query locally so we can build commands with the right hasQuery flag const [localQuery, setLocalQuery] = useState('') const commands = useMemo( () => buildCommands( asyncResults, quickActions, navigationItems, recentItems, !!localQuery, typeIcons, typeColors, ), [asyncResults, quickActions, navigationItems, recentItems, localQuery, typeIcons, typeColors] ) const handleExecute = useCallback( (command: CommandItem) => { if (typeof command.onSelect === 'string') { onNavigate(command.onSelect) } else { command.onSelect() } close() }, [onNavigate, close] ) const { query, setQuery, filteredGroups, selectedIndex, onKeyDown, } = useCommandPaletteSearch({ commands, onExecute: handleExecute, isOpen, }) // Sync local query with hook query useEffect(() => { setLocalQuery(query) }, [query]) // Focus input when opened useEffect(() => { if (isOpen) { setAsyncResults([]) setLocalQuery('') setTimeout(() => inputRef.current?.focus(), 0) } }, [isOpen]) // Search debounce for async results useEffect(() => { if (!query.trim() || !searchFn) { setAsyncResults([]) return } const timer = setTimeout(async () => { setIsLoading(true) try { const data = await searchFn(query) setAsyncResults(data) } catch (error) { console.error('Search error:', error) setAsyncResults([]) } finally { setIsLoading(false) } }, 200) return () => clearTimeout(timer) }, [query, searchFn]) if (!isOpen) return null // Compute flat index offset for each group to match selectedIndex let flatIndex = 0 return (
{/* Backdrop */}
{/* Palette */}
e.stopPropagation()} > {/* Search input */}
{ setQuery(e.target.value) setLocalQuery(e.target.value) }} onKeyDown={onKeyDown} placeholder={placeholder} className="flex-1 outline-none text-lg bg-transparent" /> ESC
{/* Results */}
{/* Search results group header with query text */} {query && asyncResults.length > 0 && (

Results for "{query}"

)} {/* Loading state */} {isLoading && (
Searching...
)} {/* No results */} {query && !isLoading && asyncResults.length === 0 && (
No results found for "{query}"
)} {filteredGroups.map((group, gi) => { // Skip the __results__ group label -- we render a custom header above const isResultsGroup = group.label === '__results__' const showBorder = !isResultsGroup && gi > 0 const groupStartIndex = flatIndex const renderedItems = group.items.map((item, i) => { const itemIndex = groupStartIndex + i const showArrow = typeof item.onSelect === 'string' return ( handleExecute(item)} /> ) }) flatIndex += group.items.length if (isResultsGroup) { // Results rendered with custom header above return {renderedItems} } return ( {renderedItems} ) })}
{/* Footer */}
↑↓ Navigate Select ESC Close
Press ⌘K anywhere
) }