{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "schema-explorer",
  "title": "SchemaExplorer",
  "description": "A collapsible Postgres schema tree: tables to columns, indexes, and relations, with types and a search.",
  "dependencies": [
    "@hugeicons/react",
    "@hugeicons/core-free-icons"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.neon.com/r/input.json",
    "https://ui.neon.com/r/neon-tokens.json"
  ],
  "files": [
    {
      "path": "src/components/schema-explorer/schema-explorer.tsx",
      "content": "\"use client\";\n\nimport {\n  ArrowRight01Icon,\n  Cancel01Icon,\n  Database01Icon,\n  HashIcon,\n  Key01Icon,\n  Link01Icon,\n  Search01Icon,\n  Table01Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport type { ComponentProps, KeyboardEvent } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface ColumnRef {\n  table: string;\n  column: string;\n}\n\nexport interface Column {\n  name: string;\n  /** SQL type, e.g. \"uuid\", \"text\", or \"timestamptz\". */\n  type: string;\n  primaryKey?: boolean;\n  unique?: boolean;\n  nullable?: boolean;\n  references?: ColumnRef;\n}\n\nexport interface Index {\n  name: string;\n  columns: string[];\n  unique?: boolean;\n}\n\nexport interface Table {\n  name: string;\n  /** Postgres schema; defaults to \"public\". */\n  schema?: string;\n  columns: Column[];\n  indexes?: Index[];\n  /** Approximate row count. */\n  rowCount?: number;\n}\n\ninterface TreeRow {\n  id: string;\n  kind: \"table\" | \"column\" | \"index\" | \"relation\";\n  table: Table;\n  column?: Column;\n  index?: Index;\n}\n\nconst formatCount = (value: number) => {\n  const formatter = new Intl.NumberFormat(\"en\", {\n    maximumFractionDigits: 1,\n    notation: \"compact\",\n  });\n  return formatter.format(value).toLowerCase();\n};\n\nconst normalize = (value: string) => value.trim().toLowerCase();\n\nconst includesQuery = (value: string, query: string) =>\n  value.toLowerCase().includes(query);\n\nconst getTableMatches = (table: Table, query: string) => {\n  if (!query || includesQuery(table.name, query)) {\n    return {\n      columns: table.columns,\n      indexes: table.indexes ?? [],\n      tableMatches: true,\n    };\n  }\n\n  return {\n    columns: table.columns.filter(\n      (column) =>\n        includesQuery(column.name, query) || includesQuery(column.type, query)\n    ),\n    indexes: (table.indexes ?? []).filter(\n      (index) =>\n        includesQuery(index.name, query) ||\n        index.columns.some((column) => includesQuery(column, query))\n    ),\n    tableMatches: false,\n  };\n};\n\nconst getVisibleRows = (\n  tables: Table[],\n  openTables: Set<string>,\n  query: string\n) => {\n  const rows: TreeRow[] = [];\n  for (const table of tables) {\n    const matches = getTableMatches(table, query);\n    const relations = matches.columns.filter((column) => column.references);\n    const isVisible =\n      matches.tableMatches ||\n      matches.columns.length > 0 ||\n      matches.indexes.length > 0;\n\n    if (!isVisible) {\n      continue;\n    }\n\n    rows.push({ id: `table:${table.name}`, kind: \"table\", table });\n    if (!(openTables.has(table.name) || query)) {\n      continue;\n    }\n\n    for (const column of matches.columns) {\n      rows.push({\n        column,\n        id: `column:${table.name}:${column.name}`,\n        kind: \"column\",\n        table,\n      });\n    }\n    for (const index of matches.indexes) {\n      rows.push({\n        id: `index:${table.name}:${index.name}`,\n        index,\n        kind: \"index\",\n        table,\n      });\n    }\n    for (const column of relations) {\n      rows.push({\n        column,\n        id: `relation:${table.name}:${column.name}`,\n        kind: \"relation\",\n        table,\n      });\n    }\n  }\n  return rows;\n};\n\nconst Match = ({ children, query }: { children: string; query: string }) => {\n  if (!query) {\n    return children;\n  }\n  const start = children.toLowerCase().indexOf(query);\n  if (start === -1) {\n    return children;\n  }\n  const end = start + query.length;\n  return (\n    <>\n      {children.slice(0, start)}\n      <mark className=\"rounded-sm bg-primary/15 text-primary\">\n        {children.slice(start, end)}\n      </mark>\n      {children.slice(end)}\n    </>\n  );\n};\n\nconst Constraint = ({ children }: { children: string }) => (\n  <span className=\"rounded-[3px] border border-border/70 bg-background px-1 py-px font-medium text-[9px] text-muted-foreground leading-none\">\n    {children}\n  </span>\n);\n\nconst TableRow = ({\n  treeId,\n  table,\n  open,\n  query,\n  active,\n  tabIndex,\n  onFocus,\n  onToggle,\n}: {\n  treeId: string;\n  table: Table;\n  open: boolean;\n  query: string;\n  active: boolean;\n  tabIndex: number;\n  onFocus: () => void;\n  onToggle: () => void;\n}) => (\n  <button\n    aria-expanded={open}\n    aria-level={1}\n    className={cn(\n      \"group flex min-h-10 w-full min-w-0 items-center gap-2 rounded-md px-2 text-left outline-none\",\n      \"hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring/50\",\n      \"active:bg-muted data-[state=open]:bg-muted/35\"\n    )}\n    data-active={active ? \"true\" : undefined}\n    data-state={open ? \"open\" : \"closed\"}\n    data-tree-id={treeId}\n    onClick={onToggle}\n    onFocus={onFocus}\n    role=\"treeitem\"\n    tabIndex={tabIndex}\n    type=\"button\"\n  >\n    <HugeiconsIcon\n      aria-hidden=\"true\"\n      className={cn(\n        \"size-3.5 shrink-0 text-muted-foreground transition-transform duration-100 motion-reduce:transition-none\",\n        open && \"rotate-90\"\n      )}\n      icon={ArrowRight01Icon}\n      strokeWidth={2}\n    />\n    <span className=\"grid size-6 shrink-0 place-items-center rounded-md border border-border/60 bg-background shadow-xs\">\n      <HugeiconsIcon\n        aria-hidden=\"true\"\n        className=\"size-3.5 text-muted-foreground group-data-[state=open]:text-foreground\"\n        icon={Table01Icon}\n        strokeWidth={2}\n      />\n    </span>\n    <span className=\"min-w-0 truncate font-medium text-xs\">\n      <Match query={query}>{table.name}</Match>\n    </span>\n    <span className=\"ml-auto flex shrink-0 items-center gap-1.5 text-[10px] text-muted-foreground tabular-nums\">\n      <span>{table.columns.length} columns</span>\n      {table.rowCount === undefined ? null : (\n        <>\n          <span aria-hidden=\"true\" className=\"text-border\">\n            /\n          </span>\n          <span>{formatCount(table.rowCount)} rows</span>\n        </>\n      )}\n    </span>\n  </button>\n);\n\nconst LeafRow = ({\n  treeId,\n  row,\n  query,\n  active,\n  tabIndex,\n  onFocus,\n  onSelect,\n}: {\n  treeId: string;\n  row: TreeRow;\n  query: string;\n  active: boolean;\n  tabIndex: number;\n  onFocus: () => void;\n  onSelect: () => void;\n}) => {\n  const { column, index, kind } = row;\n  const isColumn = kind === \"column\" && column;\n  const relation = kind === \"relation\" ? column?.references : undefined;\n\n  return (\n    <button\n      aria-level={2}\n      className={cn(\n        \"group relative flex min-h-8 w-full min-w-0 items-center gap-2 rounded-md pr-2 pl-11 text-left outline-none\",\n        \"before:absolute before:top-0 before:bottom-0 before:left-[18px] before:w-px before:bg-border/60\",\n        \"after:absolute after:top-1/2 after:left-[18px] after:h-px after:w-3 after:bg-border/60\",\n        \"hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring/50 active:bg-muted\"\n      )}\n      data-active={active ? \"true\" : undefined}\n      data-tree-id={treeId}\n      onClick={onSelect}\n      onFocus={onFocus}\n      role=\"treeitem\"\n      tabIndex={tabIndex}\n      type=\"button\"\n    >\n      <span className=\"flex size-3.5 shrink-0 items-center justify-center text-muted-foreground/65\">\n        {column?.primaryKey ? (\n          <HugeiconsIcon\n            aria-label=\"Primary key\"\n            className=\"size-3.5 text-primary\"\n            icon={Key01Icon}\n            strokeWidth={2}\n          />\n        ) : null}\n        {kind === \"index\" ? (\n          <HugeiconsIcon\n            aria-hidden=\"true\"\n            className=\"size-3\"\n            icon={HashIcon}\n            strokeWidth={2}\n          />\n        ) : null}\n        {kind === \"relation\" ? (\n          <HugeiconsIcon\n            aria-hidden=\"true\"\n            className=\"size-3\"\n            icon={Link01Icon}\n            strokeWidth={2}\n          />\n        ) : null}\n      </span>\n\n      {isColumn ? (\n        <>\n          <code className=\"min-w-0 truncate bg-transparent p-0 text-foreground/90 text-xs\">\n            <Match query={query}>{column.name}</Match>\n          </code>\n          <span className=\"ml-auto flex shrink-0 items-center gap-1.5\">\n            {column.references ? <Constraint>FK</Constraint> : null}\n            {column.unique && !column.primaryKey ? (\n              <Constraint>UNIQUE</Constraint>\n            ) : null}\n            {column.nullable ? <Constraint>NULL</Constraint> : null}\n            <code className=\"w-20 truncate bg-transparent p-0 text-right text-[11px] text-muted-foreground\">\n              <Match query={query}>{column.type}</Match>\n            </code>\n          </span>\n        </>\n      ) : null}\n\n      {index ? (\n        <>\n          <code className=\"min-w-0 truncate bg-transparent p-0 text-foreground/80 text-[11px]\">\n            <Match query={query}>{index.name}</Match>\n          </code>\n          <span className=\"ml-auto flex min-w-0 items-center gap-1.5\">\n            {index.unique ? <Constraint>UNIQUE</Constraint> : null}\n            <code className=\"max-w-28 truncate bg-transparent p-0 text-[10px] text-muted-foreground\">\n              {index.columns.join(\", \")}\n            </code>\n          </span>\n        </>\n      ) : null}\n\n      {relation && column ? (\n        <>\n          <code className=\"min-w-0 truncate bg-transparent p-0 text-foreground/80 text-[11px]\">\n            {column.name}\n          </code>\n          <HugeiconsIcon\n            aria-hidden=\"true\"\n            className=\"size-3 shrink-0 text-muted-foreground/50\"\n            icon={ArrowRight01Icon}\n            strokeWidth={2}\n          />\n          <code className=\"min-w-0 truncate bg-transparent p-0 text-[11px] text-muted-foreground\">\n            {relation.table}.{relation.column}\n          </code>\n        </>\n      ) : null}\n    </button>\n  );\n};\n\nconst LoadingTree = () => (\n  <output aria-label=\"Loading schema\" className=\"block space-y-1 p-2\">\n    {[72, 58, 84, 64].map((width) => (\n      <div className=\"flex h-10 items-center gap-2 px-2\" key={width}>\n        <span className=\"size-3.5 animate-pulse rounded-sm bg-muted motion-reduce:animate-none\" />\n        <span className=\"size-6 animate-pulse rounded-md bg-muted motion-reduce:animate-none\" />\n        <span\n          className=\"h-3 animate-pulse rounded bg-muted motion-reduce:animate-none\"\n          style={{ width }}\n        />\n      </div>\n    ))}\n  </output>\n);\n\nexport type SchemaExplorerProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  tables: Table[];\n  /** Expanded table names when controlled. */\n  expanded?: string[];\n  /** Table names expanded on first render. */\n  defaultExpanded?: string[];\n  onExpandedChange?: (expanded: string[]) => void;\n  onColumnSelect?: (table: Table, column: Column) => void;\n  onIndexSelect?: (table: Table, index: Index) => void;\n  title?: string;\n  isLoading?: boolean;\n};\n\nexport const SchemaExplorer = ({\n  tables,\n  expanded,\n  defaultExpanded,\n  onExpandedChange,\n  onColumnSelect,\n  onIndexSelect,\n  title = \"public\",\n  isLoading = false,\n  className,\n  ...props\n}: SchemaExplorerProps) => {\n  const controlled = expanded !== undefined;\n  const [uncontrolledExpanded, setUncontrolledExpanded] = useState(\n    () => new Set(defaultExpanded)\n  );\n  const [query, setQuery] = useState(\"\");\n  const [activeId, setActiveId] = useState(() =>\n    tables[0] ? `table:${tables[0].name}` : \"\"\n  );\n  const typeahead = useRef(\"\");\n  const typeaheadTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const treeRef = useRef<HTMLDivElement>(null);\n  const searchRef = useRef<HTMLInputElement>(null);\n  const openTables = useMemo(\n    () => (controlled ? new Set(expanded) : uncontrolledExpanded),\n    [controlled, expanded, uncontrolledExpanded]\n  );\n  const normalizedQuery = normalize(query);\n  const rows = useMemo(\n    () => getVisibleRows(tables, openTables, normalizedQuery),\n    [tables, openTables, normalizedQuery]\n  );\n  const tableCount = rows.filter((row) => row.kind === \"table\").length;\n  const [firstRow] = rows;\n  const resolvedActiveId = rows.some((row) => row.id === activeId)\n    ? activeId\n    : (firstRow?.id ?? \"\");\n\n  useEffect(\n    () => () => {\n      if (typeaheadTimer.current) {\n        clearTimeout(typeaheadTimer.current);\n      }\n    },\n    []\n  );\n\n  const setOpenTables = (next: Set<string>) => {\n    if (!controlled) {\n      setUncontrolledExpanded(next);\n    }\n    onExpandedChange?.([...next]);\n  };\n\n  const toggleTable = (name: string, force?: boolean) => {\n    const next = new Set(openTables);\n    const shouldOpen = force ?? !next.has(name);\n    if (shouldOpen) {\n      next.add(name);\n    } else {\n      next.delete(name);\n    }\n    setOpenTables(next);\n  };\n\n  const activateTable = (name: string) => {\n    if (normalizedQuery) {\n      setQuery(\"\");\n      toggleTable(name, true);\n      return;\n    }\n    toggleTable(name);\n  };\n\n  const focusRow = (id: string) => {\n    setActiveId(id);\n    requestAnimationFrame(() => {\n      treeRef.current\n        ?.querySelector<HTMLElement>(`[data-tree-id=\"${CSS.escape(id)}\"]`)\n        ?.focus();\n    });\n  };\n\n  const navigateByOffset = (index: number, offset: number) => {\n    const next = rows[Math.max(0, Math.min(rows.length - 1, index + offset))];\n    if (next) {\n      focusRow(next.id);\n    }\n  };\n\n  const handleHorizontalKey = (\n    event: KeyboardEvent<HTMLDivElement>,\n    active: TreeRow,\n    index: number\n  ) => {\n    if (event.key === \"ArrowRight\" && active.kind === \"table\") {\n      event.preventDefault();\n      if (openTables.has(active.table.name) || normalizedQuery) {\n        const firstChild = rows[index + 1];\n        if (firstChild?.table.name === active.table.name) {\n          focusRow(firstChild.id);\n        }\n      } else {\n        toggleTable(active.table.name, true);\n      }\n      return true;\n    }\n    if (event.key !== \"ArrowLeft\") {\n      return false;\n    }\n    event.preventDefault();\n    if (active.kind === \"table\" && openTables.has(active.table.name)) {\n      toggleTable(active.table.name, false);\n    } else if (active.kind !== \"table\") {\n      focusRow(`table:${active.table.name}`);\n    }\n    return true;\n  };\n\n  const handleTypeahead = (\n    event: KeyboardEvent<HTMLDivElement>,\n    index: number\n  ) => {\n    if (\n      event.key.length !== 1 ||\n      event.metaKey ||\n      event.ctrlKey ||\n      event.altKey\n    ) {\n      return;\n    }\n    typeahead.current += event.key.toLowerCase();\n    if (typeaheadTimer.current) {\n      clearTimeout(typeaheadTimer.current);\n    }\n    typeaheadTimer.current = setTimeout(() => {\n      typeahead.current = \"\";\n    }, 500);\n    const ordered = [...rows.slice(index + 1), ...rows.slice(0, index + 1)];\n    const match = ordered.find((row) => {\n      const label = row.column?.name ?? row.index?.name ?? row.table.name;\n      return label.toLowerCase().startsWith(typeahead.current);\n    });\n    if (match) {\n      focusRow(match.id);\n    }\n  };\n\n  const onTreeKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const index = rows.findIndex((row) => row.id === resolvedActiveId);\n    const active = rows[index];\n    if (!active) {\n      return;\n    }\n\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      navigateByOffset(index, event.key === \"ArrowDown\" ? 1 : -1);\n      return;\n    }\n    if (event.key === \"Home\" || event.key === \"End\") {\n      event.preventDefault();\n      const edge = event.key === \"Home\" ? rows[0] : rows.at(-1);\n      focusRow(edge?.id ?? active.id);\n      return;\n    }\n    if (handleHorizontalKey(event, active, index)) {\n      return;\n    }\n    if (\n      (event.key === \"Enter\" || event.key === \" \") &&\n      active.kind === \"table\"\n    ) {\n      event.preventDefault();\n      activateTable(active.table.name);\n      return;\n    }\n    if (event.key === \"/\") {\n      event.preventDefault();\n      searchRef.current?.focus();\n      return;\n    }\n    handleTypeahead(event, index);\n  };\n\n  return (\n    <div\n      className={cn(\n        \"@container w-full min-w-0 overflow-hidden rounded-lg border border-border/60 bg-card shadow-xs\",\n        className\n      )}\n      data-slot=\"schema-explorer\"\n      {...props}\n    >\n      <header className=\"flex min-h-11 items-center gap-2 border-border/60 border-b px-3\">\n        <span className=\"grid size-6 place-items-center rounded-md bg-primary/10 text-primary\">\n          <HugeiconsIcon\n            aria-hidden=\"true\"\n            className=\"size-3.5\"\n            icon={Database01Icon}\n            strokeWidth={2}\n          />\n        </span>\n        <div className=\"min-w-0\">\n          <p className=\"truncate font-medium text-xs\">{title}</p>\n          <p className=\"text-[10px] text-muted-foreground\">\n            {tables.length} {tables.length === 1 ? \"table\" : \"tables\"}\n          </p>\n        </div>\n        <span className=\"ml-auto hidden text-[10px] text-muted-foreground @[280px]:block\">\n          <kbd className=\"rounded border border-border/60 bg-background px-1 py-0.5 font-sans shadow-xs\">\n            /\n          </kbd>{\" \"}\n          to search\n        </span>\n      </header>\n\n      <label className=\"flex h-10 items-center gap-2 border-border/60 border-b px-3 focus-within:ring-2 focus-within:ring-inset focus-within:ring-ring/40\">\n        <HugeiconsIcon\n          aria-hidden=\"true\"\n          className=\"size-3.5 shrink-0 text-muted-foreground\"\n          icon={Search01Icon}\n          strokeWidth={2}\n        />\n        <input\n          className=\"min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/65\"\n          onChange={(event) => setQuery(event.target.value)}\n          placeholder=\"Find a table, column, or type\"\n          ref={searchRef}\n          type=\"search\"\n          value={query}\n        />\n        {query ? (\n          <>\n            <span\n              aria-live=\"polite\"\n              className=\"text-[10px] text-muted-foreground tabular-nums\"\n            >\n              {tableCount} {tableCount === 1 ? \"table\" : \"tables\"}\n            </span>\n            <button\n              aria-label=\"Clear search\"\n              className=\"grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 active:scale-[0.96] motion-reduce:active:scale-100\"\n              onClick={() => {\n                setQuery(\"\");\n                searchRef.current?.focus();\n              }}\n              type=\"button\"\n            >\n              <HugeiconsIcon\n                aria-hidden=\"true\"\n                className=\"size-3\"\n                icon={Cancel01Icon}\n                strokeWidth={2}\n              />\n            </button>\n          </>\n        ) : null}\n      </label>\n\n      {isLoading ? <LoadingTree /> : null}\n\n      {!isLoading && rows.length > 0 ? (\n        <div\n          aria-label={`${title} database schema`}\n          className=\"neon-scroll-fade max-h-96 space-y-0.5 overflow-y-auto p-1.5\"\n          onKeyDown={onTreeKeyDown}\n          ref={treeRef}\n          role=\"tree\"\n          tabIndex={-1}\n        >\n          {rows.map((row) => {\n            const active = row.id === resolvedActiveId;\n            const common = {\n              active,\n              onFocus: () => setActiveId(row.id),\n              query: normalizedQuery,\n              tabIndex: active ? 0 : -1,\n            };\n            return (\n              <div key={row.id} role=\"none\">\n                {row.kind === \"table\" ? (\n                  <TableRow\n                    {...common}\n                    onToggle={() => activateTable(row.table.name)}\n                    open={\n                      openTables.has(row.table.name) || Boolean(normalizedQuery)\n                    }\n                    table={row.table}\n                    treeId={row.id}\n                  />\n                ) : (\n                  <LeafRow\n                    {...common}\n                    onSelect={() => {\n                      if (row.kind === \"column\" && row.column) {\n                        onColumnSelect?.(row.table, row.column);\n                      }\n                      if (row.kind === \"index\" && row.index) {\n                        onIndexSelect?.(row.table, row.index);\n                      }\n                    }}\n                    row={row}\n                    treeId={row.id}\n                  />\n                )}\n              </div>\n            );\n          })}\n        </div>\n      ) : null}\n\n      {!isLoading && rows.length === 0 ? (\n        <div className=\"grid min-h-36 place-items-center px-6 py-8 text-center\">\n          <div>\n            <div className=\"mx-auto mb-2 grid size-8 place-items-center rounded-lg border border-border/60 bg-background shadow-xs\">\n              <HugeiconsIcon\n                aria-hidden=\"true\"\n                className=\"size-3.5 text-muted-foreground\"\n                icon={Search01Icon}\n                strokeWidth={2}\n              />\n            </div>\n            <p className=\"font-medium text-xs\">No schema objects found</p>\n            <p className=\"mt-1 text-[11px] text-muted-foreground\">\n              Try a table, column, or Postgres type.\n            </p>\n          </div>\n        </div>\n      ) : null}\n    </div>\n  );\n};\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}