{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "csv-viewer",
  "title": "CSV Viewer",
  "description": "A CSV and TSV viewer with parsing, upload, search, zoom, theme-aware grid rendering, and Glide Data Grid.",
  "dependencies": [
    "@glideapps/glide-data-grid@6.0.4-alpha24",
    "papaparse@^5.5.3"
  ],
  "devDependencies": [
    "@types/papaparse@^5.5.2"
  ],
  "registryDependencies": [
    "utils",
    "button",
    "dropdown-menu",
    "input",
    "popover",
    "select",
    "separator",
    "tooltip"
  ],
  "files": [
    {
      "path": "components/extend/csv-viewer.tsx",
      "content": "\"use client\"\n\nimport type * as GlideDataGrid from \"@glideapps/glide-data-grid\"\nimport type {\n  DataEditorRef,\n  GridCell,\n  GridCellKind,\n  GridColumn,\n  GridSelection,\n  Item,\n  Theme,\n} from \"@glideapps/glide-data-grid\"\nimport {\n  CompactSelection,\n  emptyGridSelection,\n} from \"@glideapps/glide-data-grid\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport { Input } from \"@/components/ui/input\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\"\nimport { Separator } from \"@/components/ui/separator\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\nimport { IconPlaceholder } from \"@/components/icon-placeholder\"\n\nimport \"@glideapps/glide-data-grid/dist/index.css\"\n\nimport * as React from \"react\"\nimport Papa from \"papaparse\"\n\nconst ZOOM_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const\nconst CSV_SEARCH_BATCH_ROW_COUNT = 500\nconst CSV_SEARCH_DEBOUNCE_MS = 300\ntype GlideDataGridModule = typeof GlideDataGrid\ntype CsvViewerProps = {\n  className?: string\n  data?: string\n  search?: boolean\n}\ntype CsvSearchResult = {\n  col: number\n  row: number\n  displayValue: string\n  columnTitle: string\n}\nfunction toDisplayString(value: unknown): string {\n  return value === null || value === undefined ? \"\" : String(value)\n}\nfunction normalizeHeaderTitle(header: string, index: number): string {\n  const trimmed = header.trim()\n  return trimmed.length > 0 ? trimmed : `Column ${index + 1}`\n}\nfunction columnIndexToA1(col: number) {\n  let columnNumber = col + 1\n  let columnName = \"\"\n  while (columnNumber > 0) {\n    const remainder = (columnNumber - 1) % 26\n    columnName = String.fromCharCode(65 + remainder) + columnName\n    columnNumber = Math.floor((columnNumber - 1) / 26)\n  }\n  return columnName\n}\nfunction cellAddressToA1(col: number, row: number) {\n  return `${columnIndexToA1(col)}${row + 1}`\n}\nfunction cellMatchesQuery(displayValue: string, query: string) {\n  return displayValue.toLowerCase().includes(query)\n}\nfunction createSingleCellSelection(cell: Item): GridSelection {\n  const [col, row] = cell\n  return {\n    columns: CompactSelection.empty(),\n    rows: CompactSelection.empty(),\n    current: {\n      cell,\n      range: { x: col, y: row, width: 1, height: 1 },\n      rangeStack: [],\n    },\n  }\n}\nasync function findCsvSearchResults(\n  headers: string[],\n  rows: string[][],\n  rawQuery: string\n) {\n  const query = rawQuery.trim().toLowerCase()\n  if (!query) return []\n  const results: CsvSearchResult[] = []\n  const columnCount = Math.max(\n    headers.length,\n    rows.reduce((maxCount, row) => Math.max(maxCount, row.length), 0)\n  )\n  for (\n    let batchStartRow = 0;\n    batchStartRow < rows.length;\n    batchStartRow += CSV_SEARCH_BATCH_ROW_COUNT\n  ) {\n    const batchEndRow = Math.min(\n      batchStartRow + CSV_SEARCH_BATCH_ROW_COUNT,\n      rows.length\n    )\n    for (let row = batchStartRow; row < batchEndRow; row += 1) {\n      const rowValues = rows[row] ?? []\n      for (let col = 0; col < columnCount; col += 1) {\n        const displayValue = rowValues[col] ?? \"\"\n        if (!cellMatchesQuery(displayValue, query)) continue\n        results.push({\n          col,\n          row,\n          displayValue,\n          columnTitle: headers[col] ?? `Column ${col + 1}`,\n        })\n      }\n    }\n    if (batchEndRow < rows.length) {\n      await new Promise<void>((resolve) => {\n        window.setTimeout(resolve, 0)\n      })\n    }\n  }\n  return results\n}\nfunction parseDelimitedText(text: string): {\n  headers: string[]\n  rows: string[][]\n  error: string | null\n} {\n  const results = Papa.parse<Record<string, unknown>>(text, {\n    header: true,\n    skipEmptyLines: \"greedy\",\n  })\n  const objectRows = Array.isArray(results.data)\n    ? results.data.filter(\n        (row): row is Record<string, unknown> =>\n          !!row && typeof row === \"object\" && !Array.isArray(row)\n      )\n    : []\n  const metaFields = Array.isArray(results.meta.fields)\n    ? results.meta.fields.map((field) => String(field))\n    : []\n  const fieldKeys =\n    metaFields.length > 0\n      ? metaFields\n      : Object.keys(objectRows[0] ?? {}).filter(\n          (key) => key !== \"__parsed_extra\"\n        )\n  const extraColumnCount = objectRows.reduce((maxCount, row) => {\n    const extras = row.__parsed_extra\n    return Array.isArray(extras) ? Math.max(maxCount, extras.length) : maxCount\n  }, 0)\n  const headers = [\n    ...fieldKeys.map((field, index) => normalizeHeaderTitle(field, index)),\n    ...Array.from(\n      { length: extraColumnCount },\n      (_, index) => `Extra ${index + 1}`\n    ),\n  ]\n  const rows = objectRows.map((row) => {\n    const baseValues = fieldKeys.map((fieldKey) =>\n      toDisplayString(row[fieldKey])\n    )\n    const extras = Array.isArray(row.__parsed_extra)\n      ? row.__parsed_extra.map((value) => toDisplayString(value))\n      : []\n    const paddedExtras =\n      extras.length >= extraColumnCount\n        ? extras.slice(0, extraColumnCount)\n        : [\n            ...extras,\n            ...Array.from(\n              { length: extraColumnCount - extras.length },\n              () => \"\"\n            ),\n          ]\n    return [...baseValues, ...paddedExtras]\n  })\n  const firstError =\n    Array.isArray(results.errors) && results.errors.length > 0\n      ? results.errors[0]\n      : null\n  return {\n    headers,\n    rows,\n    error:\n      rows.length === 0 && firstError\n        ? String(firstError.message ?? \"Could not parse CSV file.\")\n        : null,\n  }\n}\nfunction ensureCsvExtension(fileName: string) {\n  const lowerFileName = fileName.toLowerCase()\n  return lowerFileName.endsWith(\".csv\") || lowerFileName.endsWith(\".tsv\")\n    ? fileName\n    : `${fileName}.csv`\n}\nfunction downloadTextFile(text: string, fileName: string, type: string) {\n  const url = URL.createObjectURL(new Blob([text], { type }))\n  const anchor = document.createElement(\"a\")\n  anchor.href = url\n  anchor.download = fileName\n  anchor.rel = \"noopener\"\n  document.body.append(anchor)\n  anchor.click()\n  anchor.remove()\n  window.setTimeout(() => URL.revokeObjectURL(url), 0)\n}\nfunction CsvFileActionsMenu({\n  downloadDisabled,\n  isPending,\n  onDownload,\n  onUploadClick,\n}: {\n  downloadDisabled: boolean\n  isPending: boolean\n  onDownload: () => void\n  onUploadClick: () => void\n}) {\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger\n        render={\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon-sm\"\n            aria-label=\"Open CSV actions\"\n            disabled={isPending}\n          >\n            <IconPlaceholder\n              lucide=\"Ellipsis\"\n              tabler=\"IconDots\"\n              hugeicons=\"MoreHorizontalIcon\"\n              phosphor=\"DotsThreeIcon\"\n              remixicon=\"RiMoreLine\"\n              className=\"size-4\"\n            />\n          </Button>\n        }\n      ></DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\" className=\"w-40\">\n        <DropdownMenuItem disabled={downloadDisabled} onClick={onDownload}>\n          <IconPlaceholder\n            lucide=\"Download\"\n            tabler=\"IconDownload\"\n            hugeicons=\"Download01Icon\"\n            phosphor=\"DownloadSimpleIcon\"\n            remixicon=\"RiDownload2Line\"\n            className=\"size-4\"\n          />\n          Download\n        </DropdownMenuItem>\n        <DropdownMenuItem disabled={isPending} onClick={onUploadClick}>\n          {isPending ? (\n            <InlineSpinner className=\"size-4\" />\n          ) : (\n            <IconPlaceholder\n              lucide=\"Upload\"\n              tabler=\"IconUpload\"\n              hugeicons=\"Upload01Icon\"\n              phosphor=\"UploadSimpleIcon\"\n              remixicon=\"RiUpload2Line\"\n              className=\"size-4\"\n            />\n          )}\n          Upload\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}\nfunction ToolbarTooltip({\n  label,\n  children,\n}: {\n  label: string\n  children: React.ReactNode\n}) {\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        render={<span className=\"inline-flex\">{children}</span>}\n      ></TooltipTrigger>\n      <TooltipContent side=\"bottom\">{label}</TooltipContent>\n    </Tooltip>\n  )\n}\nfunction CsvSearchPopover({\n  headers,\n  rows,\n  gridRef,\n  controlsDisabled,\n  onGridSelectionChange,\n}: {\n  headers: string[]\n  rows: string[][]\n  gridRef: React.RefObject<DataEditorRef | null>\n  controlsDisabled: boolean\n  onGridSelectionChange: (selection: GridSelection) => void\n}) {\n  const [searchDraft, setSearchDraft] = React.useState(\"\")\n  const [searchQuery, setSearchQuery] = React.useState(\"\")\n  const [searchResults, setSearchResults] = React.useState<CsvSearchResult[]>(\n    []\n  )\n  const [activeResultIndex, setActiveResultIndex] = React.useState(0)\n  const [isSearching, setIsSearching] = React.useState(false)\n  const searchRequestIdRef = React.useRef(0)\n  const appliedResultKeyRef = React.useRef(\"\")\n  const activeResult = searchResults[activeResultIndex] ?? null\n  const activeResultKey = activeResult\n    ? `${activeResult.row}:${activeResult.col}`\n    : \"\"\n  const hasActiveQuery = Boolean(searchQuery.trim())\n  const resultLabel = isSearching\n    ? \"Searching\"\n    : !hasActiveQuery\n      ? \"No search\"\n      : searchResults.length\n        ? `${activeResultIndex + 1} / ${searchResults.length}`\n        : \"No results\"\n  const runSearch = React.useCallback(\n    (rawQuery: string) => {\n      const nextQuery = rawQuery.trim()\n      const requestId = searchRequestIdRef.current + 1\n      searchRequestIdRef.current = requestId\n      appliedResultKeyRef.current = \"\"\n      setSearchQuery(nextQuery)\n      setActiveResultIndex(0)\n      if (!nextQuery) {\n        setSearchResults([])\n        setIsSearching(false)\n        return\n      }\n      setIsSearching(true)\n      void findCsvSearchResults(headers, rows, nextQuery)\n        .then((nextResults) => {\n          if (searchRequestIdRef.current !== requestId) return\n          setSearchResults(nextResults)\n        })\n        .catch(() => {\n          if (searchRequestIdRef.current !== requestId) return\n          setSearchResults([])\n        })\n        .finally(() => {\n          if (searchRequestIdRef.current !== requestId) return\n          setIsSearching(false)\n        })\n    },\n    [headers, rows]\n  )\n  React.useEffect(() => {\n    if (!searchDraft.trim()) return\n    const timeoutId = window.setTimeout(() => {\n      runSearch(searchDraft)\n    }, CSV_SEARCH_DEBOUNCE_MS)\n    return () => window.clearTimeout(timeoutId)\n  }, [runSearch, searchDraft])\n  const handleSearchDraftChange = (nextDraft: string) => {\n    searchRequestIdRef.current += 1\n    setSearchDraft(nextDraft)\n    if (!nextDraft.trim()) {\n      runSearch(\"\")\n      return\n    }\n    setIsSearching(true)\n  }\n  const clearSearch = React.useCallback(() => {\n    searchRequestIdRef.current += 1\n    setSearchDraft(\"\")\n    setSearchQuery(\"\")\n    setSearchResults([])\n    setActiveResultIndex(0)\n    setIsSearching(false)\n    appliedResultKeyRef.current = \"\"\n    onGridSelectionChange(emptyGridSelection)\n  }, [onGridSelectionChange])\n  const goToRelativeResult = React.useCallback(\n    (direction: 1 | -1) => {\n      if (!searchResults.length) return\n      setActiveResultIndex((currentIndex) => {\n        return (\n          (currentIndex + direction + searchResults.length) %\n          searchResults.length\n        )\n      })\n    },\n    [searchResults.length]\n  )\n  React.useEffect(() => {\n    return () => {\n      searchRequestIdRef.current += 1\n    }\n  }, [])\n  React.useEffect(() => {\n    if (!activeResult) return\n    if (appliedResultKeyRef.current === activeResultKey) return\n    appliedResultKeyRef.current = activeResultKey\n    const cell: Item = [activeResult.col, activeResult.row]\n    onGridSelectionChange(createSingleCellSelection(cell))\n    const frame = window.requestAnimationFrame(() => {\n      gridRef.current?.scrollTo(\n        activeResult.col,\n        activeResult.row,\n        \"both\",\n        48,\n        48,\n        {\n          vAlign: \"center\",\n          hAlign: \"center\",\n        }\n      )\n    })\n    return () => window.cancelAnimationFrame(frame)\n  }, [activeResult, activeResultKey, gridRef, onGridSelectionChange])\n  return (\n    <Popover>\n      <ToolbarTooltip label=\"Search CSV\">\n        <PopoverTrigger\n          render={\n            <Button\n              type=\"button\"\n              variant=\"ghost\"\n              size=\"icon-sm\"\n              aria-label=\"Search CSV\"\n              disabled={controlsDisabled}\n            >\n              <IconPlaceholder\n                lucide=\"Search\"\n                tabler=\"IconSearch\"\n                hugeicons=\"Search01Icon\"\n                phosphor=\"MagnifyingGlassIcon\"\n                remixicon=\"RiSearchLine\"\n                className=\"size-4\"\n              />\n            </Button>\n          }\n        ></PopoverTrigger>\n      </ToolbarTooltip>\n      <PopoverContent align=\"end\" className=\"w-72\">\n        <div className=\"space-y-3\">\n          <Input\n            placeholder=\"Search CSV\"\n            value={searchDraft}\n            onChange={(event) => handleSearchDraftChange(event.target.value)}\n            onKeyDown={(event) => {\n              if (event.key !== \"Enter\") return\n              event.preventDefault()\n              if (event.shiftKey && searchResults.length) {\n                goToRelativeResult(-1)\n              } else if (searchResults.length) {\n                goToRelativeResult(1)\n              } else if (searchDraft.trim()) {\n                runSearch(searchDraft)\n              }\n            }}\n          />\n          <div className=\"flex items-center justify-between gap-2\">\n            <div className=\"min-w-0 text-xs text-muted-foreground\">\n              <div className=\"truncate\">\n                {searchResults.length ? (\n                  <>\n                    <span className=\"text-primary\">\n                      {activeResultIndex + 1}\n                    </span>\n                    {` / ${searchResults.length}`}\n                  </>\n                ) : (\n                  resultLabel\n                )}\n              </div>\n              {activeResult ? (\n                <div className=\"mt-0.5 truncate\">\n                  {activeResult.columnTitle}!\n                  {cellAddressToA1(activeResult.col, activeResult.row)}\n                </div>\n              ) : null}\n            </div>\n            <div className=\"flex shrink-0 items-center gap-1\">\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                size=\"icon-sm\"\n                aria-label=\"Previous result\"\n                disabled={isSearching || searchResults.length === 0}\n                onClick={() => goToRelativeResult(-1)}\n              >\n                <IconPlaceholder\n                  lucide=\"ChevronLeft\"\n                  tabler=\"IconChevronLeft\"\n                  hugeicons=\"ArrowLeft01Icon\"\n                  phosphor=\"CaretLeftIcon\"\n                  remixicon=\"RiArrowLeftSLine\"\n                  className=\"size-4\"\n                />\n              </Button>\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                size=\"icon-sm\"\n                aria-label=\"Next result\"\n                disabled={isSearching || searchResults.length === 0}\n                onClick={() => goToRelativeResult(1)}\n              >\n                <IconPlaceholder\n                  lucide=\"ArrowRight\"\n                  tabler=\"IconArrowRight\"\n                  hugeicons=\"ArrowRight01Icon\"\n                  phosphor=\"ArrowRightIcon\"\n                  remixicon=\"RiArrowRightLine\"\n                  className=\"size-4\"\n                />\n              </Button>\n            </div>\n          </div>\n          <div className=\"flex justify-end\">\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={clearSearch}\n            >\n              Clear\n            </Button>\n          </div>\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\nfunction readIsDarkTheme() {\n  return (\n    typeof document !== \"undefined\" &&\n    document.documentElement.classList.contains(\"dark\")\n  )\n}\nfunction subscribeToDarkTheme(listener: () => void) {\n  const observer = new MutationObserver(listener)\n  observer.observe(document.documentElement, {\n    attributes: true,\n    attributeFilter: [\"class\"],\n  })\n  return () => observer.disconnect()\n}\nfunction useIsDarkTheme() {\n  return React.useSyncExternalStore(\n    subscribeToDarkTheme,\n    readIsDarkTheme,\n    () => false\n  )\n}\nexport function CsvViewer({ className, data, search = false }: CsvViewerProps) {\n  const inputRef = React.useRef<HTMLInputElement | null>(null)\n  const gridRef = React.useRef<DataEditorRef | null>(null)\n  const isDark = useIsDarkTheme()\n  const [glide, setGlide] = React.useState<GlideDataGridModule | null>(null)\n  const [zoom, setZoom] = React.useState<(typeof ZOOM_OPTIONS)[number]>(1)\n  const [gridSelection, setGridSelection] =\n    React.useState<GridSelection>(emptyGridSelection)\n  const [parsed, setParsed] = React.useState(() =>\n    data ? parseDelimitedText(data) : { headers: [], rows: [], error: null }\n  )\n  const [uploadedFileName, setUploadedFileName] = React.useState<string | null>(\n    null\n  )\n  const [isPending, setIsPending] = React.useState(false)\n  const [dataRevision, setDataRevision] = React.useState(0)\n  const dataIdentity = React.useMemo(\n    () =>\n      `${dataRevision}:${parsed.headers.join(\"\\u0001\")}:${parsed.rows.length}:${parsed.error ?? \"\"}`,\n    [dataRevision, parsed.error, parsed.headers, parsed.rows.length]\n  )\n  const handleGridSelectionChange = React.useCallback(\n    (selection: GridSelection) => {\n      setGridSelection(selection)\n    },\n    []\n  )\n  const [previousData, setPreviousData] = React.useState(data)\n  if (!Object.is(previousData, data)) {\n    setPreviousData(data)\n    if (data) {\n      setParsed(parseDelimitedText(data))\n      setUploadedFileName(null)\n      setDataRevision((revision) => revision + 1)\n    }\n  }\n  const [selectionSource, setSelectionSource] = React.useState({\n    dataRevision,\n    search,\n  })\n  if (\n    selectionSource.dataRevision !== dataRevision ||\n    selectionSource.search !== search\n  ) {\n    setSelectionSource({ dataRevision, search })\n    if (selectionSource.dataRevision !== dataRevision || !search) {\n      setGridSelection(emptyGridSelection)\n    }\n  }\n  React.useEffect(() => {\n    let mounted = true\n    void import(\"@glideapps/glide-data-grid\").then((module) => {\n      if (mounted) {\n        setGlide(module)\n      }\n    })\n    return () => {\n      mounted = false\n    }\n  }, [])\n  const columnCount = Math.max(1, parsed.headers.length)\n  const scale = React.useCallback(\n    (value: number) => Math.round(value * zoom),\n    [zoom]\n  )\n  const searchDisabled =\n    Boolean(parsed.error) || parsed.rows.length === 0 || isPending\n  const theme = React.useMemo<Partial<Theme>>(\n    () => ({\n      accentColor: isDark ? \"#60a5fa\" : \"#2563eb\",\n      accentLight: isDark ? \"#1d4ed826\" : \"#dbeafe\",\n      accentFg: \"#ffffff\",\n      textDark: isDark ? \"#e5e5e5\" : \"#171717\",\n      textMedium: isDark ? \"#a3a3a3\" : \"#525252\",\n      textLight: isDark ? \"#737373\" : \"#a3a3a3\",\n      textBubble: isDark ? \"#f5f5f5\" : \"#171717\",\n      textHeader: isDark ? \"#f5f5f5\" : \"#171717\",\n      textGroupHeader: isDark ? \"#a3a3a3\" : \"#525252\",\n      bgCell: isDark ? \"#0a0a0a\" : \"#ffffff\",\n      bgCellMedium: isDark ? \"#171717\" : \"#fafafa\",\n      bgHeader: isDark ? \"#171717\" : \"#fafafa\",\n      bgHeaderHasFocus: isDark ? \"#262626\" : \"#f5f5f5\",\n      bgHeaderHovered: isDark ? \"#262626\" : \"#f5f5f5\",\n      borderColor: isDark ? \"#262626\" : \"#e5e5e5\",\n      horizontalBorderColor: isDark ? \"#262626\" : \"#e5e5e5\",\n      cellHorizontalPadding: scale(8),\n      cellVerticalPadding: Math.max(2, scale(3)),\n      headerIconSize: scale(18),\n      baseFontStyle: `${scale(13)}px`,\n      headerFontStyle: `600 ${scale(13)}px`,\n      markerFontStyle: `${scale(11)}px`,\n      fontFamily: '-apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif',\n      editorFontSize: `${scale(13)}px`,\n    }),\n    [isDark, scale]\n  )\n  const columns = React.useMemo<GridColumn[]>(\n    () =>\n      Array.from({ length: columnCount }, (_, index) => ({\n        id: `column-${index}`,\n        title: parsed.headers[index] ?? `Column ${index + 1}`,\n        width: scale(index === 0 ? 180 : 160),\n      })),\n    [columnCount, parsed.headers, scale]\n  )\n  const getCellContent = React.useCallback(\n    ([col, row]: Item): GridCell => {\n      const value = parsed.rows[row]?.[col] ?? \"\"\n      const textKind = glide?.GridCellKind.Text as GridCellKind.Text\n      return {\n        kind: textKind,\n        data: value,\n        displayData: value,\n        allowOverlay: true,\n        readonly: true,\n      }\n    },\n    [glide, parsed.rows]\n  )\n  async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {\n    const file = event.target.files?.[0]\n    if (!file) return\n    setIsPending(true)\n    try {\n      const text = await file.text()\n      setParsed(parseDelimitedText(text))\n      setUploadedFileName(file.name)\n      setDataRevision((revision) => revision + 1)\n    } catch (error) {\n      setParsed({\n        headers: [],\n        rows: [],\n        error:\n          error instanceof Error ? error.message : \"Could not read CSV file.\",\n      })\n      setDataRevision((revision) => revision + 1)\n    } finally {\n      event.target.value = \"\"\n      setIsPending(false)\n    }\n  }\n  function stepZoom(direction: -1 | 1) {\n    const index = ZOOM_OPTIONS.indexOf(zoom)\n    const nextIndex = Math.min(\n      ZOOM_OPTIONS.length - 1,\n      Math.max(0, index + direction)\n    )\n    setZoom(ZOOM_OPTIONS[nextIndex])\n  }\n  function handleDownload() {\n    const text = Papa.unparse({\n      fields: parsed.headers,\n      data: parsed.rows,\n    })\n    downloadTextFile(\n      text,\n      ensureCsvExtension(uploadedFileName ?? \"data.csv\"),\n      \"text/csv;charset=utf-8\"\n    )\n  }\n  return (\n    <div\n      className={cn(\n        \"flex h-[560px] w-full flex-col overflow-hidden bg-background\",\n        className\n      )}\n    >\n      <div className=\"flex min-h-12 flex-wrap items-center justify-end gap-2 border-b bg-background px-3 py-2\">\n        <TooltipProvider>\n          <div className=\"ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1\">\n            <div className=\"flex flex-none items-center gap-1\">\n              <ToolbarTooltip label=\"Zoom out\">\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon-sm\"\n                  aria-label=\"Zoom out\"\n                  disabled={zoom <= ZOOM_OPTIONS[0]}\n                  onClick={() => stepZoom(-1)}\n                >\n                  <IconPlaceholder\n                    lucide=\"CircleMinus\"\n                    tabler=\"IconCircleMinus\"\n                    hugeicons=\"MinusSignCircleIcon\"\n                    phosphor=\"MinusCircleIcon\"\n                    remixicon=\"RiIndeterminateCircleLine\"\n                    className=\"size-4\"\n                  />\n                </Button>\n              </ToolbarTooltip>\n              <Select\n                value={zoom.toString()}\n                onValueChange={(value) =>\n                  setZoom(Number(value) as (typeof ZOOM_OPTIONS)[number])\n                }\n                modal={false}\n              >\n                <SelectTrigger\n                  size=\"sm\"\n                  className=\"w-[84px] min-w-[84px]\"\n                  aria-label=\"Zoom level\"\n                >\n                  <SelectValue>{Math.round(zoom * 100)}%</SelectValue>\n                </SelectTrigger>\n                <SelectContent align=\"end\" alignItemWithTrigger={false}>\n                  {ZOOM_OPTIONS.map((option) => (\n                    <SelectItem key={option} value={option.toString()}>\n                      {Math.round(option * 100)}%\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n              <ToolbarTooltip label=\"Zoom in\">\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon-sm\"\n                  aria-label=\"Zoom in\"\n                  disabled={zoom >= ZOOM_OPTIONS[ZOOM_OPTIONS.length - 1]}\n                  onClick={() => stepZoom(1)}\n                >\n                  <IconPlaceholder\n                    lucide=\"CirclePlusIcon\"\n                    tabler=\"IconCirclePlusFilled\"\n                    hugeicons=\"PlusSignCircleIcon\"\n                    phosphor=\"PlusCircleIcon\"\n                    remixicon=\"RiAddCircleFill\"\n                    className=\"size-4\"\n                  />\n                </Button>\n              </ToolbarTooltip>\n            </div>\n            {search ? (\n              <>\n                <Separator\n                  orientation=\"vertical\"\n                  className=\"mx-1 h-4 self-center\"\n                />\n                <CsvSearchPopover\n                  headers={parsed.headers}\n                  rows={parsed.rows}\n                  gridRef={gridRef}\n                  key={dataIdentity}\n                  controlsDisabled={searchDisabled}\n                  onGridSelectionChange={handleGridSelectionChange}\n                />\n              </>\n            ) : null}\n            <Separator\n              orientation=\"vertical\"\n              className=\"mx-1 h-4 self-center\"\n            />\n            <input\n              ref={inputRef}\n              type=\"file\"\n              accept=\".csv,.tsv,text/csv,text/tab-separated-values\"\n              className=\"hidden\"\n              onChange={handleUpload}\n            />\n            <CsvFileActionsMenu\n              downloadDisabled={\n                Boolean(parsed.error) ||\n                isPending ||\n                (parsed.headers.length === 0 && parsed.rows.length === 0)\n              }\n              isPending={isPending}\n              onDownload={handleDownload}\n              onUploadClick={() => inputRef.current?.click()}\n            />\n          </div>\n        </TooltipProvider>\n      </div>\n      <div className=\"min-h-0 flex-1\">\n        {parsed.error ? (\n          <div className=\"flex h-full items-center justify-center px-4 text-center text-sm text-destructive\">\n            {parsed.error}\n          </div>\n        ) : parsed.rows.length === 0 ? (\n          <div className=\"grid h-full place-items-center bg-muted/30 p-4\">\n            <div className=\"max-w-md rounded-lg border bg-background p-4 text-center text-sm shadow-xs\">\n              <p className=\"font-medium\">Upload a CSV to preview</p>\n              <p className=\"mt-1 text-muted-foreground\">\n                Pass delimited text with the <code>data</code> prop or upload a\n                CSV file.\n              </p>\n              <InlineLoadingButton\n                type=\"button\"\n                variant=\"outline\"\n                size=\"sm\"\n                className=\"mt-4\"\n                loading={isPending}\n                onClick={() => inputRef.current?.click()}\n              >\n                <IconPlaceholder\n                  lucide=\"Upload\"\n                  tabler=\"IconUpload\"\n                  hugeicons=\"Upload01Icon\"\n                  phosphor=\"UploadSimpleIcon\"\n                  remixicon=\"RiUpload2Line\"\n                  className=\"size-4\"\n                />\n                Upload CSV\n              </InlineLoadingButton>\n            </div>\n          </div>\n        ) : !glide ? (\n          <div className=\"grid h-full place-items-center bg-background\">\n            <InlineSpinner className=\"size-4\" />\n          </div>\n        ) : (\n          <glide.DataEditor\n            ref={search ? gridRef : undefined}\n            key={zoom}\n            columns={columns}\n            rows={parsed.rows.length}\n            getCellContent={getCellContent}\n            rowMarkers=\"number\"\n            rowSelectionMode=\"multi\"\n            gridSelection={search ? gridSelection : undefined}\n            onGridSelectionChange={\n              search ? handleGridSelectionChange : undefined\n            }\n            scrollToActiveCell={search}\n            keybindings={{ search: true }}\n            smoothScrollX\n            smoothScrollY\n            getCellsForSelection\n            width=\"100%\"\n            height=\"100%\"\n            theme={theme}\n            rowHeight={scale(34)}\n            headerHeight={scale(36)}\n          />\n        )}\n      </div>\n    </div>\n  )\n}\nfunction InlineLoadingButton({\n  loading,\n  disabled,\n  children,\n  className,\n  ...props\n}: React.ComponentProps<typeof Button> & {\n  loading?: boolean\n}) {\n  return (\n    <Button\n      {...props}\n      className={cn(\"relative\", className)}\n      disabled={disabled || loading}\n      aria-busy={loading || undefined}\n    >\n      <span className={cn(\"contents\", loading && \"invisible\")}>{children}</span>\n      {loading ? (\n        <InlineSpinner className=\"pointer-events-none absolute\" />\n      ) : null}\n    </Button>\n  )\n}\nfunction InlineSpinner({ className, ...props }: InlineRegistryIconProps) {\n  return (\n    <IconPlaceholder\n      lucide=\"LoaderCircle\"\n      tabler=\"IconLoader2\"\n      hugeicons=\"Loading03Icon\"\n      phosphor=\"CircleNotchIcon\"\n      remixicon=\"RiLoader4Line\"\n      role=\"status\"\n      aria-label=\"Loading\"\n      className={cn(\"size-4 animate-spin\", className)}\n      {...props}\n    />\n  )\n}\ntype InlineRegistryIconProps = Omit<\n  React.ComponentProps<\"svg\">,\n  \"children\" | \"strokeWidth\"\n> & { strokeWidth?: number }\n",
      "type": "registry:ui",
      "target": "@components/extend/csv-viewer.tsx"
    }
  ],
  "categories": [
    "documents"
  ],
  "type": "registry:ui"
}
