{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "json-viewer",
  "title": "JSON Viewer",
  "description": "Collapsible, searchable JSON with typed colors, copy value or path, and chunked large arrays.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "input",
    "@drift-ui/tree-view",
    "@drift-ui/use-copy-to-clipboard"
  ],
  "files": [
    {
      "path": "registry/default/ui/json-viewer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { CheckIcon, CopyIcon, RouteIcon } from \"lucide-react\"\n\nimport { useCopyToClipboard } from \"@/registry/default/hooks/use-copy-to-clipboard\"\nimport {\n  childEntries,\n  expandedToDepth,\n  formatValue,\n  rootEntries,\n  searchJson,\n  sizeOf,\n  type JsonEntry,\n} from \"@/registry/default/lib/json-viewer\"\nimport { cn } from \"@/registry/default/lib/utils\"\nimport { Input } from \"@/registry/default/ui/input\"\nimport { TreeView, type TreeNode } from \"@/registry/default/ui/tree-view\"\n\ntype CopyTarget = \"value\" | \"path\"\n\nconst TYPE_CLASSES: Record<string, string> = {\n  string: \"text-[color-mix(in_oklab,var(--chart-2)_65%,var(--foreground))]\",\n  number: \"text-[color-mix(in_oklab,var(--chart-1)_65%,var(--foreground))]\",\n  boolean: \"text-[color-mix(in_oklab,var(--chart-3)_65%,var(--foreground))]\",\n  object: \"text-muted-foreground italic\",\n}\n\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  const index = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1\n  if (index === -1) return text\n\n  return (\n    <>\n      {text.slice(0, index)}\n      <mark className=\"rounded-sm bg-chart-4/40 text-inherit\">\n        {text.slice(index, index + query.length)}\n      </mark>\n      {text.slice(index + query.length)}\n    </>\n  )\n}\n\nfunction ValueText({ value, query }: { value: unknown; query: string }) {\n  if (Array.isArray(value)) {\n    return (\n      <span className=\"text-muted-foreground\">\n        {value.length === 0 ? \"[]\" : `Array(${value.length})`}\n      </span>\n    )\n  }\n  if (typeof value === \"object\" && value !== null) {\n    return (\n      <span className=\"text-muted-foreground\">\n        {sizeOf(value) === 0 ? \"{}\" : \"{…}\"}\n      </span>\n    )\n  }\n\n  const text = String(value)\n  return (\n    <span className={TYPE_CLASSES[typeof value]}>\n      {typeof value === \"string\" ? (\n        <>\n          &quot;\n          <Highlight text={text} query={query} />\n          &quot;\n        </>\n      ) : (\n        <Highlight text={text} query={query} />\n      )}\n    </span>\n  )\n}\n\nfunction JsonViewer({\n  data,\n  defaultExpandDepth = 1,\n  onCopy,\n  className,\n  \"aria-label\": ariaLabel = \"JSON\",\n  ...props\n}: Omit<React.ComponentProps<\"div\">, \"onCopy\"> & {\n  data: unknown\n  defaultExpandDepth?: number\n  onCopy?: (path: string, value: unknown, target: CopyTarget) => void\n}) {\n  const [expanded, setExpanded] = React.useState(() =>\n    expandedToDepth(data, defaultExpandDepth)\n  )\n  const [expandedBeforeSearch, setExpandedBeforeSearch] = React.useState<\n    string[] | null\n  >(null)\n  const [query, setQuery] = React.useState(\"\")\n  const { copied, copy } = useCopyToClipboard()\n  const needle = query.trim()\n  const { matches } = React.useMemo(\n    () => searchJson(data, needle),\n    [data, needle]\n  )\n  const expandedIds = new Set(expanded)\n  const entriesById = new Map<string, JsonEntry>()\n\n  function copyEntry(entry: JsonEntry, target: CopyTarget) {\n    const text = target === \"value\" ? formatValue(entry.value) : entry.path\n    copy(text, `${target}:${entry.id}`).then(() =>\n      onCopy?.(entry.path, entry.value, target)\n    )\n  }\n\n  function search(next: string) {\n    setQuery(next)\n    const base = expandedBeforeSearch ?? expanded\n    const { ancestors } = searchJson(data, next)\n    setExpandedBeforeSearch(next.trim() ? base : null)\n    setExpanded([...new Set([...base, ...ancestors])])\n  }\n\n  function renderRow(entry: JsonEntry) {\n    if (entry.range) {\n      return (\n        <span\n          data-json-id={entry.id}\n          className=\"font-mono text-xs text-muted-foreground\"\n        >\n          [{entry.range[0]} … {entry.range[1]}]\n        </span>\n      )\n    }\n\n    return (\n      <span\n        data-json-id={entry.id}\n        className=\"inline-flex min-w-0 items-center gap-1.5 font-mono text-xs\"\n      >\n        {entry.key !== null && (\n          <span\n            className={\n              typeof entry.key === \"number\"\n                ? \"text-muted-foreground\"\n                : \"text-foreground\"\n            }\n          >\n            <Highlight\n              text={String(entry.key)}\n              query={typeof entry.key === \"string\" ? needle : \"\"}\n            />\n            :\n          </span>\n        )}\n        <ValueText value={entry.value} query={needle} />\n        <span\n          aria-hidden\n          className=\"inline-flex opacity-0 transition-opacity pointer-coarse:opacity-100 [[role=treeitem]:focus-visible_&]:opacity-100 [[role=treeitem]:hover_&]:opacity-100\"\n        >\n          {([\"value\", \"path\"] as const).map((target) => (\n            <button\n              key={target}\n              type=\"button\"\n              tabIndex={-1}\n              aria-label={`Copy ${target}`}\n              onClick={(event) => {\n                event.stopPropagation()\n                copyEntry(entry, target)\n              }}\n              className=\"inline-flex size-6 items-center justify-center rounded-sm hover:bg-background\"\n            >\n              {copied === `${target}:${entry.id}` ? (\n                <CheckIcon />\n              ) : target === \"value\" ? (\n                <CopyIcon />\n              ) : (\n                <RouteIcon />\n              )}\n            </button>\n          ))}\n        </span>\n      </span>\n    )\n  }\n\n  function toNodes(entries: JsonEntry[]): TreeNode[] {\n    return entries.map((entry) => {\n      entriesById.set(entry.id, entry)\n      return {\n        id: entry.id,\n        label: renderRow(entry),\n        textValue: entry.key === null ? \"\" : String(entry.key),\n        isBranch: entry.isBranch,\n        children:\n          entry.isBranch && expandedIds.has(entry.id)\n            ? toNodes(childEntries(entry.value, entry.path, entry.range))\n            : undefined,\n      }\n    })\n  }\n\n  function handleKeyDown(event: React.KeyboardEvent<HTMLUListElement>) {\n    if (event.key.toLowerCase() !== \"c\") return\n    if (!(event.metaKey || event.ctrlKey)) return\n    if (window.getSelection()?.toString()) return\n    const id = (event.target as HTMLElement).querySelector<HTMLElement>(\n      \"[data-json-id]\"\n    )?.dataset.jsonId\n    const entry = id ? entriesById.get(id) : undefined\n    if (!entry || entry.range) return\n    event.preventDefault()\n    copyEntry(entry, event.shiftKey ? \"path\" : \"value\")\n  }\n\n  return (\n    <div\n      data-slot=\"json-viewer\"\n      className={cn(\"flex flex-col gap-2 rounded-lg border p-2\", className)}\n      {...props}\n    >\n      <div className=\"flex items-center gap-2\">\n        <Input\n          type=\"search\"\n          aria-label=\"Search keys and values\"\n          placeholder=\"Search keys and values\"\n          value={query}\n          onChange={(event) => search(event.target.value)}\n          className=\"h-8\"\n        />\n        <span role=\"status\" className=\"shrink-0 text-xs text-muted-foreground\">\n          {needle &&\n            `${matches.size} ${matches.size === 1 ? \"match\" : \"matches\"}`}\n        </span>\n      </div>\n      <TreeView\n        aria-label={ariaLabel}\n        items={toNodes(rootEntries(data))}\n        expanded={expanded}\n        onExpandedChange={setExpanded}\n        onKeyDown={handleKeyDown}\n        className=\"overflow-auto\"\n      />\n      <span role=\"status\" className=\"sr-only\">\n        {copied ? `Copied ${copied.slice(0, copied.indexOf(\":\"))}` : \"\"}\n      </span>\n    </div>\n  )\n}\n\nexport { JsonViewer }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/lib/json-viewer.ts",
      "content": "export const CHUNK_SIZE = 100\n\nexport type JsonEntry = {\n  id: string\n  path: string\n  key: string | number | null\n  value: unknown\n  isBranch: boolean\n  range?: [number, number]\n}\n\nconst IDENTIFIER_PATTERN = /^[A-Za-z_$][\\w$]*$/\n\nexport function isContainer(value: unknown): value is object {\n  return typeof value === \"object\" && value !== null\n}\n\nexport function sizeOf(value: object) {\n  return Array.isArray(value) ? value.length : Object.keys(value).length\n}\n\nexport function childPath(parent: string, key: string | number) {\n  if (typeof key === \"number\") return `${parent}[${key}]`\n  if (!IDENTIFIER_PATTERN.test(key)) return `${parent}[${JSON.stringify(key)}]`\n  return parent ? `${parent}.${key}` : key\n}\n\nfunction toEntry(parent: string, key: string | number, value: unknown) {\n  const path = childPath(parent, key)\n  return {\n    id: path,\n    path,\n    key,\n    value,\n    isBranch: isContainer(value) && sizeOf(value) > 0,\n  }\n}\n\nexport function childEntries(\n  value: unknown,\n  path: string,\n  range?: [number, number]\n): JsonEntry[] {\n  if (!isContainer(value)) return []\n  if (!Array.isArray(value)) {\n    return Object.entries(value).map(([key, child]) =>\n      toEntry(path, key, child)\n    )\n  }\n\n  const [start, end] = range ?? [0, value.length - 1]\n  if (range || value.length <= CHUNK_SIZE) {\n    return value\n      .slice(start, end + 1)\n      .map((child, offset) => toEntry(path, start + offset, child))\n  }\n\n  return Array.from(\n    { length: Math.ceil(value.length / CHUNK_SIZE) },\n    (_, chunk) => {\n      const chunkStart = chunk * CHUNK_SIZE\n      return {\n        id: `${path}#${chunkStart}`,\n        path,\n        key: null,\n        value,\n        isBranch: true,\n        range: [\n          chunkStart,\n          Math.min(chunkStart + CHUNK_SIZE, value.length) - 1,\n        ] as [number, number],\n      }\n    }\n  )\n}\n\nexport function rootEntries(data: unknown): JsonEntry[] {\n  if (isContainer(data)) return childEntries(data, \"\")\n  return [{ id: \"$\", path: \"\", key: null, value: data, isBranch: false }]\n}\n\nexport function expandedToDepth(data: unknown, depth: number) {\n  const ids: string[] = []\n\n  function walk(entries: JsonEntry[], level: number) {\n    if (level > depth) return\n    for (const entry of entries) {\n      if (!entry.isBranch || entry.range) continue\n      ids.push(entry.id)\n      walk(childEntries(entry.value, entry.path), level + 1)\n    }\n  }\n\n  walk(rootEntries(data), 1)\n  return ids\n}\n\nexport function searchJson(data: unknown, query: string) {\n  const matches = new Set<string>()\n  const ancestors = new Set<string>()\n  const needle = query.trim().toLowerCase()\n  if (!needle) return { matches, ancestors }\n\n  function walk(entries: JsonEntry[], parents: string[]) {\n    for (const entry of entries) {\n      const keyHit =\n        typeof entry.key === \"string\" &&\n        entry.key.toLowerCase().includes(needle)\n      const valueHit =\n        !isContainer(entry.value) &&\n        String(entry.value).toLowerCase().includes(needle)\n      if (keyHit || valueHit) {\n        matches.add(entry.id)\n        for (const parent of parents) ancestors.add(parent)\n      }\n      if (entry.isBranch) {\n        walk(childEntries(entry.value, entry.path, entry.range), [\n          ...parents,\n          entry.id,\n        ])\n      }\n    }\n  }\n\n  walk(rootEntries(data), [])\n  return { matches, ancestors }\n}\n\nexport function formatValue(value: unknown) {\n  return typeof value === \"string\" ? value : JSON.stringify(value, null, 2)\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}