GitNexus/gitnexus-web/src/components/CodeReferencesPanel.tsx

593 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, GraphNode>();
return new Map(graph.nodes.map((n) => [n.id, n]));
}, [graph]);
const [isCollapsed, setIsCollapsed] = useState(false);
const [glowRefId, setGlowRefId] = useState<string | null>(null);
const panelRef = useRef<HTMLElement | null>(null);
const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null);
const refCardEls = useRef<Map<string, HTMLDivElement | null>>(new Map());
const glowTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (glowTimerRef.current) {
window.clearTimeout(glowTimerRef.current);
glowTimerRef.current = null;
}
};
}, []);
const [panelWidth, setPanelWidth] = useState<number>(() => {
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<ReadFileResult | null>(null);
const [isLoadingFile, setIsLoadingFile] = useState(false);
const selectedViewerRef = useRef<HTMLDivElement>(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 (
<aside className="flex h-full w-12 flex-shrink-0 flex-col items-center gap-2 border-r border-border-subtle bg-surface py-3">
<button
onClick={() => setIsCollapsed(false)}
className="rounded p-2 text-text-secondary transition-colors hover:bg-cyan-500/10 hover:text-cyan-400"
title={t('graph:codePanel.expand')}
>
<PanelLeft className="h-5 w-5" />
</button>
<div className="my-1 h-px w-6 bg-border-subtle" />
{showSelectedViewer && (
<div className="rotate-90 text-[9px] font-medium tracking-wide whitespace-nowrap text-amber-400">
{t('graph:codePanel.selected')}
</div>
)}
{showCitations && (
<div className="mt-4 rotate-90 text-[9px] font-medium tracking-wide whitespace-nowrap text-cyan-400">
AI • {aiReferences.length}
</div>
)}
</aside>
);
}
return (
<aside
ref={(el) => {
panelRef.current = el;
}}
className="relative flex h-full animate-slide-in flex-col border-r border-border-subtle bg-surface/95 shadow-2xl backdrop-blur-md"
style={{ width: panelWidth }}
>
{/* Resize handle */}
<div
onMouseDown={startResize}
className="absolute top-0 right-0 h-full w-2 cursor-col-resize bg-transparent transition-colors hover:bg-cyan-500/25"
title={t('graph:codePanel.dragResize')}
/>
{/* Header */}
<div className="flex items-center justify-between border-b border-border-subtle bg-gradient-to-r from-elevated/60 to-surface/60 px-3 py-2.5">
<div className="flex items-center gap-2">
<Code className="h-4 w-4 text-cyan-400" />
<span className="text-sm font-semibold text-text-primary">
{t('graph:codePanel.title')}
</span>
</div>
<div className="flex items-center gap-1.5">
{showCitations && (
<button
onClick={() => clearCodeReferences()}
className="rounded p-1.5 text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400"
title={t('graph:codePanel.clearCitations')}
>
<Trash2 className="h-4 w-4" />
</button>
)}
<button
onClick={() => setIsCollapsed(true)}
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
title={t('common:actions.collapse')}
>
<PanelLeftClose className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col">
{/* Top: Selected file viewer (when a node is selected) */}
{showSelectedViewer && (
<div className={`${showCitations ? 'h-[42%]' : 'flex-1'} flex min-h-0 flex-col`}>
<div className="flex items-center gap-2 border-b border-amber-500/20 bg-gradient-to-r from-amber-500/8 to-orange-500/5 px-3 py-2">
<div className="flex items-center gap-1.5 rounded-md border border-amber-500/25 bg-amber-500/15 px-2 py-0.5">
<MousePointerClick className="h-3 w-3 text-amber-400" />
<span className="text-[10px] font-semibold tracking-wide text-amber-300 uppercase">
{t('graph:codePanel.selected')}
</span>
</div>
<FileCode className="ml-1 h-3.5 w-3.5 text-amber-400/70" />
<span className="flex-1 truncate font-mono text-xs text-text-primary">
{selectedNode?.properties?.filePath?.split('/').pop() ??
selectedNode?.properties?.name}
</span>
<button
onClick={() => setSelectedNode(null)}
className="rounded p-1 text-text-muted transition-colors hover:bg-amber-500/10 hover:text-amber-400"
title={t('graph:codePanel.clearSelection')}
>
<X className="h-4 w-4" />
</button>
</div>
<div ref={selectedViewerRef} className="scrollbar-thin min-h-0 flex-1 overflow-auto">
{isLoadingFile ? (
<div className="flex items-center justify-center gap-2 py-8 text-text-muted">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">{t('graph:codePanel.loadingSource')}</span>
</div>
) : selectedFileContent ? (
<SyntaxHighlighter
language={getSyntaxLanguage(selectedFilePath)}
style={customTheme as any}
showLineNumbers
startingLineNumber={fileStartLine + 1}
lineNumberStyle={{
minWidth: '3em',
paddingRight: '1em',
color: '#5a5a70',
textAlign: 'right',
userSelect: 'none',
}}
lineProps={(lineNumber) => {
const symStart = selectedNode?.properties?.startLine;
const symEnd = selectedNode?.properties?.endLine ?? symStart;
const isHighlighted =
typeof symStart === 'number' &&
lineNumber >= symStart + 1 &&
lineNumber <= (symEnd ?? symStart) + 1;
return {
style: {
display: 'block',
backgroundColor: isHighlighted ? 'rgba(6, 182, 212, 0.14)' : 'transparent',
borderLeft: isHighlighted ? '3px solid #06b6d4' : '3px solid transparent',
paddingLeft: '12px',
paddingRight: '16px',
},
};
}}
wrapLines
>
{selectedFileContent}
</SyntaxHighlighter>
) : (
<div className="px-3 py-3 text-sm text-text-muted">
{selectedIsFile ? (
<>{t('graph:codePanel.codeNotAvailable', { path: selectedFilePath })}</>
) : (
<>{t('graph:codePanel.selectFile')}</>
)}
</div>
)}
</div>
</div>
)}
{/* Divider between Selected viewer and AI refs (more visible) */}
{showSelectedViewer && showCitations && (
<div className="h-1.5 bg-gradient-to-r from-transparent via-border-subtle to-transparent" />
)}
{/* Bottom: AI citations list */}
{showCitations && (
<div className="flex min-h-0 flex-1 flex-col">
{/* AI Citations Section Header */}
<div className="flex items-center gap-2 border-b border-cyan-500/20 bg-gradient-to-r from-cyan-500/8 to-teal-500/5 px-3 py-2">
<div className="flex items-center gap-1.5 rounded-md border border-cyan-500/25 bg-cyan-500/15 px-2 py-0.5">
<Sparkles className="h-3 w-3 text-cyan-400" />
<span className="text-[10px] font-semibold tracking-wide text-cyan-300 uppercase">
{t('graph:codePanel.aiCitations')}
</span>
</div>
<span className="ml-1 text-xs text-text-muted">
{t('graph:codePanel.references', { count: aiReferences.length })}
</span>
</div>
<div className="scrollbar-thin min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
{refsWithSnippets.map(
({ ref, content, start, highlightStart, highlightEnd, totalLines }) => {
const nodeColor = ref.label
? (NODE_COLORS as any)[ref.label] || '#6b7280'
: '#6b7280';
const hasRange = typeof ref.startLine === 'number';
const startDisplay = hasRange ? (ref.startLine ?? 0) + 1 : undefined;
const endDisplay = hasRange ? (ref.endLine ?? ref.startLine ?? 0) + 1 : undefined;
const language = getSyntaxLanguage(ref.filePath);
const isGlowing = glowRefId === ref.id;
return (
<div
key={ref.id}
ref={(el) => {
refCardEls.current.set(ref.id, el);
}}
className={[
'overflow-hidden rounded-xl border border-border-subtle bg-elevated transition-all',
isGlowing
? 'animate-pulse shadow-[0_0_0_6px_rgba(34,211,238,0.14)] ring-2 ring-cyan-300/70'
: '',
].join(' ')}
>
<div className="flex items-start gap-2 border-b border-border-subtle bg-surface/40 px-3 py-2">
<span
className="mt-0.5 flex-shrink-0 rounded px-2 py-0.5 text-[10px] font-semibold tracking-wide uppercase"
style={{ backgroundColor: nodeColor, color: '#06060a' }}
title={ref.label ?? t('graph:codePanel.code')}
>
{ref.label ?? t('graph:codePanel.code')}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-text-primary">
{ref.name ?? ref.filePath.split('/').pop() ?? ref.filePath}
</div>
<div className="truncate font-mono text-[11px] text-text-muted">
{ref.filePath}
{startDisplay !== undefined && (
<span className="text-text-secondary">
{' '}
• L{startDisplay}
{endDisplay !== startDisplay ? `–${endDisplay}` : ''}
</span>
)}
{totalLines > 0 && (
<span className="text-text-muted">
{' '}
• {t('graph:codePanel.lines', { count: totalLines })}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
{ref.nodeId && (
<button
onClick={() => {
const nodeId = ref.nodeId!;
// Sync selection + focus graph
if (graph) {
const node = nodeById.get(nodeId);
if (node) setSelectedNode(node);
}
onFocusNode(nodeId);
}}
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
title={t('common:actions.focusInGraph')}
>
<Target className="h-4 w-4" />
</button>
)}
<button
onClick={() => removeCodeReference(ref.id)}
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
title={t('common:actions.remove')}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="overflow-x-auto">
{content ? (
<SyntaxHighlighter
language={language}
style={customTheme as any}
showLineNumbers
startingLineNumber={start + 1}
lineNumberStyle={{
minWidth: '3em',
paddingRight: '1em',
color: '#5a5a70',
textAlign: 'right',
userSelect: 'none',
}}
lineProps={(lineNumber) => {
const isHighlighted =
hasRange &&
lineNumber >= start + highlightStart + 1 &&
lineNumber <= start + highlightEnd + 1;
return {
style: {
display: 'block',
backgroundColor: isHighlighted
? 'rgba(6, 182, 212, 0.14)'
: 'transparent',
borderLeft: isHighlighted
? '3px solid #06b6d4'
: '3px solid transparent',
paddingLeft: '12px',
paddingRight: '16px',
},
};
}}
wrapLines
>
{content}
</SyntaxHighlighter>
) : (
<div className="px-3 py-3 text-sm text-text-muted">
{t('graph:codePanel.codeNotAvailable', { path: ref.filePath })}
</div>
)}
</div>
</div>
);
},
)}
</div>
</div>
)}
</div>
</aside>
);
};