@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
+ * Carry-over: the one capability lost by not calling `omp worktree add`.
8
+ *
9
+ * Plain git does NOT copy ignored files — measured: a repository with
10
+ * `node_modules` and a `.gitignore` yields a worktree holding only the tracked
11
+ * files, so the agent's first `bun run dev` fails on a project that was working
12
+ * a second earlier. This copies the ignored build folders with a copy-on-write
13
+ * clone where the filesystem has one.
14
+ *
15
+ * Every path is checked with `git check-ignore`: the allow-list only narrows
16
+ * WHAT to ask about, it never substitutes for git's answer. A candidate that is
17
+ * tracked, or that does not exist, is skipped rather than guessed at.
18
+ */
19
+
20
+ import { existsSync, statSync } from 'fs';
21
+ import { copyFile, mkdir } from 'fs/promises';
22
+ import path from 'path';
23
+ import { runGit } from '@/server/lib/fs/git-run';
24
+ import { pathExists } from '@/server/lib/omp/core/paths';
25
+
26
+ /** Folders worth asking git about. A name here that git does not ignore is skipped. */
27
+ export const CARRY_CANDIDATES = [
28
+ 'node_modules',
29
+ '.venv',
30
+ 'venv',
31
+ 'target',
32
+ 'dist',
33
+ 'build',
34
+ 'out',
35
+ '.next',
36
+ '.nuxt',
37
+ '.turbo',
38
+ '.parcel-cache',
39
+ '.cache',
40
+ 'vendor',
41
+ '__pycache__',
42
+ ];
43
+
44
+ /** Files whose absence breaks a run and whose contents are not in git. */
45
+ export const CARRY_FILE_CANDIDATES = ['.env', '.env.local', '.env.development', '.env.production'];
46
+
47
+ export type CopyStrategy = 'clonefile' | 'reflink' | 'plain';
48
+
49
+ export function copyStrategy(platform: NodeJS.Platform = process.platform): CopyStrategy {
50
+ if (platform === 'darwin') return 'clonefile';
51
+ if (platform === 'linux') return 'reflink';
52
+ return 'plain';
53
+ }
54
+
55
+ export interface CarryResult {
56
+ copied: string[];
57
+ /** Candidates git does not call ignored, or that are not there. */
58
+ skipped: string[];
59
+ strategy: CopyStrategy;
60
+ }
61
+
62
+ async function isIgnored(repo: string, relative: string): Promise<boolean> {
63
+ const probe = await runGit(['check-ignore', '--quiet', '--', relative], { cwd: repo });
64
+ return probe.exitCode === 0;
65
+ }
66
+
67
+ /** `cp -c` (APFS clonefile) / `cp --reflink=auto` / `cp -R`, plus a Windows branch. */
68
+ async function copyTree(source: string, destination: string, strategy: CopyStrategy): Promise<void> {
69
+ await mkdir(path.dirname(destination), { recursive: true });
70
+ // A regular FILE (`.env`) is not a tree, and every branch below is a tree
71
+ // copy. `robocopy` takes directories: measured on a Windows runner, a `.env`
72
+ // source produced no file at the destination and no exit code the caller
73
+ // reads as a failure, so the carry reported success and the worktree had no
74
+ // env file — the one thing that path exists to prevent.
75
+ if (!statSync(source).isDirectory()) {
76
+ await copyFile(source, destination);
77
+ return;
78
+ }
79
+ if (process.platform === 'win32') {
80
+ const result = Bun.spawnSync({
81
+ cmd: ['robocopy', source, destination, '/MIR', '/NFL', '/NDL', '/NJH', '/NJS', '/NC', '/NS', '/NP'],
82
+ stdout: 'ignore',
83
+ stderr: 'pipe',
84
+ });
85
+ // robocopy reports 0–7 as success codes; anything from 8 up is a real failure.
86
+ if (result.exitCode !== null && result.exitCode >= 8) {
87
+ throw new Error(`Failed to copy ${path.basename(source)}: ${result.stderr.toString().trim()}`);
88
+ }
89
+ return;
90
+ }
91
+ const args = strategy === 'clonefile' ? ['-cR'] : strategy === 'reflink' ? ['--reflink=auto', '-R'] : ['-R'];
92
+ const result = Bun.spawnSync({ cmd: ['cp', ...args, source, destination], stdout: 'ignore', stderr: 'pipe' });
93
+ if (result.exitCode !== 0) {
94
+ throw new Error(`Failed to copy ${path.basename(source)}: ${result.stderr.toString().trim()}`);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Copy the project's ignored build folders and env files into a fresh worktree.
100
+ *
101
+ * A failure here is not a failed worktree: the caller reports what could not be
102
+ * carried and keeps the checkout, because a worktree without `node_modules` is
103
+ * still a worktree.
104
+ */
105
+ export async function carryIgnoredInto(input: {
106
+ primaryRoot: string;
107
+ worktreePath: string;
108
+ candidates?: string[];
109
+ }): Promise<CarryResult> {
110
+ const strategy = copyStrategy();
111
+ const copied: string[] = [];
112
+ const skipped: string[] = [];
113
+
114
+ const candidates = [...(input.candidates ?? CARRY_CANDIDATES), ...CARRY_FILE_CANDIDATES];
115
+ for (const candidate of candidates) {
116
+ const source = path.join(input.primaryRoot, candidate);
117
+ if (!existsSync(source)) continue;
118
+ if (!(await isIgnored(input.primaryRoot, candidate))) {
119
+ skipped.push(candidate);
120
+ continue;
121
+ }
122
+ const destination = path.join(input.worktreePath, candidate);
123
+ // A tracked path already checked out in the worktree, or a directory that
124
+ // appeared meanwhile, is not ours to overwrite.
125
+ if (await pathExists(destination)) {
126
+ skipped.push(candidate);
127
+ continue;
128
+ }
129
+ await copyTree(source, destination, strategy);
130
+ copied.push(candidate);
131
+ }
132
+
133
+ return { copied, skipped, strategy };
134
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Creating a worktree, against real repositories.
8
+ *
9
+ * The refusals are the point of this file. git creates the branch BEFORE it
10
+ * discovers the problem — measured on 2.56: `Preparing worktree` is printed,
11
+ * the branch exists, and only then does the command fail with "already checked
12
+ * out", "already exists", or (unborn) `object not found: HEAD`. A create that
13
+ * relied on git's own ordering would leave a branch behind for an operation the
14
+ * user was told had failed, so every check runs first and this file proves
15
+ * nothing is left over.
16
+ *
17
+ * The ignored-file carry is the second half: plain `git worktree add` does NOT
18
+ * copy `node_modules`, so a fresh worktree cannot run a project that worked a
19
+ * second earlier in the checkout.
20
+ */
21
+
22
+ import { afterAll, describe, expect, test } from 'bun:test';
23
+ import fs from 'node:fs';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+ import { canonicalPath } from '@/server/lib/fs/canonical';
27
+ import { createWorktree, planWorktree, type CreateWorktreeDeps } from '@/server/lib/fs/worktree/create';
28
+ import { WorktreeError } from '@/server/lib/fs/worktree/errors';
29
+ import { listWorktrees } from '@/server/lib/fs/worktree/list';
30
+ import { invalidateWorktreeTopology } from '@/server/lib/fs/worktree/topology';
31
+ import { removeDir } from '@/test-support/remove-dir';
32
+
33
+ const FIXTURE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-worktree-create-'));
34
+ const DATA_DIR = path.join(FIXTURE_ROOT, 'data');
35
+
36
+ function git(cwd: string, ...args: string[]): string {
37
+ const result = Bun.spawnSync({
38
+ cmd: ['git', ...args],
39
+ cwd,
40
+ env: { ...GIT_ENV_FOR_TESTS },
41
+ });
42
+ return result.stdout.toString();
43
+ }
44
+
45
+ const GIT_ENV_FOR_TESTS = {
46
+ ...Bun.env,
47
+ GIT_EDITOR: 'true',
48
+ GIT_TERMINAL_PROMPT: '0',
49
+ GIT_AUTHOR_NAME: 'Test',
50
+ GIT_AUTHOR_EMAIL: 'test@example.invalid',
51
+ GIT_COMMITTER_NAME: 'Test',
52
+ GIT_COMMITTER_EMAIL: 'test@example.invalid',
53
+ };
54
+
55
+ /** A repository with one commit, a gitignored `node_modules` and a `.env`. */
56
+ function makeRepo(name: string): string {
57
+ const dir = path.join(FIXTURE_ROOT, name);
58
+ fs.mkdirSync(path.join(dir, 'node_modules', 'dep'), { recursive: true });
59
+ fs.writeFileSync(path.join(dir, '.gitignore'), 'node_modules\n.env\n');
60
+ fs.writeFileSync(path.join(dir, 'node_modules', 'dep', 'index.js'), 'module.exports = {};\n');
61
+ fs.writeFileSync(path.join(dir, '.env'), 'SECRET=1\n');
62
+ fs.writeFileSync(path.join(dir, 'readme.md'), 'hello\n');
63
+ git(dir, 'init', '-q', '-b', 'main', '.');
64
+ git(dir, 'add', '-A');
65
+ git(dir, 'commit', '-q', '-m', 'base');
66
+ return dir;
67
+ }
68
+
69
+ function makeEmptyRepo(name: string): string {
70
+ const dir = path.join(FIXTURE_ROOT, name);
71
+ fs.mkdirSync(dir, { recursive: true });
72
+ git(dir, 'init', '-q', '-b', 'main', '.');
73
+ return dir;
74
+ }
75
+
76
+ /** The registry, as a Map — the production one is a SQLite row write. */
77
+ function depsFor(records: Map<string, { startRef: string; projectId: string }>): CreateWorktreeDeps {
78
+ return {
79
+ identityFor: async () => null,
80
+ carryIgnored: true,
81
+ dataDir: DATA_DIR,
82
+ configuredDirectory: null,
83
+ record: async (row) => {
84
+ records.set(await canonicalPath(row.path), { startRef: row.startRef, projectId: row.projectId });
85
+ },
86
+ };
87
+ }
88
+
89
+ afterAll(async () => {
90
+ await invalidateWorktreeTopology();
91
+ removeDir(FIXTURE_ROOT);
92
+ });
93
+
94
+ describe('createWorktree', () => {
95
+ test('a new branch, a folder that follows it, and our prefix stripped', async () => {
96
+ const repo = makeRepo('create-basic');
97
+ const records = new Map<string, { startRef: string; projectId: string }>();
98
+ const created = await createWorktree(
99
+ { projectPath: repo, mode: 'new', branch: 'wt/cosmic-dolphin' },
100
+ depsFor(records),
101
+ );
102
+
103
+ expect(created.branch).toBe('wt/cosmic-dolphin');
104
+ expect(path.basename(created.path)).toBe('cosmic-dolphin');
105
+ expect(fs.existsSync(created.path)).toBe(true);
106
+ expect(git(created.path, 'rev-parse', '--abbrev-ref', 'HEAD').trim()).toBe('wt/cosmic-dolphin');
107
+ // `start_ref` is the one value git cannot answer later, so it is recorded.
108
+ expect(records.get(await canonicalPath(created.path))?.startRef).toBe('HEAD');
109
+ // The default root is keyed by the repository's identity. Canonicalized
110
+ // because the fixture root itself resolves through a symlink on macOS.
111
+ expect(created.path.startsWith(await canonicalPath(path.join(DATA_DIR, 'worktrees')))).toBe(true);
112
+ });
113
+
114
+ test('ignored build folders and .env come along, and the worktree stays clean', async () => {
115
+ const repo = makeRepo('create-carry');
116
+ const created = await createWorktree(
117
+ { projectPath: repo, mode: 'new', branch: 'wt/carry' },
118
+ depsFor(new Map()),
119
+ );
120
+
121
+ expect(fs.existsSync(path.join(created.path, 'node_modules', 'dep', 'index.js'))).toBe(true);
122
+ expect(fs.readFileSync(path.join(created.path, '.env'), 'utf8')).toContain('SECRET');
123
+ expect(created.carried?.copied).toContain('node_modules');
124
+ expect(created.carried?.strategy).toBe(
125
+ process.platform === 'darwin' ? 'clonefile' : process.platform === 'linux' ? 'reflink' : 'plain',
126
+ );
127
+ // A carry must not make the new worktree dirty: `git status` there is what a
128
+ // commit from that tree would be built on.
129
+ const status = git(created.path, 'status', '--porcelain').trim();
130
+ expect(status).toBe('');
131
+ });
132
+
133
+ test('the carry can be turned off, and turns nothing else off', async () => {
134
+ const repo = makeRepo('create-no-carry');
135
+ const created = await createWorktree(
136
+ { projectPath: repo, mode: 'new', branch: 'wt/no-carry', carryIgnored: false },
137
+ depsFor(new Map()),
138
+ );
139
+ expect(fs.existsSync(path.join(created.path, 'node_modules'))).toBe(false);
140
+ expect(created.carried).toBeNull();
141
+ });
142
+
143
+ test('an existing branch is checked out as it is, and gets no start ref', async () => {
144
+ const repo = makeRepo('create-existing');
145
+ git(repo, 'branch', 'free-branch');
146
+ const created = await createWorktree(
147
+ { projectPath: repo, mode: 'existing', branch: 'free-branch' },
148
+ depsFor(new Map()),
149
+ );
150
+
151
+ expect(created.branch).toBe('free-branch');
152
+ expect(created.createdBranch).toBe(false);
153
+ expect(created.startRef).toBe('');
154
+ expect(git(created.path, 'rev-parse', '--abbrev-ref', 'HEAD').trim()).toBe('free-branch');
155
+ });
156
+
157
+ test('an existing branch already checked out is refused before anything is created', async () => {
158
+ const repo = makeRepo('create-in-use');
159
+ const failure = createWorktree(
160
+ { projectPath: repo, mode: 'existing', branch: 'main' },
161
+ depsFor(new Map()),
162
+ );
163
+ await expect(failure).rejects.toThrow(/already checked out/i);
164
+ expect((await listWorktrees(repo)).filter((entry) => !entry.isPrimary).length).toBe(0);
165
+ });
166
+
167
+ test('an unborn repository is refused with its own code', async () => {
168
+ const empty = makeEmptyRepo('create-unborn');
169
+ const failure = createWorktree({ projectPath: empty, mode: 'new', branch: 'wt/x' }, depsFor(new Map()));
170
+ await expect(failure).rejects.toBeInstanceOf(WorktreeError);
171
+ await failure.catch((error: WorktreeError) => expect(error.code).toBe('no_commits'));
172
+ });
173
+
174
+ test('a branch that already exists is refused, and leaves no second worktree', async () => {
175
+ const repo = makeRepo('create-dup');
176
+ const records = new Map<string, { startRef: string; projectId: string }>();
177
+ const first = await createWorktree({ projectPath: repo, mode: 'new', branch: 'wt/dup' }, depsFor(records));
178
+
179
+ const failure = createWorktree({ projectPath: repo, mode: 'new', branch: 'wt/dup' }, depsFor(records));
180
+ await failure.catch((error: WorktreeError) => expect(error.code).toBe('branch_exists'));
181
+
182
+ const entries = await listWorktrees(repo);
183
+ expect(entries.filter((entry) => !entry.isPrimary).length).toBe(1);
184
+ expect(entries.some((entry) => entry.path === first.path)).toBe(true);
185
+ });
186
+
187
+ test('an invalid branch name never reaches git', async () => {
188
+ const repo = makeRepo('create-bad-name');
189
+ const failure = createWorktree({ projectPath: repo, mode: 'new', branch: 'has space' }, depsFor(new Map()));
190
+ await failure.catch((error: WorktreeError) => expect(error.code).toBe('invalid_name'));
191
+ });
192
+
193
+ test('a taken folder name is suffixed, never refused', async () => {
194
+ const repo = makeRepo('create-collide');
195
+ const records = new Map<string, { startRef: string; projectId: string }>();
196
+ const first = await createWorktree(
197
+ { projectPath: repo, mode: 'new', branch: 'wt/one', worktreeName: 'same' },
198
+ depsFor(records),
199
+ );
200
+ const second = await createWorktree(
201
+ { projectPath: repo, mode: 'new', branch: 'wt/two', worktreeName: 'same' },
202
+ depsFor(records),
203
+ );
204
+
205
+ expect(path.basename(first.path)).toBe('same');
206
+ expect(path.basename(second.path)).not.toBe('same');
207
+ expect(path.basename(second.path).startsWith('same-')).toBe(true);
208
+ });
209
+
210
+ test('a non-Latin branch gets a generated folder instead of an empty one', async () => {
211
+ const repo = makeRepo('create-cjk');
212
+ const created = await createWorktree(
213
+ { projectPath: repo, mode: 'new', branch: '分支/测试' },
214
+ depsFor(new Map()),
215
+ );
216
+ expect(path.basename(created.path).length).toBeGreaterThan(0);
217
+ // The branch is what the caller typed, verbatim — only the FOLDER needed a
218
+ // generated name because the branch left no slug.
219
+ expect(created.branch).toBe('分支/测试');
220
+ });
221
+ });
222
+
223
+ describe('planWorktree', () => {
224
+ test('answers the same refusals without touching disk', async () => {
225
+ const repo = makeRepo('plan-none');
226
+ const plan = await planWorktree(
227
+ { projectPath: repo, mode: 'new', branch: 'wt/planned' },
228
+ { identityFor: async () => null, dataDir: DATA_DIR, configuredDirectory: null },
229
+ );
230
+ expect(plan.branch).toBe('wt/planned');
231
+ expect(fs.existsSync(plan.directory)).toBe(false);
232
+
233
+ const dup = planWorktree(
234
+ { projectPath: repo, mode: 'existing', branch: 'main' },
235
+ { identityFor: async () => null, dataDir: DATA_DIR, configuredDirectory: null },
236
+ );
237
+ await dup.catch((error: WorktreeError) => expect(error.code).toBe('branch_in_use'));
238
+ });
239
+
240
+ test('a configured directory wins over the data-dir default', async () => {
241
+ const repo = makeRepo('plan-configured');
242
+ const configured = path.join(FIXTURE_ROOT, 'elsewhere');
243
+ const plan = await planWorktree(
244
+ { projectPath: repo, mode: 'new', branch: 'wt/configured' },
245
+ { identityFor: async () => null, dataDir: DATA_DIR, configuredDirectory: configured },
246
+ );
247
+ expect(plan.directory.startsWith(await canonicalPath(configured))).toBe(true);
248
+ });
249
+ });
@@ -0,0 +1,262 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Create a worktree, in the order that matters.
8
+ *
9
+ * Every refusal git would only raise mid-way is checked FIRST, because git
10
+ * creates the branch before it discovers the problem. Measured on git 2.56:
11
+ * `git worktree add` prints `Preparing worktree`, creates the branch, and only
12
+ * then fails with "already checked out", "already exists", or for an unborn
13
+ * branch `object not found: HEAD` — each leaving a branch behind for a create
14
+ * the user was told had failed.
15
+ *
16
+ * Two deliberate absences:
17
+ *
18
+ * - **no `--force`**, so git never overwrites a directory or steals a branch
19
+ * another worktree holds;
20
+ * - **no carry of uncommitted changes.** Their edits are indistinguishable from
21
+ * another session's in the same checkout, and a `stash`+`apply` is a
22
+ * repository-wide operation that can take them. The dialog says the changes
23
+ * stay put, and this is what makes that true.
24
+ */
25
+
26
+ import path from 'path';
27
+ import { mkdir } from 'fs/promises';
28
+ import { getDataDir } from '@/server/lib/lifecycle/paths';
29
+ import { runGit } from '@/server/lib/fs/git-run';
30
+ import { canonicalPath } from '@/server/lib/fs/canonical';
31
+ import { resolveProjectRoot } from '@/server/lib/omp/core/worktree';
32
+ import { pathExists } from '@/server/lib/omp/core/paths';
33
+ import {
34
+ folderNameForBranch,
35
+ isPlausibleBranchName,
36
+ normalizeBranchName,
37
+ slugWorktreeName,
38
+ WORKTREE_BRANCH_PREFIX,
39
+ } from '@/shared/lib/workspace/worktree/branch-name';
40
+ import type { WorktreeInfo } from '@/shared/lib/workspace/worktree/types';
41
+ import { carryIgnoredInto, type CarryResult } from '@/server/lib/fs/worktree/carry';
42
+ import { WorktreeError } from '@/server/lib/fs/worktree/errors';
43
+ import {
44
+ defaultWorktreeRoot,
45
+ resolveConfiguredWorktreeRoot,
46
+ resolveTargetDirectory,
47
+ } from '@/server/lib/fs/worktree/location';
48
+ import { findBranchInUse, isWorkTree, refExists } from '@/server/lib/fs/worktree/list';
49
+ import { resolveProjectId } from '@/server/lib/fs/worktree/project-id';
50
+
51
+ /**
52
+ * The `-c` flags a create carries.
53
+ *
54
+ * `core.longpaths` is not cosmetic: the data-dir prefix plus a nested repository
55
+ * path exceeds Windows' 260-character MAX_PATH, and git then fails during the
56
+ * checkout with "Filename too long" and leaves a half-populated worktree.
57
+ * `core.hooksPath` keeps a repository's hooks out of a checkout Kabmo makes.
58
+ */
59
+ export const WORKTREE_ADD_CONFIG = [
60
+ 'core.longpaths=true',
61
+ `core.hooksPath=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`,
62
+ ];
63
+
64
+ export interface CreateWorktreeInput {
65
+ /** Absolute path of the project (or any directory inside the repository). */
66
+ projectPath: string;
67
+ mode: 'new' | 'existing';
68
+ /** The branch: free text in `new`, a pick in `existing`. Empty generates one. */
69
+ branch?: string;
70
+ /** The folder name from the dialog; empty follows the branch. */
71
+ worktreeName?: string;
72
+ startRef?: string;
73
+ /** Overrides the stored setting, which is what the dialog's Quick path does. */
74
+ carryIgnored?: boolean;
75
+ }
76
+
77
+ export interface CreatedWorktree extends WorktreeInfo {
78
+ projectId: string;
79
+ primaryRoot: string;
80
+ carried: CarryResult | null;
81
+ /** False for `existing`, where the branch already belonged to the repository. */
82
+ createdBranch: boolean;
83
+ }
84
+
85
+ export interface CreateWorktreeDeps {
86
+ /** The identity cache, in production `worktreeIdentityFor(getDb(), …)`. */
87
+ identityFor: (primaryRoot: string) => Promise<string | null>;
88
+ /** The stored carry default; the request may override it. */
89
+ carryIgnored: boolean;
90
+ dataDir?: string;
91
+ configuredDirectory?: string | null;
92
+ /** Called with the finished row — the registry write, injected for tests. */
93
+ record: (row: {
94
+ path: string;
95
+ primaryRoot: string;
96
+ projectId: string;
97
+ branch: string;
98
+ startRef: string;
99
+ createdAt: string;
100
+ }) => Promise<void>;
101
+ }
102
+
103
+ async function gitOk(args: string[], cwd: string): Promise<string> {
104
+ const result = await runGit(args, { cwd, maxBuffer: 4 * 1024 * 1024 });
105
+ if (result.error) throw result.error;
106
+ if (result.exitCode !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || `git exited ${result.exitCode}`);
107
+ return result.stdout;
108
+ }
109
+
110
+ /** Resolve the destination the create would use, without touching disk. */
111
+ export async function planWorktree(
112
+ input: CreateWorktreeInput,
113
+ deps: Pick<CreateWorktreeDeps, 'identityFor' | 'dataDir' | 'configuredDirectory'>,
114
+ ): Promise<{ primaryRoot: string; projectId: string; root: string; name: string; directory: string; branch: string; startRef: string }> {
115
+ const projectPath = path.resolve(input.projectPath);
116
+ if (!(await isWorkTree(projectPath))) {
117
+ throw new WorktreeError('not_a_repo', 'This folder is not a Git repository.');
118
+ }
119
+
120
+ const primaryRoot = await resolveProjectRoot(projectPath);
121
+ const head = await runGit(['rev-parse', '--verify', 'HEAD'], { cwd: primaryRoot });
122
+ if (head.exitCode !== 0) {
123
+ throw new WorktreeError('no_commits', 'This repository has no commits yet.');
124
+ }
125
+
126
+ const existing = input.mode === 'existing';
127
+ // The RAW name is validated, then normalized only by stripping a ref prefix.
128
+ // Normalizing first was a silent rewrite: `normalizeBranchName` turns spaces
129
+ // into dashes, so a typed `has space` became the valid `has-space` and the
130
+ // dialog created a branch the user never asked for.
131
+ const typed = (input.branch ?? '').trim();
132
+ const requested = normalizeBranchName(typed);
133
+ if (existing && !requested) {
134
+ throw new WorktreeError('invalid_name', 'Pick a branch to check out.', { field: 'branch' });
135
+ }
136
+ if (typed && !isPlausibleBranchName(typed)) {
137
+ throw new WorktreeError('invalid_name', `"${typed}" is not a valid branch name.`, { field: 'branch' });
138
+ }
139
+ if (requested) {
140
+ const exists = await refExists(primaryRoot, `refs/heads/${requested}`);
141
+ if (existing && !exists) {
142
+ throw new WorktreeError('branch_exists', `Branch ${requested} does not exist.`, { field: 'branch' });
143
+ }
144
+ if (!existing && exists) {
145
+ throw new WorktreeError('branch_exists', `Branch ${requested} already exists.`, { field: 'branch' });
146
+ }
147
+ const inUse = await findBranchInUse(primaryRoot, requested);
148
+ if (inUse) {
149
+ throw new WorktreeError('branch_in_use', `${requested} is already checked out in ${inUse.path}.`, {
150
+ field: 'branch',
151
+ });
152
+ }
153
+ }
154
+
155
+ const projectId = await resolveProjectId(primaryRoot, deps.identityFor);
156
+ const dataDir = deps.dataDir ?? getDataDir();
157
+ // Canonical from the start: the directory is compared against git's own paths
158
+ // and stored as a primary key, and two spellings of one folder must be one
159
+ // row (macOS: `/tmp` typed, `/private/tmp` reported).
160
+ const root = await canonicalPath(
161
+ resolveConfiguredWorktreeRoot(deps.configuredDirectory ?? null, primaryRoot)
162
+ ?? defaultWorktreeRoot(dataDir, projectId),
163
+ );
164
+
165
+ // The folder follows the branch unless the dialog named one; a non-Latin
166
+ // branch leaves no slug, so a generated name stands in rather than an empty
167
+ // folder.
168
+ const candidateName = input.worktreeName?.trim() || folderNameForBranch(requested) || slugWorktreeName(requested);
169
+ const target = await resolveTargetDirectory({
170
+ root,
171
+ preferredName: candidateName,
172
+ // The branch the user typed is used VERBATIM in both modes; only an empty
173
+ // one generates. Passing it as generated for `new` renamed a typed
174
+ // `分支/测试` to `wt/lucid-toucan` — measured, and the caller never sees the
175
+ // branch it asked for.
176
+ explicitBranch: requested || null,
177
+ generatedBranch: (name) => `${WORKTREE_BRANCH_PREFIX}/${name}`,
178
+ branchExists: async (branch) => Boolean(await findBranchInUse(primaryRoot, branch)) || refExists(primaryRoot, `refs/heads/${branch}`),
179
+ });
180
+
181
+ const startRef = input.startRef?.trim() || 'HEAD';
182
+ return { primaryRoot, projectId, root, name: target.name, directory: target.directory, branch: target.branch, startRef };
183
+ }
184
+
185
+ /** Create the worktree, carry the ignored build folders, and record the row. */
186
+ export async function createWorktree(
187
+ input: CreateWorktreeInput,
188
+ deps: CreateWorktreeDeps,
189
+ ): Promise<CreatedWorktree> {
190
+ const plan = await planWorktree(input, deps);
191
+ const existing = input.mode === 'existing';
192
+ const carry = input.carryIgnored ?? deps.carryIgnored;
193
+
194
+ await mkdir(plan.root, { recursive: true });
195
+
196
+ // git's grammar is `worktree add [--no-checkout] [-b <new>] <path> [<commit-ish>]`:
197
+ // an EXISTING branch is the commit-ish and therefore follows the path.
198
+ // Measured: putting the branch first is read as the path and git answers
199
+ // `fatal: invalid reference: <the path we meant to use>`.
200
+ // ONE `-c` per pair: `['-c', a, b, 'worktree']` makes git read `b` as the
201
+ // SUBCOMMAND, and it answers `git: 'core.hooksPath=/dev/null' is not a git
202
+ // command` — measured, and every create failed on it.
203
+ const configArgs = WORKTREE_ADD_CONFIG.flatMap((pair) => ['-c', pair]);
204
+ const args = [...configArgs, 'worktree', 'add', '--no-checkout'];
205
+ if (existing) {
206
+ args.push(plan.directory, plan.branch);
207
+ } else {
208
+ args.push('-b', plan.branch, plan.directory);
209
+ if (plan.startRef !== 'HEAD') args.push(plan.startRef);
210
+ }
211
+
212
+ try {
213
+ await gitOk(args, plan.primaryRoot);
214
+ // `--no-checkout` keeps the add cheap; the same flags then populate it, so a
215
+ // path that cannot be checked out fails HERE and is reported.
216
+ await gitOk([...configArgs, 'checkout'], plan.directory);
217
+ } catch (error) {
218
+ // A failed create leaves nothing behind: the branch was ours, so it goes.
219
+ if (!existing) {
220
+ await runGit(['branch', '-D', plan.branch], { cwd: plan.primaryRoot });
221
+ }
222
+ throw new WorktreeError('create_failed', error instanceof Error ? error.message : String(error));
223
+ }
224
+
225
+ let carried: CarryResult | null = null;
226
+ if (carry) {
227
+ try {
228
+ carried = await carryIgnoredInto({ primaryRoot: plan.primaryRoot, worktreePath: plan.directory });
229
+ } catch (error) {
230
+ // Reported, not fatal: a worktree without node_modules is still a worktree.
231
+ carried = { copied: [], skipped: [], strategy: 'plain' };
232
+ void error;
233
+ }
234
+ }
235
+
236
+ const createdAt = new Date().toISOString();
237
+ await deps.record({
238
+ path: plan.directory,
239
+ primaryRoot: plan.primaryRoot,
240
+ projectId: plan.projectId,
241
+ branch: plan.branch,
242
+ startRef: existing ? '' : plan.startRef,
243
+ createdAt,
244
+ });
245
+
246
+ return {
247
+ path: plan.directory,
248
+ branch: plan.branch,
249
+ head: (await runGit(['rev-parse', 'HEAD'], { cwd: plan.directory })).stdout.trim(),
250
+ detached: false,
251
+ missing: !(await pathExists(plan.directory)),
252
+ isPrimary: false,
253
+ dirty: 0,
254
+ aheadOfStart: 0,
255
+ startRef: existing ? '' : plan.startRef,
256
+ sessions: 0,
257
+ projectId: plan.projectId,
258
+ primaryRoot: plan.primaryRoot,
259
+ carried,
260
+ createdBranch: !existing,
261
+ };
262
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * A worktree refusal, carrying the code and the field it belongs to.
8
+ *
9
+ * The route answers `{ error, code, field }` from this, so the dialog can mark
10
+ * the input that caused it instead of printing one sentence under the buttons.
11
+ * Codes are stable; messages are for people.
12
+ */
13
+
14
+ import type { WorktreeErrorCode, WorktreeFailure } from '@/shared/lib/workspace/worktree/types';
15
+
16
+ export class WorktreeError extends Error {
17
+ readonly code: WorktreeErrorCode;
18
+ readonly field?: WorktreeFailure['field'];
19
+ readonly count?: number;
20
+
21
+ constructor(code: WorktreeErrorCode, message: string, options: { field?: WorktreeFailure['field']; count?: number } = {}) {
22
+ super(message);
23
+ this.name = 'WorktreeError';
24
+ this.code = code;
25
+ this.field = options.field;
26
+ this.count = options.count;
27
+ }
28
+
29
+ toFailure(): WorktreeFailure {
30
+ return {
31
+ code: this.code,
32
+ message: this.message,
33
+ ...(this.field ? { field: this.field } : {}),
34
+ ...(this.count !== undefined ? { count: this.count } : {}),
35
+ };
36
+ }
37
+ }
38
+
39
+ /** Turn anything thrown into a failure, so an unexpected error is still typed. */
40
+ export function asWorktreeFailure(error: unknown): WorktreeFailure {
41
+ if (error instanceof WorktreeError) return error.toFailure();
42
+ return { code: 'create_failed', message: error instanceof Error ? error.message : String(error) };
43
+ }