JsonTreeView.tsx5.4 KBView on GitHub import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react';
import { useState } from 'react';
import { cn } from '@/styles/stylingUtils';
interface JsonNodeProps {
label?: string;
value: unknown;
depth: number;
path: string;
onCopy: (text: string, id: string) => void;
copiedId: string | null;
defaultExpandDepth?: number;
}
function getValuePreview(value: unknown): string {
if (value === null) return 'null';
if (Array.isArray(value)) return `[${value.length}]`;
if (typeof value === 'object') {
const keys = Object.keys(value as object);
return keys.length === 0 ? '{}' : `{ ${keys.slice(0, 3).join(', ')}${keys.length > 3 ? ', …' : ''} }`;
}
if (typeof value === 'string') {
const str = value.length > 60 ? value.slice(0, 60) + '…' : value;
return `"${str}"`;
}
return String(value);
}
function PrimitiveValue({ value }: { value: unknown }) {
if (value === null) return <span className="text-gray-400">null</span>;
if (value === undefined) return <span className="text-gray-400">undefined</span>;
if (typeof value === 'boolean')
return <span className="text-orange-500 dark:text-orange-400">{String(value)}</span>;
if (typeof value === 'number')
return <span className="text-blue-600 dark:text-blue-400">{String(value)}</span>;
if (typeof value === 'string') {
const isDate = /^\d{4}-\d{2}-\d{2}T/.test(value);
return (
<span className={cn(isDate ? 'text-purple-600 dark:text-purple-400' : 'text-green-700 dark:text-green-400')}>
"{value}"
</span>
);
}
return <span>{String(value)}</span>;
}
function JsonNode({ label, value, depth, path, onCopy, copiedId, defaultExpandDepth = 2 }: JsonNodeProps) {
const isArray = Array.isArray(value);
const isObject = value !== null && typeof value === 'object';
const isExpandable = isObject;
const [isExpanded, setIsExpanded] = useState(depth < defaultExpandDepth);
const childEntries = isExpandable
? isArray
? (value as unknown[]).map((v, i) => [String(i), v] as [string, unknown])
: Object.entries(value as object)
: [];
const copyJson = (e: React.MouseEvent) => {
e.stopPropagation();
try {
onCopy(JSON.stringify(value, null, 2), path);
} catch {
onCopy(String(value), path);
}
};
const isCopied = copiedId === path;
if (!isExpandable) {
return (
<div className="flex min-w-0 items-baseline gap-1 py-0.5 font-mono text-xs">
{label !== undefined && (
<span className="shrink-0 text-gray-500 dark:text-gray-400">{label}:</span>
)}
<PrimitiveValue value={value} />
</div>
);
}
const bracket = isArray ? ['[', ']'] : ['{', '}'];
const count = childEntries.length;
return (
<div className={cn(depth > 0 && 'border-l border-border/40 pl-3')}>
<div
className="group flex cursor-pointer items-center gap-1 rounded py-0.5 hover:bg-muted/40 font-mono text-xs"
onClick={() => setIsExpanded((v) => !v)}
>
{isExpanded ? (
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
)}
{label !== undefined && (
<span className="shrink-0 text-gray-500 dark:text-gray-400">{label}:</span>
)}
<span className="text-gray-400">{bracket[0]}</span>
{!isExpanded && (
<>
<span className="text-muted-foreground">{count === 0 ? '' : getValuePreview(value)}</span>
<span className="text-gray-400">{bracket[1]}</span>
<span className="ml-1 text-muted-foreground/60">{count} {isArray ? 'items' : 'keys'}</span>
</>
)}
<button
onClick={copyJson}
className="ml-auto shrink-0 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-muted"
>
{isCopied ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3 text-muted-foreground" />
)}
</button>
</div>
{isExpanded && (
<div className="pl-1">
{count === 0 ? (
<div className="py-0.5 pl-4 font-mono text-xs text-muted-foreground">
{isArray ? '(empty array)' : '(empty object)'}
</div>
) : (
childEntries.map(([key, val]) => (
<JsonNode
key=[redacted]
label={key}
value={val}
depth={depth + 1}
path={`${path}.${key}`}
onCopy={onCopy}
copiedId={copiedId}
defaultExpandDepth={defaultExpandDepth}
/>
))
)}
<div className="font-mono text-xs text-gray-400">{bracket[1]}</div>
</div>
)}
</div>
);
}
interface JsonTreeViewProps {
data: unknown;
onCopy: (text: string, id: string) => void;
copiedId: string | null;
defaultExpandDepth?: number;
className?: string;
}
export function JsonTreeView({ data, onCopy, copiedId, defaultExpandDepth = 2, className }: JsonTreeViewProps) {
return (
<div className={cn('overflow-auto rounded bg-white/50 p-2 dark:bg-black/20', className)}>
<JsonNode
value={data}
depth={0}
path="root"
onCopy={onCopy}
copiedId={copiedId}
defaultExpandDepth={defaultExpandDepth}
/>
</div>
);
}