f4b404's picture
Upload folder using huggingface_hub
f55c62a verified
Raw
History Blame Contribute Delete
12.6 kB
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<string, DiffAnnotation>;
/**
* 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<string, number> = {
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 (
<div className="text-center p-4 text-[--muted]">No changes detected.</div>
);
}
return (
<div className="w-full mt-4">
<Tabs defaultValue="visual" className="w-full">
<TabsList className="mb-4 grid w-full grid-cols-2">
<TabsTrigger value="visual">Visual</TabsTrigger>
<TabsTrigger value="json">Raw JSON</TabsTrigger>
</TabsList>
<TabsContent
value="visual"
className="max-h-[60vh] overflow-y-auto pr-2 custom-scrollbar"
>
<div className="space-y-3">
{sortedDiffs.map((diff, idx) => {
const pathKey = diff.path.join('.').replace(/\.\[/g, '[');
const pathLabel = pathFormatter
? pathFormatter(diff.path)
: pathKey;
const annotation = annotations?.get(pathKey);
return (
<div
key={`${diff.path.join('.')}-${diff.type}-${idx}`}
className="p-3 bg-gray-800/50 rounded-lg border border-gray-700 relative group"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 flex-wrap">
<Badge type={diff.type} />
{annotation && (
<span
className={`px-2 py-0.5 text-[10px] font-semibold rounded border ${annotation.className ?? 'bg-blue-500/10 text-blue-400 border-blue-500/20'}`}
>
{annotation.label}
</span>
)}
<span className="font-mono text-sm text-gray-300">
{pathLabel}
</span>
</div>
</div>
{annotation?.description && (
<div
className={`mb-2 text-xs pl-2 border-l-2 border-current/50 italic ${extractTextClass(annotation.className)}`}
>
{annotation.description}
</div>
)}
<div
className={`grid gap-4 text-sm ${
diff.type === 'CHANGE' ? 'grid-cols-2' : 'grid-cols-1'
}`}
>
{diff.type !== 'ADD' && (
<div className="space-y-1">
<div className="text-xs text-[--muted] uppercase">
Old
</div>
<div className="p-2 bg-red-900/20 text-red-200 rounded break-all border border-red-900/30 font-mono text-xs whitespace-pre-wrap">
{format(diff.oldValue)}
</div>
</div>
)}
{diff.type !== 'REMOVE' && (
<div className="space-y-1">
<div className="text-xs text-[--muted] uppercase">
New
</div>
<div className="p-2 bg-green-900/20 text-green-200 rounded break-all border border-green-900/30 font-mono text-xs whitespace-pre-wrap">
{format(diff.newValue)}
</div>
</div>
)}
</div>
</div>
);
})}
</div>
</TabsContent>
<TabsContent
value="json"
className="relative max-h-[60vh] overflow-hidden rounded-md border border-gray-800 bg-gray-950/50 flex flex-col group"
>
<JsonDiffContent textDiffs={textDiffs} />
</TabsContent>
</Tabs>
</div>
);
}
function JsonDiffContent({ textDiffs }: { textDiffs: LineDiff[] }) {
const [currentChangeIndex, setCurrentChangeIndex] = useState(0);
const scrollContainerRef = useRef<HTMLDivElement>(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<typeof setTimeout> | 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 && (
<div className="absolute top-4 right-4 z-10 flex items-center gap-2 bg-gray-900/90 border border-gray-700 rounded-md p-1.5 shadow-lg backdrop-blur-sm opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<span className="text-xs text-gray-400 font-mono px-2 select-none">
{currentChangeIndex + 1} / {changeIndices.length}
</span>
<div className="h-4 w-px bg-gray-700 mx-1" />
<Button
className="h-7 w-7 p-0 hover:bg-gray-800 bg-transparent"
onClick={handlePrev}
title="Previous Change"
aria-label="Previous Change"
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
className="h-7 w-7 p-0 hover:bg-gray-800 bg-transparent"
onClick={handleNext}
title="Next Change"
aria-label="Next Change"
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
)}
<div
ref={scrollContainerRef}
className="overflow-y-auto custom-scrollbar p-4 flex-1 font-mono text-xs"
>
<div className="flex flex-col">
{textDiffs.map((line, idx) => (
<div
key={idx}
ref={(el) => {
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' : ''}`}
>
<div className="w-8 shrink-0 text-right pr-3 select-none text-gray-600 border-r border-gray-800 mr-2">
{line.oldLineNumber || ' '}
</div>
<div className="w-8 shrink-0 text-right pr-3 select-none text-gray-600 border-r border-gray-800 mr-2">
{line.newLineNumber || ' '}
</div>
<pre className="whitespace-pre-wrap break-all flex-1">
{line.type === 'add' ? '+' : line.type === 'remove' ? '-' : ' '}{' '}
{line.content}
</pre>
</div>
))}
{textDiffs.length === 0 && (
<div className="text-center p-4 text-[--muted]">
No raw JSON available for comparison.
</div>
)}
</div>
</div>
</>
);
}
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 (
<span
className={`px-2 py-0.5 text-[10px] font-bold rounded border ${colorClass}`}
>
{type}
</span>
);
}