import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Code, PanelLeftClose, PanelLeft, Trash2, X, Target, FileCode, Sparkles, MousePointerClick, Loader2, } from '@/lib/lucide-icons'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { useAppState } from '../hooks/useAppState'; import { type GraphNode, getSyntaxLanguageFromFilename } from 'gitnexus-shared'; import { NODE_COLORS } from '../lib/constants'; import { readFile, type ReadFileResult } from '../services/backend-client'; import { useTranslation } from 'react-i18next'; const getSyntaxLanguage = (filePath: string | undefined): string => { if (!filePath) return 'text'; return getSyntaxLanguageFromFilename(filePath); }; // 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 { t } = useTranslation(['common', 'graph']); const { graph, selectedNode, codeReferences, removeCodeReference, clearCodeReferences, setSelectedNode, codeReferenceFocus, projectName, } = useAppState(); const nodeById = useMemo(() => { if (!graph) return new Map(); return new Map(graph.nodes.map((n) => [n.id, n])); }, [graph]); 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. const rafIds: number[] = []; const outerRafId = requestAnimationFrame(() => { const innerRafId = 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); }); rafIds.push(innerRafId); }); rafIds.push(outerRafId); return () => { rafIds.forEach((id) => cancelAnimationFrame(id)); }; }, [codeReferenceFocus, aiReferences]); const refsWithSnippets = useMemo(() => { return aiReferences.map((ref) => { return { ref, content: null as string | null, start: 0, end: 0, highlightStart: 0, highlightEnd: 0, totalLines: 0, }; }); }, [aiReferences]); const selectedFilePath = selectedNode?.properties?.filePath; const selectedIsFile = selectedNode?.label === 'File' && !!selectedFilePath; const showSelectedViewer = !!selectedNode && !!selectedFilePath; const showCitations = aiReferences.length > 0; // Fetch file content from the server when a node with a filePath is selected. // For non-File nodes (functions, classes, etc.), fetch a buffer around the symbol // instead of the entire file. For File nodes, fetch the whole file. const CONTEXT_LINES = 50; // lines of context above and below the symbol const [fileResult, setFileResult] = useState(null); const [isLoadingFile, setIsLoadingFile] = useState(false); const selectedViewerRef = useRef(null); const selectedFileContent = fileResult?.content; const fileStartLine = fileResult?.startLine ?? 0; useEffect(() => { if (!selectedFilePath) { setFileResult(null); return; } let cancelled = false; setIsLoadingFile(true); setFileResult(null); // Determine read range: full file for File nodes, buffered for symbols const startLine = selectedNode?.properties?.startLine as number | undefined; const endLine = selectedNode?.properties?.endLine as number | undefined; const isWholeFile = selectedIsFile || startLine === undefined; const options = isWholeFile ? { repo: projectName } : { startLine: Math.max(0, startLine - CONTEXT_LINES), endLine: (endLine ?? startLine) + CONTEXT_LINES, repo: projectName, }; readFile(selectedFilePath, { ...options, repo: projectName || undefined }) .then((result) => { if (!cancelled) { setFileResult(result); setIsLoadingFile(false); } }) .catch(() => { if (!cancelled) { setFileResult(null); setIsLoadingFile(false); } }); return () => { cancelled = true; }; }, [ selectedFilePath, selectedNode?.properties?.startLine, selectedNode?.properties?.endLine, selectedIsFile, projectName, ]); // Scroll to the selected node's startLine after content loads useEffect(() => { if (!selectedFileContent || !selectedNode?.properties?.startLine) return; const startLine = selectedNode.properties.startLine as number; // Double rAF: wait for SyntaxHighlighter to fully render before scrolling let cancelled = false; const outerRaf = requestAnimationFrame(() => { const innerRaf = requestAnimationFrame(() => { if (cancelled) return; const container = selectedViewerRef.current; if (!container) return; const lineEl = (container.querySelector(`[data-line-number="${startLine + 1}"]`) as HTMLElement) ?? (container.querySelectorAll('.linenumber')[startLine] as HTMLElement); if (lineEl) { lineEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } else { // Fallback: estimate scroll position based on line height const lineHeight = 20.8; // 13px font * 1.6 line-height container.scrollTop = Math.max(0, startLine * lineHeight - container.clientHeight / 3); } }); rafIds.push(innerRaf); }); const rafIds = [outerRaf]; return () => { cancelled = true; rafIds.forEach((id) => cancelAnimationFrame(id)); }; }, [selectedFileContent, selectedNode?.properties?.startLine]); if (isCollapsed) { return ( ); } return ( ); };