@kabmo-labs/kabmo 0.8.0 → 0.9.0

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 (254) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +314 -314
  4. package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +9 -1
  7. package/src/client/components/common/code-surface/documents.test.ts +120 -0
  8. package/src/client/components/common/code-surface/documents.ts +201 -0
  9. package/src/client/components/common/code-surface/extensions.test.ts +110 -0
  10. package/src/client/components/common/code-surface/extensions.ts +162 -0
  11. package/src/client/components/common/code-surface/handle.ts +102 -0
  12. package/src/client/components/common/code-surface/index.tsx +253 -314
  13. package/src/client/components/common/code-surface/keymap.test.ts +133 -0
  14. package/src/client/components/common/code-surface/keymap.ts +267 -0
  15. package/src/client/components/common/code-surface/marks.ts +74 -0
  16. package/src/client/components/common/code-surface/shiki.ts +234 -0
  17. package/src/client/components/common/code-surface/sync.test.ts +185 -0
  18. package/src/client/components/common/code-surface/sync.ts +58 -0
  19. package/src/client/components/common/code-surface/theme.test.ts +202 -0
  20. package/src/client/components/common/code-surface/theme.ts +111 -0
  21. package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
  22. package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
  23. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  24. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  25. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  26. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  27. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  28. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  29. package/src/client/components/layout/desktop-layout/index.tsx +47 -67
  30. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  31. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  32. package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
  33. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  34. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  35. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  36. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  37. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  38. package/src/client/components/settings/Modal.tsx +8 -0
  39. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  40. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  41. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  42. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
  43. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
  44. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  45. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  46. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  47. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  48. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  49. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  57. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  58. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  59. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  65. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  66. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  67. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  68. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  69. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  70. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  71. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  72. package/src/client/components/workspace/editor/Body.tsx +4 -7
  73. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  74. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  75. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  76. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  77. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  78. package/src/client/components/workspace/editor/index.tsx +7 -9
  79. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  80. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  81. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  82. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  83. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  84. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  85. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  86. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  87. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  88. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  89. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  90. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  91. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  92. package/src/client/hooks/chat/timeline/index.ts +19 -24
  93. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  94. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  95. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  96. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  97. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  98. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  99. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  100. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  101. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  102. package/src/client/hooks/editor/find-state.ts +1 -1
  103. package/src/client/hooks/editor/theme.test.ts +206 -0
  104. package/src/client/hooks/editor/theme.ts +82 -0
  105. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  106. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  107. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  108. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  109. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  110. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  111. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  112. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  113. package/src/client/hooks/ui/settings-modal.ts +56 -0
  114. package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
  115. package/src/client/hooks/ui/updates/shell-download.ts +55 -14
  116. package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
  117. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  118. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  119. package/src/client/hooks/workspace/diff-file.ts +29 -10
  120. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  121. package/src/client/hooks/workspace/editor-view.ts +61 -0
  122. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  123. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  124. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  125. package/src/client/hooks/workspace/worktree/index.ts +20 -5
  126. package/src/client/lib/desktop/bridge.ts +45 -0
  127. package/src/client/lib/search-reveal.ts +1 -1
  128. package/src/client/main.tsx +5 -1
  129. package/src/client/tailwind.css +37 -8
  130. package/src/server/lib/auth/guard.test.ts +28 -0
  131. package/src/server/lib/auth/guard.ts +10 -0
  132. package/src/server/lib/fs/worktree/create.ts +2 -3
  133. package/src/server/lib/fs/worktree/list.ts +13 -0
  134. package/src/server/lib/fs/worktree/remove.ts +3 -3
  135. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  136. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  137. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  138. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  139. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  140. package/src/server/lib/lifecycle/probe.ts +121 -13
  141. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  143. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  144. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  145. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  146. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  147. package/src/server/lib/realtime/signals.server.ts +10 -0
  148. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  149. package/src/server/lib/realtime/topics.server.ts +16 -0
  150. package/src/server/lib/updates/shell-assets.ts +94 -3
  151. package/src/server/lib/updates/shell-cache.ts +105 -0
  152. package/src/server/lib/updates/shell-pages.test.ts +33 -2
  153. package/src/server/lib/updates/shell-pages.ts +17 -5
  154. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  155. package/src/server/lib/updates/shell-release.test.ts +10 -27
  156. package/src/server/lib/updates/shell-release.ts +107 -111
  157. package/src/server/routes/fs/git-merge.test.ts +11 -1
  158. package/src/server/routes/fs/read.test.ts +24 -7
  159. package/src/server/routes/fs/scope.test.ts +15 -10
  160. package/src/server/routes/fs/scope.ts +3 -14
  161. package/src/server/routes/updates/android.ts +6 -1
  162. package/src/server/routes/updates/desktop.ts +29 -7
  163. package/src/server/routes/updates/ios.ts +5 -1
  164. package/src/server/routes/worktrees/list.ts +11 -1
  165. package/src/server/routes/worktrees/routes.test.ts +17 -0
  166. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  167. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  168. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  169. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  170. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  171. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  172. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  173. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  174. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  175. package/src/shared/lib/code/editor/keymap.ts +3 -3
  176. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  177. package/src/shared/lib/code/editor/lines.ts +8 -48
  178. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  179. package/src/shared/lib/code/editor/marks.ts +13 -25
  180. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  181. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  182. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  183. package/src/shared/lib/code/editor/themes.ts +165 -0
  184. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  185. package/src/shared/lib/code/highlight-memo.ts +149 -0
  186. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  187. package/src/shared/lib/code/highlighter.ts +3 -1
  188. package/src/shared/lib/code/idle.ts +37 -0
  189. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  190. package/src/shared/lib/code/outline/rows.ts +94 -0
  191. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  192. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  193. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  194. package/src/shared/lib/fs/file-kind.ts +47 -13
  195. package/src/shared/lib/fs/file-url.test.ts +52 -0
  196. package/src/shared/lib/fs/file-url.ts +37 -0
  197. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  198. package/src/shared/lib/fs/patch-sides.ts +139 -0
  199. package/src/shared/lib/realtime/protocol.ts +16 -0
  200. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  201. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  202. package/src/shared/lib/updates/shell-arch.ts +54 -0
  203. package/src/shared/lib/updates/shell-ua.ts +4 -4
  204. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  205. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  206. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  207. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  208. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  209. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  210. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  211. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  212. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  213. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  214. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  215. package/src/shared/types/realtime.ts +29 -0
  216. package/src/shared/types/settings/state.ts +10 -0
  217. package/src/test-support/loopback.ts +183 -0
  218. package/dist/client/static/js/index-gtbpewr7.css +0 -1
  219. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  220. package/src/client/components/common/code-editor/handler.ts +0 -175
  221. package/src/client/components/common/code-editor/index.tsx +0 -314
  222. package/src/client/components/common/code-surface/measure.ts +0 -148
  223. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
  224. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
  225. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  226. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  227. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  228. package/src/client/hooks/editor/scroll-container.ts +0 -26
  229. package/src/client/hooks/editor/use-code-window.ts +0 -100
  230. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  231. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  232. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  233. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  234. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  235. package/src/shared/lib/code/editor/commands.ts +0 -260
  236. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  237. package/src/shared/lib/code/editor/edits.ts +0 -122
  238. package/src/shared/lib/code/editor/history.test.ts +0 -118
  239. package/src/shared/lib/code/editor/history.ts +0 -101
  240. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  241. package/src/shared/lib/code/editor/layers.ts +0 -67
  242. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  243. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  244. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  245. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  246. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  247. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  248. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  249. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  250. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  251. package/src/shared/lib/code/lazy-window.ts +0 -156
  252. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  253. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  254. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The keymap generator.
8
+ *
9
+ * Its whole contract is negative: a command the palette lists must be BOUND, or
10
+ * the palette advertises a shortcut nothing listens for. That property is only
11
+ * checkable by comparing the generated CM6 bindings against `EDITOR_KEY_BINDINGS`
12
+ * — which is what this does, row by row, so adding a row to the table and
13
+ * forgetting the binding fails here instead of in a user's hands.
14
+ *
15
+ * The name mapping is the part that is easy to get silently wrong: CM6 matches
16
+ * on `event.keyCode` through `w3c-keyname`, so `Digit1` is `1` and `Slash` is
17
+ * `/` — a binding written `Digit1` never fires.
18
+ */
19
+
20
+ import { describe, expect, test } from 'bun:test';
21
+ import { EditorState, type TransactionSpec } from '@codemirror/state';
22
+ import type { EditorView } from '@codemirror/view';
23
+
24
+ import { EDITOR_KEY_BINDINGS } from '@/shared/lib/code/editor/keymap';
25
+ import { codeMirrorKey, runSurfaceCommand } from '@/client/components/common/code-surface/keymap';
26
+
27
+ describe('codeMirrorKey', () => {
28
+ test('maps letters, digits and function keys to the names CM6 matches', () => {
29
+ // Lower case for a bare letter: `keyName` reports the character the key
30
+ // produces, which is `d` unshifted. The shifted form (`D`) is reached
31
+ // through CM6's own base-character fallback, so the row still fires.
32
+ expect(codeMirrorKey({ code: 'KeyD', meta: true })).toBe('Meta-d');
33
+ expect(codeMirrorKey({ code: 'Digit1', meta: true, shift: true })).toBe('Shift-Meta-1');
34
+ expect(codeMirrorKey({ code: 'F3' })).toBe('F3');
35
+ });
36
+
37
+ test('maps punctuation to the character the key produces unshifted', () => {
38
+ // `Slash` is `/` on every layout that has it; `BracketRight` is `]`.
39
+ expect(codeMirrorKey({ code: 'Slash', ctrl: true })).toBe('Ctrl-/');
40
+ expect(codeMirrorKey({ code: 'BracketRight', meta: true })).toBe('Meta-]');
41
+ expect(codeMirrorKey({ code: 'BracketLeft', ctrl: true })).toBe('Ctrl-[');
42
+ });
43
+
44
+ test('names the keys that have their own spelling', () => {
45
+ expect(codeMirrorKey({ code: 'Enter', meta: true })).toBe('Meta-Enter');
46
+ expect(codeMirrorKey({ code: 'ArrowUp', alt: true })).toBe('Alt-ArrowUp');
47
+ expect(codeMirrorKey({ code: 'Home' })).toBe('Home');
48
+ });
49
+
50
+ test('orders modifiers the way CM6 canonicalises them', () => {
51
+ // CM6's `modifiers()` prepends alt, then ctrl, then meta, then shift, so
52
+ // the canonical spelling runs `Shift-Meta-Ctrl-Alt-<key>`. A different
53
+ // order here would be a binding CM6 never matches.
54
+ expect(codeMirrorKey({ code: 'ArrowDown', ctrl: true, alt: true, shift: true })).toBe('Shift-Ctrl-Alt-ArrowDown');
55
+ });
56
+
57
+ test('reports null for a key with no name, so the row is skipped', () => {
58
+ expect(codeMirrorKey({ code: 'IntlBackslash' })).toBeNull();
59
+ });
60
+ });
61
+
62
+ describe('EDITOR_KEY_BINDINGS coverage', () => {
63
+ test('every row resolves to a chord on both platforms', () => {
64
+ const missing = EDITOR_KEY_BINDINGS.filter(
65
+ (binding) => codeMirrorKey(binding.mac) === null || codeMirrorKey(binding.other) === null,
66
+ ).map((binding) => binding.command);
67
+
68
+ // A row whose chord cannot be named is a palette entry that does nothing —
69
+ // the exact failure this table exists to prevent.
70
+ expect(missing).toEqual([]);
71
+ });
72
+
73
+ test('no two rows claim the same chord on the same platform', () => {
74
+ for (const platform of ['mac', 'other'] as const) {
75
+ const seen = new Map<string, string>();
76
+ for (const binding of EDITOR_KEY_BINDINGS) {
77
+ const chord = codeMirrorKey(binding[platform]);
78
+ if (!chord) continue;
79
+ // A duplicate would make the first row win silently and leave the
80
+ // second's palette entry dead.
81
+ expect(seen.has(chord) ? `${chord} (${seen.get(chord)} and ${binding.command})` : null).toBeNull();
82
+ seen.set(chord, binding.command);
83
+ }
84
+ }
85
+ });
86
+ });
87
+
88
+ /**
89
+ * The two commands the surface implements itself, run against a state.
90
+ *
91
+ * `insertLineAbove` is the one that has no CM6 equivalent, and its indentation
92
+ * rule is the part a rewrite loses silently: a bare newline above an indented
93
+ * line puts the caret at column 0, so the next thing typed lands outside the
94
+ * block. Driven through `runSurfaceCommand` — the palette's and the handle's own
95
+ * path in — with a `{ state, dispatch }` view, which is all a `StateCommand`
96
+ * touches.
97
+ */
98
+ describe('surface commands', () => {
99
+ /**
100
+ * The two commands are `StateCommand`s: they read `state` and hand a
101
+ * transaction SPEC to `dispatch`, nothing else. A view-shaped stand-in is what
102
+ * `runSurfaceCommand` takes, so the cast states that boundary once rather than
103
+ * mounting an `EditorView` in happy-dom for a rule with no DOM in it.
104
+ */
105
+ function viewFor(doc: string, anchor: number): EditorView {
106
+ let state = EditorState.create({ doc, selection: { anchor } });
107
+ const view = {
108
+ get state() {
109
+ return state;
110
+ },
111
+ // A command dispatches a `TransactionSpec`; `EditorState.update` is the
112
+ // half of the real view's `dispatch` these commands depend on.
113
+ dispatch: (...specs: TransactionSpec[]) => {
114
+ state = state.update(...specs).state;
115
+ },
116
+ };
117
+ return view as unknown as EditorView;
118
+ }
119
+
120
+ test('insertLineAbove keeps the current line’s indentation and lands after it', () => {
121
+ const view = viewFor(' const a = 1;\nconst b = 2;\n', 4);
122
+ expect(runSurfaceCommand('insertLineAbove', view)).toBe(true);
123
+ expect(view.state.doc.toString()).toBe(' \n const a = 1;\nconst b = 2;\n');
124
+ expect(view.state.selection.main.head).toBe(2);
125
+ });
126
+
127
+ test('insertLineAbove on an unindented line inserts a bare blank line', () => {
128
+ const view = viewFor('one\ntwo\n', 5);
129
+ runSurfaceCommand('insertLineAbove', view);
130
+ expect(view.state.doc.toString()).toBe('one\n\ntwo\n');
131
+ expect(view.state.selection.main.head).toBe(4);
132
+ });
133
+ });
@@ -0,0 +1,267 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor's chords, generated from `EDITOR_KEY_BINDINGS`.
8
+ *
9
+ * The table in `shared/lib/code/editor/keymap.ts` is the ONE statement of what
10
+ * each chord means: the command palette lists it, the find bar's tooltips print
11
+ * it, and this module turns it into CodeMirror bindings. A hand-written CM6
12
+ * keymap would be a second statement that drifts — the palette would advertise
13
+ * `⌥⌘C` for a key CM6 never bound.
14
+ *
15
+ * Panel-owned commands (`find`, `replace`, the match steps, word wrap) are bound
16
+ * HERE too and delegate outward through `onCommand`, so a chord pressed inside
17
+ * the document reaches the same dispatcher the palette uses. Consuming the key
18
+ * is the point: `⌘F` inside the editor must open Kabmo's find bar, never the
19
+ * browser's.
20
+ */
21
+
22
+ import type { Extension, StateCommand } from '@codemirror/state';
23
+ import { EditorSelection } from '@codemirror/state';
24
+ import {
25
+ addCursorBelow,
26
+ defaultKeymap,
27
+ copyLineDown,
28
+ copyLineUp,
29
+ cursorDocEnd,
30
+ cursorDocStart,
31
+ cursorLineEnd,
32
+ cursorLineStart,
33
+ deleteLine,
34
+ indentLess,
35
+ indentMore,
36
+ insertBlankLine,
37
+ moveLineDown,
38
+ moveLineUp,
39
+ redo,
40
+ selectDocEnd,
41
+ selectDocStart,
42
+ selectLine,
43
+ selectLineEnd,
44
+ selectLineStart,
45
+ simplifySelection,
46
+ toggleComment,
47
+ undo,
48
+ } from '@codemirror/commands';
49
+ import { selectNextOccurrence, selectSelectionMatches } from '@codemirror/search';
50
+ import { keymap, type Command, type EditorView, type KeyBinding } from '@codemirror/view';
51
+
52
+ import { EDITOR_KEY_BINDINGS, type EditorCommand } from '@/shared/lib/code/editor/keymap';
53
+ import type { KeyStroke } from '@/shared/lib/ui/key-binding';
54
+
55
+ /** A command as this module invokes it: CM6's `Command` and `StateCommand` both fit. */
56
+ type SurfaceCommand = (view: EditorView) => boolean;
57
+
58
+ /**
59
+ * Insert a blank line ABOVE the caret's line, which CM6 has no command for.
60
+ *
61
+ * The new line carries the current line's own leading whitespace, and the caret
62
+ * lands after it — the rule the textarea surface applied, and the one that makes
63
+ * the key useful inside a block: a bare newline above an indented line left the
64
+ * caret at column 0 and the new line unindented, so the very next thing typed
65
+ * was in the wrong place. (CM6's `insertBlankLine`, used for "below", derives
66
+ * the same indentation from `IndentContext`; this mirrors it without needing a
67
+ * grammar.)
68
+ */
69
+ const insertLineAbove: SurfaceCommand = (view) => {
70
+ const { state } = view;
71
+ const range = state.selection.main;
72
+ const line = state.doc.lineAt(range.from);
73
+ const indent = /^[ \t]*/.exec(line.text)?.[0] ?? '';
74
+ view.dispatch({
75
+ changes: { from: line.from, insert: `${indent}\n` },
76
+ selection: { anchor: line.from + indent.length },
77
+ });
78
+ return true;
79
+ };
80
+
81
+ /** Select the word under the caret, or nothing when the caret is between words. */
82
+ const selectWord: SurfaceCommand = (view) => {
83
+ const { state } = view;
84
+ const range = state.selection.main;
85
+ if (range.from !== range.to) return false;
86
+ const line = state.doc.lineAt(range.from);
87
+ const offset = range.from - line.from;
88
+ const isWord = (char: string | undefined) => char !== undefined && /[\w$]/.test(char);
89
+ let start = offset;
90
+ let end = offset;
91
+ if (!isWord(line.text[start]) && isWord(line.text[start - 1])) start--;
92
+ if (!isWord(line.text[start])) return false;
93
+ end = start;
94
+ while (isWord(line.text[start - 1])) start--;
95
+ while (isWord(line.text[end])) end++;
96
+ view.dispatch({
97
+ selection: EditorSelection.range(line.from + start, line.from + end),
98
+ });
99
+ return true;
100
+ };
101
+
102
+ /**
103
+ * Collapse extra selections, keeping the main one.
104
+ *
105
+ * Escape's first job when ⌘D left several carets behind; only the next press
106
+ * hands the keyboard back to the page, which the panel's own capture handler
107
+ * does once this reports there was nothing left to collapse.
108
+ */
109
+ const collapseSelections: SurfaceCommand = (view) => {
110
+ if (view.state.selection.ranges.length <= 1) return false;
111
+ return (simplifySelection as StateCommand)(view);
112
+ };
113
+
114
+ /**
115
+ * Commands the BUFFER owns. Everything absent from this table is either the
116
+ * panel's (`find`, `replace`, `toggleWordWrap`) or a chord with no meaning in a
117
+ * CM6 surface.
118
+ */
119
+ const BUFFER_COMMANDS: Partial<Record<EditorCommand, SurfaceCommand>> = {
120
+ undo: undo as SurfaceCommand,
121
+ redo: redo as SurfaceCommand,
122
+ selectNextOccurrence: selectNextOccurrence as SurfaceCommand,
123
+ selectAllOccurrences: selectSelectionMatches as SurfaceCommand,
124
+ insertCursorBelow: addCursorBelow as SurfaceCommand,
125
+ moveLineUp: moveLineUp as SurfaceCommand,
126
+ moveLineDown: moveLineDown as SurfaceCommand,
127
+ copyLineUp: copyLineUp as SurfaceCommand,
128
+ copyLineDown: copyLineDown as SurfaceCommand,
129
+ deleteLine: deleteLine as SurfaceCommand,
130
+ toggleComment: toggleComment as SurfaceCommand,
131
+ insertLineBelow: insertBlankLine as SurfaceCommand,
132
+ insertLineAbove,
133
+ indent: indentMore as SurfaceCommand,
134
+ outdent: indentLess as SurfaceCommand,
135
+ lineStart: cursorLineStart as SurfaceCommand,
136
+ lineEnd: cursorLineEnd as SurfaceCommand,
137
+ documentStart: cursorDocStart as SurfaceCommand,
138
+ documentEnd: cursorDocEnd as SurfaceCommand,
139
+ selectToLineStart: selectLineStart as SurfaceCommand,
140
+ selectToLineEnd: selectLineEnd as SurfaceCommand,
141
+ selectToDocumentStart: selectDocStart as SurfaceCommand,
142
+ selectToDocumentEnd: selectDocEnd as SurfaceCommand,
143
+ selectWord,
144
+ selectLine: selectLine as SurfaceCommand,
145
+ escape: collapseSelections,
146
+ };
147
+
148
+ /** Run a command against a live view — the palette's and the handle's path in. */
149
+ export function runSurfaceCommand(command: EditorCommand, view: EditorView, onCommand?: (command: EditorCommand) => void): boolean {
150
+ const run = BUFFER_COMMANDS[command];
151
+ if (run) return run(view);
152
+ onCommand?.(command);
153
+ return true;
154
+ }
155
+
156
+ /** Codes whose printed name is not their `KeyboardEvent.code` suffix. */
157
+ const NAMED_KEYS: Record<string, string> = {
158
+ Enter: 'Enter',
159
+ Escape: 'Escape',
160
+ Backspace: 'Backspace',
161
+ Delete: 'Delete',
162
+ Tab: 'Tab',
163
+ Space: 'Space',
164
+ ArrowUp: 'ArrowUp',
165
+ ArrowDown: 'ArrowDown',
166
+ ArrowLeft: 'ArrowLeft',
167
+ ArrowRight: 'ArrowRight',
168
+ Home: 'Home',
169
+ End: 'End',
170
+ PageUp: 'PageUp',
171
+ PageDown: 'PageDown',
172
+ };
173
+
174
+ const PUNCTUATION_KEYS: Record<string, string> = {
175
+ Slash: '/',
176
+ Backslash: '\\',
177
+ Comma: ',',
178
+ Period: '.',
179
+ Semicolon: ';',
180
+ Quote: "'",
181
+ Backquote: '`',
182
+ Minus: '-',
183
+ Equal: '=',
184
+ BracketLeft: '[',
185
+ BracketRight: ']',
186
+ };
187
+
188
+ /**
189
+ * The key CodeMirror names a physical key by, WITHOUT modifiers.
190
+ *
191
+ * CM6 matches on `event.keyCode` through `w3c-keyname`, so the base name is the
192
+ * character the key produces unshifted — `Digit1` is `1`, not `!`, and a
193
+ * `Shift` binding is written `Shift-1`. Punctuation is its US character, which
194
+ * is what that library reports for the same key code.
195
+ */
196
+ function baseKey(code: string): string | null {
197
+ if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase();
198
+ if (/^Digit\d$/.test(code)) return code.slice(5);
199
+ if (/^F\d{1,2}$/.test(code)) return code;
200
+ return NAMED_KEYS[code] ?? PUNCTUATION_KEYS[code] ?? null;
201
+ }
202
+
203
+ /** `{code:'Digit1', meta:true, shift:true}` → `Meta-Shift-1` (CM6's canonical order). */
204
+ export function codeMirrorKey(stroke: KeyStroke): string | null {
205
+ const key = baseKey(stroke.code);
206
+ if (!key) return null;
207
+ let name = key;
208
+ if (stroke.alt) name = `Alt-${name}`;
209
+ if (stroke.ctrl) name = `Ctrl-${name}`;
210
+ if (stroke.meta) name = `Meta-${name}`;
211
+ if (stroke.shift) name = `Shift-${name}`;
212
+ return name;
213
+ }
214
+
215
+ /**
216
+ * One CM6 binding per keymap row, carrying each platform's own chord.
217
+ *
218
+ * `key` is the Windows/Linux chord, `mac` the macOS one, and `win` the Windows
219
+ * override for the single row that has one (`redo` is `Ctrl-Y` there and
220
+ * `Ctrl-Shift-Z` on Linux). CM6 falls back to `key` when a platform field is
221
+ * absent, so a row without an override needs nothing extra.
222
+ */
223
+ function bindingsFor(onCommand?: (command: EditorCommand) => void): KeyBinding[] {
224
+ const bindings: KeyBinding[] = [];
225
+ for (const binding of EDITOR_KEY_BINDINGS) {
226
+ const other = codeMirrorKey(binding.other);
227
+ const mac = codeMirrorKey(binding.mac);
228
+ const win = binding.windows ? codeMirrorKey(binding.windows) : null;
229
+ if (!other && !mac) continue;
230
+ const command = binding.command;
231
+ bindings.push({
232
+ key: other ?? undefined,
233
+ mac: mac ?? undefined,
234
+ win: win ?? undefined,
235
+ run: (view) => runSurfaceCommand(command, view, onCommand),
236
+ });
237
+ }
238
+ return bindings;
239
+ }
240
+
241
+ /**
242
+ * Tab indents and ⇧Tab outdents, exactly as `indentWithTab` does.
243
+ *
244
+ * Written out rather than imported so the row can be dropped when the surface is
245
+ * read-only: a viewer that still indented would edit the document. Leaving the
246
+ * editor with the keyboard is CM6's own answer — `Escape` arms a two-second tab
247
+ * focus mode, after which `Tab` moves focus instead of indenting.
248
+ */
249
+ const TAB_BINDINGS: KeyBinding[] = [
250
+ { key: 'Tab', run: indentMore as Command, shift: indentLess as Command },
251
+ ];
252
+
253
+ /**
254
+ * The editor's own keymap, as one extension.
255
+ *
256
+ * CM6's `defaultKeymap` is APPENDED rather than omitted: it is what makes the
257
+ * ordinary editing keys work — Enter, Backspace, arrows, Home/End, `Mod-End` to
258
+ * the document end, word-wise movement — and none of that is a Kabmo command.
259
+ * Appending it AFTER our rows is what keeps a chord the table claims from being
260
+ * shadowed by a CM6 default (`Mod-a` is a good example: both mean "select all",
261
+ * but only one of them also moves the selection the way the palette describes).
262
+ */
263
+ export function surfaceKeymap(onCommand?: (command: EditorCommand) => void, readOnly = false): Extension {
264
+ const rows = readOnly ? [] : TAB_BINDINGS;
265
+ return keymap.of([...bindingsFor(onCommand), ...rows, ...defaultKeymap]);
266
+ }
267
+
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Find matches, as a decoration field.
8
+ *
9
+ * The old surface spliced `<mark>` elements into the highlighter's own HTML, so
10
+ * every find result forced a re-tokenize of the window and the marks could not
11
+ * exist without the token layer. A field is the CM6 shape: the ranges are state,
12
+ * the decorations are derived from it, and a keystroke in the query costs one
13
+ * rebuild of the ranges instead of a second trip through Shiki.
14
+ *
15
+ * The geometry rules are inherited from that markup layer and are still
16
+ * load-bearing: `<mark>` had a background and no padding, and the current match
17
+ * is marked with horizontal edges only. A match routinely spans several tokens,
18
+ * so any decoration that draws vertical edges would paint a seam in the middle
19
+ * of it.
20
+ */
21
+
22
+ import { RangeSetBuilder, StateEffect, StateField, type Text } from '@codemirror/state';
23
+ import { Decoration, EditorView, type DecorationSet } from '@codemirror/view';
24
+
25
+ /** A document-offset range; the editor's own `TextRange` shape. */
26
+ export interface MarkRange {
27
+ start: number;
28
+ end: number;
29
+ }
30
+
31
+ export interface FindMarkState {
32
+ marks: readonly MarkRange[];
33
+ /** Index into `marks` of the match the caret is aimed at, or -1. */
34
+ current: number;
35
+ }
36
+
37
+ /** Replace the whole mark set. */
38
+ export const setFindMarks = StateEffect.define<FindMarkState>();
39
+
40
+ const MATCH = Decoration.mark({ class: 'find-match' });
41
+ const CURRENT = Decoration.mark({ class: 'find-match', attributes: { 'data-find-current': '' } });
42
+
43
+ function build(state: FindMarkState, doc: Text): DecorationSet {
44
+ if (state.marks.length === 0) return Decoration.none;
45
+ const builder = new RangeSetBuilder<Decoration>();
46
+ for (let index = 0; index < state.marks.length; index++) {
47
+ const mark = state.marks[index];
48
+ const from = Math.max(0, Math.min(mark.start, doc.length));
49
+ const to = Math.max(from, Math.min(mark.end, doc.length));
50
+ if (to === from) continue;
51
+ builder.add(from, to, index === state.current ? CURRENT : MATCH);
52
+ }
53
+ return builder.finish();
54
+ }
55
+
56
+ /**
57
+ * The mark set as editor state.
58
+ *
59
+ * A document edit MAPS the existing decorations rather than dropping them: the
60
+ * owner recomputes the list on the next render anyway (`matches` is derived from
61
+ * the live buffer), and mapping keeps the highlights roughly right for the frame
62
+ * in between instead of blinking off on every keystroke.
63
+ */
64
+ export const findMarksField = StateField.define<DecorationSet>({
65
+ create: () => Decoration.none,
66
+ update(marks, transaction) {
67
+ for (const effect of transaction.effects) {
68
+ if (effect.is(setFindMarks)) return build(effect.value, transaction.state.doc);
69
+ }
70
+ if (transaction.docChanged) return marks.map(transaction.changes);
71
+ return marks;
72
+ },
73
+ provide: (field) => EditorView.decorations.from(field),
74
+ });