{
  "name": "command-palette",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "path": "ui/command-palette.tsx",
      "content": "\"use client\";\n\nimport {\n  ChevronRight,\n  Clock,\n  CornerDownLeft,\n  Loader2,\n  type LucideIcon,\n  Search,\n  X,\n} from \"lucide-react\";\nimport { AnimatePresence, LayoutGroup, motion } from \"motion/react\";\nimport type React from \"react\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CommandItem {\n  id: string;\n  label: string;\n  description?: string;\n  icon?: LucideIcon;\n  shortcut?: string[];\n  keywords?: string[];\n  onSelect?: () => void;\n  children?: CommandGroup[]; // Support for sub-menus\n  disabled?: boolean;\n}\n\nexport interface CommandGroup {\n  id: string;\n  heading: string;\n  items: CommandItem[];\n}\n\nexport interface CommandPaletteProps {\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  groups: CommandGroup[];\n  placeholder?: string;\n  emptyMessage?: string;\n  shortcut?: string[];\n  loading?: boolean;\n  showRecent?: boolean;\n  maxRecent?: number;\n}\n\ninterface FlattenedItem {\n  type: \"group\" | \"item\";\n  groupId: string;\n  groupHeading?: string;\n  item?: CommandItem;\n  score?: number;\n  matches?: [number, number][];\n}\n\ninterface PageState {\n  id: string;\n  title: string;\n  groups: CommandGroup[];\n}\n\nexport function CommandPalette({\n  open: controlledOpen,\n  onOpenChange,\n  groups: initialGroups,\n  placeholder = \"Type a command or search...\",\n  emptyMessage = \"No results found.\",\n  shortcut = [\"⌘\", \"K\"],\n  loading = false,\n  showRecent = true,\n  maxRecent = 5,\n}: CommandPaletteProps) {\n  const [internalOpen, setInternalOpen] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [selectedIndex, setSelectedIndex] = useState(0);\n  const [pages, setPages] = useState<PageState[]>([\n    { id: \"root\", title: \"Root\", groups: initialGroups },\n  ]);\n  const [recentItems, setRecentItems] = useState<CommandItem[]>([]);\n\n  const inputRef = useRef<HTMLInputElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const itemRefs = useRef<Map<number, HTMLDivElement>>(new Map());\n\n  const isOpen = controlledOpen ?? internalOpen;\n  const setOpen = onOpenChange ?? setInternalOpen;\n  const currentPage = pages[pages.length - 1] || pages[0];\n\n  // Load recent items from localStorage\n  useEffect(() => {\n    if (typeof window !== \"undefined\") {\n      const saved = localStorage.getItem(\"jolyui-command-recent\");\n      if (saved) {\n        try {\n          setRecentItems(JSON.parse(saved));\n        } catch (e) {\n          console.error(\"Failed to load recent items\", e);\n        }\n      }\n    }\n  }, []);\n\n  const saveRecent = useCallback(\n    (item: CommandItem) => {\n      setRecentItems((prev) => {\n        const filtered = prev.filter((i) => i.id !== item.id);\n        const updated = [item, ...filtered].slice(0, maxRecent);\n        localStorage.setItem(\"jolyui-command-recent\", JSON.stringify(updated));\n        return updated;\n      });\n    },\n    [maxRecent],\n  );\n\n  // Flatten and filter items based on search query\n  const flattenedItems = useMemo(() => {\n    const items: FlattenedItem[] = [];\n    const currentGroups = currentPage?.groups || [];\n\n    // Add Recent group if on root page and query is empty\n    if (\n      pages.length === 1 &&\n      query === \"\" &&\n      showRecent &&\n      recentItems.length > 0\n    ) {\n      items.push({ type: \"group\", groupId: \"recent\", groupHeading: \"Recent\" });\n      recentItems.forEach((item) => {\n        items.push({ type: \"item\", groupId: \"recent\", item });\n      });\n    }\n\n    currentGroups.forEach((group) => {\n      const matchedItems: FlattenedItem[] = [];\n\n      group.items.forEach((item) => {\n        if (item.disabled) return;\n\n        const searchText = [item.label, ...(item.keywords || [])].join(\" \");\n        const match = fuzzySearch(query || \"\", searchText);\n\n        if (match) {\n          const labelMatch = fuzzySearch(query || \"\", item.label);\n          matchedItems.push({\n            type: \"item\",\n            groupId: group.id,\n            item,\n            score: match.score,\n            matches: labelMatch?.matches || [],\n          });\n        }\n      });\n\n      matchedItems.sort((a, b) => (b.score || 0) - (a.score || 0));\n\n      if (matchedItems.length > 0) {\n        items.push({\n          type: \"group\",\n          groupId: group.id,\n          groupHeading: group.heading,\n        });\n        items.push(...matchedItems);\n      }\n    });\n\n    return items;\n  }, [currentPage?.groups, query, pages.length, showRecent, recentItems]);\n\n  const selectableItems = useMemo(\n    () => flattenedItems.filter((item) => item.type === \"item\"),\n    [flattenedItems],\n  );\n\n  // Reset selection when query or page changes\n  useEffect(() => {\n    setSelectedIndex(0);\n  }, []);\n\n  // Keyboard shortcut to open (only in uncontrolled mode)\n  useEffect(() => {\n    if (controlledOpen !== undefined) return;\n\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if ((e.metaKey || e.ctrlKey) && e.key === \"k\") {\n        e.preventDefault();\n        setOpen(!isOpen);\n      }\n    };\n\n    document.addEventListener(\"keydown\", handleKeyDown);\n    return () => document.removeEventListener(\"keydown\", handleKeyDown);\n  }, [isOpen, setOpen, controlledOpen]);\n\n  // Focus input when opened\n  useEffect(() => {\n    if (isOpen) {\n      setQuery(\"\");\n      setSelectedIndex(0);\n      setPages([{ id: \"root\", title: \"Root\", groups: initialGroups }]);\n      setTimeout(() => inputRef.current?.focus(), 0);\n    }\n  }, [isOpen, initialGroups]);\n\n  // Scroll selected item into view\n  useEffect(() => {\n    const selectedItem = itemRefs.current.get(selectedIndex);\n    if (selectedItem && listRef.current) {\n      selectedItem.scrollIntoView({ block: \"nearest\" });\n    }\n  }, [selectedIndex]);\n\n  const handleSelect = useCallback(\n    (item: CommandItem) => {\n      if (item.children) {\n        setPages((prev) => [\n          ...prev,\n          {\n            id: item.id,\n            title: item.label,\n            groups: item.children || [],\n          },\n        ]);\n        setQuery(\"\");\n        return;\n      }\n\n      if (item.onSelect) {\n        item.onSelect();\n        saveRecent(item);\n        setOpen(false);\n      }\n    },\n    [saveRecent, setOpen],\n  );\n\n  const handleBack = useCallback(() => {\n    if (pages.length > 1) {\n      setPages((prev) => prev.slice(0, -1));\n      setQuery(\"\");\n    }\n  }, [pages.length]);\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      switch (e.key) {\n        case \"ArrowDown\":\n          e.preventDefault();\n          setSelectedIndex((i) => (i < selectableItems.length - 1 ? i + 1 : 0));\n          break;\n        case \"ArrowUp\":\n          e.preventDefault();\n          setSelectedIndex((i) => (i > 0 ? i - 1 : selectableItems.length - 1));\n          break;\n        case \"Enter\": {\n          e.preventDefault();\n          const selected = selectableItems[selectedIndex]?.item;\n          if (selected) {\n            handleSelect(selected);\n          }\n          break;\n        }\n        case \"Backspace\":\n          if (query === \"\" && pages.length > 1) {\n            e.preventDefault();\n            handleBack();\n          }\n          break;\n        case \"Escape\":\n          e.preventDefault();\n          if (pages.length > 1) {\n            handleBack();\n          } else {\n            setOpen(false);\n          }\n          break;\n      }\n    },\n    [\n      selectableItems,\n      selectedIndex,\n      setOpen,\n      handleSelect,\n      handleBack,\n      query,\n      pages.length,\n    ],\n  );\n\n  return (\n    <AnimatePresence>\n      {isOpen && (\n        <>\n          <motion.div\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0 }}\n            className=\"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm\"\n            onClick={() => setOpen(false)}\n            aria-hidden=\"true\"\n          />\n\n          <div\n            role=\"dialog\"\n            aria-modal=\"true\"\n            className=\"fixed top-[20%] left-1/2 z-50 w-full max-w-xl -translate-x-1/2\"\n          >\n            <motion.div\n              initial={{ opacity: 0, scale: 0.95, y: 10 }}\n              animate={{ opacity: 1, scale: 1, y: 0 }}\n              exit={{ opacity: 0, scale: 0.95, y: 10 }}\n              transition={{ duration: 0.2, ease: \"easeOut\" }}\n              className=\"flex flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl\"\n            >\n              {/* Header / Search */}\n              <div className=\"relative flex items-center gap-3 border-border border-b px-4\">\n                {pages.length > 1 ? (\n                  <button\n                    onClick={handleBack}\n                    className=\"rounded-md p-1 transition-colors hover:bg-muted\"\n                  >\n                    <X className=\"h-4 w-4 rotate-90 text-muted-foreground\" />\n                  </button>\n                ) : (\n                  <Search className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n                )}\n\n                <div className=\"flex min-w-0 flex-1 items-center\">\n                  {pages.length > 1 && (\n                    <div className=\"mr-2 flex shrink-0 items-center gap-1\">\n                      <span className=\"rounded bg-primary/10 px-1.5 py-0.5 font-medium text-primary text-xs\">\n                        {currentPage?.title || \"\"}\n                      </span>\n                      <span className=\"text-muted-foreground\">/</span>\n                    </div>\n                  )}\n                  <input\n                    ref={inputRef}\n                    type=\"text\"\n                    value={query}\n                    onChange={(e) => setQuery(e.target.value)}\n                    onKeyDown={handleKeyDown}\n                    placeholder={\n                      pages.length > 1\n                        ? `Search in ${currentPage?.title || \"\"}...`\n                        : placeholder\n                    }\n                    className=\"h-12 flex-1 bg-transparent text-foreground text-sm outline-none placeholder:text-muted-foreground\"\n                  />\n                </div>\n\n                {loading && (\n                  <Loader2 className=\"h-4 w-4 animate-spin text-muted-foreground\" />\n                )}\n\n                <div className=\"ml-2 flex items-center gap-1\">\n                  {shortcut.map((key, i) => (\n                    <kbd\n                      key={i}\n                      className=\"hidden h-5 min-w-[20px] items-center justify-center rounded bg-muted px-1.5 font-mono text-[10px] text-muted-foreground sm:flex\"\n                    >\n                      {key}\n                    </kbd>\n                  ))}\n                </div>\n              </div>\n\n              {/* Results */}\n              <div\n                ref={listRef}\n                className=\"max-h-[380px] overflow-y-auto scroll-smooth py-2\"\n              >\n                <LayoutGroup id=\"command-list\">\n                  {flattenedItems.length === 0 ? (\n                    <motion.div\n                      initial={{ opacity: 0 }}\n                      animate={{ opacity: 1 }}\n                      className=\"space-y-2 py-12 text-center\"\n                    >\n                      <Search className=\"mx-auto h-8 w-8 text-muted-foreground opacity-20\" />\n                      <p className=\"text-muted-foreground text-sm\">\n                        {emptyMessage}\n                      </p>\n                    </motion.div>\n                  ) : (\n                    flattenedItems.map((flatItem) => {\n                      if (flatItem.type === \"group\") {\n                        return (\n                          <div\n                            key={`group-${flatItem.groupId}`}\n                            className=\"mt-2 px-3 py-2 font-bold text-[10px] text-muted-foreground/70 uppercase tracking-widest first:mt-0\"\n                          >\n                            {flatItem.groupHeading}\n                          </div>\n                        );\n                      }\n\n                      const currentItemIndex = selectableItems.findIndex(\n                        (si) => si.item?.id === flatItem.item?.id,\n                      );\n                      const isSelected = currentItemIndex === selectedIndex;\n                      const item = flatItem.item;\n                      if (!item) return null;\n                      const Icon = item.icon;\n                      const highlightedLabel = highlightMatches(\n                        item.label,\n                        flatItem.matches || [],\n                      );\n\n                      return (\n                        <motion.div\n                          layout\n                          key={item.id}\n                          ref={(el: HTMLDivElement | null) => {\n                            if (el) itemRefs.current.set(currentItemIndex, el);\n                          }}\n                          className={cn(\n                            \"group relative mx-2 flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 transition-all duration-150\",\n                            isSelected &&\n                              \"bg-accent text-accent-foreground shadow-sm\",\n                          )}\n                          onClick={() => handleSelect(item)}\n                          onMouseEnter={() =>\n                            setSelectedIndex(currentItemIndex)\n                          }\n                        >\n                          {isSelected && (\n                            <motion.div\n                              layoutId=\"active-pill\"\n                              className=\"absolute inset-0 -z-10 rounded-lg bg-accent\"\n                              transition={{\n                                type: \"spring\",\n                                bounce: 0.2,\n                                duration: 0.4,\n                              }}\n                            />\n                          )}\n\n                          <div\n                            className={cn(\n                              \"flex h-8 w-8 items-center justify-center rounded-md border transition-colors\",\n                              isSelected\n                                ? \"border-primary/20 bg-background\"\n                                : \"border-transparent bg-muted/50\",\n                            )}\n                          >\n                            {flatItem.groupId === \"recent\" ? (\n                              <Clock className=\"h-4 w-4 text-muted-foreground\" />\n                            ) : Icon ? (\n                              <Icon className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n                            ) : (\n                              <div className=\"h-1 w-1 rounded-full bg-muted-foreground\" />\n                            )}\n                          </div>\n\n                          <div className=\"min-w-0 flex-1\">\n                            <div className=\"truncate font-medium text-foreground text-sm\">\n                              {highlightedLabel.map((part, i) => (\n                                <span\n                                  key={i}\n                                  className={\n                                    part.highlighted\n                                      ? \"font-semibold text-primary\"\n                                      : undefined\n                                  }\n                                >\n                                  {part.text}\n                                </span>\n                              ))}\n                            </div>\n                            {item.description && (\n                              <div className=\"mt-0.5 truncate text-muted-foreground text-xs\">\n                                {item.description}\n                              </div>\n                            )}\n                          </div>\n\n                          <div className=\"flex shrink-0 items-center gap-2\">\n                            {item.children && (\n                              <ChevronRight className=\"h-4 w-4 text-muted-foreground opacity-50\" />\n                            )}\n                            {item.shortcut && !item.children && (\n                              <div className=\"flex items-center gap-1\">\n                                {item.shortcut.map((key, i) => (\n                                  <kbd\n                                    key={i}\n                                    className=\"flex h-4 min-w-[18px] items-center justify-center rounded border border-border/50 bg-muted px-1 font-mono text-[9px] text-muted-foreground\"\n                                  >\n                                    {key}\n                                  </kbd>\n                                ))}\n                              </div>\n                            )}\n                          </div>\n                        </motion.div>\n                      );\n                    })\n                  )}\n                </LayoutGroup>\n              </div>\n\n              {/* Footer */}\n              <div className=\"flex items-center justify-between border-border border-t bg-muted/20 px-4 py-3 text-[10px] text-muted-foreground\">\n                <div className=\"flex items-center gap-4\">\n                  <span className=\"flex items-center gap-1.5\">\n                    <kbd className=\"rounded border border-border/50 bg-muted px-1 py-0.5 font-mono\">\n                      ↑↓\n                    </kbd>\n                    navigate\n                  </span>\n                  <span className=\"flex items-center gap-1.5\">\n                    <kbd className=\"rounded border border-border/50 bg-muted px-1 py-0.5 font-mono\">\n                      <CornerDownLeft className=\"h-2 w-2\" />\n                    </kbd>\n                    select\n                  </span>\n                  {pages.length > 1 && (\n                    <span className=\"flex items-center gap-1.5\">\n                      <kbd className=\"rounded border border-border/50 bg-muted px-1 py-0.5 font-mono\">\n                        esc\n                      </kbd>\n                      back\n                    </span>\n                  )}\n                </div>\n                <div className=\"flex items-center gap-2\">\n                  <span className=\"opacity-50\">JolyUI Command</span>\n                </div>\n              </div>\n            </motion.div>\n          </div>\n        </>\n      )}\n    </AnimatePresence>\n  );\n}\n\nexport interface FuzzyMatch {\n  item: string;\n  score: number;\n  matches: [number, number][];\n}\n\nexport function fuzzySearch(query: string, text: string): FuzzyMatch | null {\n  if (!query) return { item: text, score: 1, matches: [] };\n\n  const queryLower = query.toLowerCase();\n  const textLower = text.toLowerCase();\n\n  let queryIndex = 0;\n  let score = 0;\n  const matches: [number, number][] = [];\n  let currentMatchStart = -1;\n  let consecutiveMatches = 0;\n\n  for (let i = 0; i < text.length && queryIndex < query.length; i++) {\n    if (textLower[i] === queryLower[queryIndex]) {\n      if (currentMatchStart === -1) currentMatchStart = i;\n      consecutiveMatches++;\n      queryIndex++;\n      score += 1 + consecutiveMatches * 0.5;\n      if (i === 0) score += 2;\n      const prevChar = i > 0 ? text[i - 1] : \"\";\n      if (prevChar && /[\\s\\-_]/.test(prevChar)) score += 1.5;\n    } else {\n      if (currentMatchStart !== -1) {\n        matches.push([currentMatchStart, i]);\n        currentMatchStart = -1;\n        consecutiveMatches = 0;\n      }\n    }\n  }\n\n  if (currentMatchStart !== -1) matches.push([currentMatchStart, text.length]);\n  if (queryIndex < query.length) return null;\n\n  return { item: text, score: score / query.length, matches };\n}\n\nexport function highlightMatches(\n  text: string,\n  matches: [number, number][],\n): { text: string; highlighted: boolean }[] {\n  if (matches.length === 0) return [{ text, highlighted: false }];\n\n  const result: { text: string; highlighted: boolean }[] = [];\n  let lastIndex = 0;\n\n  for (const [start, end] of matches) {\n    if (start > lastIndex) {\n      result.push({ text: text.slice(lastIndex, start), highlighted: false });\n    }\n    result.push({ text: text.slice(start, end), highlighted: true });\n    lastIndex = end;\n  }\n\n  if (lastIndex < text.length) {\n    result.push({ text: text.slice(lastIndex), highlighted: false });\n  }\n\n  return result;\n}\n",
      "type": "registry:ui",
      "target": ""
    }
  ],
  "type": "registry:ui"
}