import React, { useMemo, useState, useRef, useEffect, useCallback, } from 'react'; import { DiffItem, formatValue } from '../../utils/diff/diff'; import { calculateLineDiff, LineDiff } from '../../utils/diff/text'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs/tabs'; import { ChevronUp, ChevronDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { toast } from 'sonner'; export interface DiffAnnotation { /** Badge label shown next to the diff type badge. */ label: string; /** Tailwind colour classes for the badge, e.g. 'bg-blue-500/10 text-blue-400 border-blue-500/20' */ className?: string; /** Optional tooltip / description shown as a small line below the path. */ description?: string; /** Severity controls sort order and summary emphasis. */ severity?: 'critical' | 'warning' | 'info'; } /** Pulls the first `text-{color}-{shade}` token out of a badge className string. */ function extractTextClass(className?: string): string { const match = className?.match(/\btext-\S+-\d+\b/); return match ? match[0] : 'text-gray-400'; } interface DiffViewerProps { diffs: DiffItem[]; valueFormatter?: (value: any) => string; oldValue?: any; newValue?: any; /** Map from dotted path string (same format as `diff.path.join('.')`) to an annotation. */ annotations?: Map; /** * Optional formatter for the path label shown in the visual tab. * Receives the raw path segment array; return a human-readable string. * Defaults to `path.join('.').replace(/\.\[/g, '[')` when omitted. */ pathFormatter?: (path: string[]) => string; } export function DiffViewer({ diffs, valueFormatter, oldValue, newValue, annotations, pathFormatter, }: DiffViewerProps) { const format = (val: any) => { if (valueFormatter) { const formatted = valueFormatter(val); if (formatted !== undefined && formatted !== null) { return formatted; } } if (val === undefined) return '(undefined)'; if (val === null) return '(null)'; return formatValue(val); }; const textDiffs = useMemo(() => { if (!oldValue && !newValue) return []; try { const oldJson = oldValue ? JSON.stringify(oldValue, null, 2) : ''; const newJson = newValue ? JSON.stringify(newValue, null, 2) : ''; return calculateLineDiff(oldJson, newJson); } catch (e) { console.error('Failed to stringify JSON for diff:', e); return calculateLineDiff('', ''); } }, [oldValue, newValue]); const sortedDiffs = useMemo(() => { if (!annotations || annotations.size === 0) return diffs; const severityOrder: Record = { critical: 0, warning: 1, info: 2, }; return [...diffs].sort((a, b) => { const keyA = a.path.join('.').replace(/\.\[/g, '['); const keyB = b.path.join('.').replace(/\.\[/g, '['); const annA = annotations.get(keyA); const annB = annotations.get(keyB); if (annA && !annB) return -1; if (!annA && annB) return 1; if (annA && annB) { const sA = severityOrder[annA.severity ?? 'info'] ?? 3; const sB = severityOrder[annB.severity ?? 'info'] ?? 3; return sA - sB; } return 0; }); }, [diffs, annotations]); if (diffs.length === 0) { return (
No changes detected.
); } return (
Visual Raw JSON
{sortedDiffs.map((diff, idx) => { const pathKey = diff.path.join('.').replace(/\.\[/g, '['); const pathLabel = pathFormatter ? pathFormatter(diff.path) : pathKey; const annotation = annotations?.get(pathKey); return (
{annotation && ( {annotation.label} )} {pathLabel}
{annotation?.description && (
{annotation.description}
)}
{diff.type !== 'ADD' && (
Old
{format(diff.oldValue)}
)} {diff.type !== 'REMOVE' && (
New
{format(diff.newValue)}
)}
); })}
); } function JsonDiffContent({ textDiffs }: { textDiffs: LineDiff[] }) { const [currentChangeIndex, setCurrentChangeIndex] = useState(0); const scrollContainerRef = useRef(null); const lineRefs = useRef<(HTMLDivElement | null)[]>([]); const changeIndices = useMemo(() => { const indices: number[] = []; let inChangeBlock = false; textDiffs.forEach((line, idx) => { if (line.type !== 'same') { if (!inChangeBlock) { indices.push(idx); inChangeBlock = true; } } else { inChangeBlock = false; } }); return indices; }, [textDiffs]); const scrollToChange = useCallback( (index: number) => { const lineIndex = changeIndices[index]; if ( lineIndex !== undefined && lineRefs.current[lineIndex] && scrollContainerRef.current ) { const element = lineRefs.current[lineIndex]; element.scrollIntoView({ behavior: 'smooth', block: 'center', }); } setCurrentChangeIndex(index); }, [changeIndices] ); const handleNext = () => { const nextIndex = (currentChangeIndex + 1) % changeIndices.length; scrollToChange(nextIndex); }; const handlePrev = () => { const prevIndex = (currentChangeIndex - 1 + changeIndices.length) % changeIndices.length; scrollToChange(prevIndex); }; // Reset index when diffs change useEffect(() => { setCurrentChangeIndex(0); // Reset refs array sizing lineRefs.current = lineRefs.current.slice(0, textDiffs.length); // Auto-scroll to first change if exists let timeoutId: ReturnType | undefined; if (changeIndices.length > 0) { // Small timeout to ensure rendering is complete before scrolling timeoutId = setTimeout(() => scrollToChange(0), 100); } return () => { if (timeoutId) clearTimeout(timeoutId); }; }, [textDiffs, changeIndices, scrollToChange]); // Check for truncation and show toast useEffect(() => { if (textDiffs.some((diff) => diff.truncated)) { toast.warning('Large File Detected: Diff simplified for performance.', { id: 'large-file-warning', }); } }, [textDiffs]); return ( <> {changeIndices.length > 0 && (
{currentChangeIndex + 1} / {changeIndices.length}
)}
{textDiffs.map((line, idx) => (
{ lineRefs.current[idx] = el; }} className={`flex ${ line.type === 'add' ? 'bg-green-900/20 text-green-300' : line.type === 'remove' ? 'bg-red-900/20 text-red-300' : 'text-gray-400' } ${idx === changeIndices[currentChangeIndex] ? 'ring-1 ring-blue-500/50' : ''}`} >
{line.oldLineNumber || ' '}
{line.newLineNumber || ' '}
                {line.type === 'add' ? '+' : line.type === 'remove' ? '-' : ' '}{' '}
                {line.content}
              
))} {textDiffs.length === 0 && (
No raw JSON available for comparison.
)}
); } function Badge({ type }: { type: string }) { const colors = { CHANGE: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20', ADD: 'bg-green-500/10 text-green-500 border-green-500/20', REMOVE: 'bg-red-500/10 text-red-500 border-red-500/20', }; const colorClass = colors[type as keyof typeof colors] || 'bg-gray-500/10 text-gray-500 border-gray-500/20'; return ( {type} ); }