@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
@@ -1,118 +0,0 @@
1
- /**
2
- * @license
3
- * SPDX-License-Identifier: Apache-2.0
4
- */
5
-
6
- import { describe, expect, test } from 'bun:test';
7
-
8
- import {
9
- continuesTyping,
10
- createHistory,
11
- mergeTyping,
12
- pushEdit,
13
- pushRecord,
14
- stepHistory,
15
- HISTORY_TIME_GAP,
16
- type HistoryRecord,
17
- } from '@/shared/lib/code/editor/history';
18
-
19
- const NOW = 1_700_000_000_000;
20
-
21
- function record(value: string, caret: number, timestamp = NOW): HistoryRecord {
22
- return { value, selectionStart: caret, selectionEnd: caret, timestamp };
23
- }
24
-
25
- describe('pushRecord', () => {
26
- test('appends and moves the current position', () => {
27
- const history = createHistory();
28
- pushRecord(history, record('a', 1));
29
- pushRecord(history, record('ab', 2));
30
-
31
- expect(history.stack.map((entry) => entry.value)).toEqual(['a', 'ab']);
32
- expect(history.offset).toBe(1);
33
- });
34
-
35
- test('drops the redo entries that follow the current position', () => {
36
- const history = createHistory();
37
- pushRecord(history, record('a', 1));
38
- pushRecord(history, record('ab', 2));
39
- history.offset = 0;
40
-
41
- pushRecord(history, record('ax', 2));
42
-
43
- expect(history.stack.map((entry) => entry.value)).toEqual(['a', 'ax']);
44
- expect(history.offset).toBe(1);
45
- });
46
- });
47
-
48
- describe('pushEdit', () => {
49
- test('caps the stack and keeps the position on the newest entry', () => {
50
- const history = createHistory();
51
- for (let i = 0; i < 120; i++) pushEdit(history, record(`v${i}`, 0, NOW + i));
52
-
53
- expect(history.stack.length).toBe(100);
54
- expect(history.stack[history.offset].value).toBe('v119');
55
- });
56
- });
57
-
58
- describe('continuesTyping', () => {
59
- const previous = record('const value = 1', 15);
60
-
61
- test('merges a keystroke that keeps extending the same word', () => {
62
- expect(continuesTyping(previous, 'const value = 12', 16, NOW + 100)).toBe(true);
63
- });
64
-
65
- test('starts a new entry once the burst window has passed', () => {
66
- expect(continuesTyping(previous, 'const value = 12', 16, NOW + HISTORY_TIME_GAP + 1)).toBe(false);
67
- });
68
-
69
- test('does not merge a different word or a fresh word', () => {
70
- expect(continuesTyping(record('const value', 11), 'const other', 11, NOW + 100)).toBe(false);
71
- expect(continuesTyping(undefined, 'const value = 12', 16, NOW + 100)).toBe(false);
72
- });
73
- });
74
-
75
- describe('mergeTyping', () => {
76
- test('rewrites the current entry in place', () => {
77
- const history = createHistory();
78
- pushRecord(history, record('a', 1));
79
- mergeTyping(history, 'ab', 2, 2, NOW + 50);
80
-
81
- expect(history.stack).toHaveLength(1);
82
- expect(history.stack[0].value).toBe('ab');
83
- expect(history.stack[0].selectionStart).toBe(2);
84
- });
85
-
86
- test('invalidates the redo entries after the current position', () => {
87
- const history = createHistory();
88
- pushRecord(history, record('a', 1));
89
- pushRecord(history, record('ab', 2));
90
- history.offset = 0;
91
-
92
- mergeTyping(history, 'ax', 2, 2, NOW + 50);
93
-
94
- expect(history.stack.map((entry) => entry.value)).toEqual(['ax']);
95
- });
96
- });
97
-
98
- describe('stepHistory', () => {
99
- test('walks back and forward through the stack', () => {
100
- const history = createHistory();
101
- pushRecord(history, record('a', 1));
102
- pushRecord(history, record('ab', 2));
103
-
104
- expect(stepHistory(history, 'undo')?.value).toBe('a');
105
- expect(history.offset).toBe(0);
106
- expect(stepHistory(history, 'redo')?.value).toBe('ab');
107
- expect(history.offset).toBe(1);
108
- });
109
-
110
- test('stops at both ends without moving', () => {
111
- const history = createHistory();
112
- pushRecord(history, record('a', 1));
113
-
114
- expect(stepHistory(history, 'redo')).toBeNull();
115
- expect(stepHistory(history, 'undo')).toBeNull();
116
- expect(history.offset).toBe(0);
117
- });
118
- });
@@ -1,101 +0,0 @@
1
- /**
2
- * @license
3
- * SPDX-License-Identifier: Apache-2.0
4
- */
5
-
6
- /**
7
- * Undo history for the code editor.
8
- *
9
- * The editor cannot lean on the browser's own undo: it renders a transparent
10
- * `<textarea>` over a highlighted `<pre>`, so a native undo would desync the two
11
- * layers. Every entry therefore stores the whole buffer plus the caret, and this
12
- * module is the bookkeeping behind ⌘Z / ⇧⌘Z — push, merge a typing burst into
13
- * the word being typed, cap the stack, and step through it.
14
- */
15
-
16
- export interface HistoryRecord {
17
- value: string;
18
- selectionStart: number;
19
- selectionEnd: number;
20
- timestamp: number;
21
- }
22
-
23
- export interface History {
24
- stack: HistoryRecord[];
25
- offset: number;
26
- }
27
-
28
- const HISTORY_LIMIT = 100;
29
- /** Typing within this window of the previous edit merges into that entry. */
30
- export const HISTORY_TIME_GAP = 3000;
31
- /** The word the caret sits at the end of, which is what a burst keeps extending. */
32
- const WORD_TAIL = /[^a-z0-9]([a-z0-9]+)$/i;
33
-
34
- export function createHistory(): History {
35
- return { stack: [], offset: -1 };
36
- }
37
-
38
- /** Append `record`, dropping the redo entries that follow the current position. */
39
- export function pushRecord(history: History, record: HistoryRecord): void {
40
- if (history.stack.length && history.offset > -1) {
41
- history.stack = history.stack.slice(0, history.offset + 1);
42
- }
43
- history.stack.push(record);
44
- history.offset = history.stack.length - 1;
45
- }
46
-
47
- /** Push an edit and cap the stack, leaving `offset` on the same entry. */
48
- export function pushEdit(history: History, record: HistoryRecord): void {
49
- pushRecord(history, record);
50
- if (history.stack.length <= HISTORY_LIMIT) return;
51
- const extras = history.stack.length - HISTORY_LIMIT;
52
- history.stack = history.stack.slice(extras);
53
- history.offset = Math.max(history.offset - extras, 0);
54
- }
55
-
56
- /** The tail word of the line `position` sits on. */
57
- function tailWord(value: string, position: number): string | undefined {
58
- return value.substring(0, position).split('\n').pop()?.match(WORD_TAIL)?.[1];
59
- }
60
-
61
- /**
62
- * Whether a change keeps typing the word the previous entry ended with — the
63
- * test that folds a run of keystrokes into one undoable edit.
64
- */
65
- export function continuesTyping(
66
- last: HistoryRecord | undefined,
67
- next: string,
68
- selectionStart: number,
69
- timestamp: number,
70
- ): boolean {
71
- if (!last || timestamp - last.timestamp >= HISTORY_TIME_GAP) return false;
72
- const previous = tailWord(last.value, last.selectionStart);
73
- const current = tailWord(next, selectionStart);
74
- return Boolean(previous && current?.startsWith(previous));
75
- }
76
-
77
- /** Fold a continuation into the current entry, which stays the current position. */
78
- export function mergeTyping(
79
- history: History,
80
- next: string,
81
- selectionStart: number,
82
- selectionEnd: number,
83
- timestamp: number,
84
- ): void {
85
- // A merge is an edit like any other, so it drops the redo entries after it —
86
- // otherwise typing straight after an undo would leave a stale redo target.
87
- history.stack = history.stack.slice(0, history.offset + 1);
88
- history.stack[history.offset] = { value: next, selectionStart, selectionEnd, timestamp };
89
- }
90
-
91
- /**
92
- * Step one entry back (undo) or forward (redo) and return the buffer to
93
- * restore, or null at either end of the stack.
94
- */
95
- export function stepHistory(history: History, direction: 'undo' | 'redo'): HistoryRecord | null {
96
- const index = direction === 'undo' ? history.offset - 1 : history.offset + 1;
97
- const record = history.stack[index];
98
- if (!record) return null;
99
- history.offset = direction === 'undo' ? Math.max(index, 0) : Math.min(index, history.stack.length - 1);
100
- return record;
101
- }
@@ -1,77 +0,0 @@
1
- /**
2
- * @license
3
- * SPDX-License-Identifier: Apache-2.0
4
- */
5
-
6
- /**
7
- * The two-layer alignment contract.
8
- *
9
- * The transparent `<textarea>` sits exactly on top of the highlighted `<pre>`,
10
- * so any typographic property that differs between them puts the caret on a
11
- * different row than the glyphs. These assertions pin the two things that
12
- * matter: both layers inherit their typography (a second copy of the font stack
13
- * is what makes them drift), and the `<pre>` reserves the unrendered lines as
14
- * padding while the textarea keeps the plain inset.
15
- */
16
-
17
- import { describe, expect, test } from 'bun:test';
18
-
19
- import { layerStyle, layerStyles } from '@/shared/lib/code/editor/layers';
20
-
21
- describe('layerStyle', () => {
22
- test('inherits every typographic property rather than restating it', () => {
23
- const style = layerStyle();
24
-
25
- for (const property of ['fontFamily', 'fontSize', 'lineHeight', 'letterSpacing', 'tabSize'] as const) {
26
- expect(style[property]).toBe('inherit');
27
- }
28
- });
29
-
30
- test('breaks words the same way the gutter measured them', () => {
31
- // The measurement mirror copies these from the editor's computed style; a
32
- // layer that broke differently would put the numbers on the wrong rows.
33
- const style = layerStyle();
34
-
35
- expect(style.whiteSpace).toBe('pre-wrap');
36
- expect(style.overflowWrap).toBe('break-word');
37
- expect(style.wordBreak).toBe('keep-all');
38
- });
39
-
40
- test('takes its box from the caller, not from the UA', () => {
41
- const style = layerStyle();
42
-
43
- expect(style.boxSizing).toBe('inherit');
44
- expect(style.display).toBe('inherit');
45
- expect(style.margin).toBe(0);
46
- expect(style.border).toBe(0);
47
- });
48
- });
49
-
50
- describe('layerStyles', () => {
51
- test('both layers carry the caller’s padding', () => {
52
- const { pre, textarea } = layerStyles(16, null);
53
-
54
- expect(textarea.paddingTop).toBe(16);
55
- expect(textarea.paddingLeft).toBe(16);
56
- expect(pre.paddingTop).toBe(16);
57
- expect(pre.paddingBottom).toBe(16);
58
- });
59
-
60
- test('the pre reserves the unrendered lines as padding', () => {
61
- // Windowing is the whole trick: the textarea holds the document, the pre
62
- // holds a slice of it, and the space of everything else is this padding.
63
- const { pre, textarea } = layerStyles(8, { start: 100, end: 120, top: 900, bottom: 200, breakAtEnd: false });
64
-
65
- expect(pre.paddingTop).toBe(908);
66
- expect(pre.paddingBottom).toBe(208);
67
- expect(textarea.paddingTop).toBe(8);
68
- expect(textarea.paddingBottom).toBe(8);
69
- });
70
-
71
- test('a window without space leaves the plain inset', () => {
72
- const { pre } = layerStyles(4, { start: 0, end: 10, top: 0, bottom: 0, breakAtEnd: true });
73
-
74
- expect(pre.paddingTop).toBe(4);
75
- expect(pre.paddingBottom).toBe(4);
76
- });
77
- });
@@ -1,67 +0,0 @@
1
- /**
2
- * @license
3
- * SPDX-License-Identifier: Apache-2.0
4
- */
5
-
6
- /**
7
- * The inline styles that keep the editor's two layers in register.
8
- *
9
- * The transparent `<textarea>` (which owns the caret, the selection and every
10
- * keystroke) sits exactly on top of the `<pre>` (which paints the glyphs), so
11
- * both must lay text out identically: `layerStyles` is the shared half —
12
- * every typographic property inherits rather than being restated, because a
13
- * second copy of the font stack is what makes the two drift.
14
- *
15
- * The `<pre>` additionally carries the space of the lines the window is not
16
- * rendering, as padding, which is the whole trick behind the lazy surface: the
17
- * textarea still holds every line, so the caret lands where the reader expects
18
- * without the highlighter ever tokenizing the rest of the document.
19
- */
20
-
21
- import type { CSSProperties } from 'preact';
22
- import type { CodeWindow } from '@/shared/lib/code/lazy-window';
23
-
24
- /** The shared typography, inherited from the caller's own class. */
25
- export function layerStyle(): CSSProperties {
26
- return {
27
- margin: 0,
28
- border: 0,
29
- background: 'none',
30
- boxSizing: 'inherit',
31
- display: 'inherit',
32
- fontFamily: 'inherit',
33
- fontSize: 'inherit',
34
- fontStyle: 'inherit',
35
- fontVariantLigatures: 'inherit',
36
- fontWeight: 'inherit',
37
- letterSpacing: 'inherit',
38
- lineHeight: 'inherit',
39
- tabSize: 'inherit',
40
- textIndent: 'inherit',
41
- textRendering: 'inherit',
42
- textTransform: 'inherit',
43
- whiteSpace: 'pre-wrap',
44
- wordBreak: 'keep-all',
45
- overflowWrap: 'break-word',
46
- };
47
- }
48
-
49
- /** The plain inset both layers take; `padding` is the editor's own padding. */
50
- function contentStyle(padding: number): CSSProperties {
51
- return { paddingTop: padding, paddingRight: padding, paddingBottom: padding, paddingLeft: padding };
52
- }
53
-
54
- /** Both layers' styles for one render: the shared typography plus each one's box. */
55
- export function layerStyles(padding: number, virtual: CodeWindow | null | undefined) {
56
- const content = contentStyle(padding);
57
- return {
58
- /** Textarea: the full document, so its padding is the plain inset. */
59
- textarea: content,
60
- /** `<pre>`: the window, with the unrendered lines reserved as padding. */
61
- pre: {
62
- ...content,
63
- paddingTop: padding + (virtual?.top ?? 0),
64
- paddingBottom: padding + (virtual?.bottom ?? 0),
65
- },
66
- };
67
- }
@@ -1,131 +0,0 @@
1
- /**
2
- * @license
3
- * SPDX-License-Identifier: Apache-2.0
4
- */
5
-
6
- /**
7
- * The line commands: move, duplicate, delete, insert, and the caret's line
8
- * edges.
9
- *
10
- * The cases that matter are the document's edges — the first and last line have
11
- * no neighbour to swap with, a last line with no trailing newline still needs its
12
- * own separator, and a line deleted rather than blanked. Each of those was a
13
- * visible bug in the naive concatenation.
14
- */
15
-
16
- import { describe, expect, test } from 'bun:test';
17
-
18
- import { deleteLines, duplicateLines, insertLine, lineEdges, moveLines } from '@/shared/lib/code/editor/line-edits';
19
-
20
- describe('moveLines', () => {
21
- test('moves the block up and carries the selection with it', () => {
22
- const result = moveLines('one\ntwo\nthree', { start: 4, end: 8 }, -1);
23
-
24
- expect(result.value).toBe('two\none\nthree');
25
- expect([result.selectionStart, result.selectionEnd]).toEqual([0, 4]);
26
- });
27
-
28
- test('moves the block down', () => {
29
- const result = moveLines('one\ntwo\nthree', { start: 4, end: 8 }, 1);
30
-
31
- expect(result.value).toBe('one\nthree\ntwo');
32
- expect([result.selectionStart, result.selectionEnd]).toEqual([10, 14]);
33
- });
34
-
35
- test('a block already at the edge does not move', () => {
36
- const first = moveLines('one\ntwo', { start: 0, end: 4 }, -1);
37
- expect(first.value).toBe('one\ntwo');
38
- expect(first.selectionStart).toBe(0);
39
-
40
- const last = moveLines('one\ntwo', { start: 4, end: 7 }, 1);
41
- expect(last.value).toBe('one\ntwo');
42
- });
43
-
44
- test('moving several lines keeps them together', () => {
45
- const result = moveLines('a\nb\nc\nd', { start: 2, end: 6 }, 1);
46
-
47
- expect(result.value).toBe('a\nd\nb\nc');
48
- });
49
- });
50
-
51
- describe('duplicateLines', () => {
52
- test('copies the line below and leaves the original selected', () => {
53
- const result = duplicateLines('one\ntwo', { start: 0, end: 4 }, 1);
54
-
55
- expect(result.value).toBe('one\none\ntwo');
56
- expect([result.selectionStart, result.selectionEnd]).toEqual([0, 4]);
57
- });
58
-
59
- test('copies above and selects the copy', () => {
60
- const result = duplicateLines('one\ntwo', { start: 4, end: 7 }, -1);
61
-
62
- expect(result.value).toBe('one\ntwo\ntwo');
63
- expect([result.selectionStart, result.selectionEnd]).toEqual([8, 11]);
64
- });
65
-
66
- test('a last line with no trailing newline still gets its own line', () => {
67
- const result = duplicateLines('one\ntwo', { start: 4, end: 7 }, 1);
68
-
69
- expect(result.value).toBe('one\ntwo\ntwo');
70
- });
71
- });
72
-
73
- describe('deleteLines', () => {
74
- test('removes the line rather than blanking it', () => {
75
- const result = deleteLines('one\ntwo\nthree', { start: 4, end: 8 });
76
-
77
- expect(result.value).toBe('one\nthree');
78
- expect(result.selectionStart).toBe(4);
79
- });
80
-
81
- test('deleting the last line leaves the caret at the buffer end', () => {
82
- const result = deleteLines('one\ntwo', { start: 4, end: 7 });
83
-
84
- expect(result.value).toBe('one\n');
85
- expect(result.selectionStart).toBe(4);
86
- });
87
-
88
- test('deleting a multi-line block removes all of it', () => {
89
- const result = deleteLines('a\nb\nc\nd', { start: 2, end: 6 });
90
-
91
- expect(result.value).toBe('a\nd');
92
- });
93
- });
94
-
95
- describe('insertLine', () => {
96
- test('opens a line below, keeping the indentation', () => {
97
- const result = insertLine(' one\ntwo', 5, 'below');
98
-
99
- expect(result.value).toBe(' one\n \ntwo');
100
- expect(result.selectionStart).toBe(8);
101
- });
102
-
103
- test('opens a line above, keeping the indentation', () => {
104
- const result = insertLine(' one\ntwo', 5, 'above');
105
-
106
- expect(result.value).toBe(' \n one\ntwo');
107
- expect(result.selectionStart).toBe(2);
108
- });
109
-
110
- test('on the buffer’s last line without a trailing newline the line is appended', () => {
111
- const result = insertLine(' one', 5, 'below');
112
-
113
- // No trailing newline: the new line simply follows, indented, and the caret
114
- // lands after that indent. Writing a second newline (what this used to do)
115
- // left a blank line under the caret.
116
- expect(result.value).toBe(' one\n ');
117
- expect(result.selectionStart).toBe(8);
118
- });
119
- });
120
-
121
- describe('lineEdges', () => {
122
- test('reports the start of the line and the end of its content', () => {
123
- // The trailing newline is not part of the line's content, so ⌘→ stops
124
- // before it rather than on the next line.
125
- expect(lineEdges('one\ntwo\n', 5)).toEqual({ start: 4, end: 7 });
126
- });
127
-
128
- test('the last line has no newline to exclude', () => {
129
- expect(lineEdges('one\ntwo', 5)).toEqual({ start: 4, end: 7 });
130
- });
131
- });
@@ -1,159 +0,0 @@
1
- /**
2
- * @license
3
- * SPDX-License-Identifier: Apache-2.0
4
- */
5
-
6
- /**
7
- * The editor's line commands: move, duplicate, delete, insert a line, and the
8
- * caret's line edges.
9
- *
10
- * Split from `editor-commands` (which owns the multi-selection and comment
11
- * concerns) because the two families have nothing in common beyond both being
12
- * pure string arithmetic — and because the repository caps a file at 350 lines.
13
- */
14
-
15
- import { lineIndexAt, lineRangeAt, lineRangesInSelection, lineStartOffset, type TextRange } from '@/shared/lib/code/editor/lines';
16
-
17
- /** The block of lines a selection touches, as ONE range. */
18
- function selectionBlock(text: string, selection: TextRange): TextRange {
19
- const lines = lineRangesInSelection(text, selection);
20
- return { start: lines[0].start, end: lines[lines.length - 1].end };
21
- }
22
-
23
- /**
24
- * Move the selected lines up (`-1`) or down (`1`).
25
- *
26
- * The two groups are swapped as a REGION rather than as two concatenations:
27
- * the neighbour may be the document's last line with no trailing newline, so
28
- * the separator between the swapped halves has to be re-emitted from the
29
- * region's own shape. Getting that wrong turns `one\ntwo\nthree` into
30
- * `one\nthreetwo` — which is what the naive `body + neighbour` swap produced.
31
- *
32
- * The selection travels with its lines, so a repeated ⌥↑ keeps moving the same
33
- * block. At the document's first/last line there is no neighbour and the buffer
34
- * is returned unchanged.
35
- */
36
- export function moveLines(
37
- text: string,
38
- selection: TextRange,
39
- direction: -1 | 1,
40
- ): { value: string; selectionStart: number; selectionEnd: number } {
41
- const block = selectionBlock(text, selection);
42
- const blockText = text.slice(block.start, block.end);
43
- const neighbour = direction === -1 ? lineRangeAt(text, block.start - 1) : lineRangeAt(text, block.end);
44
- if (direction === -1 && block.start === 0) {
45
- return { value: text, selectionStart: selection.start, selectionEnd: selection.end };
46
- }
47
- if (direction === 1 && block.end >= text.length) {
48
- return { value: text, selectionStart: selection.start, selectionEnd: selection.end };
49
- }
50
-
51
- const neighbourText = text.slice(neighbour.start, neighbour.end);
52
- // Both groups are whole lines, so each carries at most one trailing newline.
53
- const blockBody = blockText.replace(/\n$/, '');
54
- const neighbourBody = neighbourText.replace(/\n$/, '');
55
- const regionEndsWithNewline = text.slice(Math.min(block.start, neighbour.start), Math.max(block.end, neighbour.end)).endsWith('\n');
56
-
57
- const ordered = direction === -1
58
- ? `${blockBody}\n${neighbourBody}`
59
- : `${neighbourBody}\n${blockBody}`;
60
- const region = ordered + (regionEndsWithNewline ? '\n' : '');
61
- const regionStart = Math.min(block.start, neighbour.start);
62
- const regionEnd = Math.max(block.end, neighbour.end);
63
-
64
- // The block's own first line moves by exactly the neighbour's length plus the
65
- // separator that now precedes it.
66
- const shift = neighbourBody.length + 1;
67
- const delta = direction === -1 ? -shift : shift;
68
- return {
69
- value: text.slice(0, regionStart) + region + text.slice(regionEnd),
70
- selectionStart: selection.start + delta,
71
- selectionEnd: selection.end + delta,
72
- };
73
- }
74
-
75
- /** Copy the selected lines below (`1`) or above (`-1`); the copy stays selected. */
76
- export function duplicateLines(
77
- text: string,
78
- selection: TextRange,
79
- direction: -1 | 1,
80
- ): { value: string; selectionStart: number; selectionEnd: number } {
81
- const block = selectionBlock(text, selection);
82
- const body = text.slice(block.start, block.end);
83
- // A block that reaches the end of a document with no trailing newline needs
84
- // one, or the copy would land on the same line it came from.
85
- const separator = body.endsWith('\n') ? '' : '\n';
86
-
87
- if (direction === 1) {
88
- const inserted = separator + body;
89
- return {
90
- value: text.slice(0, block.end) + inserted + text.slice(block.end),
91
- selectionStart: selection.start,
92
- selectionEnd: selection.end,
93
- };
94
- }
95
- const inserted = body + separator;
96
- return {
97
- value: text.slice(0, block.start) + inserted + text.slice(block.start),
98
- selectionStart: selection.start + inserted.length,
99
- selectionEnd: selection.end + inserted.length,
100
- };
101
- }
102
-
103
- /**
104
- * Delete the selected lines, newline included — so the line is gone rather than
105
- * blanked. The caret lands at the start of the line that took its place, or at
106
- * the end of the buffer when the last line was deleted.
107
- */
108
- export function deleteLines(
109
- text: string,
110
- selection: TextRange,
111
- ): { value: string; selectionStart: number; selectionEnd: number } {
112
- const block = selectionBlock(text, selection);
113
- const value = text.slice(0, block.start) + text.slice(block.end);
114
- const caret = Math.min(block.start, value.length);
115
- return { value, selectionStart: caret, selectionEnd: caret };
116
- }
117
-
118
- /**
119
- * Open a new line above or below the caret's line, keeping that line's
120
- * indentation. ⌘⏎ is "below" and ⌘⇧⏎ is "above", like VS Code.
121
- */
122
- export function insertLine(
123
- text: string,
124
- offset: number,
125
- where: 'above' | 'below',
126
- ): { value: string; selectionStart: number; selectionEnd: number } {
127
- const line = lineRangeAt(text, offset);
128
- const lineText = text.slice(line.start, line.end).replace(/\n$/, '');
129
- const indent = lineText.match(/^\s*/)?.[0] ?? '';
130
-
131
- if (where === 'above') {
132
- const caret = line.start + indent.length;
133
- return {
134
- value: `${text.slice(0, line.start)}${indent}\n${text.slice(line.start)}`,
135
- selectionStart: caret,
136
- selectionEnd: caret,
137
- };
138
- }
139
-
140
- // Below a line that already ends in a newline the new line is inserted
141
- // BEFORE that successor; below the document's last line the newline has to be
142
- // written first. Emitting both unconditionally left a blank line behind
143
- // (measured: `a\nb` + ⌘⏎ became `a\nb\n\n`).
144
- const lineEndsWithNewline = text[line.end - 1] === '\n';
145
- const inserted = lineEndsWithNewline ? `${indent}\n` : `\n${indent}`;
146
- const caret = line.end + inserted.length - (lineEndsWithNewline ? 1 : 0);
147
- return {
148
- value: text.slice(0, line.end) + inserted + text.slice(line.end),
149
- selectionStart: caret,
150
- selectionEnd: caret,
151
- };
152
- }
153
-
154
- /** The offset at the start of the caret's line, and at the end of it (⌘← / ⌘→). */
155
- export function lineEdges(text: string, offset: number): { start: number; end: number } {
156
- const line = lineRangeAt(text, offset);
157
- const start = lineStartOffset(text, lineIndexAt(text, offset));
158
- return { start, end: Math.max(start, line.end - (text[line.end - 1] === '\n' ? 1 : 0)) };
159
- }