command.tsx 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
  2. import { createSimpleContext } from "@opencode-ai/ui/context"
  3. import { useDialog } from "@opencode-ai/ui/context/dialog"
  4. const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
  5. export type KeybindConfig = string
  6. export interface Keybind {
  7. key: string
  8. ctrl: boolean
  9. meta: boolean
  10. shift: boolean
  11. alt: boolean
  12. }
  13. export interface CommandOption {
  14. id: string
  15. title: string
  16. description?: string
  17. category?: string
  18. keybind?: KeybindConfig
  19. slash?: string
  20. suggested?: boolean
  21. disabled?: boolean
  22. onSelect?: (source?: "palette" | "keybind" | "slash") => void
  23. onHighlight?: () => (() => void) | void
  24. }
  25. export function parseKeybind(config: string): Keybind[] {
  26. if (!config || config === "none") return []
  27. return config.split(",").map((combo) => {
  28. const parts = combo.trim().toLowerCase().split("+")
  29. const keybind: Keybind = {
  30. key: "",
  31. ctrl: false,
  32. meta: false,
  33. shift: false,
  34. alt: false,
  35. }
  36. for (const part of parts) {
  37. switch (part) {
  38. case "ctrl":
  39. case "control":
  40. keybind.ctrl = true
  41. break
  42. case "meta":
  43. case "cmd":
  44. case "command":
  45. keybind.meta = true
  46. break
  47. case "mod":
  48. if (IS_MAC) keybind.meta = true
  49. else keybind.ctrl = true
  50. break
  51. case "alt":
  52. case "option":
  53. keybind.alt = true
  54. break
  55. case "shift":
  56. keybind.shift = true
  57. break
  58. default:
  59. keybind.key = part
  60. break
  61. }
  62. }
  63. return keybind
  64. })
  65. }
  66. export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
  67. const eventKey = event.key.toLowerCase()
  68. for (const kb of keybinds) {
  69. const keyMatch = kb.key === eventKey
  70. const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
  71. const metaMatch = kb.meta === (event.metaKey || false)
  72. const shiftMatch = kb.shift === (event.shiftKey || false)
  73. const altMatch = kb.alt === (event.altKey || false)
  74. if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
  75. return true
  76. }
  77. }
  78. return false
  79. }
  80. export function formatKeybind(config: string): string {
  81. if (!config || config === "none") return ""
  82. const keybinds = parseKeybind(config)
  83. if (keybinds.length === 0) return ""
  84. const kb = keybinds[0]
  85. const parts: string[] = []
  86. if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
  87. if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
  88. if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
  89. if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
  90. if (kb.key) {
  91. const arrows: Record<string, string> = {
  92. arrowup: "↑",
  93. arrowdown: "↓",
  94. arrowleft: "←",
  95. arrowright: "→",
  96. }
  97. const displayKey =
  98. arrows[kb.key.toLowerCase()] ??
  99. (kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1))
  100. parts.push(displayKey)
  101. }
  102. return IS_MAC ? parts.join("") : parts.join("+")
  103. }
  104. export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
  105. name: "Command",
  106. init: () => {
  107. const dialog = useDialog()
  108. const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
  109. const [suspendCount, setSuspendCount] = createSignal(0)
  110. const options = createMemo(() => {
  111. const seen = new Set<string>()
  112. const all: CommandOption[] = []
  113. for (const reg of registrations()) {
  114. for (const opt of reg()) {
  115. if (seen.has(opt.id)) continue
  116. seen.add(opt.id)
  117. all.push(opt)
  118. }
  119. }
  120. const suggested = all.filter((x) => x.suggested && !x.disabled)
  121. return [
  122. ...suggested.map((x) => ({
  123. ...x,
  124. id: "suggested." + x.id,
  125. category: "Suggested",
  126. })),
  127. ...all,
  128. ]
  129. })
  130. const suspended = () => suspendCount() > 0
  131. const run = (id: string, source?: "palette" | "keybind" | "slash") => {
  132. for (const option of options()) {
  133. if (option.id === id || option.id === "suggested." + id) {
  134. option.onSelect?.(source)
  135. return
  136. }
  137. }
  138. }
  139. const showPalette = () => {
  140. run("file.open", "palette")
  141. }
  142. const handleKeyDown = (event: KeyboardEvent) => {
  143. if (suspended() || dialog.active) return
  144. const paletteKeybinds = parseKeybind("mod+shift+p")
  145. if (matchKeybind(paletteKeybinds, event)) {
  146. event.preventDefault()
  147. showPalette()
  148. return
  149. }
  150. for (const option of options()) {
  151. if (option.disabled) continue
  152. if (!option.keybind) continue
  153. const keybinds = parseKeybind(option.keybind)
  154. if (matchKeybind(keybinds, event)) {
  155. event.preventDefault()
  156. option.onSelect?.("keybind")
  157. return
  158. }
  159. }
  160. }
  161. onMount(() => {
  162. document.addEventListener("keydown", handleKeyDown)
  163. })
  164. onCleanup(() => {
  165. document.removeEventListener("keydown", handleKeyDown)
  166. })
  167. return {
  168. register(cb: () => CommandOption[]) {
  169. const results = createMemo(cb)
  170. setRegistrations((arr) => [results, ...arr])
  171. onCleanup(() => {
  172. setRegistrations((arr) => arr.filter((x) => x !== results))
  173. })
  174. },
  175. trigger(id: string, source?: "palette" | "keybind" | "slash") {
  176. run(id, source)
  177. },
  178. keybind(id: string) {
  179. const option = options().find((x) => x.id === id || x.id === "suggested." + id)
  180. if (!option?.keybind) return ""
  181. return formatKeybind(option.keybind)
  182. },
  183. show: showPalette,
  184. keybinds(enabled: boolean) {
  185. setSuspendCount((count) => count + (enabled ? -1 : 1))
  186. },
  187. suspended,
  188. get options() {
  189. return options()
  190. },
  191. }
  192. },
  193. })