command.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  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 type CommandRegistration = {
  57. key?: string
  58. options: Accessor<CommandOption[]>
  59. }
  60. export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
  61. if (entry.key === undefined) return [entry, ...registrations]
  62. return [entry, ...registrations.filter((x) => x.key !== entry.key)]
  63. }
  64. export function parseKeybind(config: string): Keybind[] {
  65. if (!config || config === "none") return []
  66. return config.split(",").map((combo) => {
  67. const parts = combo.trim().toLowerCase().split("+")
  68. const keybind: Keybind = {
  69. key: "",
  70. ctrl: false,
  71. meta: false,
  72. shift: false,
  73. alt: false,
  74. }
  75. for (const part of parts) {
  76. switch (part) {
  77. case "ctrl":
  78. case "control":
  79. keybind.ctrl = true
  80. break
  81. case "meta":
  82. case "cmd":
  83. case "command":
  84. keybind.meta = true
  85. break
  86. case "mod":
  87. if (IS_MAC) keybind.meta = true
  88. else keybind.ctrl = true
  89. break
  90. case "alt":
  91. case "option":
  92. keybind.alt = true
  93. break
  94. case "shift":
  95. keybind.shift = true
  96. break
  97. default:
  98. keybind.key = part
  99. break
  100. }
  101. }
  102. return keybind
  103. })
  104. }
  105. export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
  106. const eventKey = normalizeKey(event.key)
  107. for (const kb of keybinds) {
  108. const keyMatch = kb.key === eventKey
  109. const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
  110. const metaMatch = kb.meta === (event.metaKey || false)
  111. const shiftMatch = kb.shift === (event.shiftKey || false)
  112. const altMatch = kb.alt === (event.altKey || false)
  113. if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
  114. return true
  115. }
  116. }
  117. return false
  118. }
  119. export function formatKeybind(config: string): string {
  120. if (!config || config === "none") return ""
  121. const keybinds = parseKeybind(config)
  122. if (keybinds.length === 0) return ""
  123. const kb = keybinds[0]
  124. const parts: string[] = []
  125. if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
  126. if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
  127. if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
  128. if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
  129. if (kb.key) {
  130. const keys: Record<string, string> = {
  131. arrowup: "↑",
  132. arrowdown: "↓",
  133. arrowleft: "←",
  134. arrowright: "→",
  135. comma: ",",
  136. plus: "+",
  137. space: "Space",
  138. }
  139. const key = kb.key.toLowerCase()
  140. const displayKey = keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1))
  141. parts.push(displayKey)
  142. }
  143. return IS_MAC ? parts.join("") : parts.join("+")
  144. }
  145. export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
  146. name: "Command",
  147. init: () => {
  148. const dialog = useDialog()
  149. const settings = useSettings()
  150. const language = useLanguage()
  151. const [store, setStore] = createStore({
  152. registrations: [] as CommandRegistration[],
  153. suspendCount: 0,
  154. })
  155. const warnedDuplicates = new Set<string>()
  156. const [catalog, setCatalog, _, catalogReady] = persisted(
  157. Persist.global("command.catalog.v1"),
  158. createStore<Record<string, CommandCatalogItem>>({}),
  159. )
  160. const bind = (id: string, def: KeybindConfig | undefined) => {
  161. const custom = settings.keybinds.get(actionId(id))
  162. const config = custom ?? def
  163. if (!config || config === "none") return
  164. return config
  165. }
  166. const registered = createMemo(() => {
  167. const seen = new Set<string>()
  168. const all: CommandOption[] = []
  169. for (const reg of store.registrations) {
  170. for (const opt of reg.options()) {
  171. if (seen.has(opt.id)) {
  172. if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
  173. warnedDuplicates.add(opt.id)
  174. console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`)
  175. }
  176. continue
  177. }
  178. seen.add(opt.id)
  179. all.push(opt)
  180. }
  181. }
  182. return all
  183. })
  184. createEffect(() => {
  185. if (!catalogReady()) return
  186. for (const opt of registered()) {
  187. const id = actionId(opt.id)
  188. setCatalog(id, {
  189. title: opt.title,
  190. description: opt.description,
  191. category: opt.category,
  192. keybind: opt.keybind,
  193. slash: opt.slash,
  194. })
  195. }
  196. })
  197. const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
  198. const options = createMemo(() => {
  199. const resolved = registered().map((opt) => ({
  200. ...opt,
  201. keybind: bind(opt.id, opt.keybind),
  202. }))
  203. const suggested = resolved.filter((x) => x.suggested && !x.disabled)
  204. return [
  205. ...suggested.map((x) => ({
  206. ...x,
  207. id: SUGGESTED_PREFIX + x.id,
  208. category: language.t("command.category.suggested"),
  209. })),
  210. ...resolved,
  211. ]
  212. })
  213. const suspended = () => store.suspendCount > 0
  214. const palette = createMemo(() => {
  215. const config = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
  216. const keybinds = parseKeybind(config)
  217. return new Set(keybinds.map((kb) => signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)))
  218. })
  219. const keymap = createMemo(() => {
  220. const map = new Map<string, CommandOption>()
  221. for (const option of options()) {
  222. if (option.id.startsWith(SUGGESTED_PREFIX)) continue
  223. if (option.disabled) continue
  224. if (!option.keybind) continue
  225. const keybinds = parseKeybind(option.keybind)
  226. for (const kb of keybinds) {
  227. if (!kb.key) continue
  228. const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)
  229. if (map.has(sig)) continue
  230. map.set(sig, option)
  231. }
  232. }
  233. return map
  234. })
  235. const run = (id: string, source?: "palette" | "keybind" | "slash") => {
  236. for (const option of options()) {
  237. if (option.id === id || option.id === "suggested." + id) {
  238. option.onSelect?.(source)
  239. return
  240. }
  241. }
  242. }
  243. const showPalette = () => {
  244. run("file.open", "palette")
  245. }
  246. const handleKeyDown = (event: KeyboardEvent) => {
  247. if (suspended() || dialog.active) return
  248. const sig = signatureFromEvent(event)
  249. if (palette().has(sig)) {
  250. event.preventDefault()
  251. showPalette()
  252. return
  253. }
  254. const option = keymap().get(sig)
  255. if (!option) return
  256. event.preventDefault()
  257. option.onSelect?.("keybind")
  258. }
  259. onMount(() => {
  260. document.addEventListener("keydown", handleKeyDown)
  261. })
  262. onCleanup(() => {
  263. document.removeEventListener("keydown", handleKeyDown)
  264. })
  265. function register(cb: () => CommandOption[]): void
  266. function register(key: string, cb: () => CommandOption[]): void
  267. function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) {
  268. const id = typeof key === "string" ? key : undefined
  269. const next = typeof key === "function" ? key : cb
  270. if (!next) return
  271. const options = createMemo(next)
  272. const entry: CommandRegistration = {
  273. key: id,
  274. options,
  275. }
  276. setStore("registrations", (arr) => upsertCommandRegistration(arr, entry))
  277. onCleanup(() => {
  278. setStore("registrations", (arr) => arr.filter((x) => x !== entry))
  279. })
  280. }
  281. return {
  282. register,
  283. trigger(id: string, source?: "palette" | "keybind" | "slash") {
  284. run(id, source)
  285. },
  286. keybind(id: string) {
  287. if (id === PALETTE_ID) {
  288. return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
  289. }
  290. const base = actionId(id)
  291. const option = options().find((x) => actionId(x.id) === base)
  292. if (option?.keybind) return formatKeybind(option.keybind)
  293. const meta = catalog[base]
  294. const config = bind(base, meta?.keybind)
  295. if (!config) return ""
  296. return formatKeybind(config)
  297. },
  298. show: showPalette,
  299. keybinds(enabled: boolean) {
  300. setStore("suspendCount", (count) => count + (enabled ? -1 : 1))
  301. },
  302. suspended,
  303. get catalog() {
  304. return catalogOptions()
  305. },
  306. get options() {
  307. return options()
  308. },
  309. }
  310. },
  311. })