reigncode-app 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. package/AGENTS.md +30 -0
  2. package/Dockerfile +21 -0
  3. package/README.md +51 -0
  4. package/bunfig.toml +3 -0
  5. package/create-effect-simplification-spec.md +515 -0
  6. package/e2e/AGENTS.md +226 -0
  7. package/e2e/actions.ts +1018 -0
  8. package/e2e/app/home.spec.ts +24 -0
  9. package/e2e/app/navigation.spec.ts +10 -0
  10. package/e2e/app/palette.spec.ts +20 -0
  11. package/e2e/app/server-default.spec.ts +58 -0
  12. package/e2e/app/session.spec.ts +16 -0
  13. package/e2e/app/titlebar-history.spec.ts +120 -0
  14. package/e2e/commands/input-focus.spec.ts +15 -0
  15. package/e2e/commands/panels.spec.ts +33 -0
  16. package/e2e/commands/tab-close.spec.ts +32 -0
  17. package/e2e/files/file-open.spec.ts +31 -0
  18. package/e2e/files/file-tree.spec.ts +56 -0
  19. package/e2e/files/file-viewer.spec.ts +156 -0
  20. package/e2e/fixtures.ts +154 -0
  21. package/e2e/models/model-picker.spec.ts +48 -0
  22. package/e2e/models/models-visibility.spec.ts +61 -0
  23. package/e2e/projects/project-edit.spec.ts +43 -0
  24. package/e2e/projects/projects-close.spec.ts +54 -0
  25. package/e2e/projects/projects-switch.spec.ts +116 -0
  26. package/e2e/projects/workspace-new-session.spec.ts +94 -0
  27. package/e2e/projects/workspaces.spec.ts +375 -0
  28. package/e2e/prompt/context.spec.ts +95 -0
  29. package/e2e/prompt/prompt-async.spec.ts +76 -0
  30. package/e2e/prompt/prompt-drop-file-uri.spec.ts +22 -0
  31. package/e2e/prompt/prompt-drop-file.spec.ts +30 -0
  32. package/e2e/prompt/prompt-history.spec.ts +184 -0
  33. package/e2e/prompt/prompt-mention.spec.ts +26 -0
  34. package/e2e/prompt/prompt-multiline.spec.ts +24 -0
  35. package/e2e/prompt/prompt-shell.spec.ts +62 -0
  36. package/e2e/prompt/prompt-slash-open.spec.ts +22 -0
  37. package/e2e/prompt/prompt-slash-share.spec.ts +64 -0
  38. package/e2e/prompt/prompt-slash-terminal.spec.ts +18 -0
  39. package/e2e/prompt/prompt.spec.ts +55 -0
  40. package/e2e/selectors.ts +75 -0
  41. package/e2e/session/session-child-navigation.spec.ts +37 -0
  42. package/e2e/session/session-composer-dock.spec.ts +530 -0
  43. package/e2e/session/session-model-persistence.spec.ts +359 -0
  44. package/e2e/session/session-review.spec.ts +426 -0
  45. package/e2e/session/session-undo-redo.spec.ts +233 -0
  46. package/e2e/session/session.spec.ts +174 -0
  47. package/e2e/settings/settings-keybinds.spec.ts +389 -0
  48. package/e2e/settings/settings-models.spec.ts +122 -0
  49. package/e2e/settings/settings-providers.spec.ts +136 -0
  50. package/e2e/settings/settings.spec.ts +519 -0
  51. package/e2e/sidebar/sidebar-popover-actions.spec.ts +118 -0
  52. package/e2e/sidebar/sidebar-session-links.spec.ts +30 -0
  53. package/e2e/sidebar/sidebar.spec.ts +40 -0
  54. package/e2e/status/status-popover.spec.ts +94 -0
  55. package/e2e/terminal/terminal-init.spec.ts +28 -0
  56. package/e2e/terminal/terminal-reconnect.spec.ts +46 -0
  57. package/e2e/terminal/terminal-tabs.spec.ts +168 -0
  58. package/e2e/terminal/terminal.spec.ts +18 -0
  59. package/e2e/thinking-level.spec.ts +25 -0
  60. package/e2e/tsconfig.json +9 -0
  61. package/e2e/utils.ts +63 -0
  62. package/happydom.ts +75 -0
  63. package/index.html +23 -0
  64. package/package.json +77 -0
  65. package/playwright.config.ts +45 -0
  66. package/public/_headers +17 -0
  67. package/public/oc-theme-preload.js +35 -0
  68. package/script/e2e-local.ts +180 -0
  69. package/src/addons/serialize.test.ts +319 -0
  70. package/src/addons/serialize.ts +634 -0
  71. package/src/app.tsx +308 -0
  72. package/src/components/debug-bar.tsx +443 -0
  73. package/src/components/dialog-connect-provider.tsx +617 -0
  74. package/src/components/dialog-custom-provider-form.ts +158 -0
  75. package/src/components/dialog-custom-provider.test.ts +80 -0
  76. package/src/components/dialog-custom-provider.tsx +329 -0
  77. package/src/components/dialog-edit-project.tsx +255 -0
  78. package/src/components/dialog-fork.tsx +108 -0
  79. package/src/components/dialog-manage-models.tsx +101 -0
  80. package/src/components/dialog-release-notes.tsx +144 -0
  81. package/src/components/dialog-select-directory.tsx +392 -0
  82. package/src/components/dialog-select-file.tsx +466 -0
  83. package/src/components/dialog-select-mcp.tsx +107 -0
  84. package/src/components/dialog-select-model-unpaid.tsx +137 -0
  85. package/src/components/dialog-select-model.tsx +220 -0
  86. package/src/components/dialog-select-provider.tsx +86 -0
  87. package/src/components/dialog-select-server.tsx +649 -0
  88. package/src/components/dialog-settings.tsx +73 -0
  89. package/src/components/file-tree.test.ts +78 -0
  90. package/src/components/file-tree.tsx +507 -0
  91. package/src/components/link.tsx +26 -0
  92. package/src/components/model-tooltip.tsx +91 -0
  93. package/src/components/prompt-input/attachments.test.ts +44 -0
  94. package/src/components/prompt-input/attachments.ts +201 -0
  95. package/src/components/prompt-input/build-request-parts.test.ts +312 -0
  96. package/src/components/prompt-input/build-request-parts.ts +175 -0
  97. package/src/components/prompt-input/context-items.tsx +88 -0
  98. package/src/components/prompt-input/drag-overlay.tsx +25 -0
  99. package/src/components/prompt-input/editor-dom.test.ts +99 -0
  100. package/src/components/prompt-input/editor-dom.ts +148 -0
  101. package/src/components/prompt-input/files.ts +66 -0
  102. package/src/components/prompt-input/history.test.ts +153 -0
  103. package/src/components/prompt-input/history.ts +256 -0
  104. package/src/components/prompt-input/image-attachments.tsx +58 -0
  105. package/src/components/prompt-input/paste.ts +24 -0
  106. package/src/components/prompt-input/placeholder.test.ts +48 -0
  107. package/src/components/prompt-input/placeholder.ts +15 -0
  108. package/src/components/prompt-input/slash-popover.tsx +141 -0
  109. package/src/components/prompt-input/submit.test.ts +346 -0
  110. package/src/components/prompt-input/submit.ts +579 -0
  111. package/src/components/prompt-input.tsx +1595 -0
  112. package/src/components/server/server-row.tsx +130 -0
  113. package/src/components/session/index.ts +5 -0
  114. package/src/components/session/session-context-breakdown.test.ts +61 -0
  115. package/src/components/session/session-context-breakdown.ts +132 -0
  116. package/src/components/session/session-context-format.ts +20 -0
  117. package/src/components/session/session-context-metrics.test.ts +101 -0
  118. package/src/components/session/session-context-metrics.ts +82 -0
  119. package/src/components/session/session-context-tab.tsx +339 -0
  120. package/src/components/session/session-header.tsx +486 -0
  121. package/src/components/session/session-new-view.tsx +91 -0
  122. package/src/components/session/session-sortable-tab.tsx +70 -0
  123. package/src/components/session/session-sortable-terminal-tab.tsx +193 -0
  124. package/src/components/session-context-usage.tsx +122 -0
  125. package/src/components/settings-general.tsx +585 -0
  126. package/src/components/settings-keybinds.tsx +453 -0
  127. package/src/components/settings-list.tsx +5 -0
  128. package/src/components/settings-models.tsx +137 -0
  129. package/src/components/settings-providers.tsx +251 -0
  130. package/src/components/status-popover.tsx +419 -0
  131. package/src/components/terminal.tsx +653 -0
  132. package/src/components/titlebar-history.test.ts +63 -0
  133. package/src/components/titlebar-history.ts +57 -0
  134. package/src/components/titlebar.tsx +312 -0
  135. package/src/constants/file-picker.ts +89 -0
  136. package/src/context/command-keybind.test.ts +69 -0
  137. package/src/context/command.test.ts +25 -0
  138. package/src/context/command.tsx +437 -0
  139. package/src/context/comments.test.ts +186 -0
  140. package/src/context/comments.tsx +243 -0
  141. package/src/context/file/content-cache.ts +88 -0
  142. package/src/context/file/path.test.ts +360 -0
  143. package/src/context/file/path.ts +151 -0
  144. package/src/context/file/tree-store.ts +170 -0
  145. package/src/context/file/types.ts +41 -0
  146. package/src/context/file/view-cache.ts +146 -0
  147. package/src/context/file/watcher.test.ts +149 -0
  148. package/src/context/file/watcher.ts +53 -0
  149. package/src/context/file-content-eviction-accounting.test.ts +65 -0
  150. package/src/context/file.tsx +280 -0
  151. package/src/context/global-sdk.tsx +232 -0
  152. package/src/context/global-sync/bootstrap.ts +206 -0
  153. package/src/context/global-sync/child-store.test.ts +38 -0
  154. package/src/context/global-sync/child-store.ts +281 -0
  155. package/src/context/global-sync/event-reducer.test.ts +552 -0
  156. package/src/context/global-sync/event-reducer.ts +359 -0
  157. package/src/context/global-sync/eviction.ts +28 -0
  158. package/src/context/global-sync/queue.ts +83 -0
  159. package/src/context/global-sync/session-cache.test.ts +102 -0
  160. package/src/context/global-sync/session-cache.ts +62 -0
  161. package/src/context/global-sync/session-load.ts +25 -0
  162. package/src/context/global-sync/session-prefetch.test.ts +96 -0
  163. package/src/context/global-sync/session-prefetch.ts +100 -0
  164. package/src/context/global-sync/session-trim.test.ts +59 -0
  165. package/src/context/global-sync/session-trim.ts +56 -0
  166. package/src/context/global-sync/types.ts +133 -0
  167. package/src/context/global-sync/utils.ts +25 -0
  168. package/src/context/global-sync.test.ts +122 -0
  169. package/src/context/global-sync.tsx +408 -0
  170. package/src/context/highlights.tsx +233 -0
  171. package/src/context/language.tsx +248 -0
  172. package/src/context/layout-scroll.test.ts +64 -0
  173. package/src/context/layout-scroll.ts +126 -0
  174. package/src/context/layout.test.ts +69 -0
  175. package/src/context/layout.tsx +937 -0
  176. package/src/context/local.tsx +422 -0
  177. package/src/context/model-variant.test.ts +86 -0
  178. package/src/context/model-variant.ts +52 -0
  179. package/src/context/models.tsx +163 -0
  180. package/src/context/notification.tsx +373 -0
  181. package/src/context/permission-auto-respond.test.ts +102 -0
  182. package/src/context/permission-auto-respond.ts +51 -0
  183. package/src/context/permission.tsx +277 -0
  184. package/src/context/platform.tsx +99 -0
  185. package/src/context/prompt.tsx +297 -0
  186. package/src/context/sdk.tsx +49 -0
  187. package/src/context/server.tsx +295 -0
  188. package/src/context/settings.tsx +241 -0
  189. package/src/context/sync-optimistic.test.ts +123 -0
  190. package/src/context/sync.tsx +618 -0
  191. package/src/context/terminal-title.ts +51 -0
  192. package/src/context/terminal.test.ts +82 -0
  193. package/src/context/terminal.tsx +437 -0
  194. package/src/entry.tsx +144 -0
  195. package/src/env.d.ts +18 -0
  196. package/src/hooks/use-providers.ts +44 -0
  197. package/src/i18n/ar.ts +855 -0
  198. package/src/i18n/br.ts +867 -0
  199. package/src/i18n/bs.ts +943 -0
  200. package/src/i18n/da.ts +937 -0
  201. package/src/i18n/de.ts +879 -0
  202. package/src/i18n/en.ts +948 -0
  203. package/src/i18n/es.ts +950 -0
  204. package/src/i18n/fr.ts +878 -0
  205. package/src/i18n/ja.ts +861 -0
  206. package/src/i18n/ko.ts +860 -0
  207. package/src/i18n/no.ts +944 -0
  208. package/src/i18n/parity.test.ts +32 -0
  209. package/src/i18n/pl.ts +865 -0
  210. package/src/i18n/ru.ts +946 -0
  211. package/src/i18n/th.ts +933 -0
  212. package/src/i18n/tr.ts +952 -0
  213. package/src/i18n/zh.ts +930 -0
  214. package/src/i18n/zht.ts +925 -0
  215. package/src/index.css +29 -0
  216. package/src/index.ts +6 -0
  217. package/src/pages/directory-layout.tsx +88 -0
  218. package/src/pages/error.tsx +327 -0
  219. package/src/pages/home.tsx +131 -0
  220. package/src/pages/layout/deep-links.ts +50 -0
  221. package/src/pages/layout/helpers.test.ts +211 -0
  222. package/src/pages/layout/helpers.ts +98 -0
  223. package/src/pages/layout/inline-editor.tsx +126 -0
  224. package/src/pages/layout/sidebar-items.tsx +437 -0
  225. package/src/pages/layout/sidebar-project.tsx +384 -0
  226. package/src/pages/layout/sidebar-shell.tsx +125 -0
  227. package/src/pages/layout/sidebar-workspace.tsx +504 -0
  228. package/src/pages/layout.tsx +2509 -0
  229. package/src/pages/session/composer/index.ts +2 -0
  230. package/src/pages/session/composer/session-composer-region.tsx +255 -0
  231. package/src/pages/session/composer/session-composer-state.test.ts +128 -0
  232. package/src/pages/session/composer/session-composer-state.ts +249 -0
  233. package/src/pages/session/composer/session-followup-dock.tsx +109 -0
  234. package/src/pages/session/composer/session-permission-dock.tsx +74 -0
  235. package/src/pages/session/composer/session-question-dock.tsx +449 -0
  236. package/src/pages/session/composer/session-request-tree.ts +52 -0
  237. package/src/pages/session/composer/session-revert-dock.tsx +99 -0
  238. package/src/pages/session/composer/session-todo-dock.tsx +330 -0
  239. package/src/pages/session/file-tab-scroll.test.ts +40 -0
  240. package/src/pages/session/file-tab-scroll.ts +67 -0
  241. package/src/pages/session/file-tabs.tsx +456 -0
  242. package/src/pages/session/handoff.ts +36 -0
  243. package/src/pages/session/helpers.test.ts +181 -0
  244. package/src/pages/session/helpers.ts +198 -0
  245. package/src/pages/session/message-gesture.test.ts +62 -0
  246. package/src/pages/session/message-gesture.ts +21 -0
  247. package/src/pages/session/message-id-from-hash.ts +6 -0
  248. package/src/pages/session/message-timeline.tsx +1013 -0
  249. package/src/pages/session/review-tab.tsx +170 -0
  250. package/src/pages/session/session-layout.ts +20 -0
  251. package/src/pages/session/session-model-helpers.test.ts +51 -0
  252. package/src/pages/session/session-model-helpers.ts +16 -0
  253. package/src/pages/session/session-side-panel.tsx +453 -0
  254. package/src/pages/session/terminal-label.ts +16 -0
  255. package/src/pages/session/terminal-panel.test.ts +25 -0
  256. package/src/pages/session/terminal-panel.tsx +326 -0
  257. package/src/pages/session/use-session-commands.tsx +495 -0
  258. package/src/pages/session/use-session-hash-scroll.test.ts +16 -0
  259. package/src/pages/session/use-session-hash-scroll.ts +197 -0
  260. package/src/pages/session.tsx +1841 -0
  261. package/src/sst-env.d.ts +12 -0
  262. package/src/testing/model-selection.ts +80 -0
  263. package/src/testing/prompt.ts +56 -0
  264. package/src/testing/session-composer.ts +84 -0
  265. package/src/testing/terminal.ts +118 -0
  266. package/src/theme-preload.test.ts +46 -0
  267. package/src/utils/agent.ts +23 -0
  268. package/src/utils/aim.ts +138 -0
  269. package/src/utils/base64.ts +10 -0
  270. package/src/utils/comment-note.ts +88 -0
  271. package/src/utils/id.ts +99 -0
  272. package/src/utils/notification-click.test.ts +27 -0
  273. package/src/utils/notification-click.ts +13 -0
  274. package/src/utils/persist.test.ts +115 -0
  275. package/src/utils/persist.ts +476 -0
  276. package/src/utils/prompt.test.ts +44 -0
  277. package/src/utils/prompt.ts +203 -0
  278. package/src/utils/runtime-adapters.test.ts +62 -0
  279. package/src/utils/runtime-adapters.ts +39 -0
  280. package/src/utils/same.ts +6 -0
  281. package/src/utils/scoped-cache.test.ts +69 -0
  282. package/src/utils/scoped-cache.ts +104 -0
  283. package/src/utils/server-errors.test.ts +131 -0
  284. package/src/utils/server-errors.ts +80 -0
  285. package/src/utils/server-health.test.ts +123 -0
  286. package/src/utils/server-health.ts +91 -0
  287. package/src/utils/server.ts +22 -0
  288. package/src/utils/solid-dnd.tsx +49 -0
  289. package/src/utils/sound.ts +117 -0
  290. package/src/utils/terminal-writer.test.ts +64 -0
  291. package/src/utils/terminal-writer.ts +65 -0
  292. package/src/utils/time.ts +22 -0
  293. package/src/utils/uuid.test.ts +78 -0
  294. package/src/utils/uuid.ts +12 -0
  295. package/src/utils/worktree.test.ts +46 -0
  296. package/src/utils/worktree.ts +73 -0
  297. package/sst-env.d.ts +10 -0
  298. package/tsconfig.json +26 -0
  299. package/vite.config.ts +15 -0
  300. package/vite.js +26 -0
@@ -0,0 +1,437 @@
1
+ import { createSimpleContext } from "@reign-labs/ui/context"
2
+ import { useDialog } from "@reign-labs/ui/context/dialog"
3
+ import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
4
+ import { createStore } from "solid-js/store"
5
+ import { useLanguage } from "@/context/language"
6
+ import { useSettings } from "@/context/settings"
7
+ import { dict as en } from "@/i18n/en"
8
+ import { Persist, persisted } from "@/utils/persist"
9
+
10
+ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
11
+
12
+ const PALETTE_ID = "command.palette"
13
+ const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
14
+ const SUGGESTED_PREFIX = "suggested."
15
+ const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
16
+
17
+ type KeyLabel =
18
+ | "common.key.ctrl"
19
+ | "common.key.alt"
20
+ | "common.key.shift"
21
+ | "common.key.meta"
22
+ | "common.key.space"
23
+ | "common.key.backspace"
24
+ | "common.key.enter"
25
+ | "common.key.tab"
26
+ | "common.key.delete"
27
+ | "common.key.home"
28
+ | "common.key.end"
29
+ | "common.key.pageUp"
30
+ | "common.key.pageDown"
31
+ | "common.key.insert"
32
+ | "common.key.esc"
33
+
34
+ function keyText(key: KeyLabel, t?: (key: KeyLabel) => string) {
35
+ return t ? t(key) : en[key]
36
+ }
37
+
38
+ function actionId(id: string) {
39
+ if (!id.startsWith(SUGGESTED_PREFIX)) return id
40
+ return id.slice(SUGGESTED_PREFIX.length)
41
+ }
42
+
43
+ function normalizeKey(key: string) {
44
+ if (key === ",") return "comma"
45
+ if (key === "+") return "plus"
46
+ if (key === " ") return "space"
47
+ return key.toLowerCase()
48
+ }
49
+
50
+ function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean) {
51
+ const mask = (ctrl ? 1 : 0) | (meta ? 2 : 0) | (shift ? 4 : 0) | (alt ? 8 : 0)
52
+ return `${key}:${mask}`
53
+ }
54
+
55
+ function signatureFromEvent(event: KeyboardEvent) {
56
+ return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
57
+ }
58
+
59
+ function isAllowedEditableKeybind(id: string | undefined) {
60
+ if (!id) return false
61
+ return EDITABLE_KEYBIND_IDS.has(actionId(id))
62
+ }
63
+
64
+ export type KeybindConfig = string
65
+
66
+ export interface Keybind {
67
+ key: string
68
+ ctrl: boolean
69
+ meta: boolean
70
+ shift: boolean
71
+ alt: boolean
72
+ }
73
+
74
+ export interface CommandOption {
75
+ id: string
76
+ title: string
77
+ description?: string
78
+ category?: string
79
+ keybind?: KeybindConfig
80
+ slash?: string
81
+ suggested?: boolean
82
+ disabled?: boolean
83
+ onSelect?: (source?: "palette" | "keybind" | "slash") => void
84
+ onHighlight?: () => (() => void) | void
85
+ }
86
+
87
+ type CommandSource = "palette" | "keybind" | "slash"
88
+
89
+ export type CommandCatalogItem = {
90
+ title: string
91
+ description?: string
92
+ category?: string
93
+ keybind?: KeybindConfig
94
+ slash?: string
95
+ }
96
+
97
+ export type CommandRegistration = {
98
+ key?: string
99
+ options: Accessor<CommandOption[]>
100
+ }
101
+
102
+ export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
103
+ if (entry.key === undefined) return [entry, ...registrations]
104
+ return [entry, ...registrations.filter((x) => x.key !== entry.key)]
105
+ }
106
+
107
+ export function parseKeybind(config: string): Keybind[] {
108
+ if (!config || config === "none") return []
109
+
110
+ return config.split(",").map((combo) => {
111
+ const parts = combo.trim().toLowerCase().split("+")
112
+ const keybind: Keybind = {
113
+ key: "",
114
+ ctrl: false,
115
+ meta: false,
116
+ shift: false,
117
+ alt: false,
118
+ }
119
+
120
+ for (const part of parts) {
121
+ switch (part) {
122
+ case "ctrl":
123
+ case "control":
124
+ keybind.ctrl = true
125
+ break
126
+ case "meta":
127
+ case "cmd":
128
+ case "command":
129
+ keybind.meta = true
130
+ break
131
+ case "mod":
132
+ if (IS_MAC) keybind.meta = true
133
+ else keybind.ctrl = true
134
+ break
135
+ case "alt":
136
+ case "option":
137
+ keybind.alt = true
138
+ break
139
+ case "shift":
140
+ keybind.shift = true
141
+ break
142
+ default:
143
+ keybind.key = part
144
+ break
145
+ }
146
+ }
147
+
148
+ return keybind
149
+ })
150
+ }
151
+
152
+ export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
153
+ const eventKey = normalizeKey(event.key)
154
+
155
+ for (const kb of keybinds) {
156
+ const keyMatch = kb.key === eventKey
157
+ const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
158
+ const metaMatch = kb.meta === (event.metaKey || false)
159
+ const shiftMatch = kb.shift === (event.shiftKey || false)
160
+ const altMatch = kb.alt === (event.altKey || false)
161
+
162
+ if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
163
+ return true
164
+ }
165
+ }
166
+
167
+ return false
168
+ }
169
+
170
+ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string {
171
+ if (!config || config === "none") return ""
172
+
173
+ const keybinds = parseKeybind(config)
174
+ if (keybinds.length === 0) return ""
175
+
176
+ const kb = keybinds[0]
177
+ const parts: string[] = []
178
+
179
+ if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t))
180
+ if (kb.alt) parts.push(IS_MAC ? "⌥" : keyText("common.key.alt", t))
181
+ if (kb.shift) parts.push(IS_MAC ? "⇧" : keyText("common.key.shift", t))
182
+ if (kb.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t))
183
+
184
+ if (kb.key) {
185
+ const keys: Record<string, string> = {
186
+ arrowup: "↑",
187
+ arrowdown: "↓",
188
+ arrowleft: "←",
189
+ arrowright: "→",
190
+ comma: ",",
191
+ plus: "+",
192
+ }
193
+ const named: Record<string, KeyLabel> = {
194
+ backspace: "common.key.backspace",
195
+ delete: "common.key.delete",
196
+ end: "common.key.end",
197
+ enter: "common.key.enter",
198
+ esc: "common.key.esc",
199
+ escape: "common.key.esc",
200
+ home: "common.key.home",
201
+ insert: "common.key.insert",
202
+ pagedown: "common.key.pageDown",
203
+ pageup: "common.key.pageUp",
204
+ space: "common.key.space",
205
+ tab: "common.key.tab",
206
+ }
207
+ const key = kb.key.toLowerCase()
208
+ const displayKey =
209
+ keys[key] ??
210
+ (named[key]
211
+ ? keyText(named[key], t)
212
+ : key.length === 1
213
+ ? key.toUpperCase()
214
+ : key.charAt(0).toUpperCase() + key.slice(1))
215
+ parts.push(displayKey)
216
+ }
217
+
218
+ return IS_MAC ? parts.join("") : parts.join("+")
219
+ }
220
+
221
+ function isEditableTarget(target: EventTarget | null) {
222
+ if (!(target instanceof HTMLElement)) return false
223
+ if (target.isContentEditable) return true
224
+ if (target.closest("[contenteditable='true']")) return true
225
+ if (target.closest("input, textarea, select")) return true
226
+ return false
227
+ }
228
+
229
+ export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
230
+ name: "Command",
231
+ init: () => {
232
+ const dialog = useDialog()
233
+ const settings = useSettings()
234
+ const language = useLanguage()
235
+ const [store, setStore] = createStore({
236
+ registrations: [] as CommandRegistration[],
237
+ suspendCount: 0,
238
+ })
239
+ const warnedDuplicates = new Set<string>()
240
+
241
+ type CommandCatalog = Record<string, CommandCatalogItem>
242
+ const [catalog, setCatalog, _, catalogReady] = persisted(
243
+ Persist.global("command.catalog.v1"),
244
+ createStore<CommandCatalog>({}),
245
+ )
246
+
247
+ const bind = (id: string, def: KeybindConfig | undefined) => {
248
+ const custom = settings.keybinds.get(actionId(id))
249
+ const config = custom ?? def
250
+ if (!config || config === "none") return
251
+ return config
252
+ }
253
+
254
+ const registered = createMemo(() => {
255
+ const seen = new Set<string>()
256
+ const all: CommandOption[] = []
257
+
258
+ for (const reg of store.registrations) {
259
+ for (const opt of reg.options()) {
260
+ if (seen.has(opt.id)) {
261
+ if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
262
+ warnedDuplicates.add(opt.id)
263
+ console.warn(`[command] duplicate command id "${opt.id}" registered; keeping first entry`)
264
+ }
265
+ continue
266
+ }
267
+ seen.add(opt.id)
268
+ all.push(opt)
269
+ }
270
+ }
271
+
272
+ return all
273
+ })
274
+
275
+ createEffect(() => {
276
+ if (!catalogReady()) return
277
+
278
+ setCatalog(
279
+ registered().reduce((acc, opt) => {
280
+ const id = actionId(opt.id)
281
+ acc[id] = {
282
+ title: opt.title,
283
+ description: opt.description,
284
+ category: opt.category,
285
+ keybind: opt.keybind,
286
+ slash: opt.slash,
287
+ }
288
+ return acc
289
+ }, {} as CommandCatalog),
290
+ )
291
+ })
292
+
293
+ const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
294
+
295
+ const options = createMemo(() => {
296
+ const resolved = registered().map((opt) => ({
297
+ ...opt,
298
+ keybind: bind(opt.id, opt.keybind),
299
+ }))
300
+
301
+ const suggested = resolved.filter((x) => x.suggested && !x.disabled)
302
+
303
+ return [
304
+ ...suggested.map((x) => ({
305
+ ...x,
306
+ id: SUGGESTED_PREFIX + x.id,
307
+ category: language.t("command.category.suggested"),
308
+ })),
309
+ ...resolved,
310
+ ]
311
+ })
312
+
313
+ const suspended = () => store.suspendCount > 0
314
+
315
+ const palette = createMemo(() => {
316
+ const config = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
317
+ const keybinds = parseKeybind(config)
318
+ return new Set(keybinds.map((kb) => signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)))
319
+ })
320
+
321
+ const keymap = createMemo(() => {
322
+ const map = new Map<string, CommandOption>()
323
+ for (const option of options()) {
324
+ if (option.id.startsWith(SUGGESTED_PREFIX)) continue
325
+ if (option.disabled) continue
326
+ if (!option.keybind) continue
327
+
328
+ const keybinds = parseKeybind(option.keybind)
329
+ for (const kb of keybinds) {
330
+ if (!kb.key) continue
331
+ const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)
332
+ if (map.has(sig)) continue
333
+ map.set(sig, option)
334
+ }
335
+ }
336
+ return map
337
+ })
338
+
339
+ const optionMap = createMemo(() => {
340
+ const map = new Map<string, CommandOption>()
341
+ for (const option of options()) {
342
+ map.set(option.id, option)
343
+ map.set(actionId(option.id), option)
344
+ }
345
+ return map
346
+ })
347
+
348
+ const run = (id: string, source?: CommandSource) => {
349
+ const option = optionMap().get(id)
350
+ option?.onSelect?.(source)
351
+ }
352
+
353
+ const showPalette = () => {
354
+ run("file.open", "palette")
355
+ }
356
+
357
+ const handleKeyDown = (event: KeyboardEvent) => {
358
+ if (suspended() || dialog.active) return
359
+
360
+ const sig = signatureFromEvent(event)
361
+ const isPalette = palette().has(sig)
362
+ const option = keymap().get(sig)
363
+ const modified = event.ctrlKey || event.metaKey || event.altKey
364
+ const isTab = event.key === "Tab"
365
+
366
+ if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified && !isTab)
367
+ return
368
+
369
+ if (isPalette) {
370
+ event.preventDefault()
371
+ showPalette()
372
+ return
373
+ }
374
+
375
+ if (!option) return
376
+ event.preventDefault()
377
+ option.onSelect?.("keybind")
378
+ }
379
+
380
+ onMount(() => {
381
+ document.addEventListener("keydown", handleKeyDown)
382
+ })
383
+
384
+ onCleanup(() => {
385
+ document.removeEventListener("keydown", handleKeyDown)
386
+ })
387
+
388
+ function register(cb: () => CommandOption[]): void
389
+ function register(key: string, cb: () => CommandOption[]): void
390
+ function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) {
391
+ const id = typeof key === "string" ? key : undefined
392
+ const next = typeof key === "function" ? key : cb
393
+ if (!next) return
394
+ const options = createMemo(next)
395
+ const entry: CommandRegistration = {
396
+ key: id,
397
+ options,
398
+ }
399
+ setStore("registrations", (arr) => upsertCommandRegistration(arr, entry))
400
+ onCleanup(() => {
401
+ setStore("registrations", (arr) => arr.filter((x) => x !== entry))
402
+ })
403
+ }
404
+
405
+ return {
406
+ register,
407
+ trigger(id: string, source?: CommandSource) {
408
+ run(id, source)
409
+ },
410
+ keybind(id: string) {
411
+ if (id === PALETTE_ID) {
412
+ return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND, language.t)
413
+ }
414
+
415
+ const base = actionId(id)
416
+ const option = options().find((x) => actionId(x.id) === base)
417
+ if (option?.keybind) return formatKeybind(option.keybind, language.t)
418
+
419
+ const meta = catalog[base]
420
+ const config = bind(base, meta?.keybind)
421
+ if (!config) return ""
422
+ return formatKeybind(config, language.t)
423
+ },
424
+ show: showPalette,
425
+ keybinds(enabled: boolean) {
426
+ setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))
427
+ },
428
+ suspended,
429
+ get catalog() {
430
+ return catalogOptions()
431
+ },
432
+ get options() {
433
+ return options()
434
+ },
435
+ }
436
+ },
437
+ })
@@ -0,0 +1,186 @@
1
+ import { beforeAll, describe, expect, mock, test } from "bun:test"
2
+ import { createRoot } from "solid-js"
3
+ import type { LineComment } from "./comments"
4
+
5
+ let createCommentSessionForTest: typeof import("./comments").createCommentSessionForTest
6
+
7
+ beforeAll(async () => {
8
+ mock.module("@solidjs/router", () => ({
9
+ useNavigate: () => () => undefined,
10
+ useParams: () => ({}),
11
+ }))
12
+ mock.module("@reign-labs/ui/context", () => ({
13
+ createSimpleContext: () => ({
14
+ use: () => undefined,
15
+ provider: () => undefined,
16
+ }),
17
+ }))
18
+ const mod = await import("./comments")
19
+ createCommentSessionForTest = mod.createCommentSessionForTest
20
+ })
21
+
22
+ function line(file: string, id: string, time: number): LineComment {
23
+ return {
24
+ id,
25
+ file,
26
+ comment: id,
27
+ time,
28
+ selection: { start: 1, end: 1 },
29
+ }
30
+ }
31
+
32
+ describe("comments session indexing", () => {
33
+ test("keeps file list behavior and aggregate chronological order", () => {
34
+ createRoot((dispose) => {
35
+ const now = Date.now()
36
+ const comments = createCommentSessionForTest({
37
+ "a.ts": [line("a.ts", "a-late", now + 20_000), line("a.ts", "a-early", now + 1_000)],
38
+ "b.ts": [line("b.ts", "b-mid", now + 10_000)],
39
+ })
40
+
41
+ expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a-late", "a-early"])
42
+ expect(comments.all().map((item) => item.id)).toEqual(["a-early", "b-mid", "a-late"])
43
+
44
+ const next = comments.add({
45
+ file: "b.ts",
46
+ comment: "next",
47
+ selection: { start: 2, end: 2 },
48
+ })
49
+
50
+ expect(comments.list("b.ts").at(-1)?.id).toBe(next.id)
51
+ expect(comments.all().map((item) => item.time)).toEqual(
52
+ comments
53
+ .all()
54
+ .map((item) => item.time)
55
+ .slice()
56
+ .sort((a, b) => a - b),
57
+ )
58
+
59
+ dispose()
60
+ })
61
+ })
62
+
63
+ test("remove updates file and aggregate indexes consistently", () => {
64
+ createRoot((dispose) => {
65
+ const comments = createCommentSessionForTest({
66
+ "a.ts": [line("a.ts", "a1", 10), line("a.ts", "shared", 20)],
67
+ "b.ts": [line("b.ts", "shared", 30)],
68
+ })
69
+
70
+ comments.setFocus({ file: "a.ts", id: "shared" })
71
+ comments.setActive({ file: "a.ts", id: "shared" })
72
+ comments.remove("a.ts", "shared")
73
+
74
+ expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a1"])
75
+ expect(
76
+ comments
77
+ .all()
78
+ .filter((item) => item.id === "shared")
79
+ .map((item) => item.file),
80
+ ).toEqual(["b.ts"])
81
+ expect(comments.focus()).toBeNull()
82
+ expect(comments.active()).toEqual({ file: "a.ts", id: "shared" })
83
+
84
+ dispose()
85
+ })
86
+ })
87
+
88
+ test("clear resets file and aggregate indexes plus focus state", () => {
89
+ createRoot((dispose) => {
90
+ const comments = createCommentSessionForTest({
91
+ "a.ts": [line("a.ts", "a1", 10)],
92
+ })
93
+
94
+ const next = comments.add({
95
+ file: "b.ts",
96
+ comment: "next",
97
+ selection: { start: 2, end: 2 },
98
+ })
99
+
100
+ comments.setActive({ file: "b.ts", id: next.id })
101
+ comments.clear()
102
+
103
+ expect(comments.list("a.ts")).toEqual([])
104
+ expect(comments.list("b.ts")).toEqual([])
105
+ expect(comments.all()).toEqual([])
106
+ expect(comments.focus()).toBeNull()
107
+ expect(comments.active()).toBeNull()
108
+
109
+ dispose()
110
+ })
111
+ })
112
+
113
+ test("remove keeps focus when same comment id exists in another file", () => {
114
+ createRoot((dispose) => {
115
+ const comments = createCommentSessionForTest({
116
+ "a.ts": [line("a.ts", "shared", 10)],
117
+ "b.ts": [line("b.ts", "shared", 20)],
118
+ })
119
+
120
+ comments.setFocus({ file: "b.ts", id: "shared" })
121
+ comments.remove("a.ts", "shared")
122
+
123
+ expect(comments.focus()).toEqual({ file: "b.ts", id: "shared" })
124
+ expect(comments.list("a.ts")).toEqual([])
125
+ expect(comments.list("b.ts").map((item) => item.id)).toEqual(["shared"])
126
+
127
+ dispose()
128
+ })
129
+ })
130
+
131
+ test("setFocus and setActive updater callbacks receive current state", () => {
132
+ createRoot((dispose) => {
133
+ const comments = createCommentSessionForTest()
134
+
135
+ comments.setFocus({ file: "a.ts", id: "a1" })
136
+ comments.setFocus((current) => {
137
+ expect(current).toEqual({ file: "a.ts", id: "a1" })
138
+ return { file: "b.ts", id: "b1" }
139
+ })
140
+
141
+ comments.setActive({ file: "c.ts", id: "c1" })
142
+ comments.setActive((current) => {
143
+ expect(current).toEqual({ file: "c.ts", id: "c1" })
144
+ return null
145
+ })
146
+
147
+ expect(comments.focus()).toEqual({ file: "b.ts", id: "b1" })
148
+ expect(comments.active()).toBeNull()
149
+
150
+ dispose()
151
+ })
152
+ })
153
+
154
+ test("update changes only the targeted comment body", () => {
155
+ createRoot((dispose) => {
156
+ const comments = createCommentSessionForTest({
157
+ "a.ts": [line("a.ts", "a1", 10), line("a.ts", "a2", 20)],
158
+ })
159
+
160
+ comments.update("a.ts", "a2", "edited")
161
+
162
+ expect(comments.list("a.ts").map((item) => item.comment)).toEqual(["a1", "edited"])
163
+
164
+ dispose()
165
+ })
166
+ })
167
+
168
+ test("replace swaps comment state and clears focus state", () => {
169
+ createRoot((dispose) => {
170
+ const comments = createCommentSessionForTest({
171
+ "a.ts": [line("a.ts", "a1", 10)],
172
+ })
173
+
174
+ comments.setFocus({ file: "a.ts", id: "a1" })
175
+ comments.setActive({ file: "a.ts", id: "a1" })
176
+ comments.replace([line("b.ts", "b1", 30)])
177
+
178
+ expect(comments.list("a.ts")).toEqual([])
179
+ expect(comments.list("b.ts").map((item) => item.id)).toEqual(["b1"])
180
+ expect(comments.focus()).toBeNull()
181
+ expect(comments.active()).toBeNull()
182
+
183
+ dispose()
184
+ })
185
+ })
186
+ })