@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,177 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Find scanning over one document buffer.
8
+ *
9
+ * The SCAN is CodeMirror's: `RegExpCursor` walks the document and hands back the
10
+ * same ordered match list the surface paints, so the matcher and the editor
11
+ * cannot disagree about where a match is. That replaced a hand-written
12
+ * `RegExp.exec` loop, which had to re-implement the two things a cursor gets
13
+ * right for free — never returning a zero-width match (nothing to highlight,
14
+ * nothing to replace, and stepping past it is what keeps the scan terminating)
15
+ * and starting the next search after the previous match rather than at its own
16
+ * index.
17
+ *
18
+ * Everything around the scan is still Kabmo's, deliberately: the options shape,
19
+ * the cap and the `truncated`/`invalid` distinction are the find widget's
20
+ * contract. Replacement expansion lives in `find-replace.ts`, because a caller
21
+ * that only paints matches should not carry the template grammar.
22
+ *
23
+ * A regex that does not compile is a DIFFERENT answer from "no matches", which
24
+ * is why `invalid` exists: a user typing `(` must be told their pattern is
25
+ * broken, not that the file does not contain it.
26
+ */
27
+
28
+ import { Text } from '@codemirror/state';
29
+ import { RegExpCursor } from '@codemirror/search';
30
+
31
+ import { lineIndexAt, lineStartOffset, type TextRange } from '@/shared/lib/code/editor/lines';
32
+
33
+ export { lineIndexAt, lineStartOffset };
34
+
35
+ export interface FindOptions {
36
+ matchCase: boolean;
37
+ wholeWord: boolean;
38
+ isRegex: boolean;
39
+ }
40
+
41
+ /** Half-open `[start, end)` offset range in the document. */
42
+ export type FindMatch = TextRange;
43
+
44
+ export interface FindResult {
45
+ matches: FindMatch[];
46
+ /** The document holds more matches than the cap; the list stops at it. */
47
+ truncated: boolean;
48
+ /** A regex query that does not compile — a different answer from "no matches". */
49
+ invalid: boolean;
50
+ }
51
+
52
+ /**
53
+ * Ceiling on collected matches. A one-character query over a large file matches
54
+ * tens of thousands of times and every match is a decoration, so past this the
55
+ * list stops and the widget reports `N+`.
56
+ */
57
+ export const MAX_FIND_MATCHES = 5_000;
58
+
59
+ export const DEFAULT_FIND_OPTIONS: FindOptions = {
60
+ matchCase: false,
61
+ wholeWord: false,
62
+ isRegex: false,
63
+ };
64
+
65
+ /**
66
+ * The flags `RegExpCursor` compiles with, stated once.
67
+ *
68
+ * This is not a preference: the cursor builds its own `RegExp` from the pattern
69
+ * SOURCE with `gm` (plus `u` where the engine has unicode property escapes), and
70
+ * a write path that compiled the same source with different flags would answer
71
+ * differently from the list on screen. Measured before this was aligned:
72
+ *
73
+ * - `^foo` / `foo$` — the cursor's `m` makes the anchor per LINE, so the widget
74
+ * counted 2 matches on a three-line file while Replace All rewrote only the
75
+ * first (no `m` = start/end of the whole document).
76
+ * - `\p{L}\d` — matched three times in the scan and zero in the replacement,
77
+ * because a property escape is only a property escape with `u`; without it
78
+ * `\p` is a literal `p`, so Replace All and Replace One did nothing at all.
79
+ * - `\-`, `\A` — invalid escapes under `u`, so the scan called the pattern
80
+ * broken while Replace All still edited the file with the non-unicode reading.
81
+ *
82
+ * So the flags live here, `buildFindRegex` is the only compiler, and the scan,
83
+ * the replace preview and both write paths read it.
84
+ */
85
+ const BASE_FLAGS = 'gm' + ('unicode' in RegExp.prototype ? 'u' : '');
86
+
87
+ /**
88
+ * The pattern a query compiles to, or null when the query is empty or does not
89
+ * compile. A literal query is escaped here rather than at the call site so the
90
+ * match scan and the replacement splice can never disagree about what the
91
+ * pattern was.
92
+ *
93
+ * Whole-word is a `\b`-anchored non-capturing group rather than a bare wrap so
94
+ * an alternation (`foo|bar`) keeps its own precedence.
95
+ */
96
+ export function buildFindRegex(query: string, options: FindOptions): RegExp | null {
97
+ if (!query) return null;
98
+ const source = options.isRegex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
99
+ const wrapped = options.wholeWord ? `\\b(?:${source})\\b` : source;
100
+ try {
101
+ return new RegExp(wrapped, options.matchCase ? BASE_FLAGS : `${BASE_FLAGS}i`);
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * The last string converted to a `Text`, kept for one call.
109
+ *
110
+ * A cursor needs a `Text`, and `Text.of(text.split('\n'))` builds a rope over
111
+ * the whole file. The find widget asks for matches on every keystroke in its
112
+ * query and on every edit, always for the same document, so keeping the last
113
+ * conversion is the difference between one split per render and one per call.
114
+ */
115
+ let lastTextSource: string | null = null;
116
+ let lastText: Text | null = null;
117
+
118
+ function asText(source: string): Text {
119
+ if (lastTextSource === source && lastText) return lastText;
120
+ lastText = Text.of(source.split('\n'));
121
+ lastTextSource = source;
122
+ return lastText;
123
+ }
124
+
125
+ /**
126
+ * Every match of `query` in `text`, in document order.
127
+ *
128
+ * `RegExpCursor` searches LINE by line for a pattern that cannot span one, and
129
+ * falls back to a whole-document cursor when the pattern mentions `\n`, `\r`,
130
+ * `\s`, `\W`, `\D`, `\b`-free character classes or `[^`. Both return document
131
+ * offsets, so the caller never learns which one ran.
132
+ */
133
+ export function findMatches(text: string, query: string, options: FindOptions): FindResult {
134
+ if (!query) return { matches: [], truncated: false, invalid: false };
135
+ const pattern = buildFindRegex(query, options);
136
+ if (!pattern) return { matches: [], truncated: false, invalid: true };
137
+
138
+ const matches: FindMatch[] = [];
139
+ let cursor: RegExpCursor;
140
+ try {
141
+ cursor = new RegExpCursor(asText(text), pattern.source, { ignoreCase: !options.matchCase });
142
+ } catch {
143
+ // A pattern `new RegExp` accepts but the cursor's own construction refuses
144
+ // (an unsupported flag combination) is still a broken pattern to the user.
145
+ return { matches: [], truncated: false, invalid: true };
146
+ }
147
+
148
+ while (matches.length < MAX_FIND_MATCHES && !cursor.next().done) {
149
+ const { from, to } = cursor.value;
150
+ if (from === to) continue;
151
+ matches.push({ start: from, end: to });
152
+ }
153
+
154
+ return { matches, truncated: matches.length >= MAX_FIND_MATCHES, invalid: false };
155
+ }
156
+
157
+ /**
158
+ * The match to call "current" for a caret/anchor at `offset`: the first one
159
+ * that ends after it, so a caret sitting inside a match still counts as being
160
+ * on that match. Wraps to the first match when the anchor is past the last one,
161
+ * and reports -1 for an empty list.
162
+ */
163
+ export function matchIndexAtOrAfter(matches: readonly FindMatch[], offset: number): number {
164
+ if (matches.length === 0) return -1;
165
+ for (let index = 0; index < matches.length; index++) {
166
+ if (matches[index].end > offset) return index;
167
+ }
168
+ return 0;
169
+ }
170
+
171
+ /** Step one match forward (`1`) or back (`-1`), wrapping at either end. */
172
+ export function stepMatchIndex(index: number, count: number, direction: 1 | -1): number {
173
+ if (count <= 0) return -1;
174
+ if (index < 0) return direction === 1 ? 0 : count - 1;
175
+ return (index + direction + count) % count;
176
+ }
177
+
@@ -8,9 +8,9 @@
8
8
  *
9
9
  * One table is the whole truth about an editor shortcut: what it does, which
10
10
  * chords reach it on each platform, and what the palette calls it. The
11
- * textarea's handler, the find bar's buttons, the tooltips and the command
12
- * palette all read this table, so a shortcut cannot be listed in the palette
13
- * and unbound in the editor.
11
+ * generated CodeMirror keymap, the find bar's buttons, the tooltips and the
12
+ * command palette all read this table, so a shortcut cannot be listed in the
13
+ * palette and unbound in the editor.
14
14
  *
15
15
  * VS Code's defaults are followed wherever this editor has an equivalent. Where
16
16
  * it has none — folding, go-to-definition, the `⌘K` prefix chords — the command
@@ -4,21 +4,17 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Line arithmetic, which every line command and the gutter depend on agreeing
8
- * about. The interesting cases are the two the arithmetic gets wrong by
9
- * accident: a caret on the empty line a trailing newline opens, and a selection
10
- * that ends exactly at a line start (which does not touch that line).
7
+ * Line arithmetic, which the highlighter's per-line walk and the find widget's
8
+ * "jump to line N" both read.
9
+ *
10
+ * The interesting cases are the two the arithmetic gets wrong by accident: the
11
+ * empty line a trailing newline opens, and an offset past the end of the
12
+ * document.
11
13
  */
12
14
 
13
15
  import { describe, expect, test } from 'bun:test';
14
16
 
15
- import {
16
- countLines,
17
- lineIndexAt,
18
- lineRangeAt,
19
- lineRangesInSelection,
20
- lineStartOffset,
21
- } from '@/shared/lib/code/editor/lines';
17
+ import { lineIndexAt, lineStartOffset } from '@/shared/lib/code/editor/lines';
22
18
 
23
19
  describe('lineIndexAt and lineStartOffset', () => {
24
20
  test('are inverses for every line start', () => {
@@ -34,48 +30,4 @@ describe('lineIndexAt and lineStartOffset', () => {
34
30
  expect(lineIndexAt('one\ntwo', 999)).toBe(1);
35
31
  expect(lineStartOffset('one\ntwo', 99)).toBe(7);
36
32
  });
37
-
38
- test('count a trailing newline as opening one more, empty line', () => {
39
- expect(countLines('one\ntwo')).toBe(2);
40
- expect(countLines('one\ntwo\n')).toBe(3);
41
- expect(countLines('')).toBe(1);
42
- });
43
- });
44
-
45
- describe('lineRangeAt', () => {
46
- test('includes the trailing newline, so the range can delete or move the line', () => {
47
- expect(lineRangeAt('one\ntwo\nthree', 5)).toEqual({ start: 4, end: 8 });
48
- });
49
-
50
- test('the last line has no newline to include', () => {
51
- expect(lineRangeAt('one\ntwo', 5)).toEqual({ start: 4, end: 7 });
52
- });
53
-
54
- test('a caret on the empty line a trailing newline opens yields an empty range there', () => {
55
- // `a\n` has an empty second line at offset 2; the honest reading of that
56
- // position is "no characters of its own", not the previous line.
57
- expect(lineRangeAt('a\n', 2)).toEqual({ start: 2, end: 2 });
58
- });
59
- });
60
-
61
- describe('lineRangesInSelection', () => {
62
- test('covers every line the selection touches', () => {
63
- const text = 'one\ntwo\nthree';
64
- expect(lineRangesInSelection(text, { start: 4, end: 7 })).toEqual([{ start: 4, end: 8 }]);
65
- expect(lineRangesInSelection(text, { start: 0, end: 7 })).toEqual([
66
- { start: 0, end: 4 },
67
- { start: 4, end: 8 },
68
- ]);
69
- });
70
-
71
- test('a selection ending exactly at a line start does not touch that line', () => {
72
- // The rule VS Code applies, and the one that keeps a whole-line selection
73
- // from splitting off an empty trailing entry.
74
- const text = 'one\ntwo';
75
- expect(lineRangesInSelection(text, { start: 0, end: 4 })).toEqual([{ start: 0, end: 4 }]);
76
- });
77
-
78
- test('an empty selection still names the line the caret is on', () => {
79
- expect(lineRangesInSelection('one\ntwo', { start: 5, end: 5 })).toEqual([{ start: 4, end: 7 }]);
80
- });
81
33
  });
@@ -4,13 +4,15 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Line arithmetic over a buffer.
7
+ * Line arithmetic over a buffer: which line an offset is on, and where that
8
+ * line begins.
8
9
  *
9
- * Extracted from the find module because the editor's line commands (delete,
10
- * move, duplicate, comment) and the highlighter need the same two facts — which
11
- * line an offset is on, and where that line begins — and neither of them is a
12
- * find concern. Keeping one implementation is what stops a line command from
13
- * disagreeing with the gutter about where line 12 starts.
10
+ * Two consumers, and they must agree — the highlighter's per-line walk and the
11
+ * find widget's "jump to line N" both turn a line number into an offset. The
12
+ * editor's own line COMMANDS no longer come through here: every one of them
13
+ * (delete, move, duplicate, comment, indent) is a CodeMirror command now, and
14
+ * `state.doc.line*` is what they read. `lineRangeAt`/`lineRangesInSelection`
15
+ * went with them.
14
16
  */
15
17
 
16
18
  /** Half-open `[start, end)` offset range in a document. */
@@ -39,45 +41,3 @@ export function lineStartOffset(text: string, lineIndex: number): number {
39
41
  }
40
42
  return offset;
41
43
  }
42
-
43
- /** How many lines `text` holds; a trailing newline opens one more (empty) line. */
44
- export function countLines(text: string): number {
45
- let count = 1;
46
- for (let index = text.indexOf('\n'); index !== -1; index = text.indexOf('\n', index + 1)) count++;
47
- return count;
48
- }
49
-
50
- /**
51
- * The whole line containing `offset`, INCLUDING its trailing newline.
52
- *
53
- * Including the newline is what makes the range directly usable by the line
54
- * commands: deleting it removes the line rather than blanking it, and moving it
55
- * carries the line's own separator with it. A caret on the empty line a
56
- * trailing newline opens (`"a\n"` at offset 2) yields an empty range there,
57
- * which the commands treat as "the newline above" — the honest reading of a
58
- * position that has no characters of its own.
59
- */
60
- export function lineRangeAt(text: string, offset: number): TextRange {
61
- const lineStart = lineStartOffset(text, lineIndexAt(text, offset));
62
- const lineEnd = text.indexOf('\n', lineStart);
63
- if (lineEnd === -1) return { start: lineStart, end: text.length };
64
- return { start: lineStart, end: lineEnd + 1 };
65
- }
66
-
67
- /**
68
- * The ranges of every line a selection touches, in document order.
69
- *
70
- * A selection ending exactly at a line start does not touch that line — the
71
- * same rule VS Code applies, and the one that keeps ⌘⇧L on a whole-line
72
- * selection from splitting off an empty trailing entry.
73
- */
74
- export function lineRangesInSelection(text: string, selection: TextRange): TextRange[] {
75
- const startLine = lineIndexAt(text, selection.start);
76
- const lastOffset = selection.end > selection.start && text[selection.end - 1] === '\n' ? selection.end - 1 : selection.end;
77
- const endLine = lineIndexAt(text, lastOffset);
78
- const ranges: TextRange[] = [];
79
- for (let line = startLine; line <= endLine; line++) {
80
- ranges.push(lineRangeAt(text, lineStartOffset(text, line)));
81
- }
82
- return ranges;
83
- }
@@ -57,12 +57,11 @@ describe('tokenMarkup', () => {
57
57
  expect(tokenMarkup('<', 0, [{ start: 0, end: 1 }], -1)).toContain('&lt;');
58
58
  });
59
59
 
60
- test('the current match and an extra selection are painted differently', () => {
61
- // The caret can only be in the primary range, so the two must not look
62
- // alike — the user has to see where their keystrokes will land.
60
+ test('the current match is painted differently from the others', () => {
61
+ // The caret is aimed at exactly one match, so the reader has to be able to
62
+ // see which — and the others must not claim that mark.
63
63
  expect(tokenMarkup('a', 0, [{ start: 0, end: 1 }], 0)).toContain('data-find-current');
64
- expect(tokenMarkup('a', 0, [{ start: 0, end: 1 }], -1, 0)).toContain('data-occurrence');
65
- expect(tokenMarkup('a', 0, [{ start: 0, end: 1 }], -1, 0)).not.toContain('data-find-current');
64
+ expect(tokenMarkup('a', 0, [{ start: 0, end: 1 }], -1)).not.toContain('data-find-current');
66
65
  });
67
66
 
68
67
  test('a plain match is neither current nor an occurrence', () => {
@@ -12,11 +12,16 @@
12
12
  * match routinely spans several tokens (`const alpha` is three) and a token
13
13
  * routinely holds several matches.
14
14
  *
15
- * `<mark>` is not decoration: it is an INLINE element with a background and no
16
- * padding or border, which is what keeps the transparent `<textarea>` above the
17
- * layer aligned with the glyphs underneath. Anything that changes the box —
18
- * a block display, a border, an outline with an offset — moves the text under
19
- * the caret and breaks editing.
15
+ * This is the markup path for the surfaces that render highlighted HTML (the
16
+ * chat's excerpt cards, the search panel's rows, the wiki reader). The EDITOR no
17
+ * longer uses it: CodeMirror draws its own decorations from a state field
18
+ * (`code-surface/marks.ts`), which is what stopped every find result from
19
+ * forcing a re-tokenize.
20
+ *
21
+ * `<mark>` is an INLINE element with a background and no padding or border, so
22
+ * it paints without moving the glyphs it sits between — anything that changes
23
+ * the box (a block display, a border, an outline with an offset) shifts the text
24
+ * and breaks the alignment a caller's own layout depends on.
20
25
  *
21
26
  * Kept out of `syntax-highlight.ts` so the tokenizer module stays under the
22
27
  * repository's line ceiling and because this is the find feature's concern
@@ -24,7 +29,7 @@
24
29
  */
25
30
 
26
31
  import { escapeCode } from '@/shared/lib/code/highlighter';
27
- import type { FindMatch } from '@/shared/lib/code/editor/find';
32
+ import type { FindMatch } from '@/shared/lib/code/editor/find-scan';
28
33
 
29
34
  /**
30
35
  * How `highlightLines` paints its marks. Declared here rather than in the
@@ -41,32 +46,16 @@ export interface HighlightLinesOptions {
41
46
  * Match ranges (absolute offsets in `code`) to wrap in `<mark class="find-match">`.
42
47
  * The find widget's highlights ride on the same markup the tokens do, so a
43
48
  * match cannot drift from the text it belongs to — and an inline `<mark>`
44
- * paints a background without moving the glyphs, which is what keeps the
45
- * transparent `<textarea>` above aligned with this layer.
49
+ * paints a background without moving the glyphs it sits between.
46
50
  */
47
51
  marks?: readonly FindMatch[];
48
52
  /** Index into `marks` of the current match, marked `data-find-current`. */
49
53
  currentMark?: number;
50
- /**
51
- * Marks from this index on are extra ⌘D SELECTIONS rather than find matches,
52
- * and are painted as such. The caller appends them after the matches, so one
53
- * index is all the split needs.
54
- */
55
- occurrenceFrom?: number;
56
54
  }
57
55
 
58
56
  export const MARK_OPEN = '<mark class="find-match">';
59
57
  /** The current match is the one the caret/next-step is aimed at; the CSS paints it darker. */
60
58
  export const MARK_CURRENT_OPEN = '<mark class="find-match" data-find-current="">';
61
- /**
62
- * An extra ⌘D range. It is not a find match — it is a second SELECTION, and it
63
- * has to read as one: the caret can only be in the primary range, so painting
64
- * them alike would leave the user unable to tell which one their keystrokes go
65
- * to. The two differ only in colour; the geometry is shared, which is what
66
- * keeps the layers aligned.
67
- */
68
- export const MARK_OCCURRENCE_OPEN = '<mark class="find-match" data-occurrence="">';
69
-
70
59
  /**
71
60
  * `content` with every intersecting mark spliced in, HTML-escaped.
72
61
  *
@@ -79,7 +68,6 @@ export function tokenMarkup(
79
68
  tokenStart: number,
80
69
  marks: readonly FindMatch[] | undefined,
81
70
  currentMark: number,
82
- occurrenceFrom = -1,
83
71
  ): string {
84
72
  const tokenEnd = tokenStart + content.length;
85
73
  if (!marks || marks.length === 0) return escapeCode(content);
@@ -98,7 +86,7 @@ export function tokenMarkup(
98
86
  const to = Math.min(mark.end, tokenEnd) - tokenStart;
99
87
  if (to <= cursor) continue;
100
88
  html += escapeCode(content.slice(cursor, from));
101
- const open = index === currentMark ? MARK_CURRENT_OPEN : index >= occurrenceFrom && occurrenceFrom >= 0 ? MARK_OCCURRENCE_OPEN : MARK_OPEN;
89
+ const open = index === currentMark ? MARK_CURRENT_OPEN : MARK_OPEN;
102
90
  html += open + escapeCode(content.slice(from, to)) + '</mark>';
103
91
  cursor = to;
104
92
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Registering an editor theme with the shared Shiki highlighter, on demand.
8
+ *
9
+ * Shiki throws for a theme it has not been given, and `@shikijs/themes` ships 65
10
+ * of them (1.3 MB of TextMate JSON). So a theme is loaded the first time it is
11
+ * SELECTED — the picker's grid draws from the generated colour rows and fetches
12
+ * nothing — and a surface that asks for one before it lands keeps the plain
13
+ * fallback and re-runs on `onEditorThemeReady`.
14
+ *
15
+ * This lives beside the tokenizer rather than in a client hook because the
16
+ * decoration plugin is what needs the notification, and it must not import a
17
+ * Preact module to get it.
18
+ */
19
+
20
+ import { getHighlighterSync } from '@/shared/lib/code/highlighter';
21
+ import {
22
+ EDITOR_AUTO_THEME_ID,
23
+ EDITOR_THEME_LOADERS,
24
+ } from '@/shared/lib/code/editor/themes';
25
+
26
+ /** Ids registered with the highlighter. */
27
+ const loaded = new Set<string>();
28
+ /** In-flight loads, so two surfaces selecting one theme share a fetch. */
29
+ const pending = new Map<string, Promise<void>>();
30
+ const listeners = new Set<(id: string) => void>();
31
+
32
+ function notifyReady(id: string): void {
33
+ for (const listener of [...listeners]) {
34
+ try {
35
+ listener(id);
36
+ } catch {
37
+ // A listener must never break the others.
38
+ }
39
+ }
40
+ }
41
+
42
+ /** Fires when a theme module lands, so a pending tokenize can retry. */
43
+ export function onEditorThemeReady(listener: (id: string) => void): () => void {
44
+ listeners.add(listener);
45
+ return () => {
46
+ listeners.delete(listener);
47
+ };
48
+ }
49
+
50
+ /** Whether Shiki can tokenize with `id` synchronously right now. */
51
+ export function isEditorThemeReady(id: string): boolean {
52
+ if (id === EDITOR_AUTO_THEME_ID) return true;
53
+ return loaded.has(id);
54
+ }
55
+
56
+ /**
57
+ * Register `id`'s theme with the shared highlighter.
58
+ *
59
+ * Resolves immediately for `auto`, for an id this build does not ship, and for
60
+ * a highlighter that has not booted yet (the caller keeps the fallback and is
61
+ * retried by `onSyntaxReady`). Never rejects: a theme that fails to load leaves
62
+ * the surface on the app palette, which is a wrong palette rather than a blank
63
+ * editor.
64
+ */
65
+ export function loadEditorTheme(id: string): Promise<void> {
66
+ if (id === EDITOR_AUTO_THEME_ID || loaded.has(id)) return Promise.resolve();
67
+ const loader = EDITOR_THEME_LOADERS[id];
68
+ if (!loader) return Promise.resolve();
69
+ const inFlight = pending.get(id);
70
+ if (inFlight) return inFlight;
71
+
72
+ const highlighter = getHighlighterSync();
73
+ if (!highlighter) return Promise.resolve();
74
+
75
+ const load = highlighter
76
+ .loadTheme(loader as never)
77
+ .then(() => {
78
+ loaded.add(id);
79
+ notifyReady(id);
80
+ })
81
+ .catch(() => {
82
+ // Left out of `loaded` so a later attempt can still succeed.
83
+ })
84
+ .finally(() => {
85
+ pending.delete(id);
86
+ });
87
+
88
+ pending.set(id, load);
89
+ return load;
90
+ }
91
+
92
+ /** Test seam: forget every registration (a highlighter restart). */
93
+ export function resetEditorThemeLoader(): void {
94
+ loaded.clear();
95
+ pending.clear();
96
+ listeners.clear();
97
+ }