import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Code, PanelLeftClose, PanelLeft, Trash2, X, Target, FileCode, Sparkles, MousePointerClick } from 'lucide-react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { useAppState } from '../hooks/useAppState'; import { NODE_COLORS } from '../lib/constants'; // Match the code theme used elsewhere in the app const customTheme = { ...vscDarkPlus, 'pre[class*="language-"]': { ...vscDarkPlus['pre[class*="language-"]'], background: '#0a0a10', margin: 0, padding: '12px 0', fontSize: '13px', lineHeight: '1.6', }, 'code[class*="language-"]': { ...vscDarkPlus['code[class*="language-"]'], background: 'transparent', fontFamily: '"JetBrains Mono", "Fira Code", monospace', }, }; export interface CodeReferencesPanelProps { onFocusNode: (nodeId: string) => void; } export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) => { const { graph, fileContents, selectedNode, codeReferences, removeCodeReference, clearCodeReferences, setSelectedNode, codeReferenceFocus, } = useAppState(); const [isCollapsed, setIsCollapsed] = useState(false); const [glowRefId, setGlowRefId] = useState(null); const panelRef = useRef(null); const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null); const refCardEls = useRef>(new Map()); const glowTimerRef = useRef(null); useEffect(() => { return () => { if (glowTimerRef.current) { window.clearTimeout(glowTimerRef.current); glowTimerRef.current = null; } }; }, []); const [panelWidth, setPanelWidth] = useState(() => { try { const saved = window.localStorage.getItem('gitnexus.codePanelWidth'); const parsed = saved ? parseInt(saved, 10) : NaN; if (!Number.isFinite(parsed)) return 560; // increased default return Math.max(420, Math.min(parsed, 900)); } catch { return 560; } }); useEffect(() => { try { window.localStorage.setItem('gitnexus.codePanelWidth', String(panelWidth)); } catch { // ignore } }, [panelWidth]); const startResize = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); resizeRef.current = { startX: e.clientX, startWidth: panelWidth }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; const onMove = (ev: MouseEvent) => { const state = resizeRef.current; if (!state) return; const delta = ev.clientX - state.startX; const next = Math.max(420, Math.min(state.startWidth + delta, 900)); setPanelWidth(next); }; const onUp = () => { resizeRef.current = null; document.body.style.cursor = ''; document.body.style.userSelect = ''; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }, [panelWidth]); const aiReferences = useMemo(() => codeReferences.filter(r => r.source === 'ai'), [codeReferences]); // When the user clicks a citation badge in chat, focus the corresponding snippet card: // - expand the panel if collapsed // - smooth-scroll the card into view // - briefly glow it for discoverability useEffect(() => { if (!codeReferenceFocus) return; // Ensure panel is expanded setIsCollapsed(false); const { filePath, startLine, endLine } = codeReferenceFocus; const target = aiReferences.find(r => r.filePath === filePath && r.startLine === startLine && r.endLine === endLine ) ?? aiReferences.find(r => r.filePath === filePath); if (!target) return; // Double rAF: wait for collapse state + list DOM to render. requestAnimationFrame(() => { requestAnimationFrame(() => { const el = refCardEls.current.get(target.id); if (!el) return; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); setGlowRefId(target.id); if (glowTimerRef.current) { window.clearTimeout(glowTimerRef.current); } glowTimerRef.current = window.setTimeout(() => { setGlowRefId((prev) => (prev === target.id ? null : prev)); glowTimerRef.current = null; }, 1200); }); }); }, [codeReferenceFocus?.ts, aiReferences]); const refsWithSnippets = useMemo(() => { return aiReferences.map((ref) => { const content = fileContents.get(ref.filePath); if (!content) { return { ref, content: null as string | null, start: 0, end: 0, highlightStart: 0, highlightEnd: 0, totalLines: 0 }; } const lines = content.split('\n'); const totalLines = lines.length; const startLine = ref.startLine ?? 0; const endLine = ref.endLine ?? startLine; const contextBefore = 3; const contextAfter = 20; const start = Math.max(0, startLine - contextBefore); const end = Math.min(totalLines - 1, endLine + contextAfter); return { ref, content: lines.slice(start, end + 1).join('\n'), start, end, highlightStart: Math.max(0, startLine - start), highlightEnd: Math.max(0, endLine - start), totalLines, }; }); }, [aiReferences, fileContents]); const selectedFilePath = selectedNode?.properties?.filePath; const selectedFileContent = selectedFilePath ? fileContents.get(selectedFilePath) : undefined; const selectedIsFile = selectedNode?.label === 'File' && !!selectedFilePath; const showSelectedViewer = !!selectedNode && !!selectedFilePath; const showCitations = aiReferences.length > 0; if (isCollapsed) { return ( ); } return ( ); };