{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "motion-code-tree-viewer",
  "title": "Code tree viewer",
  "description": "A file tree beside a code panel with highlighted code, file name and copy.",
  "dependencies": [
    "@headless-tree/core",
    "shiki"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/chat-code-block.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/tree.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "components/core/code-tree-viewer.tsx",
      "content": "'use client';\n\nimport {\n  hotkeysCoreFeature,\n  selectionFeature,\n  syncDataLoaderFeature,\n  type FeatureImplementation,\n  type ItemInstance,\n} from '@headless-tree/core';\nimport { Fragment, useEffect, useMemo, useState } from 'react';\nimport { bundledLanguages, codeToHtml, type BundledTheme } from 'shiki';\n\nimport { cn } from '@/lib/utils';\nimport {\n  FileIcon,\n  FileTextIcon,\n  FolderIcon,\n  FolderOpenIcon,\n} from '@/registry/ndeko/lib/icons';\nimport { CodeBlockCopyButton } from '@/registry/ndeko/ui/chat-code-block';\nimport {\n  Tree,\n  TreeItem,\n  TreeItemLabel,\n  useTree,\n} from '@/registry/ndeko/ui/tree';\n\nexport type CodeTreeFile = {\n  path: string;\n  code: string;\n  language?: string;\n};\n\ntype TreeNode = {\n  name: string;\n  children?: string[];\n};\n\nconst ROOT_ID = '__root__';\n\nconst INDENT = 16;\n\nconst PLAIN_LANGUAGE = 'text';\n\nconst THEMES = {\n  light: 'github-light',\n  dark: 'github-dark',\n} satisfies Record<string, BundledTheme>;\n\nconst EXTENSION_LANGUAGES: Record<string, string> = {\n  ts: 'typescript',\n  tsx: 'tsx',\n  js: 'javascript',\n  jsx: 'jsx',\n  mjs: 'javascript',\n  json: 'json',\n  css: 'css',\n  md: 'markdown',\n  mdx: 'mdx',\n  html: 'html',\n  yml: 'yaml',\n  yaml: 'yaml',\n  sh: 'bash',\n};\n\nconst PROSE_EXTENSIONS = new Set(['md', 'mdx', 'txt']);\n\nconst highlighted = new Map<string, Promise<string>>();\n\nfunction extensionOf(path: string) {\n  const segments = path.split('/');\n  const name = segments.pop() ?? '';\n  const dot = name.lastIndexOf('.');\n  if (dot < 0) {\n    return '';\n  }\n  const extension = name.slice(dot + 1);\n  return extension.toLowerCase();\n}\n\nfunction languageOf(file: CodeTreeFile) {\n  if (file.language) {\n    return file.language;\n  }\n  const extension = extensionOf(file.path);\n  return EXTENSION_LANGUAGES[extension] ?? extension;\n}\n\nfunction highlightableLanguageOf(language: string) {\n  if (language in bundledLanguages) {\n    return language;\n  }\n  return PLAIN_LANGUAGE;\n}\n\nfunction shikiLanguageOf(file: CodeTreeFile) {\n  const language = languageOf(file);\n  return highlightableLanguageOf(language);\n}\n\nfunction highlightKeyOf(file: CodeTreeFile) {\n  const language = shikiLanguageOf(file);\n  return `${language}\\n${file.code}`;\n}\n\nfunction highlight(file: CodeTreeFile) {\n  const key = highlightKeyOf(file);\n  const cached = highlighted.get(key);\n  if (cached) {\n    return cached;\n  }\n  const lang = shikiLanguageOf(file);\n  const pending = codeToHtml(file.code, {\n    lang,\n    themes: THEMES,\n    defaultColor: false,\n  });\n  pending.catch(() => highlighted.delete(key));\n  highlighted.set(key, pending);\n  return pending;\n}\n\nfunction useHighlightedFiles(files: CodeTreeFile[]) {\n  const [htmlByKey, setHtmlByKey] = useState<Record<string, string>>({});\n\n  useEffect(() => {\n    let isCurrent = true;\n\n    function keep(key: string, html: string) {\n      if (!isCurrent) {\n        return;\n      }\n      setHtmlByKey((current) => ({ ...current, [key]: html }));\n    }\n\n    function keepPlainCode() {\n      return null;\n    }\n\n    for (const file of files) {\n      const key = highlightKeyOf(file);\n      highlight(file)\n        .then((html) => keep(key, html))\n        .catch(keepPlainCode);\n    }\n\n    return () => {\n      isCurrent = false;\n    };\n  }, [files]);\n\n  return htmlByKey;\n}\n\nfunction folderChildren(nodes: Map<string, TreeNode>, id: string) {\n  const node = nodes.get(id);\n  if (!node?.children) {\n    return null;\n  }\n  return node.children;\n}\n\nfunction addPath(nodes: Map<string, TreeNode>, path: string) {\n  const segments = path.split('/');\n  let parentId = ROOT_ID;\n  const lastIndex = segments.length - 1;\n  segments.forEach((segment, index) => {\n    const idSegments = segments.slice(0, index + 1);\n    const id = idSegments.join('/');\n    const isFile = index === lastIndex;\n    if (!nodes.has(id)) {\n      const node: TreeNode = { name: segment };\n      if (!isFile) {\n        node.children = [];\n      }\n      nodes.set(id, node);\n      const siblings = folderChildren(nodes, parentId);\n      siblings?.push(id);\n    }\n    parentId = id;\n  });\n}\n\nfunction isFolderNode(node: TreeNode | undefined) {\n  return node?.children !== undefined;\n}\n\nfunction sortChildren(nodes: Map<string, TreeNode>) {\n  function compare(first: string, second: string) {\n    const firstNode = nodes.get(first);\n    const secondNode = nodes.get(second);\n    const firstIsFolder = isFolderNode(firstNode);\n    const secondIsFolder = isFolderNode(secondNode);\n    if (firstIsFolder === secondIsFolder) {\n      return first.localeCompare(second);\n    }\n    if (firstIsFolder) {\n      return -1;\n    }\n    return 1;\n  }\n  for (const node of nodes.values()) {\n    node.children?.sort(compare);\n  }\n}\n\nfunction buildNodes(files: CodeTreeFile[]) {\n  const nodes = new Map<string, TreeNode>();\n  nodes.set(ROOT_ID, { name: '', children: [] });\n  for (const file of files) {\n    addPath(nodes, file.path);\n  }\n  sortChildren(nodes);\n  return nodes;\n}\n\nfunction folderIdsOf(nodes: Map<string, TreeNode>) {\n  const ids: string[] = [];\n  for (const [id, node] of nodes) {\n    const isRealFolder = id !== ROOT_ID && isFolderNode(node);\n    if (isRealFolder) {\n      ids.push(id);\n    }\n  }\n  return ids;\n}\n\nfunction toggleExpanded(item: ItemInstance<TreeNode>) {\n  if (item.isExpanded()) {\n    item.collapse();\n    return;\n  }\n  item.expand();\n}\n\nconst fileSelectionFeature: FeatureImplementation<TreeNode> = {\n  itemInstance: {\n    getProps: ({ tree, item, prev }) => ({\n      ...prev?.(),\n      onClick: () => {\n        item.setFocused();\n        if (item.isFolder()) {\n          toggleExpanded(item);\n          return;\n        }\n        tree.setSelectedItems([item.getId()]);\n        item.primaryAction();\n      },\n    }),\n  },\n};\n\nconst GLYPH =\n  'text-muted-foreground col-start-1 row-start-1 size-4 transition-[opacity,scale,filter] duration-150 ease-(--ease-out-strong)';\n\nconst SHOWN_WHEN_CLOSED =\n  'in-aria-expanded:scale-60 in-aria-expanded:opacity-0 in-aria-expanded:blur-[2px]';\n\nconst SHOWN_WHEN_OPEN =\n  'scale-60 opacity-0 blur-[2px] in-aria-expanded:scale-100 in-aria-expanded:opacity-100 in-aria-expanded:blur-none';\n\nfunction FolderGlyph() {\n  return (\n    <span className='grid'>\n      <FolderIcon className={cn(GLYPH, SHOWN_WHEN_CLOSED)} />\n      <FolderOpenIcon className={cn(GLYPH, SHOWN_WHEN_OPEN)} />\n    </span>\n  );\n}\n\nfunction FileGlyph({ path, className }: { path: string; className?: string }) {\n  const isProse = PROSE_EXTENSIONS.has(extensionOf(path));\n  if (isProse) {\n    return (\n      <FileTextIcon className={cn('text-muted-foreground size-4', className)} />\n    );\n  }\n  return <FileIcon className={cn('text-muted-foreground size-4', className)} />;\n}\n\nfunction NodeGlyph({ item }: { item: ItemInstance<TreeNode> }) {\n  if (item.isFolder()) {\n    return <FolderGlyph />;\n  }\n  return <FileGlyph path={item.getId()} />;\n}\n\nconst CODE_AREA = [\n  'min-h-0 flex-1 overflow-auto font-mono text-[13px] leading-relaxed',\n  '[&_pre]:min-w-max [&_pre]:bg-transparent [&_pre]:px-4 [&_pre]:py-3 [&_pre]:outline-none',\n  '[&_.shiki]:text-(--shiki-light) [&_.shiki_span]:text-(--shiki-light)',\n  'dark:[&_.shiki]:text-(--shiki-dark) dark:[&_.shiki_span]:text-(--shiki-dark)',\n].join(' ');\n\nconst LINE_NUMBERS = [\n  '[&_code]:[counter-reset:line]',\n  '[&_.line]:before:inline-block [&_.line]:before:w-6 [&_.line]:before:me-4 [&_.line]:before:text-end',\n  '[&_.line]:before:text-muted-foreground/60 [&_.line]:before:[counter-increment:line] [&_.line]:before:content-[counter(line)]',\n].join(' ');\n\nfunction PlainCode({ code }: { code: string }) {\n  const lines = code.split('\\n');\n  return (\n    <pre>\n      <code>\n        {lines.map((line, index) => (\n          <Fragment key={index}>\n            <span className='line'>{line}</span>\n            {'\\n'}\n          </Fragment>\n        ))}\n      </code>\n    </pre>\n  );\n}\n\nfunction CodePanel({\n  file,\n  html,\n  showLineNumbers,\n}: {\n  file: CodeTreeFile;\n  html: string | undefined;\n  showLineNumbers: boolean;\n}) {\n  const segments = file.path.split('/');\n  const name = segments.pop() ?? file.path;\n  const folder = segments.join('/');\n  const areaClassName = cn(CODE_AREA, showLineNumbers && LINE_NUMBERS);\n\n  return (\n    <section\n      aria-label={`Code of ${file.path}`}\n      className='flex min-h-0 min-w-0 flex-1 flex-col'\n    >\n      <header className='flex items-center gap-2 py-1.5 ps-4 pe-1.5'>\n        <FileGlyph path={file.path} className='size-3.5' />\n        <span className='text-foreground truncate font-mono text-xs'>\n          {name}\n        </span>\n        {folder && (\n          <span className='text-muted-foreground hidden truncate font-mono text-xs sm:inline'>\n            {folder}\n          </span>\n        )}\n        <CodeBlockCopyButton code={file.code} className='ms-auto' />\n      </header>\n      {html && (\n        <div\n          className={areaClassName}\n          dangerouslySetInnerHTML={{ __html: html }}\n        />\n      )}\n      {!html && (\n        <div className={areaClassName}>\n          <PlainCode code={file.code} />\n        </div>\n      )}\n    </section>\n  );\n}\n\nfunction selectedFileOf(files: CodeTreeFile[], path: string | undefined) {\n  const match = files.find((file) => file.path === path);\n  return match ?? files[0];\n}\n\nfunction nameOf(item: ItemInstance<TreeNode>) {\n  return item.getItemData().name;\n}\n\nfunction isFolderItem(item: ItemInstance<TreeNode>) {\n  return isFolderNode(item.getItemData());\n}\n\nexport type CodeTreeViewerProps = {\n  files: CodeTreeFile[];\n  defaultPath?: string;\n  showLineNumbers?: boolean;\n  className?: string;\n  'aria-label'?: string;\n};\n\nexport function CodeTreeViewer({\n  files,\n  defaultPath,\n  showLineNumbers = true,\n  className,\n  'aria-label': ariaLabel = 'Files',\n}: CodeTreeViewerProps) {\n  const nodes = useMemo(() => buildNodes(files), [files]);\n  const initialFile = selectedFileOf(files, defaultPath);\n  const [selectedPath, setSelectedPath] = useState(initialFile?.path);\n  const htmlByKey = useHighlightedFiles(files);\n  const selectedFile = selectedFileOf(files, selectedPath);\n\n  function showFile(item: ItemInstance<TreeNode>) {\n    setSelectedPath(item.getId());\n  }\n\n  function nodeOf(id: string) {\n    return nodes.get(id) ?? { name: id };\n  }\n\n  function childrenOf(id: string) {\n    return nodes.get(id)?.children ?? [];\n  }\n\n  const tree = useTree<TreeNode>({\n    rootItemId: ROOT_ID,\n    initialState: {\n      expandedItems: folderIdsOf(nodes),\n      selectedItems: [initialFile?.path ?? ''],\n      focusedItem: initialFile?.path ?? null,\n    },\n    indent: INDENT,\n    getItemName: nameOf,\n    isItemFolder: isFolderItem,\n    onPrimaryAction: showFile,\n    dataLoader: { getItem: nodeOf, getChildren: childrenOf },\n    features: [\n      syncDataLoaderFeature,\n      selectionFeature,\n      hotkeysCoreFeature,\n      fileSelectionFeature,\n    ],\n  });\n\n  if (!selectedFile) {\n    return (\n      <div\n        className={cn(\n          'bg-card text-muted-foreground hairline-ring ring-border grid h-40 place-items-center rounded-xl text-[13px]',\n          className\n        )}\n      >\n        No files to show\n      </div>\n    );\n  }\n\n  const selectedKey = highlightKeyOf(selectedFile);\n  const html = htmlByKey[selectedKey];\n\n  return (\n    <div\n      className={cn(\n        'bg-card text-card-foreground hairline-ring ring-border flex h-[30rem] flex-col overflow-hidden rounded-xl shadow-[0_1px_2px_rgb(0_0_0/0.04)] md:h-96 md:flex-row',\n        className\n      )}\n    >\n      <div className='bg-foreground/[0.04] max-h-40 shrink-0 overflow-auto p-1.5 md:max-h-none md:w-56'>\n        <Tree aria-label={ariaLabel} indent={INDENT} tree={tree}>\n          {tree.getItems().map((item) => (\n            <TreeItem key={item.getId()} item={item}>\n              <TreeItemLabel>\n                <span className='flex min-w-0 items-center gap-2'>\n                  <NodeGlyph item={item} />\n                  <span className='truncate'>{item.getItemName()}</span>\n                </span>\n              </TreeItemLabel>\n            </TreeItem>\n          ))}\n        </Tree>\n      </div>\n      <CodePanel\n        file={selectedFile}\n        html={html}\n        showLineNumbers={showLineNumbers}\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/core/code-tree-viewer.tsx"
    }
  ],
  "categories": [
    "motion"
  ],
  "type": "registry:component"
}