| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337 |
- import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
- import { createStore } from "solid-js/store"
- import { createSimpleContext } from "@opencode-ai/ui/context"
- import { useDialog } from "@opencode-ai/ui/context/dialog"
- import { useLanguage } from "@/context/language"
- import { useSettings } from "@/context/settings"
- import { Persist, persisted } from "@/utils/persist"
- const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
- const PALETTE_ID = "command.palette"
- const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
- const SUGGESTED_PREFIX = "suggested."
- function actionId(id: string) {
- if (!id.startsWith(SUGGESTED_PREFIX)) return id
- return id.slice(SUGGESTED_PREFIX.length)
- }
- function normalizeKey(key: string) {
- if (key === ",") return "comma"
- if (key === "+") return "plus"
- if (key === " ") return "space"
- return key.toLowerCase()
- }
- function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean) {
- const mask = (ctrl ? 1 : 0) | (meta ? 2 : 0) | (shift ? 4 : 0) | (alt ? 8 : 0)
- return `${key}:${mask}`
- }
- function signatureFromEvent(event: KeyboardEvent) {
- return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
- }
- export type KeybindConfig = string
- export interface Keybind {
- key: string
- ctrl: boolean
- meta: boolean
- shift: boolean
- alt: boolean
- }
- export interface CommandOption {
- id: string
- title: string
- description?: string
- category?: string
- keybind?: KeybindConfig
- slash?: string
- suggested?: boolean
- disabled?: boolean
- onSelect?: (source?: "palette" | "keybind" | "slash") => void
- onHighlight?: () => (() => void) | void
- }
- export type CommandCatalogItem = {
- title: string
- description?: string
- category?: string
- keybind?: KeybindConfig
- slash?: string
- }
- export function parseKeybind(config: string): Keybind[] {
- if (!config || config === "none") return []
- return config.split(",").map((combo) => {
- const parts = combo.trim().toLowerCase().split("+")
- const keybind: Keybind = {
- key: "",
- ctrl: false,
- meta: false,
- shift: false,
- alt: false,
- }
- for (const part of parts) {
- switch (part) {
- case "ctrl":
- case "control":
- keybind.ctrl = true
- break
- case "meta":
- case "cmd":
- case "command":
- keybind.meta = true
- break
- case "mod":
- if (IS_MAC) keybind.meta = true
- else keybind.ctrl = true
- break
- case "alt":
- case "option":
- keybind.alt = true
- break
- case "shift":
- keybind.shift = true
- break
- default:
- keybind.key = part
- break
- }
- }
- return keybind
- })
- }
- export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
- const eventKey = normalizeKey(event.key)
- for (const kb of keybinds) {
- const keyMatch = kb.key === eventKey
- const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
- const metaMatch = kb.meta === (event.metaKey || false)
- const shiftMatch = kb.shift === (event.shiftKey || false)
- const altMatch = kb.alt === (event.altKey || false)
- if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
- return true
- }
- }
- return false
- }
- export function formatKeybind(config: string): string {
- if (!config || config === "none") return ""
- const keybinds = parseKeybind(config)
- if (keybinds.length === 0) return ""
- const kb = keybinds[0]
- const parts: string[] = []
- if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
- if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
- if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
- if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
- if (kb.key) {
- const keys: Record<string, string> = {
- arrowup: "↑",
- arrowdown: "↓",
- arrowleft: "←",
- arrowright: "→",
- comma: ",",
- plus: "+",
- space: "Space",
- }
- const key = kb.key.toLowerCase()
- const displayKey = keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1))
- parts.push(displayKey)
- }
- return IS_MAC ? parts.join("") : parts.join("+")
- }
- export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
- name: "Command",
- init: () => {
- const dialog = useDialog()
- const settings = useSettings()
- const language = useLanguage()
- const [store, setStore] = createStore({
- registrations: [] as Accessor<CommandOption[]>[],
- suspendCount: 0,
- })
- const [catalog, setCatalog, _, catalogReady] = persisted(
- Persist.global("command.catalog.v1"),
- createStore<Record<string, CommandCatalogItem>>({}),
- )
- const bind = (id: string, def: KeybindConfig | undefined) => {
- const custom = settings.keybinds.get(actionId(id))
- const config = custom ?? def
- if (!config || config === "none") return
- return config
- }
- const registered = createMemo(() => {
- const seen = new Set<string>()
- const all: CommandOption[] = []
- for (const reg of store.registrations) {
- for (const opt of reg()) {
- if (seen.has(opt.id)) continue
- seen.add(opt.id)
- all.push(opt)
- }
- }
- return all
- })
- createEffect(() => {
- if (!catalogReady()) return
- for (const opt of registered()) {
- const id = actionId(opt.id)
- setCatalog(id, {
- title: opt.title,
- description: opt.description,
- category: opt.category,
- keybind: opt.keybind,
- slash: opt.slash,
- })
- }
- })
- const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
- const options = createMemo(() => {
- const resolved = registered().map((opt) => ({
- ...opt,
- keybind: bind(opt.id, opt.keybind),
- }))
- const suggested = resolved.filter((x) => x.suggested && !x.disabled)
- return [
- ...suggested.map((x) => ({
- ...x,
- id: SUGGESTED_PREFIX + x.id,
- category: language.t("command.category.suggested"),
- })),
- ...resolved,
- ]
- })
- const suspended = () => store.suspendCount > 0
- const palette = createMemo(() => {
- const config = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
- const keybinds = parseKeybind(config)
- return new Set(keybinds.map((kb) => signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)))
- })
- const keymap = createMemo(() => {
- const map = new Map<string, CommandOption>()
- for (const option of options()) {
- if (option.id.startsWith(SUGGESTED_PREFIX)) continue
- if (option.disabled) continue
- if (!option.keybind) continue
- const keybinds = parseKeybind(option.keybind)
- for (const kb of keybinds) {
- if (!kb.key) continue
- const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)
- if (map.has(sig)) continue
- map.set(sig, option)
- }
- }
- return map
- })
- const run = (id: string, source?: "palette" | "keybind" | "slash") => {
- for (const option of options()) {
- if (option.id === id || option.id === "suggested." + id) {
- option.onSelect?.(source)
- return
- }
- }
- }
- const showPalette = () => {
- run("file.open", "palette")
- }
- const handleKeyDown = (event: KeyboardEvent) => {
- if (suspended() || dialog.active) return
- const sig = signatureFromEvent(event)
- if (palette().has(sig)) {
- event.preventDefault()
- showPalette()
- return
- }
- const option = keymap().get(sig)
- if (!option) return
- event.preventDefault()
- option.onSelect?.("keybind")
- }
- onMount(() => {
- document.addEventListener("keydown", handleKeyDown)
- })
- onCleanup(() => {
- document.removeEventListener("keydown", handleKeyDown)
- })
- return {
- register(cb: () => CommandOption[]) {
- const results = createMemo(cb)
- setStore("registrations", (arr) => [results, ...arr])
- onCleanup(() => {
- setStore("registrations", (arr) => arr.filter((x) => x !== results))
- })
- },
- trigger(id: string, source?: "palette" | "keybind" | "slash") {
- run(id, source)
- },
- keybind(id: string) {
- if (id === PALETTE_ID) {
- return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
- }
- const base = actionId(id)
- const option = options().find((x) => actionId(x.id) === base)
- if (option?.keybind) return formatKeybind(option.keybind)
- const meta = catalog[base]
- const config = bind(base, meta?.keybind)
- if (!config) return ""
- return formatKeybind(config)
- },
- show: showPalette,
- keybinds(enabled: boolean) {
- setStore("suspendCount", (count) => count + (enabled ? -1 : 1))
- },
- suspended,
- get catalog() {
- return catalogOptions()
- },
- get options() {
- return options()
- },
- }
- },
- })
|