command.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
  2. import { createStore } from "solid-js/store"
  3. import { createSimpleContext } from "@opencode-ai/ui/context"
  4. import { useDialog } from "@opencode-ai/ui/context/dialog"
  5. import { useLanguage } from "@/context/language"
  6. import { useSettings } from "@/context/settings"
  7. import { Persist, persisted } from "@/utils/persist"
  8. const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
  9. const PALETTE_ID = "command.palette"
  10. const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
  11. const SUGGESTED_PREFIX = "suggested."
  12. function actionId(id: string) {
  13. if (!id.startsWith(SUGGESTED_PREFIX)) return id
  14. return id.slice(SUGGESTED_PREFIX.length)
  15. }
  16. function normalizeKey(key: string) {
  17. if (key === ",") return "comma"
  18. if (key === "+") return "plus"
  19. if (key === " ") return "space"
  20. return key.toLowerCase()
  21. }
  22. function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean) {
  23. const mask = (ctrl ? 1 : 0) | (meta ? 2 : 0) | (shift ? 4 : 0) | (alt ? 8 : 0)
  24. return `${key}:${mask}`
  25. }
  26. function signatureFromEvent(event: KeyboardEvent) {
  27. return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
  28. }
  29. export type KeybindConfig = string
  30. export interface Keybind {
  31. key: string
  32. ctrl: boolean
  33. meta: boolean
  34. shift: boolean
  35. alt: boolean
  36. }
  37. export interface CommandOption {
  38. id: string
  39. title: string
  40. description?: string
  41. category?: string
  42. keybind?: KeybindConfig
  43. slash?: string
  44. suggested?: boolean
  45. disabled?: boolean
  46. onSelect?: (source?: "palette" | "keybind" | "slash") => void
  47. onHighlight?: () => (() => void) | void
  48. }
  49. export type CommandCatalogItem = {
  50. title: string
  51. description?: string
  52. category?: string
  53. keybind?: KeybindConfig
  54. slash?: string
  55. }
  56. export function parseKeybind(config: string): Keybind[] {
  57. if (!config || config === "none") return []
  58. return config.split(",").map((combo) => {
  59. const parts = combo.trim().toLowerCase().split("+")
  60. const keybind: Keybind = {
  61. key: "",
  62. ctrl: false,
  63. meta: false,
  64. shift: false,
  65. alt: false,
  66. }
  67. for (const part of parts) {
  68. switch (part) {
  69. case "ctrl":
  70. case "control":
  71. keybind.ctrl = true
  72. break
  73. case "meta":
  74. case "cmd":
  75. case "command":
  76. keybind.meta = true
  77. break
  78. case "mod":
  79. if (IS_MAC) keybind.meta = true
  80. else keybind.ctrl = true
  81. break
  82. case "alt":
  83. case "option":
  84. keybind.alt = true
  85. break
  86. case "shift":
  87. keybind.shift = true
  88. break
  89. default:
  90. keybind.key = part
  91. break
  92. }
  93. }
  94. return keybind
  95. })
  96. }
  97. export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
  98. const eventKey = normalizeKey(event.key)
  99. for (const kb of keybinds) {
  100. const keyMatch = kb.key === eventKey
  101. const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
  102. const metaMatch = kb.meta === (event.metaKey || false)
  103. const shiftMatch = kb.shift === (event.shiftKey || false)
  104. const altMatch = kb.alt === (event.altKey || false)
  105. if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
  106. return true
  107. }
  108. }
  109. return false
  110. }
  111. export function formatKeybind(config: string): string {
  112. if (!config || config === "none") return ""
  113. const keybinds = parseKeybind(config)
  114. if (keybinds.length === 0) return ""
  115. const kb = keybinds[0]
  116. const parts: string[] = []
  117. if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
  118. if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
  119. if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
  120. if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
  121. if (kb.key) {
  122. const keys: Record<string, string> = {
  123. arrowup: "↑",
  124. arrowdown: "↓",
  125. arrowleft: "←",
  126. arrowright: "→",
  127. comma: ",",
  128. plus: "+",
  129. space: "Space",
  130. }
  131. const key = kb.key.toLowerCase()
  132. const displayKey = keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1))
  133. parts.push(displayKey)
  134. }
  135. return IS_MAC ? parts.join("") : parts.join("+")
  136. }
  137. export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
  138. name: "Command",
  139. init: () => {
  140. const dialog = useDialog()
  141. const settings = useSettings()
  142. const language = useLanguage()
  143. const [store, setStore] = createStore({
  144. registrations: [] as Accessor<CommandOption[]>[],
  145. suspendCount: 0,
  146. })
  147. const [catalog, setCatalog, _, catalogReady] = persisted(
  148. Persist.global("command.catalog.v1"),
  149. createStore<Record<string, CommandCatalogItem>>({}),
  150. )
  151. const bind = (id: string, def: KeybindConfig | undefined) => {
  152. const custom = settings.keybinds.get(actionId(id))
  153. const config = custom ?? def
  154. if (!config || config === "none") return
  155. return config
  156. }
  157. const registered = createMemo(() => {
  158. const seen = new Set<string>()
  159. const all: CommandOption[] = []
  160. for (const reg of store.registrations) {
  161. for (const opt of reg()) {
  162. if (seen.has(opt.id)) continue
  163. seen.add(opt.id)
  164. all.push(opt)
  165. }
  166. }
  167. return all
  168. })
  169. createEffect(() => {
  170. if (!catalogReady()) return
  171. for (const opt of registered()) {
  172. const id = actionId(opt.id)
  173. setCatalog(id, {
  174. title: opt.title,
  175. description: opt.description,
  176. category: opt.category,
  177. keybind: opt.keybind,
  178. slash: opt.slash,
  179. })
  180. }
  181. })
  182. const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
  183. const options = createMemo(() => {
  184. const resolved = registered().map((opt) => ({
  185. ...opt,
  186. keybind: bind(opt.id, opt.keybind),
  187. }))
  188. const suggested = resolved.filter((x) => x.suggested && !x.disabled)
  189. return [
  190. ...suggested.map((x) => ({
  191. ...x,
  192. id: SUGGESTED_PREFIX + x.id,
  193. category: language.t("command.category.suggested"),
  194. })),
  195. ...resolved,
  196. ]
  197. })
  198. const suspended = () => store.suspendCount > 0
  199. const palette = createMemo(() => {
  200. const config = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
  201. const keybinds = parseKeybind(config)
  202. return new Set(keybinds.map((kb) => signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)))
  203. })
  204. const keymap = createMemo(() => {
  205. const map = new Map<string, CommandOption>()
  206. for (const option of options()) {
  207. if (option.id.startsWith(SUGGESTED_PREFIX)) continue
  208. if (option.disabled) continue
  209. if (!option.keybind) continue
  210. const keybinds = parseKeybind(option.keybind)
  211. for (const kb of keybinds) {
  212. if (!kb.key) continue
  213. const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)
  214. if (map.has(sig)) continue
  215. map.set(sig, option)
  216. }
  217. }
  218. return map
  219. })
  220. const run = (id: string, source?: "palette" | "keybind" | "slash") => {
  221. for (const option of options()) {
  222. if (option.id === id || option.id === "suggested." + id) {
  223. option.onSelect?.(source)
  224. return
  225. }
  226. }
  227. }
  228. const showPalette = () => {
  229. run("file.open", "palette")
  230. }
  231. const handleKeyDown = (event: KeyboardEvent) => {
  232. if (suspended() || dialog.active) return
  233. const sig = signatureFromEvent(event)
  234. if (palette().has(sig)) {
  235. event.preventDefault()
  236. showPalette()
  237. return
  238. }
  239. const option = keymap().get(sig)
  240. if (!option) return
  241. event.preventDefault()
  242. option.onSelect?.("keybind")
  243. }
  244. onMount(() => {
  245. document.addEventListener("keydown", handleKeyDown)
  246. })
  247. onCleanup(() => {
  248. document.removeEventListener("keydown", handleKeyDown)
  249. })
  250. return {
  251. register(cb: () => CommandOption[]) {
  252. const results = createMemo(cb)
  253. setStore("registrations", (arr) => [results, ...arr])
  254. onCleanup(() => {
  255. setStore("registrations", (arr) => arr.filter((x) => x !== results))
  256. })
  257. },
  258. trigger(id: string, source?: "palette" | "keybind" | "slash") {
  259. run(id, source)
  260. },
  261. keybind(id: string) {
  262. if (id === PALETTE_ID) {
  263. return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
  264. }
  265. const base = actionId(id)
  266. const option = options().find((x) => actionId(x.id) === base)
  267. if (option?.keybind) return formatKeybind(option.keybind)
  268. const meta = catalog[base]
  269. const config = bind(base, meta?.keybind)
  270. if (!config) return ""
  271. return formatKeybind(config)
  272. },
  273. show: showPalette,
  274. keybinds(enabled: boolean) {
  275. setStore("suspendCount", (count) => count + (enabled ? -1 : 1))
  276. },
  277. suspended,
  278. get catalog() {
  279. return catalogOptions()
  280. },
  281. get options() {
  282. return options()
  283. },
  284. }
  285. },
  286. })