import { Search, Settings, HelpCircle, Sparkles, Github, Star, FolderOpen, ChevronDown, Trash2, RefreshCw, Loader2, } from '@/lib/lucide-icons'; import { useAppState } from '../hooks/useAppState'; import { deleteRepo, fetchRepos, startAnalyze, streamAnalyzeProgress, type BackendRepo, type JobProgress, } from '../services/backend-client'; import { useState, useMemo, useRef, useEffect } from 'react'; import { GraphNode } from 'gitnexus-shared'; import { EmbeddingStatus } from './EmbeddingStatus'; import { RepoAnalyzer } from './RepoAnalyzer'; // Color mapping for node types in search results const NODE_TYPE_COLORS: Record = { Folder: '#6366f1', File: '#3b82f6', Function: '#10b981', Class: '#f59e0b', Method: '#14b8a6', Interface: '#ec4899', Variable: '#64748b', Import: '#475569', Type: '#a78bfa', }; interface HeaderProps { onFocusNode?: (nodeId: string) => void; availableRepos?: BackendRepo[]; onSwitchRepo?: (repoName: string) => void; /** Called when a newly-analyzed repo is ready; triggers connectToServer. */ onAnalyzeComplete?: (repoName: string) => void; /** Called after a repo is deleted or list needs refresh. */ onReposChanged?: (repos: BackendRepo[]) => void; } export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo, onAnalyzeComplete, onReposChanged, }: HeaderProps) => { const { projectName, graph, openChatPanel, isRightPanelOpen, rightPanelTab, setSettingsPanelOpen, setHelpDialogBoxOpen, } = useAppState(); const [searchQuery, setSearchQuery] = useState(''); const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false); const [showAnalyzer, setShowAnalyzer] = useState(false); const [reanalyzing, setReanalyzing] = useState(null); // repo name being re-analyzed const [reanalyzeProgress, setReanalyzeProgress] = useState(null); const reanalyzeSseRef = useRef(null); const repoDropdownRef = useRef(null); const [isSearchOpen, setIsSearchOpen] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const searchRef = useRef(null); const inputRef = useRef(null); const nodeCount = graph?.nodes.length ?? 0; const edgeCount = graph?.relationships.length ?? 0; // Search results - filter nodes by name const searchResults = useMemo(() => { if (!graph || !searchQuery.trim()) return []; const query = searchQuery.toLowerCase(); return graph.nodes .filter((node) => node.properties.name.toLowerCase().includes(query)) .slice(0, 10); // Limit to 10 results }, [graph, searchQuery]); // Handle clicking outside search or repo dropdown to close them useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (searchRef.current && !searchRef.current.contains(e.target as Node)) { setIsSearchOpen(false); } if (repoDropdownRef.current && !repoDropdownRef.current.contains(e.target as Node)) { setIsRepoDropdownOpen(false); setShowAnalyzer(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // Cleanup re-analyze SSE on unmount useEffect(() => { return () => { reanalyzeSseRef.current?.abort(); }; }, []); // Keyboard shortcut (Cmd+K / Ctrl+K) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); inputRef.current?.focus(); setIsSearchOpen(true); } if (e.key === 'Escape') { setIsSearchOpen(false); inputRef.current?.blur(); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, []); // Handle keyboard navigation in results const handleKeyDown = (e: React.KeyboardEvent) => { if (!isSearchOpen || searchResults.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex((i) => Math.min(i + 1, searchResults.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((i) => Math.max(i - 1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); const selected = searchResults[selectedIndex]; if (selected) { handleSelectNode(selected); } } }; const handleSelectNode = (node: GraphNode) => { // onFocusNode handles both camera focus AND selection in useSigma onFocusNode?.(node.id); setSearchQuery(''); setIsSearchOpen(false); setSelectedIndex(0); }; return (
{/* Left section */}
{/* Logo */}
◇
GitNexus
{/* Project badge + repo dropdown */} {projectName && (
{isRepoDropdownOpen && (
{showAnalyzer ? (
{ setShowAnalyzer(false); setIsRepoDropdownOpen(false); onAnalyzeComplete?.(repoName); }} onCancel={() => setShowAnalyzer(false)} />
) : ( <> {/* Repo list */} {availableRepos.length > 0 && (
Repositories
{availableRepos.map((repo) => (
{/* Re-analyze */} {/* Delete */}
))}
)} {/* Re-analyze progress bar */} {reanalyzing && reanalyzeProgress && (
Re-analyzing {reanalyzing}: {reanalyzeProgress.message}
)} {/* Analyze new */}
0 || reanalyzing ? 'border-t border-border-subtle' : '' } >
)}
)}
)}
{/* Center - Search */}
{ setSearchQuery(e.target.value); setIsSearchOpen(true); setSelectedIndex(0); }} onFocus={() => setIsSearchOpen(true)} onKeyDown={handleKeyDown} className="flex-1 border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted" /> ⌘K
{/* Search Results Dropdown */} {isSearchOpen && searchQuery.trim() && (
{searchResults.length === 0 ? (
No nodes found for “{searchQuery}”
) : (
{searchResults.map((node, index) => ( ))}
)}
)}
{/* Right section */}
{/* GitHub Star Button */} Star if cool ✨ {/* Stats */} {graph && (
{nodeCount} nodes {edgeCount} edges
)} {/* Embedding Status */} {/* Icon buttons */} {/* AI Button */}
); };