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 { BackendError, readFile, type ReadFileResult } from '../services/backend-client'; import { selectedNodeDisplayLine, selectedNodeFileRange, selectedNodeLineHighlighted, } from './code-panel-lines'; 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; } /** A fetched code excerpt for one AI citation card. Line numbers are 0-based file offsets. */ interface CitationSnippet { content: string; start: number; end: number; /** Highlight range relative to `start`. */ highlightStart: number; highlightEnd: number; totalLines: number; } const CITATION_CONTEXT_LINES = 5; /** Max lines to fetch when a citation has no start/end range. */ const RANGELESS_CITATION_LINES = 80; /** Cap simultaneous `/api/file` reads when a reply cites many files. */ const CITATION_SNIPPET_CONCURRENCY = 4; async function mapWithConcurrency( items: T[], concurrency: number, worker: (item: T) => Promise, ): Promise { if (items.length === 0) return []; const results: R[] = new Array(items.length); let nextIndex = 0; const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => { while (nextIndex < items.length) { const currentIndex = nextIndex; nextIndex += 1; results[currentIndex] = await worker(items[currentIndex]); } }); await Promise.all(runners); return results; } export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) => { const { t } = useTranslation(['common', 'graph']); const { graph, selectedNode, codeReferences, removeCodeReference, clearCodeReferences, setSelectedNode, codeReferenceFocus, projectName, currentRepo, } = 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]); // Per-citation snippets, fetched from the server around each reference's // (0-based) line range. Keyed by reference id; a failed read stays absent so // the card falls back to the "code not available" notice. const [citationSnippets, setCitationSnippets] = useState>( () => new Map(), ); // Ids already requested (loaded or failed) — a failed read is not retried. const requestedSnippetIds = useRef>(new Set()); const snippetRepoKey = currentRepo || projectName || undefined; const snippetRepoKeyRef = useRef(undefined); // Live citation ids at apply time — in-flight batches must not resurrect // excerpts after clearAICodeReferences() mints a fresh list. const liveCitationIdsRef = useRef>(new Set()); liveCitationIdsRef.current = new Set(aiReferences.map((ref) => ref.id)); useEffect(() => { if (snippetRepoKeyRef.current !== snippetRepoKey) { snippetRepoKeyRef.current = snippetRepoKey; requestedSnippetIds.current.clear(); setCitationSnippets(new Map()); } const liveIds = liveCitationIdsRef.current; for (const id of [...requestedSnippetIds.current]) { if (!liveIds.has(id)) requestedSnippetIds.current.delete(id); } setCitationSnippets((prev) => { if (prev.size === 0) return prev; let removed = false; const next = new Map(); for (const [id, snippet] of prev) { if (liveIds.has(id)) next.set(id, snippet); else removed = true; } return removed ? next : prev; }); const pending = aiReferences.filter((ref) => !requestedSnippetIds.current.has(ref.id)); if (pending.length === 0) return; for (const ref of pending) requestedSnippetIds.current.add(ref.id); mapWithConcurrency(pending, CITATION_SNIPPET_CONCURRENCY, async (ref) => { const hasRange = typeof ref.startLine === 'number'; // Range-less citations must not download/highlight the entire file. const refStart = hasRange ? (ref.startLine as number) : 0; const refEnd = hasRange ? (ref.endLine ?? refStart) : Math.max(0, RANGELESS_CITATION_LINES - 1); const options = hasRange ? selectedNodeFileRange(refStart, refEnd, CITATION_CONTEXT_LINES) : { startLine: 0, endLine: refEnd }; try { const result = await readFile(ref.filePath, { ...options, repo: snippetRepoKey }); const start = result.startLine ?? 0; const lineCount = result.content.split('\n').length; const snippet: CitationSnippet = { content: result.content, start, end: result.endLine ?? start + lineCount - 1, highlightStart: hasRange ? refStart - start : 0, highlightEnd: hasRange ? refEnd - start : 0, totalLines: result.totalLines, }; return [ref.id, snippet] as const; } catch { return null; } }).then((entries) => { // Repo switch already cleared the set and started a replacement batch. if (snippetRepoKeyRef.current !== snippetRepoKey) return; const loaded = entries.filter((e): e is readonly [string, CitationSnippet] => e !== null); if (loaded.length === 0) return; setCitationSnippets((prev) => { const next = new Map(prev); for (const [id, snippet] of loaded) { if (liveCitationIdsRef.current.has(id)) next.set(id, snippet); } return next; }); }); }, [aiReferences, snippetRepoKey]); const refsWithSnippets = useMemo(() => { return aiReferences.map((ref) => { const snippet = citationSnippets.get(ref.id); return { ref, content: snippet?.content ?? null, start: snippet?.start ?? 0, end: snippet?.end ?? 0, highlightStart: snippet?.highlightStart ?? 0, highlightEnd: snippet?.highlightEnd ?? 0, totalLines: snippet?.totalLines ?? 0, }; }); }, [aiReferences, citationSnippets]); 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 [sourceUnavailable, setSourceUnavailable] = useState(false); const [isLoadingFile, setIsLoadingFile] = useState(false); const selectedViewerRef = useRef(null); const selectedFileContent = fileResult?.content; const fileStartLine = fileResult?.startLine ?? 0; useEffect(() => { if (!selectedFilePath) { setFileResult(null); setSourceUnavailable(false); return; } let cancelled = false; setIsLoadingFile(true); setFileResult(null); setSourceUnavailable(false); // Determine read range: full file for File nodes, buffered for symbols. // Graph node lines are 0-based (#2377); /api/file ranges are 0-indexed. const startLine = selectedNode?.properties?.startLine as number | undefined; const endLine = selectedNode?.properties?.endLine as number | undefined; const isWholeFile = selectedIsFile || startLine === undefined; const options = isWholeFile ? {} : selectedNodeFileRange(startLine, endLine, CONTEXT_LINES); // Prefer the repo path identity over the display name — duplicate display // names would otherwise resolve to the wrong repository's file (#2420). readFile(selectedFilePath, { ...options, repo: currentRepo || projectName || undefined }) .then((result) => { if (!cancelled) { setFileResult(result); setIsLoadingFile(false); } }) .catch((error) => { if (!cancelled) { setFileResult(null); setSourceUnavailable( error instanceof BackendError && error.code === 'source_unavailable', ); setIsLoadingFile(false); } }); return () => { cancelled = true; }; }, [ selectedFilePath, selectedNode?.properties?.startLine, selectedNode?.properties?.endLine, selectedIsFile, projectName, currentRepo, ]); // Scroll to the selected node's startLine after content loads. // GraphNode startLine is 0-based; displayed gutters are 1-based. useEffect(() => { if (!selectedFileContent || typeof selectedNode?.properties?.startLine !== 'number') 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; // Index into the rendered lines: the viewer starts at `fileStartLine` // (0-based), so the symbol's 0-based file line minus that offset. const renderedIndex = Math.max(0, startLine - fileStartLine); const lineEl = (container.querySelector( `[data-line-number="${selectedNodeDisplayLine(startLine)}"]`, ) as HTMLElement) ?? (container.querySelectorAll('.linenumber')[renderedIndex] 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, renderedIndex * lineHeight - container.clientHeight / 3, ); } }); rafIds.push(innerRaf); }); const rafIds = [outerRaf]; return () => { cancelled = true; rafIds.forEach((id) => cancelAnimationFrame(id)); }; }, [selectedFileContent, selectedNode?.properties?.startLine, fileStartLine]); if (isCollapsed) { return ( ); } return ( ); };