@kabmo-labs/kabmo 0.7.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 (351) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +403 -384
  4. package/dist/client/static/js/{index-0wvbca0b.js → index-bpptfkz2.js} +905 -875
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +10 -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/header-controls/index.tsx +24 -1
  24. package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
  25. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  26. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  27. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  28. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  29. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  30. package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
  31. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
  32. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  33. package/src/client/components/layout/desktop-layout/index.tsx +55 -69
  34. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  35. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
  36. package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
  37. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
  38. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  39. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
  40. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  41. package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
  42. package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
  43. package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
  44. package/src/client/components/mobile/LayoutWrapper.tsx +23 -16
  45. package/src/client/components/mobile/RightSidebar.tsx +21 -10
  46. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  47. package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
  48. package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
  49. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  50. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  51. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  52. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  53. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  54. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
  55. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
  56. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
  57. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
  58. package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
  59. package/src/client/components/mobile/worktree-session.ts +51 -0
  60. package/src/client/components/settings/Modal.tsx +8 -0
  61. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  62. package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
  63. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  64. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  65. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +146 -0
  66. package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
  67. package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
  68. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +36 -55
  69. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
  70. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  71. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  72. package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
  73. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  74. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  75. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  76. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  77. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  78. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  79. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  80. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  81. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  82. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
  83. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +32 -17
  84. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  85. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  86. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  87. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  88. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  89. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  90. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  91. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  92. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  93. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  94. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  95. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  96. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  97. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  98. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  99. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  100. package/src/client/components/workspace/editor/Body.tsx +4 -7
  101. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  102. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  103. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  104. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  105. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  106. package/src/client/components/workspace/editor/index.tsx +7 -9
  107. package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
  108. package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
  109. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  110. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  111. package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
  112. package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
  113. package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
  114. package/src/client/components/workspace/file-explorer/filter.ts +47 -0
  115. package/src/client/components/workspace/file-explorer/index.tsx +77 -64
  116. package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
  117. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  118. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  119. package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
  120. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  121. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  122. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  123. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  124. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  125. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  126. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  127. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  128. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  129. package/src/client/hooks/chat/timeline/index.ts +27 -28
  130. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  131. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  132. package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
  133. package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
  134. package/src/client/hooks/chat/timeline/send.test.ts +2 -0
  135. package/src/client/hooks/chat/timeline/send.ts +11 -1
  136. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  137. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  138. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  139. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  140. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  141. package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
  142. package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
  143. package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
  144. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  145. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  146. package/src/client/hooks/editor/find-state.ts +1 -1
  147. package/src/client/hooks/editor/theme.test.ts +206 -0
  148. package/src/client/hooks/editor/theme.ts +82 -0
  149. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  150. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  151. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  152. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  153. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  154. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  155. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  156. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  157. package/src/client/hooks/ui/long-press.test.ts +174 -0
  158. package/src/client/hooks/ui/long-press.ts +112 -0
  159. package/src/client/hooks/ui/settings-modal.ts +56 -0
  160. package/src/client/hooks/ui/updates/shell-download.test.ts +75 -0
  161. package/src/client/hooks/ui/updates/shell-download.ts +153 -28
  162. package/src/client/hooks/ui/updates/shell-offer.test.ts +213 -0
  163. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  164. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  165. package/src/client/hooks/workspace/diff-file.ts +29 -10
  166. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  167. package/src/client/hooks/workspace/editor-view.ts +61 -0
  168. package/src/client/hooks/workspace/file-create.ts +41 -11
  169. package/src/client/hooks/workspace/file-listing.ts +56 -1
  170. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  171. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  172. package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
  173. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  174. package/src/client/hooks/workspace/worktree/index.ts +165 -0
  175. package/src/client/lib/desktop/bridge.ts +45 -0
  176. package/src/client/lib/search-reveal.ts +1 -1
  177. package/src/client/main.tsx +5 -1
  178. package/src/client/tailwind.css +56 -8
  179. package/src/server/index.ts +7 -2
  180. package/src/server/lib/auth/guard.test.ts +28 -0
  181. package/src/server/lib/auth/guard.ts +10 -0
  182. package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
  183. package/src/server/lib/db/schema.ts +4 -0
  184. package/src/server/lib/db/worktree-registry.server.ts +91 -0
  185. package/src/server/lib/fs/canonical.ts +52 -0
  186. package/src/server/lib/fs/relative-path.ts +48 -0
  187. package/src/server/lib/fs/root-worktree.test.ts +116 -0
  188. package/src/server/lib/fs/root.ts +39 -10
  189. package/src/server/lib/fs/worktree/carry.ts +134 -0
  190. package/src/server/lib/fs/worktree/create.test.ts +249 -0
  191. package/src/server/lib/fs/worktree/create.ts +262 -0
  192. package/src/server/lib/fs/worktree/errors.ts +43 -0
  193. package/src/server/lib/fs/worktree/list.test.ts +207 -0
  194. package/src/server/lib/fs/worktree/list.ts +142 -0
  195. package/src/server/lib/fs/worktree/location.test.ts +206 -0
  196. package/src/server/lib/fs/worktree/location.ts +114 -0
  197. package/src/server/lib/fs/worktree/project-id.ts +53 -0
  198. package/src/server/lib/fs/worktree/remove.test.ts +242 -0
  199. package/src/server/lib/fs/worktree/remove.ts +152 -0
  200. package/src/server/lib/fs/worktree/settings.server.ts +56 -0
  201. package/src/server/lib/fs/worktree/topology.ts +142 -0
  202. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  203. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  204. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  205. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  206. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  207. package/src/server/lib/lifecycle/probe.ts +121 -13
  208. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  209. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  210. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  211. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  212. package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
  213. package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
  214. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  215. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  216. package/src/server/lib/panels/local-edits.server.ts +33 -0
  217. package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
  218. package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
  219. package/src/server/lib/panels/sync.server.test.ts +125 -147
  220. package/src/server/lib/panels/sync.server.ts +153 -48
  221. package/src/server/lib/realtime/signals.server.ts +10 -0
  222. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  223. package/src/server/lib/realtime/topics.server.ts +16 -0
  224. package/src/server/lib/updates/shell-assets.test.ts +114 -0
  225. package/src/server/lib/updates/shell-assets.ts +200 -0
  226. package/src/server/lib/updates/shell-cache.ts +105 -0
  227. package/src/server/lib/updates/shell-pages.test.ts +187 -0
  228. package/src/server/lib/updates/shell-pages.ts +157 -0
  229. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  230. package/src/server/lib/updates/shell-release.test.ts +59 -71
  231. package/src/server/lib/updates/shell-release.ts +178 -198
  232. package/src/server/routes/fs/action-create.test.ts +18 -13
  233. package/src/server/routes/fs/action-nested.test.ts +184 -0
  234. package/src/server/routes/fs/action.ts +47 -16
  235. package/src/server/routes/fs/git-merge.test.ts +11 -1
  236. package/src/server/routes/fs/read.test.ts +24 -7
  237. package/src/server/routes/fs/scope.test.ts +15 -10
  238. package/src/server/routes/fs/scope.ts +3 -14
  239. package/src/server/routes/index.ts +2 -0
  240. package/src/server/routes/updates/android.ts +6 -1
  241. package/src/server/routes/updates/desktop.ts +59 -0
  242. package/src/server/routes/updates/index.ts +2 -0
  243. package/src/server/routes/updates/ios.ts +5 -1
  244. package/src/server/routes/worktrees/create.ts +138 -0
  245. package/src/server/routes/worktrees/index.ts +25 -0
  246. package/src/server/routes/worktrees/list.ts +134 -0
  247. package/src/server/routes/worktrees/remove.ts +102 -0
  248. package/src/server/routes/worktrees/routes.test.ts +326 -0
  249. package/src/server/routes/worktrees/shared.ts +90 -0
  250. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  251. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  252. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  253. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  254. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  255. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  256. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  257. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  258. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  259. package/src/shared/lib/code/editor/keymap.ts +3 -3
  260. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  261. package/src/shared/lib/code/editor/lines.ts +8 -48
  262. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  263. package/src/shared/lib/code/editor/marks.ts +13 -25
  264. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  265. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  266. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  267. package/src/shared/lib/code/editor/themes.ts +165 -0
  268. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  269. package/src/shared/lib/code/highlight-memo.ts +149 -0
  270. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  271. package/src/shared/lib/code/highlighter.ts +3 -1
  272. package/src/shared/lib/code/idle.ts +37 -0
  273. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  274. package/src/shared/lib/code/outline/rows.ts +94 -0
  275. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  276. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  277. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  278. package/src/shared/lib/fs/file-kind.ts +47 -13
  279. package/src/shared/lib/fs/file-tree.test.ts +183 -0
  280. package/src/shared/lib/fs/file-tree.ts +159 -0
  281. package/src/shared/lib/fs/file-url.test.ts +52 -0
  282. package/src/shared/lib/fs/file-url.ts +37 -0
  283. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  284. package/src/shared/lib/fs/patch-sides.ts +139 -0
  285. package/src/shared/lib/fs/paths.test.ts +24 -1
  286. package/src/shared/lib/fs/paths.ts +17 -0
  287. package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
  288. package/src/shared/lib/omp/session/prewarm.ts +7 -0
  289. package/src/shared/lib/realtime/protocol.ts +16 -0
  290. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  291. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  292. package/src/shared/lib/updates/shell-arch.ts +54 -0
  293. package/src/shared/lib/updates/shell-ua.ts +24 -4
  294. package/src/shared/lib/workspace/active-project.ts +23 -0
  295. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  296. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  297. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  298. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  299. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  300. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  301. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  302. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  303. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  304. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  305. package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
  306. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  307. package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
  308. package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
  309. package/src/shared/lib/workspace/worktree/types.ts +104 -0
  310. package/src/shared/types/fs.ts +10 -0
  311. package/src/shared/types/realtime.ts +29 -0
  312. package/src/shared/types/settings/state.ts +10 -0
  313. package/src/shared/types/workspace.ts +14 -0
  314. package/src/test-support/file-explorer-disk.test-util.ts +76 -0
  315. package/src/test-support/file-explorer.test-util.ts +71 -40
  316. package/src/test-support/loopback.ts +183 -0
  317. package/dist/client/static/js/index-nzyjf92m.css +0 -1
  318. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  319. package/src/client/components/common/code-editor/handler.ts +0 -175
  320. package/src/client/components/common/code-editor/index.tsx +0 -314
  321. package/src/client/components/common/code-surface/measure.ts +0 -148
  322. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  323. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  324. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  325. package/src/client/hooks/editor/scroll-container.ts +0 -26
  326. package/src/client/hooks/editor/use-code-window.ts +0 -100
  327. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  328. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  329. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  330. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  331. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  332. package/src/shared/lib/code/editor/commands.ts +0 -260
  333. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  334. package/src/shared/lib/code/editor/edits.ts +0 -122
  335. package/src/shared/lib/code/editor/history.test.ts +0 -118
  336. package/src/shared/lib/code/editor/history.ts +0 -101
  337. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  338. package/src/shared/lib/code/editor/layers.ts +0 -67
  339. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  340. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  341. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  342. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  343. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  344. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  345. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  346. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  347. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  348. package/src/shared/lib/code/lazy-window.ts +0 -156
  349. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  350. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  351. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Replacement expansion: what one match becomes, and how a template is read.
8
+ *
9
+ * Each rule is asserted against the engine's own `String.replace` where one
10
+ * exists, because the search panel's replace PREVIEW and the write that lands
11
+ * must agree — a preview that painted `$1` over a file that would receive the
12
+ * captured group is exactly the drift this covers.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+
17
+ import {
18
+ expandReplacement,
19
+ replaceAllMatches,
20
+ replaceMatch,
21
+ } from '@/shared/lib/code/editor/find-replace';
22
+ import type { FindOptions } from '@/shared/lib/code/editor/find-scan';
23
+ import { findMatches } from '@/shared/lib/code/editor/find-scan';
24
+
25
+ const PLAIN: FindOptions = { matchCase: false, wholeWord: false, isRegex: false };
26
+
27
+ describe('replaceMatch', () => {
28
+ test('literal replacement inserts the field verbatim, dollar signs included', () => {
29
+ const text = 'const a = 1;';
30
+ const result = replaceMatch(text, { start: 6, end: 7 }, 'a', '$&x', PLAIN);
31
+ expect(result?.text).toBe('const $&x = 1;');
32
+ expect(result?.caret).toBe(6 + 3);
33
+ });
34
+
35
+ test('regex replacement expands capture groups like String.replace', () => {
36
+ const text = 'key = value';
37
+ const result = replaceMatch(text, { start: 6, end: 11 }, '(\\w+)', '[$1]', { ...PLAIN, isRegex: true });
38
+ expect(result?.text).toBe('key = [value]');
39
+ expect(result?.caret).toBe(13);
40
+ });
41
+
42
+ test('refuses a match that is no longer at its offset', () => {
43
+ expect(replaceMatch('abc', { start: 5, end: 7 }, 'a', 'z', PLAIN)).toBeNull();
44
+ });
45
+ });
46
+
47
+ describe('replaceAllMatches', () => {
48
+ test('literal replace-all rewrites every occurrence', () => {
49
+ expect(replaceAllMatches('a a a', 'a', 'b', PLAIN)).toBe('b b b');
50
+ });
51
+
52
+ test('regex replace-all agrees with the engine', () => {
53
+ const text = 'foo1 bar2';
54
+ const options: FindOptions = { ...PLAIN, isRegex: true };
55
+ expect(replaceAllMatches(text, '([a-z]+)(\\d)', '$2-$1', options)).toBe(text.replace(/([a-z]+)(\d)/g, '$2-$1'));
56
+ });
57
+
58
+ test('whole-word replace-all leaves substrings alone', () => {
59
+ expect(replaceAllMatches('cat category', 'cat', 'dog', { ...PLAIN, wholeWord: true })).toBe('dog category');
60
+ });
61
+
62
+ test('returns null for an unusable query', () => {
63
+ expect(replaceAllMatches('abc', '(', 'x', { ...PLAIN, isRegex: true })).toBeNull();
64
+ expect(replaceAllMatches('abc', '', 'x', PLAIN)).toBeNull();
65
+ });
66
+ });
67
+
68
+ /**
69
+ * The write path and the match list must be the SAME answer.
70
+ *
71
+ * They were not, and every case below was silent: the scan runs through
72
+ * CodeMirror's `RegExpCursor` (`gm` + `u`) while the replacement compiled the
73
+ * query itself (`g`/`gi`), so the count in the widget and the bytes written
74
+ * disagreed about anchors, unicode property escapes and which escapes are even
75
+ * legal. `find-scan.ts` now states the flags once (`BASE_FLAGS`), and these
76
+ * assert the two ends together rather than one engine in isolation.
77
+ */
78
+ describe('the write path agrees with the scan', () => {
79
+ const REGEX: FindOptions = { ...PLAIN, isRegex: true };
80
+
81
+ test('an anchored pattern rewrites every line the scan counted', () => {
82
+ const text = 'foo\nbar\nfoo';
83
+ expect(findMatches(text, '^foo', REGEX).matches).toHaveLength(2);
84
+ expect(replaceAllMatches(text, '^foo', 'X', REGEX)).toBe('X\nbar\nX');
85
+
86
+ expect(findMatches(text, 'foo$', REGEX).matches).toHaveLength(2);
87
+ expect(replaceAllMatches(text, 'foo$', 'X', REGEX)).toBe('X\nbar\nX');
88
+ });
89
+
90
+ test('a unicode property escape finds AND replaces', () => {
91
+ const text = 'a1 b2';
92
+ expect(findMatches(text, '\\p{L}\\d', REGEX).matches).toHaveLength(2);
93
+ expect(replaceAllMatches(text, '\\p{L}\\d', 'X', REGEX)).toBe('X X');
94
+ expect(replaceMatch(text, { start: 0, end: 2 }, '\\p{L}\\d', 'X', REGEX)?.text).toBe('X b2');
95
+ });
96
+
97
+ test('an escape the scan calls invalid is refused by the write too', () => {
98
+ // `\-` is a legal identity escape without `u` and a syntax error with it.
99
+ expect(findMatches('a-b', '\\-', REGEX).invalid).toBe(true);
100
+ expect(replaceAllMatches('a-b', '\\-', 'X', REGEX)).toBeNull();
101
+ });
102
+
103
+ test('a zero-width match is replaced by nothing, exactly as it is uncounted', () => {
104
+ // The widget says "No results" for `^`; inserting at every line start under
105
+ // it was the write contradicting the count it was shown beside.
106
+ expect(findMatches('a\nb', '^', REGEX).matches).toEqual([]);
107
+ expect(replaceAllMatches('a\nb', '^', 'X', REGEX)).toBe('a\nb');
108
+ expect(replaceAllMatches('abc', 'x*', 'X', REGEX)).toBe('abc');
109
+ });
110
+ });
111
+
112
+ describe('expandReplacement', () => {
113
+ /** Indexed by group number, `[0]` unused — the shape `groupsOf` produces. */
114
+ const groups = { full: 'ab12', numbered: [undefined, 'a', 'b'] as const, named: { tail: '12' } };
115
+
116
+ test('expands $&, $1 and $<name>', () => {
117
+ expect(expandReplacement('$&|$1|$2|$<tail>', groups)).toBe('ab12|a|b|12');
118
+ });
119
+
120
+ test('$$ is a literal dollar and an unknown group is empty', () => {
121
+ expect(expandReplacement('$$$9', groups)).toBe('$');
122
+ });
123
+
124
+ test('a missing two-digit group falls back to the one-digit group plus the digit', () => {
125
+ // The engine's own reading of `$12` with two groups: `$1` then a literal `2`.
126
+ expect('x'.replace(/(x)/, '$12')).toBe('x2');
127
+ expect(expandReplacement('$12', groups)).toBe('a2');
128
+ });
129
+
130
+ test('a template without a dollar is returned as-is', () => {
131
+ expect(expandReplacement('plain', groups)).toBe('plain');
132
+ });
133
+ });
134
+
@@ -3,117 +3,23 @@
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
 
6
- import { lineIndexAt, lineStartOffset, type TextRange } from '@/shared/lib/code/editor/lines';
7
-
8
- export { lineIndexAt, lineStartOffset };
9
-
10
6
  /**
11
- * Find and replace over one document buffer — the pure half of the editor's
12
- * find widget.
7
+ * Replacement expansion, split from the scan.
13
8
  *
14
- * Everything here is offset arithmetic on a string: what the query compiles
15
- * to, which ranges match, where the caret lands after a replacement, and how
16
- * `$1` is expanded. The widget, the hook that owns its state and the editor
17
- * surface that paints the matches all read these results, so none of them
18
- * re-implements a rule and the count in the widget cannot disagree with the
19
- * ranges on screen.
20
- */
21
-
22
- export interface FindOptions {
23
- matchCase: boolean;
24
- wholeWord: boolean;
25
- isRegex: boolean;
26
- }
27
-
28
- /** Half-open `[start, end)` offset range in the document. */
29
- export type FindMatch = TextRange;
30
-
31
- export interface FindResult {
32
- matches: FindMatch[];
33
- /** The document holds more matches than the cap; the list stops at it. */
34
- truncated: boolean;
35
- /** A regex query that does not compile — a different answer from "no matches". */
36
- invalid: boolean;
37
- }
38
-
39
- /**
40
- * Ceiling on collected matches. A one-character query over a large file matches
41
- * tens of thousands of times and every match costs a Range in the highlight
42
- * registry, so past this the list stops and the widget reports `N+`.
43
- */
44
- export const MAX_FIND_MATCHES = 5_000;
45
-
46
- export const DEFAULT_FIND_OPTIONS: FindOptions = {
47
- matchCase: false,
48
- wholeWord: false,
49
- isRegex: false,
50
- };
51
-
52
- /**
53
- * The pattern a query compiles to, or null when the query is empty or does not
54
- * compile. A literal query is escaped here rather than at the call site so the
55
- * match scan and the replacement splice can never disagree about what the
56
- * pattern was.
9
+ * The scan (`find-scan.ts`) answers "where does this query match"; this answers
10
+ * "what does one match become". They are separate because the search panel's
11
+ * replace PREVIEW and the write that actually lands must read ONE expansion
12
+ * rule — a row that painted `$1` literally over a file that would receive the
13
+ * captured group is the drift this module exists to prevent — while the scan has
14
+ * a different consumer (the find bar's match list and count).
57
15
  *
58
- * Whole-word is a `\b`-anchored non-capturing group rather than a bare wrap so
59
- * an alternation (`foo|bar`) keeps its own precedence.
16
+ * The template grammar is `String.prototype.replace`'s for a string replacement:
17
+ * `$$`, `$&`, `$1`–`$99` and `$<name>`. The prefix/suffix forms (`` $` `` /
18
+ * `$'`) are deliberately left literal — nothing in the UI offers them, and
19
+ * leaving the two characters alone is the honest reading.
60
20
  */
61
- export function buildFindRegex(query: string, options: FindOptions): RegExp | null {
62
- if (!query) return null;
63
- const source = options.isRegex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
64
- const wrapped = options.wholeWord ? `\\b(?:${source})\\b` : source;
65
- try {
66
- return new RegExp(wrapped, options.matchCase ? 'g' : 'gi');
67
- } catch {
68
- return null;
69
- }
70
- }
71
21
 
72
- /**
73
- * Every match of `query` in `text`, in document order.
74
- *
75
- * Zero-width matches (`^`, `\b`, `a*`) are skipped: they have nothing to
76
- * highlight and nothing to replace, and stepping past them is what keeps the
77
- * scan terminating.
78
- */
79
- export function findMatches(text: string, query: string, options: FindOptions): FindResult {
80
- const regex = buildFindRegex(query, options);
81
- if (!query) return { matches: [], truncated: false, invalid: false };
82
- if (!regex) return { matches: [], truncated: false, invalid: true };
83
-
84
- const matches: FindMatch[] = [];
85
- let match: RegExpExecArray | null;
86
- while ((match = regex.exec(text)) !== null) {
87
- if (match[0].length === 0) {
88
- regex.lastIndex += 1;
89
- continue;
90
- }
91
- matches.push({ start: match.index, end: match.index + match[0].length });
92
- if (matches.length >= MAX_FIND_MATCHES) return { matches, truncated: true, invalid: false };
93
- }
94
- return { matches, truncated: false, invalid: false };
95
- }
96
-
97
- /**
98
- * The match to call "current" for a caret/anchor at `offset`: the first one
99
- * that ends after it, so a caret sitting inside a match still counts as being
100
- * on that match. Wraps to the first match when the anchor is past the last one,
101
- * and reports -1 for an empty list.
102
- */
103
- export function matchIndexAtOrAfter(matches: readonly FindMatch[], offset: number): number {
104
- if (matches.length === 0) return -1;
105
- for (let index = 0; index < matches.length; index++) {
106
- if (matches[index].end > offset) return index;
107
- }
108
- return 0;
109
- }
110
-
111
- /** Step one match forward (`1`) or back (`-1`), wrapping at either end. */
112
- export function stepMatchIndex(index: number, count: number, direction: 1 | -1): number {
113
- if (count <= 0) return -1;
114
- if (index < 0) return direction === 1 ? 0 : count - 1;
115
- return (index + direction + count) % count;
116
- }
22
+ import { buildFindRegex, type FindMatch, type FindOptions } from '@/shared/lib/code/editor/find-scan';
117
23
 
118
24
  /** What a replacement template is expanded against. */
119
25
  export interface MatchGroups {
@@ -210,7 +116,16 @@ export function replaceMatch(
210
116
  };
211
117
  }
212
118
 
213
- /** Replace every match. Null when the query is empty or does not compile. */
119
+ /**
120
+ * Replace every match. Null when the query is empty or does not compile.
121
+ *
122
+ * A ZERO-WIDTH match is left alone, exactly as the scan leaves it out of the
123
+ * list: `^`, `$`, `\b` and `a*` have nothing to highlight and nothing to
124
+ * replace, so a `^` query reads "No results" in the widget — and rewriting every
125
+ * line start under it was the write disagreeing with the count it was shown
126
+ * next to. (A non-empty literal query can never match zero characters, so this
127
+ * only ever applies to regex mode.)
128
+ */
214
129
  export function replaceAllMatches(
215
130
  text: string,
216
131
  query: string,
@@ -221,6 +136,7 @@ export function replaceAllMatches(
221
136
  if (!regex) return null;
222
137
  if (!options.isRegex) return text.replace(regex, () => replacement);
223
138
  return text.replace(regex, (...args: unknown[]) => {
139
+ if (String(args[0]).length === 0) return '';
224
140
  // A replacer's arguments are `(match, …groups, offset, string, groups?)`;
225
141
  // rebuilding an exec result from them keeps one expansion rule for both
226
142
  // the single and the bulk path.
@@ -4,28 +4,27 @@
4
4
  */
5
5
 
6
6
  /**
7
- * The find widget's arithmetic: what a query matches, where the caret lands
8
- * after a replacement, and how `$1` is expanded. These are the rules the widget
9
- * count, the on-screen highlights and the written buffer all read, so each is
10
- * asserted against the engine's own `String.replace` behaviour where one
11
- * exists.
7
+ * The find widget's scan: what a query matches and how the caret anchors to it.
8
+ *
9
+ * The scan runs on CodeMirror's `RegExpCursor`, so these cases are the CONTRACT
10
+ * that cursor has to keep — the same ordered list, the same case/whole-word/regex
11
+ * options, a broken pattern reported as `invalid` rather than as "no matches",
12
+ * and the cap. They are asserted here rather than through a live editor because
13
+ * the widget, the count and the painted marks all read this one function.
12
14
  */
13
15
 
14
16
  import { describe, expect, test } from 'bun:test';
15
17
 
16
18
  import {
17
19
  buildFindRegex,
18
- expandReplacement,
19
20
  findMatches,
20
21
  lineIndexAt,
21
22
  lineStartOffset,
22
23
  matchIndexAtOrAfter,
23
24
  MAX_FIND_MATCHES,
24
- replaceAllMatches,
25
- replaceMatch,
26
25
  stepMatchIndex,
27
26
  type FindOptions,
28
- } from '@/shared/lib/code/editor/find';
27
+ } from '@/shared/lib/code/editor/find-scan';
29
28
 
30
29
  const PLAIN: FindOptions = { matchCase: false, wholeWord: false, isRegex: false };
31
30
 
@@ -83,6 +82,17 @@ describe('findMatches', () => {
83
82
  expect(result.invalid).toBe(false);
84
83
  });
85
84
 
85
+ test('finds a match that begins at a line start, and one that spans lines', () => {
86
+ // The cursor searches line by line, so both edges are where it can lose a
87
+ // match: a pattern anchored at a line start, and one whose `\n` forces the
88
+ // whole-document path.
89
+ expect(findMatches('one\ntwo\ntwo', 'two', PLAIN).matches).toEqual([
90
+ { start: 4, end: 7 },
91
+ { start: 8, end: 11 },
92
+ ]);
93
+ expect(findMatches('one\ntwo', 'one\ntwo', { ...PLAIN, isRegex: true }).matches).toEqual([{ start: 0, end: 7 }]);
94
+ });
95
+
86
96
  test('caps the collected matches and says so', () => {
87
97
  const text = 'a'.repeat(MAX_FIND_MATCHES + 10);
88
98
  const result = findMatches(text, 'a', PLAIN);
@@ -104,70 +114,6 @@ describe('buildFindRegex', () => {
104
114
  });
105
115
  });
106
116
 
107
- describe('replaceMatch', () => {
108
- test('literal replacement inserts the field verbatim, dollar signs included', () => {
109
- const text = 'const a = 1;';
110
- const result = replaceMatch(text, { start: 6, end: 7 }, 'a', '$&x', PLAIN);
111
- expect(result?.text).toBe('const $&x = 1;');
112
- expect(result?.caret).toBe(6 + 3);
113
- });
114
-
115
- test('regex replacement expands capture groups like String.replace', () => {
116
- const text = 'key = value';
117
- const result = replaceMatch(text, { start: 6, end: 11 }, '(\\w+)', '[$1]', { ...PLAIN, isRegex: true });
118
- expect(result?.text).toBe('key = [value]');
119
- expect(result?.caret).toBe(13);
120
- });
121
-
122
- test('refuses a match that is no longer at its offset', () => {
123
- expect(replaceMatch('abc', { start: 5, end: 7 }, 'a', 'z', PLAIN)).toBeNull();
124
- });
125
- });
126
-
127
- describe('replaceAllMatches', () => {
128
- test('literal replace-all rewrites every occurrence', () => {
129
- expect(replaceAllMatches('a a a', 'a', 'b', PLAIN)).toBe('b b b');
130
- });
131
-
132
- test('regex replace-all agrees with the engine', () => {
133
- const text = 'foo1 bar2';
134
- const options: FindOptions = { ...PLAIN, isRegex: true };
135
- expect(replaceAllMatches(text, '([a-z]+)(\\d)', '$2-$1', options)).toBe(text.replace(/([a-z]+)(\d)/g, '$2-$1'));
136
- });
137
-
138
- test('whole-word replace-all leaves substrings alone', () => {
139
- expect(replaceAllMatches('cat category', 'cat', 'dog', { ...PLAIN, wholeWord: true })).toBe('dog category');
140
- });
141
-
142
- test('returns null for an unusable query', () => {
143
- expect(replaceAllMatches('abc', '(', 'x', { ...PLAIN, isRegex: true })).toBeNull();
144
- expect(replaceAllMatches('abc', '', 'x', PLAIN)).toBeNull();
145
- });
146
- });
147
-
148
- describe('expandReplacement', () => {
149
- /** Indexed by group number, `[0]` unused — the shape `groupsOf` produces. */
150
- const groups = { full: 'ab12', numbered: [undefined, 'a', 'b'] as const, named: { tail: '12' } };
151
-
152
- test('expands $&, $1 and $<name>', () => {
153
- expect(expandReplacement('$&|$1|$2|$<tail>', groups)).toBe('ab12|a|b|12');
154
- });
155
-
156
- test('$$ is a literal dollar and an unknown group is empty', () => {
157
- expect(expandReplacement('$$$9', groups)).toBe('$');
158
- });
159
-
160
- test('a missing two-digit group falls back to the one-digit group plus the digit', () => {
161
- // The engine's own reading of `$12` with two groups: `$1` then a literal `2`.
162
- expect('x'.replace(/(x)/, '$12')).toBe('x2');
163
- expect(expandReplacement('$12', groups)).toBe('a2');
164
- });
165
-
166
- test('a template without a dollar is returned as-is', () => {
167
- expect(expandReplacement('plain', groups)).toBe('plain');
168
- });
169
- });
170
-
171
117
  describe('caret anchoring', () => {
172
118
  test('lineIndexAt and lineStartOffset are inverses for every line start', () => {
173
119
  const text = 'one\ntwo\n\nfour';
@@ -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