@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
@@ -3,6 +3,7 @@ import type { SettingsState } from '@/shared/types';
3
3
  import { resolveThemeId } from '@/shared/lib/theme/catalog';
4
4
  import { ThemeGrid } from '@/client/components/settings/categories/appearance-settings/ThemeGrid';
5
5
  import { EditorFontSection } from '@/client/components/settings/categories/appearance-settings/EditorFontSection';
6
+ import { EditorThemeSection } from '@/client/components/settings/categories/appearance-settings/EditorThemeSection';
6
7
 
7
8
  interface AppearanceSettingsProps {
8
9
  settings: SettingsState;
@@ -25,6 +26,8 @@ export function AppearanceSettings({ settings, onUpdate }: AppearanceSettingsPro
25
26
  </section>
26
27
 
27
28
  <EditorFontSection settings={settings} onUpdate={onUpdate} />
29
+
30
+ <EditorThemeSection settings={settings} onUpdate={onUpdate} />
28
31
  </div>
29
32
  );
30
33
  }
@@ -6,9 +6,12 @@ import { LoadingState } from '@/client/components/settings/LoadingState';
6
6
  import { useSettingsMasterDetail } from '@/client/hooks/settings/master-detail';
7
7
  import { useSidebarData } from '@/client/hooks/chat/omp/session-list';
8
8
  import { SettingsMasterDetail } from '@/client/components/settings/master-detail';
9
+ import { WorktreeSettings } from '@/client/components/settings/categories/project-settings/WorktreeSettings';
10
+ import { useToasts } from '@/client/hooks/ui/toasts';
9
11
 
10
12
  export function ProjectSettings() {
11
13
  const masterDetail = useSettingsMasterDetail();
14
+ const { pushToast } = useToasts();
12
15
  const [projects, setProjects] = useState<ProjectConfigItem[]>([]);
13
16
  const [availableModels, setAvailableModels] = useState<string[]>([]);
14
17
  const [accentColorOptions, setAccentColorOptions] = useState<AccentColorOption[]>([]);
@@ -179,14 +182,25 @@ export function ProjectSettings() {
179
182
  }
180
183
  detail={
181
184
  selectedProject ? (
182
- <ProjectDetailsForm
183
- project={selectedProject}
184
- canDelete={projects.length > 1}
185
- onUpdateField={handleUpdateField}
186
- onDeleteProject={handleDeleteProject}
187
- availableModels={availableModels}
188
- accentColorOptions={accentColorOptions}
189
- />
185
+ <div className="flex flex-col">
186
+ <ProjectDetailsForm
187
+ project={selectedProject}
188
+ canDelete={projects.length > 1}
189
+ onUpdateField={handleUpdateField}
190
+ onDeleteProject={handleDeleteProject}
191
+ availableModels={availableModels}
192
+ accentColorOptions={accentColorOptions}
193
+ />
194
+ {/* Worktrees belong to the PROJECT, not to a session: a create here
195
+ has no chat, and the next chat picks it from the composer. */}
196
+ <div className="border-t border-ink/10 px-4 py-4">
197
+ <WorktreeSettings
198
+ root={selectedProject.path}
199
+ folderId={selectedProject.folderId ?? null}
200
+ onReport={(message, tone) => pushToast(message, tone ?? 'success', { duration: 6000 })}
201
+ />
202
+ </div>
203
+ </div>
190
204
  ) : null
191
205
  }
192
206
  />
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor theme picker.
8
+ *
9
+ * Three properties are invisible to a markup check and each is a bug if dropped:
10
+ *
11
+ * - **`Auto` is a card and is selected by default**, so an install that never
12
+ * touches this section renders exactly what it did before the feature.
13
+ * - **The swatch is drawn from the row's own colours**, not from the app
14
+ * palette: a card that previewed every theme in the app's ink would make 65
15
+ * themes look identical, which is the whole point of the grid.
16
+ * - **A click writes the theme id through `onUpdate`**, which is what the modal
17
+ * turns into the event an already-open editor subscribes to.
18
+ *
19
+ * Rendered with `h()` against happy-dom; the modal's write path is pinned in
20
+ * `settings/Modal`'s own suite.
21
+ */
22
+
23
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
24
+ import { Window } from 'happy-dom';
25
+ import { h, render } from 'preact';
26
+ import { act } from 'preact/test-utils';
27
+
28
+ import { EditorThemeSection } from '@/client/components/settings/categories/appearance-settings/EditorThemeSection';
29
+ import { EDITOR_AUTO_THEME_ID, EDITOR_THEMES, resolveEditorTheme } from '@/shared/lib/code/editor/themes';
30
+ import { EDITOR_DEFAULT_FONT_FAMILY } from '@/shared/lib/code/editor/typography';
31
+ import type { SettingsState } from '@/shared/types';
32
+
33
+ const DOM_GLOBALS = [
34
+ 'window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'CustomEvent',
35
+ 'getComputedStyle',
36
+ ] as const;
37
+
38
+ const native: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
39
+
40
+ beforeAll(() => {
41
+ const win = new Window({ url: 'http://localhost' });
42
+ const target = globalThis as unknown as Record<string, unknown>;
43
+ for (const key of DOM_GLOBALS) {
44
+ if (!(key in native)) native[key] = target[key];
45
+ const value = (win as unknown as Record<string, unknown>)[key];
46
+ if (value !== undefined) target[key] = value;
47
+ }
48
+ });
49
+
50
+ afterAll(() => {
51
+ const target = globalThis as unknown as Record<string, unknown>;
52
+ for (const key of DOM_GLOBALS) {
53
+ if (native[key] === undefined) delete target[key];
54
+ else target[key] = native[key];
55
+ }
56
+ });
57
+
58
+ let container: HTMLElement | null = null;
59
+ type Update = Partial<SettingsState> | ((prev: SettingsState) => SettingsState);
60
+ let lastUpdate: Update | null = null;
61
+
62
+ /** Reset through a function: assigning `null` inline would narrow the variable
63
+ * at every later read, and the assertions below exist to see the VALUE. */
64
+ function resetUpdate(): void {
65
+ lastUpdate = null;
66
+ }
67
+
68
+ /** What the last click asked to persist, as a value the assertions can read. */
69
+ function lastPatch(): unknown {
70
+ return lastUpdate;
71
+ }
72
+
73
+ function settings(editorTheme: string): SettingsState {
74
+ return { editorTheme, editorFont: EDITOR_DEFAULT_FONT_FAMILY } as SettingsState;
75
+ }
76
+
77
+ async function mount(editorTheme: string): Promise<HTMLElement> {
78
+ if (container) render(null, container);
79
+ container = document.body.appendChild(document.createElement('div'));
80
+ await act(async () => {
81
+ render(
82
+ h(EditorThemeSection, {
83
+ settings: settings(editorTheme),
84
+ onUpdate: (update: Update) => {
85
+ lastUpdate = update;
86
+ },
87
+ }),
88
+ container!,
89
+ );
90
+ });
91
+ return container;
92
+ }
93
+
94
+ /** The card whose label starts with `label` (the label is the first text). */
95
+ function card(root: HTMLElement, label: string): HTMLButtonElement | undefined {
96
+ return Array.from(root.querySelectorAll('button')).find((button) =>
97
+ (button.textContent ?? '').trim().startsWith(label),
98
+ );
99
+ }
100
+
101
+ describe('editor theme picker', () => {
102
+ test('Auto is a card, and it is the selected one by default', async () => {
103
+ const root = await mount(EDITOR_AUTO_THEME_ID);
104
+ const auto = card(root, 'Auto');
105
+ expect(auto).toBeDefined();
106
+ expect(auto!.getAttribute('aria-pressed')).toBe('true');
107
+ // Every shipped theme is offered beside it, so the grid is not a two-item
108
+ // list with the catalogue hidden behind a filter.
109
+ expect(root.querySelectorAll('button[aria-pressed]').length).toBe(EDITOR_THEMES.length + 1);
110
+ });
111
+
112
+ test('a stored theme is the selected card, and Auto is not', async () => {
113
+ const root = await mount('dracula');
114
+ expect(card(root, 'Dracula Theme')!.getAttribute('aria-pressed')).toBe('true');
115
+ expect(card(root, 'Auto')!.getAttribute('aria-pressed')).toBe('false');
116
+ });
117
+
118
+ test('the swatch is drawn in the theme’s OWN colours', async () => {
119
+ const root = await mount('dracula');
120
+ const theme = resolveEditorTheme('dracula');
121
+ expect(theme).not.toBeNull();
122
+ const swatch = Array.from(card(root, 'Dracula Theme')!.querySelectorAll('span')).find(
123
+ (node) => (node as HTMLElement).style.backgroundColor !== '',
124
+ ) as HTMLElement | undefined;
125
+ expect(swatch).toBeDefined();
126
+ // The card paints the theme's own surface, not the app's — which is what
127
+ // makes 65 themes distinguishable in one grid.
128
+ expect(swatch!.style.backgroundColor.toLowerCase()).toBe(theme!.bg.toLowerCase());
129
+ });
130
+
131
+ test('a click writes the theme id', async () => {
132
+ resetUpdate();
133
+ const root = await mount(EDITOR_AUTO_THEME_ID);
134
+ await act(async () => {
135
+ card(root, 'Nord')!.click();
136
+ });
137
+ expect(lastPatch()).toEqual({ editorTheme: 'nord' });
138
+ });
139
+
140
+ test('the Auto card writes the sentinel, not an empty string', async () => {
141
+ resetUpdate();
142
+ const root = await mount('nord');
143
+ await act(async () => {
144
+ card(root, 'Auto')!.click();
145
+ });
146
+ // An empty value would resolve to `auto` too, but it would also be a value
147
+ // no reader can distinguish from a missing setting.
148
+ expect(lastPatch()).toEqual({ editorTheme: EDITOR_AUTO_THEME_ID });
149
+ });
150
+
151
+ test('the light/dark filters narrow the grid without dropping Auto', async () => {
152
+ const root = await mount(EDITOR_AUTO_THEME_ID);
153
+ const darkFilter = Array.from(root.querySelectorAll('button')).find(
154
+ (button) => (button.textContent ?? '').trim() === 'Dark',
155
+ );
156
+ await act(async () => {
157
+ darkFilter!.click();
158
+ });
159
+ const labels = Array.from(root.querySelectorAll('button[aria-pressed]')).map((button) =>
160
+ (button.textContent ?? '').trim().split(/\d/)[0],
161
+ );
162
+ expect(labels.length).toBe(EDITOR_THEMES.filter((theme) => theme.variant === 'dark').length + 1);
163
+ expect(labels.some((label) => label.startsWith('GitHub Light'))).toBe(false);
164
+ // Auto is a choice, not a light/dark theme, so a variant filter keeps it.
165
+ expect(labels.some((label) => label.startsWith('Auto'))).toBe(true);
166
+ });
167
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The code editor's colour theme, as a filterable grid of cards.
8
+ *
9
+ * A second axis beside the app palette (see `themes.ts`): the console keeps its
10
+ * own theme and the editor wears whichever TextMate theme the reader works in.
11
+ * `auto` is the first card and the default, so an install that never opens this
12
+ * picker behaves exactly as before.
13
+ *
14
+ * Three properties are load-bearing and each is a bug if dropped:
15
+ *
16
+ * - **The swatch is drawn from the generated colour rows, not from the theme
17
+ * module.** 65 TextMate definitions is 1.3 MB; a grid that fetched them to
18
+ * draw itself would download the whole catalogue to show a colour. The rows
19
+ * carry the surface, the ink and three syntax accents — the colours that
20
+ * actually tell two themes apart at card size.
21
+ * - **The preview is set in the theme's OWN colours**, so an unselected card
22
+ * still shows what selecting it does.
23
+ * - **Selecting is a write, and the write is announced** (`Modal` dispatches
24
+ * `omp:editor-theme-changed`), which is what reaches an editor that is
25
+ * already open — `appSettings` is a boot-time prop and would only apply on a
26
+ * reload.
27
+ */
28
+
29
+ import { useMemo, useState } from 'preact/hooks';
30
+ import { Check, Moon, Palette, Sun } from 'lucide-preact';
31
+ import type { SettingsState } from '@/shared/types';
32
+ import {
33
+ EDITOR_AUTO_THEME_ID,
34
+ EDITOR_THEMES,
35
+ type EditorTheme,
36
+ } from '@/shared/lib/code/editor/themes';
37
+ import { EDITOR_FONT_CHOICES } from '@/shared/lib/code/editor/typography';
38
+
39
+ interface EditorThemeSectionProps {
40
+ settings: SettingsState;
41
+ onUpdate: (updater: Partial<SettingsState> | ((prev: SettingsState) => SettingsState)) => void;
42
+ }
43
+
44
+ type VariantFilter = 'all' | 'light' | 'dark';
45
+
46
+ const FILTERS: ReadonlyArray<{ id: VariantFilter; label: string }> = [
47
+ { id: 'all', label: 'All' },
48
+ { id: 'light', label: 'Light' },
49
+ { id: 'dark', label: 'Dark' },
50
+ ];
51
+
52
+ /** The face the cards' sample line is set in: the editor's own choice. */
53
+ function sampleFace(font: string): string {
54
+ const choice = EDITOR_FONT_CHOICES.find((entry) => entry.id === font);
55
+ return `"${choice?.id ?? EDITOR_FONT_CHOICES[0].id}", monospace`;
56
+ }
57
+
58
+ function ThemeSwatch({ theme, face }: { theme: EditorTheme; face: string }) {
59
+ return (
60
+ <span
61
+ className="block overflow-hidden rounded border border-ink/10"
62
+ style={{ backgroundColor: theme.bg, color: theme.fg }}
63
+ >
64
+ <span className="block px-1.5 py-1 font-mono text-[10px] leading-[1.4]" style={{ fontFamily: face }}>
65
+ <span style={{ color: theme.keyword }}>const</span> <span>answer</span>{' '}
66
+ <span style={{ color: theme.keyword }}>=</span> <span style={{ color: theme.string }}>42</span>
67
+ <span>{';'}</span>
68
+ <span className="block truncate" style={{ color: theme.comment }}>
69
+ {'// '}
70
+ {theme.label}
71
+ </span>
72
+ </span>
73
+ </span>
74
+ );
75
+ }
76
+
77
+ export function EditorThemeSection({ settings, onUpdate }: EditorThemeSectionProps) {
78
+ const active = settings.editorTheme || EDITOR_AUTO_THEME_ID;
79
+ const [filter, setFilter] = useState<VariantFilter>('all');
80
+ const face = sampleFace(settings.editorFont);
81
+
82
+ const themes = useMemo(
83
+ () => (filter === 'all' ? EDITOR_THEMES : EDITOR_THEMES.filter((theme) => theme.variant === filter)),
84
+ [filter],
85
+ );
86
+
87
+ return (
88
+ <section className="space-y-3">
89
+ <div>
90
+ <h3 className="font-semibold text-sm uppercase tracking-wider text-ink/80 flex items-center gap-2">
91
+ <Palette size={16} className="text-ink/60" />
92
+ Editor Theme
93
+ </h3>
94
+ <p className="text-[11px] text-ink/60 mt-1">
95
+ Colours for the code editor only — the console around it keeps the theme above. <strong>Auto</strong>{' '}
96
+ follows the app palette; any other choice paints the editor's own surface, gutter, selection and
97
+ syntax, and applies to every open editor without a reload.
98
+ </p>
99
+ </div>
100
+
101
+ <div className="flex items-center gap-1.5">
102
+ {FILTERS.map((option) => (
103
+ <button
104
+ key={option.id}
105
+ type="button"
106
+ onClick={() => setFilter(option.id)}
107
+ className={`rounded-full border px-2.5 py-1 text-[11px] font-medium transition-colors cursor-pointer ${
108
+ filter === option.id
109
+ ? 'border-ink bg-ink text-paper'
110
+ : 'border-ink/20 bg-paper text-ink/70 hover:border-ink/40 hover:text-ink'
111
+ }`}
112
+ >
113
+ {option.id === 'light' && <Sun size={11} className="mr-1 inline align-[-1px]" />}
114
+ {option.id === 'dark' && <Moon size={11} className="mr-1 inline align-[-1px]" />}
115
+ {option.label}
116
+ </button>
117
+ ))}
118
+ <span className="ml-auto font-mono text-[11px] text-ink/50">
119
+ {filter === 'all' ? `${themes.length} themes` : `${themes.length} shown`}
120
+ </span>
121
+ </div>
122
+
123
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
124
+ {/* `Auto` is a card rather than a checkbox: it is one of the choices,
125
+ and a checkbox beside a grid makes the default look like an
126
+ override of the thing it is a peer of. It is drawn under EVERY
127
+ filter — it is neither light nor dark, and a user who filtered to
128
+ Dark must still be able to get back to the app palette without
129
+ first clearing the filter. */}
130
+ {(
131
+ <button
132
+ type="button"
133
+ onClick={() => onUpdate({ editorTheme: EDITOR_AUTO_THEME_ID })}
134
+ aria-pressed={active === EDITOR_AUTO_THEME_ID}
135
+ className={`flex w-full flex-col gap-2 rounded-lg border p-2 text-left transition-colors cursor-pointer ${
136
+ active === EDITOR_AUTO_THEME_ID
137
+ ? 'border-ink bg-ink/5 ring-1 ring-ink/20'
138
+ : 'border-ink/20 bg-paper hover:border-ink/40 hover:bg-ink/5'
139
+ }`}
140
+ >
141
+ <span className="flex items-center gap-2">
142
+ <span className="truncate font-semibold text-ink">Auto</span>
143
+ <span className="shrink-0 rounded-full border border-ink/15 px-1.5 py-px text-[9px] font-semibold uppercase tracking-wider text-ink/50">
144
+ app palette
145
+ </span>
146
+ {active === EDITOR_AUTO_THEME_ID && <Check size={14} className="ml-auto shrink-0 text-ink" />}
147
+ </span>
148
+ <span className="flex h-8 items-center justify-center rounded border border-dashed border-ink/20 text-[10px] text-ink/50">
149
+ Follows the theme above
150
+ </span>
151
+ </button>
152
+ )}
153
+
154
+ {themes.map((theme) => {
155
+ const selected = active === theme.id;
156
+ return (
157
+ <button
158
+ key={theme.id}
159
+ type="button"
160
+ onClick={() => onUpdate({ editorTheme: theme.id })}
161
+ aria-pressed={selected}
162
+ className={`flex w-full flex-col gap-2 rounded-lg border p-2 text-left transition-colors cursor-pointer ${
163
+ selected
164
+ ? 'border-ink bg-ink/5 ring-1 ring-ink/20'
165
+ : 'border-ink/20 bg-paper hover:border-ink/40 hover:bg-ink/5'
166
+ }`}
167
+ >
168
+ <span className="flex items-center gap-2">
169
+ <span className="truncate font-semibold text-ink" title={theme.label}>
170
+ {theme.label}
171
+ </span>
172
+ <span className="shrink-0 rounded-full border border-ink/15 px-1.5 py-px text-[9px] font-semibold uppercase tracking-wider text-ink/50">
173
+ {theme.variant}
174
+ </span>
175
+ {selected && <Check size={14} className="ml-auto shrink-0 text-ink" />}
176
+ </span>
177
+ <ThemeSwatch theme={theme} face={face} />
178
+ </button>
179
+ );
180
+ })}
181
+ </div>
182
+ </section>
183
+ );
184
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Settings → Projects → Worktrees: the list, and the two settings that decide
8
+ * where a new one lands.
9
+ *
10
+ * A row carries what the reader actually asks of a list of worktrees: which
11
+ * branch is held there (and therefore cannot be checked out anywhere else), how
12
+ * many chats start in it, and whether it is dirty. `missing` is its own line
13
+ * because a worktree whose folder was deleted outside Kabmo still owns its
14
+ * sessions, and hiding it would hide them.
15
+ *
16
+ * The two fields below the list are the settings a create reads on the server
17
+ * (`worktreeDirectory`, `worktreeCarryIgnored`), so what is typed here is what
18
+ * the next create uses — including one started by a scheduled task.
19
+ */
20
+
21
+ import { useState } from 'preact/hooks';
22
+ import { readKabmoSetting, writeSetting } from '@/shared/lib/settings/client';
23
+ import { useWorktrees } from '@/client/hooks/workspace/worktree/index';
24
+ import { removeWorktree } from '@/client/hooks/workspace/worktree/index';
25
+ import { WORKTREE_CARRY_SETTING, WORKTREE_DIRECTORY_SETTING } from '@/shared/lib/fs/worktree/settings-keys';
26
+ import { branchDeletable, removalRefusal, worktreeRowDetail } from '@/client/components/settings/categories/project-settings/worktree-row';
27
+
28
+ export interface WorktreeSettingsProps {
29
+ /** The selected project's `project_path`; the section is inert without one. */
30
+ root: string | null;
31
+ folderId: number | null;
32
+ /** Report a refusal — the section never claims a removal it did not get. */
33
+ onReport: (message: string, tone?: 'success' | 'error') => void;
34
+ }
35
+
36
+ export function WorktreeSettings({ root, folderId, onReport }: WorktreeSettingsProps) {
37
+ const { worktrees, base, loading, error, isRepo, refresh } = useWorktrees({ root, folderId }, Boolean(root));
38
+ const [directory, setDirectory] = useState(() => {
39
+ const stored = readKabmoSetting<string>(WORKTREE_DIRECTORY_SETTING);
40
+ return typeof stored === 'string' ? stored : '';
41
+ });
42
+ const [carry, setCarry] = useState(() => readKabmoSetting<boolean>(WORKTREE_CARRY_SETTING) !== false);
43
+ // `detached` rows name a commit instead of a branch, and the payload carries it.
44
+
45
+ const [removing, setRemoving] = useState<string | null>(null);
46
+
47
+ if (!root) {
48
+ return <p className="text-xs text-ink/50">Bind this workspace to a repository to manage its worktrees.</p>;
49
+ }
50
+
51
+ const removable = worktrees.filter((worktree) => !worktree.isPrimary);
52
+
53
+ return (
54
+ <div className="flex flex-col gap-4">
55
+ <section className="flex flex-col gap-1">
56
+ <h3 className="text-xs font-semibold text-ink">Worktrees</h3>
57
+ {loading && <p className="text-[11px] text-ink/50">Reading worktrees…</p>}
58
+ {error && (
59
+ // A failed read is REPORTED, never rendered as "none": the two look
60
+ // identical on screen and only one of them is actionable.
61
+ <p className="text-[11px] text-error" title={error}>
62
+ Worktrees could not be read: {error}
63
+ </p>
64
+ )}
65
+ {!loading && !error && isRepo === false && (
66
+ // The list is empty for a reason the row has to NAME: a folder that
67
+ // is not a repository can never have a worktree, and "No worktrees
68
+ // yet" would read as one that could be created.
69
+ <p className="text-[11px] text-ink/50">Worktrees need a Git repository.</p>
70
+ )}
71
+ {!loading && !error && isRepo !== false && removable.length === 0 && (
72
+ <p className="text-[11px] text-ink/50">No worktrees yet.</p>
73
+ )}
74
+ {removable.map((worktree) => (
75
+ <div key={worktree.path} className="group flex items-center gap-2 rounded-md px-1 py-1.5 hover:bg-ink/5">
76
+ <div className="min-w-0 flex-1">
77
+ <p className="truncate text-xs text-ink">{worktree.branch || 'detached'}</p>
78
+ <p className="truncate text-[10.5px] text-ink/50" title={worktree.path}>
79
+ {worktreeRowDetail(worktree)}
80
+ </p>
81
+ </div>
82
+ <button
83
+ type="button"
84
+ disabled={removing === worktree.path || removalRefusal(worktree) !== null}
85
+ aria-label={`Remove worktree ${worktree.branch || worktree.path}`}
86
+ title={removalRefusal(worktree) ?? 'Remove worktree'}
87
+ onClick={() => {
88
+ setRemoving(worktree.path);
89
+ void removeWorktree({ root, folderId }, worktree.path, { deleteBranch: branchDeletable(worktree) })
90
+ .then((result) => {
91
+ onReport(
92
+ result.branchDeleted
93
+ ? `Removed ${worktree.branch} and its branch`
94
+ : `Removed ${worktree.branch}${result.branchKept ? ' (branch kept)' : ''}`,
95
+ 'success',
96
+ );
97
+ return refresh();
98
+ })
99
+ .catch((cause: unknown) => onReport(cause instanceof Error ? cause.message : String(cause), 'error'))
100
+ .finally(() => setRemoving(null));
101
+ }}
102
+ className="shrink-0 rounded px-2 py-1 text-[11px] text-ink/60 hover:bg-error/10 hover:text-error disabled:opacity-40"
103
+ >
104
+ Remove
105
+ </button>
106
+ </div>
107
+ ))}
108
+ </section>
109
+
110
+ <label className="flex flex-col gap-1">
111
+ <span className="text-xs font-semibold text-ink">Worktree location</span>
112
+ <input
113
+ value={directory}
114
+ onChange={(event) => setDirectory(event.currentTarget.value)}
115
+ onBlur={() => writeSetting(WORKTREE_DIRECTORY_SETTING, directory.trim())}
116
+ placeholder={base}
117
+ className="h-8 rounded-md border border-ink/15 bg-paper px-2 text-xs text-ink outline-none focus:border-ink/40"
118
+ />
119
+ <span className="text-[10.5px] text-ink/50">
120
+ Where new worktrees are created. Relative paths are resolved against the project; ~ is your home directory.
121
+ Empty uses {base}.
122
+ </span>
123
+ </label>
124
+
125
+ <label className="flex items-start gap-2">
126
+ <input
127
+ type="checkbox"
128
+ checked={carry}
129
+ onChange={(event) => {
130
+ const next = event.currentTarget.checked;
131
+ setCarry(next);
132
+ writeSetting(WORKTREE_CARRY_SETTING, next);
133
+ }}
134
+ className="mt-0.5"
135
+ />
136
+ <span className="flex flex-col gap-0.5">
137
+ <span className="text-xs font-semibold text-ink">Copy ignored files into new worktrees</span>
138
+ <span className="text-[10.5px] text-ink/50">
139
+ A new worktree gets the project's ignored build folders (node_modules, .venv, target) copied in, so it works
140
+ without reinstalling.
141
+ </span>
142
+ </span>
143
+ </label>
144
+ </div>
145
+ );
146
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * What a worktree row says, and when its Remove control is refused.
8
+ *
9
+ * These are the sentences a reader decides on, so they are functions rather than
10
+ * inline template strings: a markup test would prove the component renders
11
+ * *something*, while the questions here are "which branch is held" (and therefore
12
+ * cannot be checked out anywhere else), "how many chats start here", and "would
13
+ * this removal lose work".
14
+ */
15
+
16
+ import { describe, expect, test } from 'bun:test';
17
+ import {
18
+ branchDeletable,
19
+ removalRefusal,
20
+ worktreeRowDetail,
21
+ } from '@/client/components/settings/categories/project-settings/worktree-row';
22
+ import type { WorktreeInfo } from '@/shared/lib/workspace/worktree/types';
23
+
24
+ const base: WorktreeInfo = {
25
+ path: '/data/worktrees/abc/cosmic-dolphin',
26
+ branch: 'wt/cosmic-dolphin',
27
+ head: 'a1b2c3d4e5f6',
28
+ detached: false,
29
+ missing: false,
30
+ isPrimary: false,
31
+ dirty: 0,
32
+ aheadOfStart: 0,
33
+ startRef: 'HEAD',
34
+ sessions: 2,
35
+ };
36
+
37
+ describe('worktreeRowDetail', () => {
38
+ test('names the branch, the chat count and the tree state', () => {
39
+ expect(worktreeRowDetail(base)).toBe('wt/cosmic-dolphin checked out here · 2 chats · clean');
40
+ });
41
+
42
+ test('a single chat is not "1 chats"', () => {
43
+ expect(worktreeRowDetail({ ...base, sessions: 1 })).toContain('1 chat ·');
44
+ });
45
+
46
+ test('changed files are counted', () => {
47
+ expect(worktreeRowDetail({ ...base, dirty: 3 })).toContain('3 changed');
48
+ });
49
+
50
+ test('a detached row names a commit instead of a branch', () => {
51
+ expect(worktreeRowDetail({ ...base, branch: '', detached: true })).toContain('detached @ a1b2c3d');
52
+ });
53
+
54
+ test('a missing worktree says its chats still open — that is why the row stays', () => {
55
+ expect(worktreeRowDetail({ ...base, missing: true })).toContain('its chats still open');
56
+ });
57
+ });
58
+
59
+ describe('removal rules', () => {
60
+ test('a clean worktree with no commits of its own may lose its branch', () => {
61
+ expect(branchDeletable(base)).toBe(true);
62
+ expect(removalRefusal(base)).toBeNull();
63
+ });
64
+
65
+ test('a branch with commits of its own is kept, and the checkbox is disabled', () => {
66
+ expect(branchDeletable({ ...base, aheadOfStart: 2 })).toBe(false);
67
+ // The worktree itself is still removable: only the branch is protected.
68
+ expect(removalRefusal({ ...base, aheadOfStart: 2 })).toBeNull();
69
+ });
70
+
71
+ test('a dirty worktree is refused with its count', () => {
72
+ expect(removalRefusal({ ...base, dirty: 1 })).toBe('1 uncommitted file here would be lost.');
73
+ expect(removalRefusal({ ...base, dirty: 4 })).toBe('4 uncommitted files here would be lost.');
74
+ });
75
+
76
+ test('the main checkout is never removable', () => {
77
+ expect(removalRefusal({ ...base, isPrimary: true })).toContain('main checkout');
78
+ });
79
+
80
+ test('a detached row has no branch to delete', () => {
81
+ expect(branchDeletable({ ...base, branch: '', detached: true })).toBe(false);
82
+ });
83
+ });