import React, { useEffect, useMemo, useRef, useState } from 'react'; import { ChevronDown, ChevronRight } from 'lucide-react'; import type { DiffNode, DiffType } from '../types'; import { cn } from '@/lib/utils'; export type TreeSide = 'left' | 'right'; export interface JsonTreeProps extends React.HTMLAttributes { node: DiffNode; side: TreeSide; defaultExpandDepth?: number; activePath?: string; } interface NodeRowProps { node: DiffNode; side: TreeSide; depth: number; defaultExpandDepth: number; activePath?: string; isLastChild: boolean; } const formatPrimitive = (v: unknown): string => { if (v === null) return 'null'; if (typeof v === 'string') return JSON.stringify(v); if (typeof v === 'number' || typeof v === 'boolean') return String(v); return JSON.stringify(v); }; const shouldRenderOnSide = (type: DiffType, side: TreeSide): boolean => { if (type === 'added') return side === 'right'; if (type === 'removed') return side === 'left'; return true; }; const getValueForSide = (node: DiffNode, side: TreeSide): unknown => { return side === 'left' ? node.oldValue : node.newValue; }; const typeThemeMap = { added: { text: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-500/5 dark:bg-emerald-500/10 hover:bg-emerald-500/10 dark:hover:bg-emerald-500/15', }, removed: { text: 'text-destructive', bg: 'bg-destructive/5 dark:bg-destructive/10 hover:bg-destructive/10 dark:hover:bg-destructive/15', }, modified: { text: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-500/5 dark:bg-amber-500/10 hover:bg-amber-500/10 dark:hover:bg-amber-500/15', }, unchanged: { text: 'text-foreground/80', bg: 'hover:bg-muted/60', }, }; const isContainerValue = (v: unknown): boolean => { return (typeof v === 'object' && v !== null) || Array.isArray(v); }; const NodeRow = React.memo( ({ node, side, depth, defaultExpandDepth, activePath, isLastChild }: NodeRowProps) => { const [override, setOverride] = useState<'auto' | 'open' | 'closed'>('auto'); const rowRef = useRef(null); const onActivePath = useMemo(() => { return Boolean( activePath && (activePath === node.path || activePath.startsWith(`${node.path}.`) || activePath.startsWith(`${node.path}[`)), ); }, [activePath, node.path]); const expanded = override === 'open' ? true : override === 'closed' ? false : onActivePath || depth < defaultExpandDepth; useEffect(() => { if (activePath === node.path) { rowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, [activePath, node.path]); if (!shouldRenderOnSide(node.type, side)) { return (
ยท
); } const value = getValueForSide(node, side); const isContainer = isContainerValue(value) && Array.isArray(node.children); const isArray = Array.isArray(value); const theme = typeThemeMap[node.type] || typeThemeMap.unchanged; const isActive = activePath === node.path; const isRoot = depth === 0; const indentStyle = { paddingLeft: `${Math.max(0.25, depth * 1.15)}rem`, }; const indentClass = cn( 'relative', depth > 0 && 'before:absolute before:left-[4px] before:top-0 before:bottom-0 before:w-[1px] before:bg-border/40', ); if (isContainer && node.children) { const open = isArray ? '[' : '{'; const close = isArray ? ']' : '}'; return (
setOverride(expanded ? 'closed' : 'open')} className={cn( 'group flex items-center gap-1 py-0.5 pr-2 text-xs font-mono select-none cursor-pointer rounded-sm w-full h-[22px] leading-relaxed', theme.bg, isActive && 'bg-primary/10 relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-0.5 before:bg-blue-500 rounded-none ring-0', )} style={indentStyle} > {expanded ? ( ) : ( )} {!isRoot && ( {node.key}: )} {open} {!expanded && ( {summarize(value)} )} {!expanded && ( {close} {isLastChild ? '' : ','} )}
{expanded && (
{node.children.map((child, idx) => ( ))}
)} {expanded && (
{close} {isLastChild ? '' : ','}
)}
); } return (
{!isRoot && ( {node.key}: )} {formatPrimitive(value)} {isLastChild ? '' : ','}
); }, ); NodeRow.displayName = 'NodeRow'; export default function JsonTree({ node, side, defaultExpandDepth = 2, activePath, className, ...props }: JsonTreeProps) { return (
); } const summarize = (v: unknown): string => { if (Array.isArray(v)) return `${v.length} ${v.length === 1 ? 'item' : 'items'}`; if (v && typeof v === 'object') { const n = Object.keys(v).length; return `${n} ${n === 1 ? 'key' : 'keys'}`; } return ''; };