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')}>
        &quot;{value}&quot;
      </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>
  );
}