import { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { globalSearch } from '../api'; import { GlobalSearchResponse } from '../types'; import './GlobalSearch.css'; export function GlobalSearch() { const navigate = useNavigate(); const [query, setQuery] = useState(''); const [results, setResults] = useState(null); const [loading, setLoading] = useState(false); const [isOpen, setIsOpen] = useState(false); const [selectedIndex, setSelectedIndex] = useState(-1); const inputRef = useRef(null); const containerRef = useRef(null); // Build flat list of results for keyboard navigation const flatResults = results ? [ ...results.projects.map((p) => ({ type: 'project' as const, item: p })), ...results.packages.map((p) => ({ type: 'package' as const, item: p })), ...results.artifacts.map((a) => ({ type: 'artifact' as const, item: a })), ] : []; const handleSearch = useCallback(async (searchQuery: string) => { if (!searchQuery.trim()) { setResults(null); setIsOpen(false); return; } setLoading(true); try { const data = await globalSearch(searchQuery); setResults(data); setIsOpen(true); setSelectedIndex(-1); } catch (err) { console.error('Search failed:', err); setResults(null); } finally { setLoading(false); } }, []); // Debounced search useEffect(() => { const timer = setTimeout(() => { handleSearch(query); }, 300); return () => clearTimeout(timer); }, [query, handleSearch]); // Close on click outside useEffect(() => { function handleClickOutside(event: MouseEvent) { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setIsOpen(false); } } document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // Keyboard navigation useEffect(() => { function handleKeyDown(event: KeyboardEvent) { if (event.key === '/' && !['INPUT', 'TEXTAREA'].includes((event.target as HTMLElement).tagName)) { event.preventDefault(); inputRef.current?.focus(); } if (!isOpen) return; switch (event.key) { case 'ArrowDown': event.preventDefault(); setSelectedIndex((prev) => Math.min(prev + 1, flatResults.length - 1)); break; case 'ArrowUp': event.preventDefault(); setSelectedIndex((prev) => Math.max(prev - 1, -1)); break; case 'Enter': if (selectedIndex >= 0 && flatResults[selectedIndex]) { event.preventDefault(); navigateToResult(flatResults[selectedIndex]); } break; case 'Escape': setIsOpen(false); inputRef.current?.blur(); break; } } document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, selectedIndex, flatResults]); function navigateToResult(result: (typeof flatResults)[0]) { setIsOpen(false); setQuery(''); switch (result.type) { case 'project': navigate(`/project/${result.item.name}`); break; case 'package': navigate(`/project/${result.item.project_name}/${result.item.name}`); break; case 'artifact': navigate(`/project/${result.item.project_name}/${result.item.package_name}`); break; } } const hasResults = results && results.counts.total > 0; return (
setQuery(e.target.value)} onFocus={() => query && results && setIsOpen(true)} placeholder="Search projects, packages, artifacts..." className="global-search__input" /> / {loading && }
{isOpen && (
{!hasResults && query && (
No results found for "{query}"
)} {hasResults && ( <> {results.projects.length > 0 && (
Projects {results.counts.projects}
{results.projects.map((project, index) => { const flatIndex = index; return ( ); })}
)} {results.packages.length > 0 && (
Packages {results.counts.packages}
{results.packages.map((pkg, index) => { const flatIndex = results.projects.length + index; return ( ); })}
)} {results.artifacts.length > 0 && (
Artifacts / Tags {results.counts.artifacts}
{results.artifacts.map((artifact, index) => { const flatIndex = results.projects.length + results.packages.length + index; return ( ); })}
)} )}
)}
); }