288 lines
9.8 KiB
TypeScript
288 lines
9.8 KiB
TypeScript
import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js"
|
|
import { Dialog } from "@kobalte/core/dialog"
|
|
import type { Command } from "../lib/commands"
|
|
import Kbd from "./kbd"
|
|
|
|
interface CommandPaletteProps {
|
|
open: boolean
|
|
onClose: () => void
|
|
commands: Command[]
|
|
onExecute: (command: Command) => void
|
|
}
|
|
|
|
function buildShortcutString(shortcut: Command["shortcut"]): string {
|
|
if (!shortcut) return ""
|
|
|
|
const parts: string[] = []
|
|
|
|
if (shortcut.meta || shortcut.ctrl) parts.push("cmd")
|
|
if (shortcut.shift) parts.push("shift")
|
|
if (shortcut.alt) parts.push("alt")
|
|
parts.push(shortcut.key)
|
|
|
|
return parts.join("+")
|
|
}
|
|
|
|
const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|
const [query, setQuery] = createSignal("")
|
|
const [selectedCommandId, setSelectedCommandId] = createSignal<string | null>(null)
|
|
const [isPointerSelecting, setIsPointerSelecting] = createSignal(false)
|
|
let inputRef: HTMLInputElement | undefined
|
|
let listRef: HTMLDivElement | undefined
|
|
|
|
const categoryOrder = ["Custom Commands", "Instance", "Session", "Agent & Model", "Input & Focus", "System", "Other"] as const
|
|
|
|
type CommandGroup = { category: string; commands: Command[]; startIndex: number }
|
|
type ProcessedCommands = { groups: CommandGroup[]; ordered: Command[] }
|
|
|
|
const processedCommands = createMemo<ProcessedCommands>(() => {
|
|
const source = props.commands ?? []
|
|
const q = query().trim().toLowerCase()
|
|
|
|
const filtered = q
|
|
? source.filter((cmd) => {
|
|
const label = typeof cmd.label === "function" ? cmd.label() : cmd.label
|
|
const labelMatch = label.toLowerCase().includes(q)
|
|
const descMatch = cmd.description.toLowerCase().includes(q)
|
|
const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(q))
|
|
const categoryMatch = cmd.category?.toLowerCase().includes(q)
|
|
return labelMatch || descMatch || keywordMatch || categoryMatch
|
|
})
|
|
: source
|
|
|
|
const groupsMap = new Map<string, Command[]>()
|
|
for (const cmd of filtered) {
|
|
const category = cmd.category || "Other"
|
|
const list = groupsMap.get(category)
|
|
if (list) {
|
|
list.push(cmd)
|
|
} else {
|
|
groupsMap.set(category, [cmd])
|
|
}
|
|
}
|
|
|
|
const groups: CommandGroup[] = []
|
|
const ordered: Command[] = []
|
|
const processedCategories = new Set<string>()
|
|
|
|
const addGroup = (category: string) => {
|
|
const cmds = groupsMap.get(category)
|
|
if (!cmds || cmds.length === 0 || processedCategories.has(category)) return
|
|
groups.push({ category, commands: cmds, startIndex: ordered.length })
|
|
ordered.push(...cmds)
|
|
processedCategories.add(category)
|
|
}
|
|
|
|
for (const category of categoryOrder) {
|
|
addGroup(category)
|
|
}
|
|
|
|
for (const [category] of groupsMap) {
|
|
addGroup(category)
|
|
}
|
|
|
|
return { groups, ordered }
|
|
})
|
|
|
|
const groupedCommandList = () => processedCommands().groups
|
|
const orderedCommands = () => processedCommands().ordered
|
|
const selectedIndex = createMemo(() => {
|
|
const ordered = orderedCommands()
|
|
if (ordered.length === 0) return -1
|
|
const id = selectedCommandId()
|
|
if (!id) return 0
|
|
const index = ordered.findIndex((cmd) => cmd.id === id)
|
|
return index >= 0 ? index : 0
|
|
})
|
|
|
|
createEffect(() => {
|
|
if (props.open) {
|
|
setQuery("")
|
|
setSelectedCommandId(null)
|
|
setIsPointerSelecting(false)
|
|
setTimeout(() => inputRef?.focus(), 100)
|
|
}
|
|
})
|
|
|
|
createEffect(() => {
|
|
const ordered = orderedCommands()
|
|
if (ordered.length === 0) {
|
|
if (selectedCommandId() !== null) {
|
|
setSelectedCommandId(null)
|
|
}
|
|
return
|
|
}
|
|
|
|
const currentId = selectedCommandId()
|
|
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
|
|
setSelectedCommandId(ordered[0].id)
|
|
}
|
|
})
|
|
|
|
|
|
createEffect(() => {
|
|
const index = selectedIndex()
|
|
if (!listRef || index < 0) return
|
|
|
|
const selectedButton = listRef.querySelector(`[data-command-index="${index}"]`) as HTMLElement
|
|
if (selectedButton) {
|
|
selectedButton.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
|
}
|
|
})
|
|
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
const ordered = orderedCommands()
|
|
|
|
if (e.key === "Escape") {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
props.onClose()
|
|
return
|
|
}
|
|
|
|
if (ordered.length === 0) {
|
|
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter") {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
}
|
|
return
|
|
}
|
|
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setIsPointerSelecting(false)
|
|
const current = selectedIndex()
|
|
const nextIndex = Math.min((current < 0 ? 0 : current) + 1, ordered.length - 1)
|
|
setSelectedCommandId(ordered[nextIndex]?.id ?? null)
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setIsPointerSelecting(false)
|
|
const current = selectedIndex()
|
|
const nextIndex = current <= 0 ? ordered.length - 1 : current - 1
|
|
setSelectedCommandId(ordered[nextIndex]?.id ?? null)
|
|
} else if (e.key === "Enter") {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
const index = selectedIndex()
|
|
if (index < 0 || index >= ordered.length) return
|
|
const command = ordered[index]
|
|
if (!command) return
|
|
props.onExecute(command)
|
|
props.onClose()
|
|
}
|
|
}
|
|
|
|
function handleCommandClick(command: Command) {
|
|
props.onExecute(command)
|
|
props.onClose()
|
|
}
|
|
|
|
function handlePointerLeave() {
|
|
setIsPointerSelecting(false)
|
|
}
|
|
|
|
return (
|
|
|
|
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
|
<Dialog.Portal>
|
|
<Dialog.Overlay class="modal-overlay" />
|
|
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
|
|
<Dialog.Content
|
|
class="modal-surface w-full max-w-2xl max-h-[60vh]"
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
<Dialog.Title class="sr-only">Command Palette</Dialog.Title>
|
|
<Dialog.Description class="sr-only">Search and execute commands</Dialog.Description>
|
|
|
|
<div class="modal-search-container">
|
|
<div class="flex items-center gap-3">
|
|
<svg class="w-5 h-5 modal-search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
/>
|
|
</svg>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query()}
|
|
onInput={(e) => {
|
|
setQuery(e.currentTarget.value)
|
|
setSelectedCommandId(null)
|
|
}}
|
|
placeholder="Type a command or search..."
|
|
class="modal-search-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
ref={listRef}
|
|
class="modal-list-container"
|
|
data-pointer-mode={isPointerSelecting() ? "pointer" : "keyboard"}
|
|
onPointerLeave={handlePointerLeave}
|
|
>
|
|
<Show
|
|
when={orderedCommands().length > 0}
|
|
fallback={<div class="modal-empty-state">No commands found for "{query()}"</div>}
|
|
>
|
|
<For each={groupedCommandList()}>
|
|
{(group) => (
|
|
<div class="py-2">
|
|
<div class="modal-section-header">
|
|
{group.category}
|
|
</div>
|
|
<For each={group.commands}>
|
|
{(command, localIndex) => {
|
|
const commandIndex = group.startIndex + localIndex()
|
|
return (
|
|
<button
|
|
type="button"
|
|
data-command-index={commandIndex}
|
|
onClick={() => handleCommandClick(command)}
|
|
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
|
|
onPointerMove={(event) => {
|
|
if (event.movementX === 0 && event.movementY === 0) return
|
|
if (event.pointerType === "mouse" || event.pointerType === "pen" || event.pointerType === "touch") {
|
|
if (!isPointerSelecting()) {
|
|
setIsPointerSelecting(true)
|
|
}
|
|
setSelectedCommandId(command.id)
|
|
}
|
|
}}
|
|
>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="modal-item-label">
|
|
{typeof command.label === "function" ? command.label() : command.label}
|
|
</div>
|
|
<div class="modal-item-description">
|
|
{command.description}
|
|
</div>
|
|
</div>
|
|
<Show when={command.shortcut}>
|
|
<div class="mt-1">
|
|
<Kbd shortcut={buildShortcutString(command.shortcut)} />
|
|
</div>
|
|
</Show>
|
|
</button>
|
|
)
|
|
}}
|
|
</For>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</Show>
|
|
</div>
|
|
</Dialog.Content>
|
|
</div>
|
|
</Dialog.Portal>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
export default CommandPalette
|