command.tsx 11 KB

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