@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,207 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Worktree listing, against real repositories.
8
+ *
9
+ * Three answers here cannot be faked by a hand-written porcelain string, and
10
+ * each is a case that has to keep working:
11
+ *
12
+ * - a linked worktree is NOT the primary, and a deleted directory is `missing`
13
+ * while its entry stays in the list (the sessions that lived there must keep
14
+ * their row);
15
+ * - two spellings of one directory reconcile — on macOS git prints
16
+ * `/private/tmp/...` for a path the caller typed as `/tmp/...`, and a
17
+ * comparison that ignored that decided a worktree was not inside itself;
18
+ * - the session → worktree lookup picks the INNER worktree, because that is
19
+ * the directory the agent writes to.
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, pathHolds } from '@/server/lib/fs/canonical';
27
+ import { findBranchInUse, listWorktrees, parseWorktreePorcelain } from '@/server/lib/fs/worktree/list';
28
+ import { invalidateWorktreeTopology, readProjectWorktrees, worktreeForCwd } from '@/server/lib/fs/worktree/topology';
29
+ import { removeDir } from '@/test-support/remove-dir';
30
+
31
+ const FIXTURE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-worktree-list-'));
32
+
33
+ function git(cwd: string, ...args: string[]): string {
34
+ const result = Bun.spawnSync({
35
+ cmd: ['git', ...args],
36
+ cwd,
37
+ env: { ...Bun.env, GIT_EDITOR: 'true', GIT_TERMINAL_PROMPT: '0' },
38
+ });
39
+ return result.stdout.toString();
40
+ }
41
+
42
+ function makeRepo(name: string): string {
43
+ const dir = path.join(FIXTURE_ROOT, name);
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ git(dir, 'init', '-q', '-b', 'main', '.');
46
+ git(dir, 'config', 'user.email', 'test@example.invalid');
47
+ git(dir, 'config', 'user.name', 'Test');
48
+ fs.writeFileSync(path.join(dir, 'f.txt'), 'a\n');
49
+ git(dir, 'add', '-A');
50
+ git(dir, 'commit', '-q', '-m', 'base');
51
+ return dir;
52
+ }
53
+
54
+ afterAll(async () => {
55
+ await invalidateWorktreeTopology();
56
+ removeDir(FIXTURE_ROOT);
57
+ });
58
+
59
+ describe('parseWorktreePorcelain', () => {
60
+ const PORCELAIN = [
61
+ 'worktree /repo',
62
+ 'HEAD aaa',
63
+ 'branch refs/heads/main',
64
+ '',
65
+ 'worktree /wt/detached',
66
+ 'HEAD bbb',
67
+ 'detached',
68
+ '',
69
+ 'worktree /wt/gone',
70
+ 'HEAD ccc',
71
+ 'branch refs/heads/feat',
72
+ 'prunable gitdir file points to non-existent location',
73
+ '',
74
+ 'worktree /bare',
75
+ 'bare',
76
+ '',
77
+ ].join('\n');
78
+
79
+ test('reads the branch, a detached HEAD, a prunable entry, and drops a bare repo', () => {
80
+ const entries = parseWorktreePorcelain(PORCELAIN);
81
+ expect(entries.map((entry) => entry.path)).toEqual(['/repo', '/wt/detached', '/wt/gone']);
82
+ expect(entries[0]?.branch).toBe('main');
83
+ expect(entries[1]?.detached).toBe(true);
84
+ expect(entries[1]?.branch).toBe('');
85
+ expect(entries[2]?.missing).toBe(true);
86
+ });
87
+
88
+ test('a bare repository is not a checkout anything can run in', () => {
89
+ expect(parseWorktreePorcelain(PORCELAIN).some((entry) => entry.path === '/bare')).toBe(false);
90
+ });
91
+ });
92
+
93
+ describe('listWorktrees', () => {
94
+ test('flags the primary and a linked worktree apart', async () => {
95
+ const repo = makeRepo('list-basic');
96
+ const linked = path.join(FIXTURE_ROOT, 'list-basic-wt');
97
+ git(repo, 'worktree', 'add', '-q', '-b', 'feat', linked);
98
+
99
+ const entries = await listWorktrees(repo);
100
+ const primary = entries.find((entry) => entry.isPrimary);
101
+ const other = entries.find((entry) => !entry.isPrimary);
102
+ expect(primary?.path).toBe(await canonicalPath(repo));
103
+ expect(other?.branch).toBe('feat');
104
+ expect(other?.missing).toBe(false);
105
+ // Paths come back canonical, whichever spelling git was handed.
106
+ expect(other?.path).toBe(await canonicalPath(linked));
107
+ });
108
+
109
+ test('a directory deleted outside git stays listed as missing', async () => {
110
+ const repo = makeRepo('list-missing');
111
+ const linked = path.join(FIXTURE_ROOT, 'list-missing-wt');
112
+ git(repo, 'worktree', 'add', '-q', '-b', 'gone', linked);
113
+ fs.rmSync(linked, { recursive: true, force: true });
114
+
115
+ const entries = await listWorktrees(repo);
116
+ const entry = entries.find((candidate) => !candidate.isPrimary);
117
+ expect(entry?.missing).toBe(true);
118
+ // And the missing path still canonicalizes, so it can be matched at all.
119
+ expect(entry?.path).toBe(await canonicalPath(linked));
120
+ });
121
+
122
+ test('findBranchInUse names the worktree that holds a branch', async () => {
123
+ const repo = makeRepo('list-in-use');
124
+ const linked = path.join(FIXTURE_ROOT, 'list-in-use-wt');
125
+ git(repo, 'worktree', 'add', '-q', '-b', 'held', linked);
126
+
127
+ const hit = await findBranchInUse(repo, 'held');
128
+ expect(hit?.path).toBe(await canonicalPath(linked));
129
+ expect(await findBranchInUse(repo, 'free')).toBeNull();
130
+ });
131
+ });
132
+
133
+ describe('canonical paths', () => {
134
+ test('a sibling sharing a prefix is not inside', async () => {
135
+ const base = path.join(FIXTURE_ROOT, 'holds');
136
+ fs.mkdirSync(path.join(base, 'repo', 'src'), { recursive: true });
137
+ fs.mkdirSync(path.join(base, 'repo-other'), { recursive: true });
138
+
139
+ expect(await pathHolds(path.join(base, 'repo'), path.join(base, 'repo'))).toBe(true);
140
+ expect(await pathHolds(path.join(base, 'repo'), path.join(base, 'repo', 'src'))).toBe(true);
141
+ expect(await pathHolds(path.join(base, 'repo'), path.join(base, 'repo-other'))).toBe(false);
142
+ });
143
+
144
+ test('a MISSING path resolves through its nearest existing ancestor', async () => {
145
+ const base = path.join(FIXTURE_ROOT, 'canonical-missing');
146
+ fs.mkdirSync(base, { recursive: true });
147
+ const missing = path.join(base, 'gone', 'deeper');
148
+ expect(await canonicalPath(missing)).toBe(path.join(await canonicalPath(base), 'gone', 'deeper'));
149
+ });
150
+
151
+ test('`..` cannot escape', async () => {
152
+ const base = path.join(FIXTURE_ROOT, 'canonical-escape');
153
+ fs.mkdirSync(base, { recursive: true });
154
+ expect(await pathHolds(base, path.join(base, '..', 'elsewhere'))).toBe(false);
155
+ });
156
+ });
157
+
158
+ describe('session → worktree', () => {
159
+ test('an inner worktree wins over the checkout that contains it', async () => {
160
+ const repo = makeRepo('topology');
161
+ const outer = path.join(FIXTURE_ROOT, 'topology-outer');
162
+ git(repo, 'worktree', 'add', '-q', '-b', 'outer', outer);
163
+ fs.mkdirSync(path.join(outer, 'sub'), { recursive: true });
164
+
165
+ const entries = await listWorktrees(repo);
166
+ const primary = entries.find((entry) => entry.isPrimary)?.path as string;
167
+
168
+ const inner = await worktreeForCwd(entries, path.join(outer, 'sub'), primary);
169
+ expect(inner?.path).toBe(await canonicalPath(outer));
170
+ expect(inner?.branch).toBe('outer');
171
+ expect(inner?.status).toBe('ready');
172
+
173
+ // A session in the checkout itself has no badge — it is not a worktree session.
174
+ expect(await worktreeForCwd(entries, primary, primary)).toBeNull();
175
+ });
176
+
177
+ test('a missing worktree still answers, with status `missing`', async () => {
178
+ const repo = makeRepo('topology-missing');
179
+ const linked = path.join(FIXTURE_ROOT, 'topology-missing-wt');
180
+ git(repo, 'worktree', 'add', '-q', '-b', 'vanish', linked);
181
+ fs.rmSync(linked, { recursive: true, force: true });
182
+
183
+ const entries = await listWorktrees(repo);
184
+ const primary = entries.find((entry) => entry.isPrimary)?.path as string;
185
+ const badge = await worktreeForCwd(entries, linked, primary);
186
+ expect(badge?.status).toBe('missing');
187
+ expect(badge?.branch).toBe('vanish');
188
+ });
189
+
190
+ test('the topology read is cached, and invalidation drops it', async () => {
191
+ const repo = makeRepo('topology-cache');
192
+ const first = await readProjectWorktrees(repo);
193
+ const second = await readProjectWorktrees(repo);
194
+ expect(second).toBe(first); // same array identity — a real cache hit
195
+
196
+ const linked = path.join(FIXTURE_ROOT, 'topology-cache-wt');
197
+ git(repo, 'worktree', 'add', '-q', '-b', 'new-one', linked);
198
+ // Cached: the new worktree is not visible yet.
199
+ expect((await readProjectWorktrees(repo)).length).toBe(1);
200
+
201
+ // AWAITED: the invalidation resolves the canonical key asynchronously, so an
202
+ // un-awaited call races the read below — measured on both CI legs, where the
203
+ // read won and the cache still answered one entry.
204
+ await invalidateWorktreeTopology(await canonicalPath(repo));
205
+ expect((await readProjectWorktrees(repo)).length).toBe(2);
206
+ });
207
+ });
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `git worktree list --porcelain`, parsed into rows.
8
+ *
9
+ * The two markers a naive parse loses, and both matter to a reader:
10
+ *
11
+ * - **no `branch` line** → detached HEAD, which the row shows as `detached @
12
+ * <sha>` and the delete dialog treats as not-a-branch;
13
+ * - **`prunable`** → the directory is gone or the gitdir pointer is broken. The
14
+ * entry STAYS in the list: the sessions that lived there must keep their row,
15
+ * and dropping it would hide them with no way back.
16
+ *
17
+ * Paths are canonicalized here, once, for every consumer — git echoes the
18
+ * spelling it was given while the repository root resolves through symlinks, and
19
+ * two spellings of one directory must not read as two worktrees.
20
+ */
21
+
22
+ import { existsSync } from 'fs';
23
+ import { canonicalPath } from '@/server/lib/fs/canonical';
24
+ import { runGit } from '@/server/lib/fs/git-run';
25
+ import { resolveProjectRoot } from '@/server/lib/omp/core/worktree';
26
+
27
+ export interface WorktreeEntry {
28
+ path: string;
29
+ /** `refs/heads/` stripped; `''` when detached. */
30
+ branch: string;
31
+ head: string;
32
+ detached: boolean;
33
+ /** git reports it prunable, or the directory is not there. */
34
+ missing: boolean;
35
+ /** The checkout the worktree was created from — never removable. */
36
+ isPrimary: boolean;
37
+ }
38
+
39
+ /** Split on blank lines; a bare repository is not a checkout anything can run in. */
40
+ export function parseWorktreePorcelain(stdout: string): Omit<WorktreeEntry, 'isPrimary'>[] {
41
+ interface Pending {
42
+ path: string;
43
+ branch?: string;
44
+ head?: string;
45
+ missing: boolean;
46
+ bare: boolean;
47
+ }
48
+
49
+ const entries: Omit<WorktreeEntry, 'isPrimary'>[] = [];
50
+ let current: Pending | null = null;
51
+
52
+ const flush = () => {
53
+ if (!current?.path || current.bare) {
54
+ current = null;
55
+ return;
56
+ }
57
+ const branch = current.branch ?? '';
58
+ const head = current.head ?? '';
59
+ entries.push({
60
+ path: current.path,
61
+ branch,
62
+ head,
63
+ detached: !branch && Boolean(head),
64
+ missing: current.missing,
65
+ });
66
+ current = null;
67
+ };
68
+
69
+ for (const rawLine of stdout.split('\n')) {
70
+ const line = rawLine.trimEnd();
71
+ if (!line) {
72
+ flush();
73
+ continue;
74
+ }
75
+ if (line.startsWith('worktree ')) {
76
+ flush();
77
+ current = { path: line.slice('worktree '.length).trim(), missing: false, bare: false };
78
+ continue;
79
+ }
80
+ if (!current) continue;
81
+ if (line.startsWith('HEAD ')) {
82
+ current.head = line.slice('HEAD '.length).trim();
83
+ continue;
84
+ }
85
+ if (line.startsWith('branch ')) {
86
+ current.branch = line.slice('branch '.length).trim().replace(/^refs\/heads\//, '');
87
+ continue;
88
+ }
89
+ if (line === 'bare') current.bare = true;
90
+ if (line === 'prunable' || line.startsWith('prunable ')) current.missing = true;
91
+ }
92
+ flush();
93
+
94
+ return entries;
95
+ }
96
+
97
+ /** Every worktree of the repository `cwd` belongs to, primary first. */
98
+ export async function listWorktrees(cwd: string): Promise<WorktreeEntry[]> {
99
+ const primaryRoot = await resolveProjectRoot(cwd);
100
+ const listed = await runGit(['worktree', 'list', '--porcelain'], { cwd: primaryRoot, maxBuffer: 1024 * 1024 });
101
+ if (listed.exitCode !== 0) throw new Error(listed.stderr.trim() || 'Failed to list git worktrees');
102
+
103
+ const canonicalPrimary = await canonicalPath(primaryRoot);
104
+ const entries: WorktreeEntry[] = [];
105
+ for (const entry of parseWorktreePorcelain(listed.stdout)) {
106
+ const canonical = await canonicalPath(entry.path);
107
+ entries.push({
108
+ ...entry,
109
+ path: canonical,
110
+ isPrimary: canonical === canonicalPrimary,
111
+ // A directory removed without `prune` answers `existsSync` false, and both
112
+ // signals mean the same thing to a reader.
113
+ missing: entry.missing || !existsSync(canonical),
114
+ });
115
+ }
116
+ return entries;
117
+ }
118
+
119
+ /**
120
+ * Whether `cwd` is inside a work tree at all.
121
+ *
122
+ * The check a plain directory fails, and the one every caller here needs before
123
+ * it may talk about worktrees: `git worktree list` in a non-repository exits
124
+ * non-zero, so without this a notes folder bound as a workspace reads as a
125
+ * BROKEN repository rather than as one that has none.
126
+ */
127
+ export async function isWorkTree(cwd: string): Promise<boolean> {
128
+ const probe = await runGit(['rev-parse', '--is-inside-work-tree'], { cwd });
129
+ return probe.exitCode === 0 && probe.stdout.trim() === 'true';
130
+ }
131
+
132
+ /** Whether `branch` is checked out in any worktree of this repository, and where. */
133
+ export async function findBranchInUse(cwd: string, branch: string): Promise<{ path: string } | null> {
134
+ const entries = await listWorktrees(cwd);
135
+ const hit = entries.find((entry) => entry.branch === branch);
136
+ return hit ? { path: hit.path } : null;
137
+ }
138
+
139
+ export async function refExists(cwd: string, ref: string): Promise<boolean> {
140
+ const probe = await runGit(['show-ref', '--verify', '--quiet', ref], { cwd });
141
+ return probe.exitCode === 0;
142
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Repository identity, and the settings-driven location rule.
8
+ *
9
+ * Identity has one property that is easy to get wrong and expensive when it is:
10
+ * the derivation (`rev-list --max-parents=0 --all`, sorted, first) is NOT stable.
11
+ * Measured: a repository that gains a branch with unrelated history inserts a
12
+ * second root and the sorted-first value MOVES, so a repository would start
13
+ * looking for its worktrees in a different folder. That is why a stored value
14
+ * always wins — the behavior this file pins.
15
+ */
16
+
17
+ import { afterAll, describe, expect, test } from 'bun:test';
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { canonicalPath } from '@/server/lib/fs/canonical';
22
+ import { deriveProjectId, resolveProjectId } from '@/server/lib/fs/worktree/project-id';
23
+ import {
24
+ defaultWorktreeRoot,
25
+ generateAvailableName,
26
+ resolveConfiguredWorktreeRoot,
27
+ resolveTargetDirectory,
28
+ worktreeNameCandidates,
29
+ } from '@/server/lib/fs/worktree/location';
30
+ import { removeDir } from '@/test-support/remove-dir';
31
+
32
+ const FIXTURE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-worktree-location-'));
33
+
34
+ const GIT_ENV_FOR_TESTS = {
35
+ ...Bun.env,
36
+ GIT_EDITOR: 'true',
37
+ GIT_TERMINAL_PROMPT: '0',
38
+ GIT_AUTHOR_NAME: 'Test',
39
+ GIT_AUTHOR_EMAIL: 'test@example.invalid',
40
+ GIT_COMMITTER_NAME: 'Test',
41
+ GIT_COMMITTER_EMAIL: 'test@example.invalid',
42
+ };
43
+
44
+ function git(cwd: string, ...args: string[]): string {
45
+ return Bun.spawnSync({ cmd: ['git', ...args], cwd, env: GIT_ENV_FOR_TESTS }).stdout.toString();
46
+ }
47
+
48
+ function makeRepo(name: string): string {
49
+ const dir = path.join(FIXTURE_ROOT, name);
50
+ fs.mkdirSync(dir, { recursive: true });
51
+ git(dir, 'init', '-q', '-b', 'main', '.');
52
+ fs.writeFileSync(path.join(dir, 'f.txt'), 'a\n');
53
+ git(dir, 'add', '-A');
54
+ git(dir, 'commit', '-q', '-m', 'base');
55
+ return dir;
56
+ }
57
+
58
+ afterAll(() => {
59
+ removeDir(FIXTURE_ROOT);
60
+ });
61
+
62
+ describe('deriveProjectId', () => {
63
+ test('two clones of one repository share the identity', async () => {
64
+ const bare = path.join(FIXTURE_ROOT, 'origin.git');
65
+ fs.mkdirSync(bare, { recursive: true });
66
+ git(bare, 'init', '-q', '--bare');
67
+
68
+ const first = makeRepo('clone-a');
69
+ git(first, 'remote', 'add', 'origin', bare);
70
+ const head = git(first, 'rev-parse', 'HEAD').trim();
71
+ git(first, 'push', '-q', 'origin', `${head}:refs/heads/main`);
72
+
73
+ const second = path.join(FIXTURE_ROOT, 'clone-b');
74
+ git(FIXTURE_ROOT, 'clone', '-q', bare, second);
75
+
76
+ const a = await deriveProjectId(first);
77
+ const b = await deriveProjectId(second);
78
+ expect(a).not.toBeNull();
79
+ expect(a).toBe(b);
80
+ });
81
+
82
+ test('an unborn repository has no identity — the `no_commits` refusal', async () => {
83
+ const empty = path.join(FIXTURE_ROOT, 'unborn');
84
+ fs.mkdirSync(empty, { recursive: true });
85
+ git(empty, 'init', '-q', '-b', 'main', '.');
86
+ expect(await deriveProjectId(empty)).toBeNull();
87
+ });
88
+
89
+ test('a stored identity wins over a re-derivation', async () => {
90
+ const repo = makeRepo('identity-stored');
91
+ const stored = 'stored-project-id';
92
+ const identityFor = async () => stored;
93
+ expect(await resolveProjectId(await canonicalPath(repo), identityFor)).toBe(stored);
94
+ });
95
+
96
+ test('an added orphan branch changes the derivation — hence the cache', async () => {
97
+ const repo = makeRepo('identity-orphan');
98
+ const derived = await deriveProjectId(repo);
99
+ expect(derived).not.toBeNull();
100
+
101
+ git(repo, 'checkout', '-q', '--orphan', 'other');
102
+ git(repo, 'rm', '-rq', '--cached', '.');
103
+ git(repo, 'add', '-A');
104
+ git(repo, 'commit', '-q', '-m', 'other root');
105
+
106
+ // Two roots now exist, so the sorted-first value is a different question
107
+ // than it was — which is exactly why the stored value is consulted first.
108
+ const roots = git(repo, 'rev-list', '--max-parents=0', '--all')
109
+ .split('\n')
110
+ .map((line) => line.trim())
111
+ .filter(Boolean);
112
+ expect(roots.length).toBe(2);
113
+
114
+ // The stored value is consulted first, whatever the derivation now answers.
115
+ const stored = derived as string;
116
+ const later = await resolveProjectId(await canonicalPath(repo), async () => stored);
117
+ expect(later).toBe(stored);
118
+ });
119
+ });
120
+
121
+ describe('worktree location', () => {
122
+ // A literal POSIX spelling is not portable: `path.resolve('/repo', '.worktrees')`
123
+ // answers `D:\repo\.worktrees` on a Windows runner, which is the CORRECT
124
+ // answer for that platform and not a product bug. Every expectation is
125
+ // therefore built through `path`, so the rule is asserted rather than the
126
+ // separator.
127
+ const checkout = path.resolve('/repo');
128
+ const home = path.resolve('/home/u');
129
+
130
+ test('three spellings, and the resolved value is always the parent', () => {
131
+ expect(resolveConfiguredWorktreeRoot('', checkout)).toBeNull();
132
+ expect(resolveConfiguredWorktreeRoot(null, checkout)).toBeNull();
133
+ expect(resolveConfiguredWorktreeRoot(' ', checkout)).toBeNull();
134
+ expect(resolveConfiguredWorktreeRoot('../wt', checkout)).toBe(path.resolve(checkout, '..', 'wt'));
135
+ expect(resolveConfiguredWorktreeRoot('.worktrees', checkout)).toBe(path.join(checkout, '.worktrees'));
136
+ expect(resolveConfiguredWorktreeRoot('/abs/wt', checkout)).toBe(path.resolve(checkout, '/abs/wt'));
137
+ expect(resolveConfiguredWorktreeRoot('~/wt', checkout, home)).toBe(path.join(home, 'wt'));
138
+ expect(resolveConfiguredWorktreeRoot('~', checkout, home)).toBe(home);
139
+ });
140
+
141
+ test('the default root is keyed per repository, outside every checkout', () => {
142
+ expect(defaultWorktreeRoot('/data', 'abc123')).toBe(path.join('/data', 'worktrees', 'abc123'));
143
+ });
144
+
145
+ test('the slug is tried first, then slug-word-word', () => {
146
+ const candidates = worktreeNameCandidates('quick-otter');
147
+ expect(candidates[0]).toBe('quick-otter');
148
+ expect(candidates[1]).toMatch(/^quick-otter-[a-z]+-[a-z]+$/);
149
+ expect(candidates.length).toBe(26);
150
+ });
151
+
152
+ test('a taken folder moves to the next candidate rather than refusing', async () => {
153
+ const root = path.join(FIXTURE_ROOT, 'collide');
154
+ fs.mkdirSync(path.join(root, 'quick-otter'), { recursive: true });
155
+
156
+ const target = await resolveTargetDirectory({
157
+ root,
158
+ preferredName: 'quick-otter',
159
+ generatedBranch: (name) => `wt/${name}`,
160
+ branchExists: async () => false,
161
+ });
162
+ expect(target.directory).not.toBe(path.join(root, 'quick-otter'));
163
+ expect(target.name.startsWith('quick-otter-')).toBe(true);
164
+ });
165
+
166
+ test('a taken BRANCH is skipped too, so folder and branch never disagree', async () => {
167
+ const root = path.join(FIXTURE_ROOT, 'branch-collide');
168
+ fs.mkdirSync(root, { recursive: true });
169
+
170
+ const target = await resolveTargetDirectory({
171
+ root,
172
+ preferredName: 'quick-otter',
173
+ generatedBranch: (name) => `wt/${name}`,
174
+ branchExists: async (branch) => branch === 'wt/quick-otter',
175
+ });
176
+ expect(target.branch).not.toBe('wt/quick-otter');
177
+ expect(target.branch).toBe(`wt/${target.name}`);
178
+ });
179
+
180
+ test('a typed branch is used verbatim in both modes', async () => {
181
+ const root = path.join(FIXTURE_ROOT, 'verbatim');
182
+ fs.mkdirSync(root, { recursive: true });
183
+ const target = await resolveTargetDirectory({
184
+ root,
185
+ preferredName: 'payments',
186
+ explicitBranch: 'feat/payments',
187
+ generatedBranch: (name) => `wt/${name}`,
188
+ // Even when every generated name is taken, the typed branch is used.
189
+ branchExists: async () => true,
190
+ });
191
+ expect(target.branch).toBe('feat/payments');
192
+ expect(target.name).toBe('payments');
193
+ });
194
+
195
+ test('a generated name avoids both a folder and a branch', async () => {
196
+ const root = path.join(FIXTURE_ROOT, 'generated');
197
+ fs.mkdirSync(root, { recursive: true });
198
+ const name = await generateAvailableName({
199
+ root,
200
+ branchExists: async (branch) => branch === 'wt/blocked',
201
+ attempts: 4,
202
+ });
203
+ expect(name).not.toBe('blocked');
204
+ expect(name).toMatch(/^[a-z]+-[a-z]+$/);
205
+ });
206
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Where a worktree lands — OpenChamber's rule, adopted as-is:
8
+ *
9
+ * root = configured directory (resolved) || <data dir>/worktrees/<projectID>
10
+ * path = <root>/<name>
11
+ *
12
+ * Three spellings for the setting, unchanged from theirs: a relative path
13
+ * resolves against the CANONICAL checkout (so a linked worktree still reads the
14
+ * project's own setting), an absolute path is used as-is, and `~` means the home
15
+ * directory. The resolved value is always the PARENT — the name is appended.
16
+ *
17
+ * A name is never refused because it is taken: candidates are tried in order, so
18
+ * "quick otter" in a repository that already has one becomes
19
+ * `quick-otter-cosmic-dolphin` rather than an error the user has to read.
20
+ */
21
+
22
+ import path from 'path';
23
+ import { homedir } from 'os';
24
+ import { pathExists } from '@/server/lib/omp/core/paths';
25
+ import {
26
+ generateBranchSlug,
27
+ slugWorktreeName,
28
+ WORKTREE_BRANCH_PREFIX,
29
+ } from '@/shared/lib/workspace/worktree/branch-name';
30
+
31
+ /** How many names to try before giving up. OpenChamber's 26, kept. */
32
+ export const WORKTREE_NAME_ATTEMPTS = 26;
33
+
34
+ /**
35
+ * Resolve the setting into a parent directory. `null` when unset, so the caller
36
+ * keeps its own default rather than resolving an empty string to the checkout.
37
+ */
38
+ export function resolveConfiguredWorktreeRoot(
39
+ configured: unknown,
40
+ primaryCheckout: string,
41
+ home: string = homedir(),
42
+ ): string | null {
43
+ const trimmed = String(configured ?? '').trim();
44
+ if (!trimmed) return null;
45
+ if (trimmed === '~') return path.resolve(home);
46
+ if (/^~[\\/]/.test(trimmed)) return path.resolve(home, trimmed.slice(2));
47
+ return path.resolve(primaryCheckout, trimmed);
48
+ }
49
+
50
+ /** `<data dir>/worktrees/<projectID>` — outside every checkout, keyed per repository. */
51
+ export function defaultWorktreeRoot(dataDir: string, projectId: string): string {
52
+ return path.join(dataDir, 'worktrees', projectId);
53
+ }
54
+
55
+ /** `<slug>`, then `<slug>-<adjective>-<noun>` × attempts-1. */
56
+ export function worktreeNameCandidates(preferredName: string, attempts = WORKTREE_NAME_ATTEMPTS): string[] {
57
+ const base = slugWorktreeName(preferredName);
58
+ if (!base) {
59
+ return Array.from({ length: attempts }, () => generateBranchSlug());
60
+ }
61
+ return Array.from({ length: attempts }, (_, index) =>
62
+ index === 0 ? base : `${base}-${generateBranchSlug()}`,
63
+ );
64
+ }
65
+
66
+ export interface ResolvedWorktreeTarget {
67
+ name: string;
68
+ directory: string;
69
+ branch: string;
70
+ }
71
+
72
+ export interface ResolveTargetInput {
73
+ root: string;
74
+ preferredName: string;
75
+ /** The dialog's branch, used verbatim and only the directory is checked. */
76
+ explicitBranch?: string | null;
77
+ /** The branch a generated candidate would get. */
78
+ generatedBranch: (name: string) => string;
79
+ branchExists: (branch: string) => Promise<boolean>;
80
+ }
81
+
82
+ /**
83
+ * Pick a free name. A candidate is rejected when its directory exists OR — for a
84
+ * generated branch — when the branch it would get already exists, so the folder
85
+ * and the branch can never disagree about which one is "the name".
86
+ */
87
+ export async function resolveTargetDirectory(input: ResolveTargetInput): Promise<ResolvedWorktreeTarget> {
88
+ const explicit = input.explicitBranch?.trim() || null;
89
+ for (const name of worktreeNameCandidates(input.preferredName)) {
90
+ const directory = path.join(input.root, name);
91
+ if (await pathExists(directory)) continue;
92
+ if (explicit) return { name, directory, branch: explicit };
93
+ const branch = input.generatedBranch(name);
94
+ if (await input.branchExists(branch)) continue;
95
+ return { name, directory, branch };
96
+ }
97
+ throw new Error('Failed to generate a unique worktree name');
98
+ }
99
+
100
+ /** A generated slug that collides with neither a folder nor a branch. */
101
+ export async function generateAvailableName(input: {
102
+ root: string;
103
+ branchExists: (branch: string) => Promise<boolean>;
104
+ attempts?: number;
105
+ }): Promise<string> {
106
+ const attempts = input.attempts ?? WORKTREE_NAME_ATTEMPTS;
107
+ for (let index = 0; index < attempts; index += 1) {
108
+ const slug = generateBranchSlug();
109
+ if (await pathExists(path.join(input.root, slug))) continue;
110
+ if (await input.branchExists(`${WORKTREE_BRANCH_PREFIX}/${slug}`)) continue;
111
+ return slug;
112
+ }
113
+ throw new Error('Failed to generate a unique worktree name');
114
+ }