@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
@@ -245,6 +245,25 @@
245
245
  }
246
246
  }
247
247
 
248
+ /* The Files panel's tree, on a finger.
249
+ *
250
+ * Every row is `select-none`, but a long press on iOS/Android still raises the
251
+ * native callout and a selection of the CONTAINER's text — measured on a phone:
252
+ * holding a file row highlighted the whole panel instead of opening its menu.
253
+ * `user-select` alone does not stop it; `-webkit-touch-callout` is what removes
254
+ * the callout menu, and `user-select: none` on the scroller (not only the rows)
255
+ * is what keeps a press that drifts between two rows from selecting the text of
256
+ * both. Rows themselves stay `select-none` and no row carries `select-text`:
257
+ * a path is copied through the context menu's Copy Path, never by dragging over
258
+ * it, and a selectable row makes every press a candidate for a selection.
259
+ */
260
+ .file-tree-touch,
261
+ .file-tree-touch * {
262
+ -webkit-touch-callout: none;
263
+ -webkit-user-select: none;
264
+ user-select: none;
265
+ }
266
+
248
267
  /* Horizontally scrollable strips (mobile tab bars) that pan without a visible
249
268
  * scrollbar track eating vertical space. */
250
269
  .no-scrollbar {
@@ -365,6 +384,43 @@
365
384
  .shiki span { color: var(--shiki-light); }
366
385
  html[data-theme-variant="dark"] .shiki span { color: var(--shiki-dark); }
367
386
 
387
+ /* No per-token underline in a diff.
388
+ *
389
+ * `@codemirror/merge` wraps the characters that changed in `.cm-changedText`
390
+ * (and a deleted chunk's in `.cm-deletedText`) and its BASE THEME paints a 2px
391
+ * gradient along the bottom of each fragment. A word-level change is usually
392
+ * several fragments — `2` → `22` is one — so the reader got a run of short
393
+ * coloured bars under the code that reads as a second, broken baseline rather
394
+ * than as emphasis. The row's own band and the gutter marker already say which
395
+ * side a row belongs to.
396
+ *
397
+ * Three facts about how this has to be written, each measured:
398
+ *
399
+ * 1. **It cannot be suppressed from the panel's own `EditorView.theme`.** That
400
+ * theme rejects the `&light`/`&dark` selectors the package's rules are spelled
401
+ * with (`RangeError: Unsupported selector: &light`, which took the whole
402
+ * merge view down and left an empty host).
403
+ * 2. **A plain `.cm-changedText { background: none }` LOSES.** The package's rule
404
+ * is `.ͼ2.cm-merge-b .cm-changedText` — two classes on the root plus one on the
405
+ * span, i.e. specificity (0,3,0) — and CodeMirror mounts its style modules
406
+ * after this sheet, so document order is against us as well.
407
+ * 3. **So the selector carries three class-level parts of its own** (`.cm-editor`
408
+ * + `.cm-merge-b`/`.cm-merge-a` + the span class), which wins on specificity
409
+ * rather than by `!important` — an `!important` here would also beat a
410
+ * future theme that legitimately wants a different treatment, and this is a
411
+ * cosmetic suppression, not a hard override.
412
+ *
413
+ * `diff-panel.e2e.ts` asserts the computed `background-image` on a real changed
414
+ * token, so a package bump that reintroduces the bar fails there rather than in
415
+ * a screenshot review. */
416
+ .cm-editor.cm-merge-a .cm-changedText,
417
+ .cm-editor.cm-merge-b .cm-changedText,
418
+ .cm-editor.cm-merge-a .cm-deletedChunk .cm-deletedText,
419
+ .cm-editor.cm-merge-b .cm-deletedChunk .cm-deletedText,
420
+ .cm-editor .cm-deletedChunk .cm-deletedText {
421
+ background: none;
422
+ }
423
+
368
424
  /* Find matches, painted inside the token spans the highlighter emits. An
369
425
  * inline <mark> colours a background without moving a glyph, which is what
370
426
  * keeps the transparent <textarea> above aligned with this layer — a block or
@@ -381,14 +437,6 @@ html[data-theme-variant="dark"] .shiki span { color: var(--shiki-dark); }
381
437
  color: inherit;
382
438
  background: color-mix(in srgb, var(--theme-warning) 32%, transparent);
383
439
  }
384
- /* An extra ⌘D selection: same geometry as a find match, different ink. The
385
- * caret can only live in the primary range, so the two must not look alike —
386
- * the user has to see where their keystrokes will land. */
387
- .shiki .find-match[data-occurrence] {
388
- background: color-mix(in srgb, var(--theme-info) 30%, transparent);
389
- border-top: 1px solid color-mix(in srgb, var(--theme-info) 55%, transparent);
390
- border-bottom: 1px solid color-mix(in srgb, var(--theme-info) 55%, transparent);
391
- }
392
440
  .shiki .find-match[data-find-current] {
393
441
  background: color-mix(in srgb, var(--theme-warning) 55%, transparent);
394
442
  border-top: 1px solid color-mix(in srgb, var(--theme-warning) 75%, transparent);
@@ -268,7 +268,9 @@ startWaitingRuntime();
268
268
  *
269
269
  * A Kabmo update replaces the store and leaves the install — it is a
270
270
  * copied directory of ordinary files — so this is the only thing that notices.
271
- * It is version-gated and skips git clones, so a plugin the user installed from
271
+ * An install Kabmo wrote is compared by CONTENT, so a shipped fix applies even
272
+ * when its version did not move; a directory of unknown provenance keeps the
273
+ * version gate, and git clones are skipped, so a plugin the user installed from
272
274
  * a URL is never overwritten by a store entry that happens to share its id.
273
275
  *
274
276
  * BEFORE the server halves load, and that order is the whole point: a plugin's
@@ -283,7 +285,10 @@ startWaitingRuntime();
283
285
  try {
284
286
  const synced = await syncBundledPlugins();
285
287
  for (const item of synced.updated) {
286
- console.log(`[kabmo] plugins: synced ${item.pluginId} ${item.from} → ${item.to}`);
288
+ // A content-only sync has no version to show, and printing `1.0.0 → 1.0.0`
289
+ // would read as a no-op when a shipped fix actually landed.
290
+ const span = item.contentOnly ? `${item.to} (content changed, version unchanged)` : `${item.from} → ${item.to}`;
291
+ console.log(`[kabmo] plugins: synced ${item.pluginId} ${span}`);
287
292
  if (item.warning) console.error(`[plugins] ${item.pluginId}: rebuild after sync failed — ${item.warning}`);
288
293
  }
289
294
  for (const failure of synced.failed) console.error(`[plugins] ${failure.pluginId}: bundled sync failed — ${failure.error}`);
@@ -30,6 +30,18 @@ describe('public paths', () => {
30
30
  expect(isPublicPath('/login')).toBe(true);
31
31
  });
32
32
 
33
+ /**
34
+ * The two shells ask their update check from native code, which has no
35
+ * session cookie — and the button is for a shell so stale it may not be able
36
+ * to load the console at all. Gating these answered `401` to every shell on a
37
+ * password-protected console, which the settings screen could only report as
38
+ * "Could not ask the server" (measured against a real install).
39
+ */
40
+ test('the native shells can ask what is published without a session', () => {
41
+ expect(isPublicPath('/api/updates/android')).toBe(true);
42
+ expect(isPublicPath('/api/updates/ios')).toBe(true);
43
+ });
44
+
33
45
  test('the assets the login screen is built from are public', () => {
34
46
  for (const path of [
35
47
  '/_dev-assets/client/index-abc.js',
@@ -153,6 +165,22 @@ describe('authentication is per run, never inherited from disk', () => {
153
165
  expect(response?.status).toBe(401);
154
166
  });
155
167
 
168
+ /**
169
+ * The bug this pins: both shells ask their update check from NATIVE code,
170
+ * which carries no cookie, so a password-protected console answered `401` and
171
+ * the settings screen could only say "Could not ask the server" (measured on a
172
+ * real install). The desktop route stays gated because the CONSOLE asks it,
173
+ * and the console is behind the login.
174
+ */
175
+ test('a password does not gate the native shells\u2019 update check', async () => {
176
+ useTempDataDir();
177
+ await initAuth('correct-horse');
178
+
179
+ expect(authGate(new Request('http://localhost/api/updates/android'))).toBeUndefined();
180
+ expect(authGate(new Request('http://localhost/api/updates/ios'))).toBeUndefined();
181
+ expect(authGate(new Request('http://localhost/api/updates/desktop?kind=macos'))?.status).toBe(401);
182
+ });
183
+
156
184
  test('a document request is redirected to the login screen, not answered 401', async () => {
157
185
  useTempDataDir();
158
186
  await initAuth('correct-horse');
@@ -41,6 +41,16 @@ const LOGIN_PATH = '/login';
41
41
  /** Paths that never require a session, because they ARE the way in. */
42
42
  const PUBLIC_EXACT = new Set([
43
43
  '/api/health',
44
+ // The native shells' own update check. Android and iOS ask these from
45
+ // NATIVE code (`AppUpdateCheck.kt`, `SettingsViewController.fetchRelease`),
46
+ // which carries no WebView cookie — and the button exists precisely for a
47
+ // shell too stale to load the console, so it cannot depend on a session
48
+ // either. Both answer GitHub's public release list (a version and a download
49
+ // URL), which is the same data an unauthenticated browser can read from
50
+ // github.com; `/api/updates/desktop` stays gated because only the CONSOLE
51
+ // asks it, and the console is behind the login.
52
+ '/api/updates/android',
53
+ '/api/updates/ios',
44
54
  '/api/auth/state',
45
55
  '/api/auth/login',
46
56
  '/api/auth/logout',
@@ -50,11 +50,21 @@ describe('window-drag contract', () => {
50
50
  /**
51
51
  * The shell's hit test starts at the element under the pointer and walks up.
52
52
  * A header without the attribute is a header that cannot move the window.
53
+ *
54
+ * EVERY header that can be the window's top strip is listed, not just the
55
+ * desktop pair. A desktop shell window may be narrower than `App`'s 768px
56
+ * layout breakpoint (all three shells allow 720px), so the phone layout's
57
+ * headers are title bars too — and the mobile ones were missing the attribute
58
+ * entirely, which on macOS left a narrowed window with no draggable strip in
59
+ * its main screen and no way to move it.
53
60
  */
54
- test('both title-bar headers carry data-drag-region', () => {
61
+ test('every title-bar header carries data-drag-region', () => {
55
62
  for (const path of [
56
63
  'src/client/components/layout/desktop-layout/TopNavbar.tsx',
57
64
  'src/client/components/layout/session-sidebar/Header.tsx',
65
+ 'src/client/components/mobile/mobile-main-view/Header.tsx',
66
+ 'src/client/components/mobile/mobile-session-sidebar/Header.tsx',
67
+ 'src/client/components/mobile/RightSidebar.tsx',
58
68
  ]) {
59
69
  const file = source(path);
60
70
  expect(file).toContain('dragRegionProps()');
@@ -63,6 +73,37 @@ describe('window-drag contract', () => {
63
73
  expect(source('src/client/lib/desktop/titlebar.ts')).toContain("'data-drag-region'");
64
74
  });
65
75
 
76
+ /**
77
+ * The phone headers reserve the shell's own inset, not only the PWA's.
78
+ *
79
+ * `titlebarPaddingLeft` reads `env(titlebar-area-x)`, which a native WKWebView
80
+ * never defines — so the shell's 76px of traffic lights has to come from
81
+ * `titlebarInsetLeft`, the helper the desktop headers already use. Without it
82
+ * the phone header's hamburger and wordmark sat UNDER the traffic lights.
83
+ */
84
+ test('the phone headers use the shell inset', () => {
85
+ for (const path of [
86
+ 'src/client/components/mobile/mobile-main-view/Header.tsx',
87
+ 'src/client/components/mobile/mobile-session-sidebar/Header.tsx',
88
+ 'src/client/components/mobile/RightSidebar.tsx',
89
+ ]) {
90
+ expect(source(path)).toContain('titlebarInsetLeft(');
91
+ }
92
+ });
93
+
94
+ /**
95
+ * A header marked as a drag region makes its controls drag handles unless
96
+ * they say otherwise — `app-region` INHERITS (WebView2's spec, enforced by
97
+ * Chromium 152), and a drag handle swallows the click.
98
+ */
99
+ test('a drag-region header marks its controls no-drag', () => {
100
+ const pairs: Array<[string, string]> = [
101
+ ['src/client/components/mobile/RightSidebar.tsx', 'titlebar-no-drag'],
102
+ ['src/client/components/common/header-controls/index.tsx', 'titlebar-no-drag'],
103
+ ];
104
+ for (const [path, marker] of pairs) expect(source(path)).toContain(marker);
105
+ });
106
+
66
107
  /** The PWA keeps its drag region: the property has to reach the browser. */
67
108
  test('the webkit property survives the bundle', async () => {
68
109
  const out = await build(`
@@ -18,6 +18,7 @@ import { ensureScheduleSchema } from '@/server/lib/schedule/schema.server';
18
18
  import { ensureWaitingSchema } from '@/server/lib/waiting/schema.server';
19
19
  import { ensurePluginSchema } from '@/server/lib/plugins/schema.server';
20
20
  import { ensureModelRolePresetSchema } from '@/server/lib/model-roles/schema.server';
21
+ import { ensureWorktreeSchema } from '@/server/lib/db/worktree-registry.server';
21
22
  import { migrateWorkspaceFolderColumns } from '@/shared/lib/workspace/schema-migrations';
22
23
  import { migrateSessionStreamStateColumns } from '@/shared/lib/omp/session/schema-migrations';
23
24
 
@@ -112,6 +113,9 @@ export async function initSchema(db: DbClient): Promise<void> {
112
113
  await ensurePluginSchema(db);
113
114
  // Named model-role presets (the role editor's preset picker).
114
115
  await ensureModelRolePresetSchema(db);
116
+ // Worktree registry: the ref each worktree branched from, and the identity
117
+ // cache that keys its folder.
118
+ await ensureWorktreeSchema(db);
115
119
 
116
120
  await db.exec(`
117
121
  CREATE TABLE IF NOT EXISTS files (
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The `worktrees` table: one row per worktree Kabmo created.
8
+ *
9
+ * Everything a worktree row shows is read from git — except two things this
10
+ * table is the only home for:
11
+ *
12
+ * - **`start_ref`**, the ref the branch was created from. `aheadOfStart` gates
13
+ * the delete dialog's branch checkbox, and git cannot answer it later: the
14
+ * branch has moved on, and for an existing-branch worktree there was no start
15
+ * ref at all.
16
+ * - **`project_id`**, the repository's identity, which is what the default
17
+ * root is keyed by. It is a CACHE, not a derivation result: the derivation
18
+ * moves when a repository gains an unrelated root commit (see
19
+ * `worktree/project-id.ts`), so a stored value always wins.
20
+ */
21
+
22
+ import type { DbClient } from '@/server/lib/db/client';
23
+
24
+ const WORKTREE_SCHEMA_SQL = `
25
+ CREATE TABLE IF NOT EXISTS worktrees (
26
+ path TEXT PRIMARY KEY,
27
+ primary_root TEXT NOT NULL,
28
+ project_id TEXT NOT NULL,
29
+ branch TEXT NOT NULL,
30
+ start_ref TEXT NOT NULL DEFAULT '',
31
+ created_at TEXT NOT NULL
32
+ );
33
+ CREATE INDEX IF NOT EXISTS idx_worktrees_primary ON worktrees (primary_root);
34
+ `;
35
+
36
+ export interface WorktreeRow {
37
+ path: string;
38
+ primary_root: string;
39
+ project_id: string;
40
+ branch: string;
41
+ start_ref: string;
42
+ created_at: string;
43
+ }
44
+
45
+ /** Idempotent: the worktree registry, created on every database open. */
46
+ export async function ensureWorktreeSchema(db: DbClient): Promise<void> {
47
+ await db.exec(WORKTREE_SCHEMA_SQL);
48
+ }
49
+
50
+ /**
51
+ * The identity cache: the project id already recorded for this repository.
52
+ * `LIMIT 1` because every row of one repository carries the same value.
53
+ */
54
+ export async function worktreeIdentityFor(db: DbClient, primaryRoot: string): Promise<string | null> {
55
+ try {
56
+ const row = await db.get<{ project_id: string }>(
57
+ 'SELECT project_id FROM worktrees WHERE primary_root = ? LIMIT 1',
58
+ [primaryRoot],
59
+ );
60
+ return typeof row?.project_id === 'string' && row.project_id ? row.project_id : null;
61
+ } catch {
62
+ // A database that cannot answer is not a reason to refuse the operation:
63
+ // the derivation still gives a usable key.
64
+ return null;
65
+ }
66
+ }
67
+
68
+ export async function worktreeRowFor(db: DbClient, worktreePath: string): Promise<WorktreeRow | undefined> {
69
+ return db.get<WorktreeRow>('SELECT * FROM worktrees WHERE path = ?', [worktreePath]);
70
+ }
71
+
72
+ export async function listWorktreeRows(db: DbClient, primaryRoot: string): Promise<WorktreeRow[]> {
73
+ return db.all<WorktreeRow>('SELECT * FROM worktrees WHERE primary_root = ?', [primaryRoot]);
74
+ }
75
+
76
+ export async function putWorktreeRow(db: DbClient, row: WorktreeRow): Promise<void> {
77
+ await db.run(
78
+ `INSERT INTO worktrees (path, primary_root, project_id, branch, start_ref, created_at)
79
+ VALUES (?, ?, ?, ?, ?, ?)
80
+ ON CONFLICT(path) DO UPDATE SET
81
+ primary_root = excluded.primary_root,
82
+ project_id = excluded.project_id,
83
+ branch = excluded.branch,
84
+ start_ref = excluded.start_ref`,
85
+ [row.path, row.primary_root, row.project_id, row.branch, row.start_ref, row.created_at],
86
+ );
87
+ }
88
+
89
+ export async function removeWorktreeRow(db: DbClient, worktreePath: string): Promise<void> {
90
+ await db.run('DELETE FROM worktrees WHERE path = ?', [worktreePath]);
91
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Canonical paths and containment for filesystem work.
8
+ *
9
+ * Two traps this exists to close, both measured:
10
+ *
11
+ * - **A spelling difference is not a difference.** On macOS git prints
12
+ * `/private/tmp/...` for a directory the caller typed as `/tmp/...`, and
13
+ * `path.resolve` alone does not reconcile them. A worktree compared against
14
+ * its own entry answered "not inside", so the session badge silently never
15
+ * appeared.
16
+ * - **A missing path cannot be `realpath`'d at all** — and that is exactly the
17
+ * `missing` worktree, whose directory was removed outside Kabmo. Resolving
18
+ * the nearest existing ANCESTOR and re-appending the tail is what lets such a
19
+ * worktree still be matched against the entry git keeps listing.
20
+ *
21
+ * Everything that compares two paths goes through here; `isWithinRoot` in
22
+ * `lib/fs/root.ts` stays the raw prefix test it always was.
23
+ */
24
+
25
+ import path from 'path';
26
+ import { realpath } from 'fs/promises';
27
+
28
+ /** Absolute, symlink-resolved, no trailing separator. */
29
+ export async function canonicalPath(target: string): Promise<string> {
30
+ const absolute = path.resolve(target).replace(/\/+$/, '');
31
+ const pending: string[] = [];
32
+ let cursor = absolute;
33
+ while (true) {
34
+ try {
35
+ const resolved = (await realpath(cursor)).replace(/\/+$/, '');
36
+ return pending.length > 0 ? [resolved, ...pending].join(path.sep) : resolved;
37
+ } catch {
38
+ const parent = path.dirname(cursor);
39
+ // The whole ancestor chain is gone (or this is the filesystem root): the
40
+ // `..`-folded path is the best answer that exists.
41
+ if (parent === cursor) return absolute;
42
+ pending.unshift(path.basename(cursor));
43
+ cursor = parent;
44
+ }
45
+ }
46
+ }
47
+
48
+ /** True when `target` is `root` itself or lies inside it. Both are canonicalized. */
49
+ export async function pathHolds(root: string, target: string): Promise<boolean> {
50
+ const [canonicalRoot, canonicalTarget] = await Promise.all([canonicalPath(root), canonicalPath(target)]);
51
+ return canonicalTarget === canonicalRoot || canonicalTarget.startsWith(`${canonicalRoot}${path.sep}`);
52
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One rule for every name the Files panel WRITES: a path relative to the folder
8
+ * that was named, nested segments allowed, and nothing that can leave it.
9
+ *
10
+ * The panel's create and rename both used to accept a bare name only, which made
11
+ * a nested path unreachable from the UI: creating `src/lib/parse.ts` meant
12
+ * creating `src`, expanding it, then `lib`, then the file. The restriction was
13
+ * never about safety — `resolveWithinRoot` already refuses an escape — it was
14
+ * the shape of the input field, so the grammar is now stated once and both
15
+ * callers read it.
16
+ *
17
+ * What is refused, and why each one is a distinct decision:
18
+ *
19
+ * - an ABSOLUTE path (`/etc/passwd`, `C:\x`): `path.join(dir, abs)` discards the
20
+ * folder the user named, so the write would land somewhere the dialog never
21
+ * showed. `resolveWithinRoot` catches most of it, but a drive-relative `C:x`
22
+ * normalizes under the root and would silently create a file named `C:x`.
23
+ * - any `..` SEGMENT: the escape itself. `resolveWithinRoot` would refuse a
24
+ * result outside the root, but `a/../b.ts` resolves INSIDE it, so the refusal
25
+ * would be about the wrong thing — the user would get "Invalid path" for a
26
+ * path that looks fine.
27
+ * - a NUL byte: it truncates the path at the syscall boundary.
28
+ *
29
+ * A backslash is treated as a separator rather than refused: a Windows user
30
+ * types `src\lib\x.ts` into the same field, and refusing it would be a rule the
31
+ * field cannot explain. Empty and `.` segments are dropped, so `src//x.ts` and
32
+ * `./src/x.ts` name the same file as `src/x.ts`.
33
+ *
34
+ * Returns the normalized POSIX-relative path, or null when it names nothing.
35
+ */
36
+ export function normalizeRelativePath(raw: string): string | null {
37
+ const trimmed = raw.trim().replace(/\\/g, '/');
38
+ if (!trimmed || trimmed.includes('\0')) return null;
39
+ if (trimmed.startsWith('/') || /^[a-zA-Z]:/.test(trimmed)) return null;
40
+
41
+ const segments: string[] = [];
42
+ for (const segment of trimmed.split('/')) {
43
+ if (segment === '' || segment === '.') continue;
44
+ if (segment === '..') return null;
45
+ segments.push(segment);
46
+ }
47
+ return segments.length > 0 ? segments.join('/') : null;
48
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The WORKTREE branch of the scoped-root allow-list.
8
+ *
9
+ * A chat started in a linked checkout runs THERE, outside its folder's
10
+ * `project_path` — and `resolveRoot` answered the fallback for it (measured), so
11
+ * Files/Search/Git/Terminal silently showed the app root while the agent wrote
12
+ * to the worktree.
13
+ *
14
+ * The rule is deliberately narrow: the candidate's own REPOSITORY ROOT must be a
15
+ * registered project, so a registered project never becomes a filesystem grant.
16
+ * A SUBDIRECTORY of a registered checkout stays refused — the allow-list is exact
17
+ * roots, and a panel is pointed at a worktree root, never at a folder inside it.
18
+ *
19
+ * Its own file, with its own database, because it needs a REAL git repository:
20
+ * `resolveProjectRoot` only answers for a directory holding a `.git` entry, and
21
+ * faking that would test the fake.
22
+ */
23
+
24
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
25
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
26
+ import { tmpdir } from 'node:os';
27
+ import { join } from 'node:path';
28
+ import { resolveRoot } from '@/server/lib/fs/root';
29
+ import { getDb } from '@/server/db.server';
30
+ import { isolateDb, releaseDb } from '@/test-support/isolated-db';
31
+ import { removeDir } from '@/test-support/remove-dir';
32
+
33
+ const GIT_ENV = { ...Bun.env, GIT_EDITOR: 'true', GIT_TERMINAL_PROMPT: '0' };
34
+
35
+ function git(cwd: string, ...args: string[]): string {
36
+ return Bun.spawnSync({ cmd: ['git', ...args], cwd, env: GIT_ENV }).stdout.toString();
37
+ }
38
+
39
+ function makeRepo(dir: string): string {
40
+ mkdirSync(dir, { recursive: true });
41
+ git(dir, 'init', '-q', '-b', 'main', '.');
42
+ git(dir, 'config', 'user.email', 'test@example.invalid');
43
+ git(dir, 'config', 'user.name', 'Test');
44
+ writeFileSync(join(dir, 'f.txt'), 'a\n');
45
+ git(dir, 'add', '-A');
46
+ git(dir, 'commit', '-q', '-m', 'base');
47
+ return dir;
48
+ }
49
+
50
+ let workRoot: string;
51
+ const saved = { SYNC_WORKSPACE: Bun.env.SYNC_WORKSPACE, MOCK: Bun.env.MOCK };
52
+
53
+ beforeEach(async () => {
54
+ await isolateDb();
55
+ Bun.env.SYNC_WORKSPACE = 'false';
56
+ Bun.env.MOCK = 'false';
57
+ workRoot = mkdtempSync(join(tmpdir(), 'kabmo-root-worktree-'));
58
+ });
59
+
60
+ afterEach(async () => {
61
+ await releaseDb();
62
+ removeDir(workRoot);
63
+ for (const [key, value] of Object.entries(saved)) {
64
+ if (value === undefined) delete Bun.env[key];
65
+ else Bun.env[key] = value;
66
+ }
67
+ });
68
+
69
+ describe('resolveRoot and worktrees', () => {
70
+ test('a linked worktree of a registered project is allowed', async () => {
71
+ const repo = makeRepo(join(workRoot, 'repo'));
72
+ const linked = join(workRoot, 'linked-wt');
73
+ git(repo, 'worktree', 'add', '-q', '-b', 'feat', linked);
74
+
75
+ const db = await getDb();
76
+ await db.run('INSERT INTO workspace_folders (name, project_path) VALUES (?, ?)', ['proj', repo]);
77
+
78
+ expect(await resolveRoot(linked, '/fallback')).toBe(linked);
79
+ });
80
+
81
+ test('a SUBDIRECTORY of a registered checkout is still refused', async () => {
82
+ const repo = makeRepo(join(workRoot, 'repo-sub'));
83
+ const sub = join(repo, 'src');
84
+ mkdirSync(sub, { recursive: true });
85
+
86
+ const db = await getDb();
87
+ await db.run('INSERT INTO workspace_folders (name, project_path) VALUES (?, ?)', ['proj', repo]);
88
+
89
+ // The allow-list is EXACT roots. Panels point at a worktree root, which is
90
+ // what the new branch admits; widening it to subdirectories would turn a
91
+ // registered project into a filesystem grant for anything beneath it.
92
+ expect(await resolveRoot(sub, '/fallback')).toBe('/fallback');
93
+ });
94
+
95
+ test('a worktree of an UNREGISTERED repository is refused', async () => {
96
+ const registered = makeRepo(join(workRoot, 'registered'));
97
+ const stranger = makeRepo(join(workRoot, 'stranger'));
98
+ const strangerWt = join(workRoot, 'stranger-wt');
99
+ git(stranger, 'worktree', 'add', '-q', '-b', 's', strangerWt);
100
+
101
+ const db = await getDb();
102
+ await db.run('INSERT INTO workspace_folders (name, project_path) VALUES (?, ?)', ['proj', registered]);
103
+
104
+ expect(await resolveRoot(strangerWt, '/fallback')).toBe('/fallback');
105
+ expect(await resolveRoot(stranger, '/fallback')).toBe('/fallback');
106
+ });
107
+
108
+ test('an exact registered project path is still honored, and a missing one falls back', async () => {
109
+ const repo = makeRepo(join(workRoot, 'exact'));
110
+ const db = await getDb();
111
+ await db.run('INSERT INTO workspace_folders (name, project_path) VALUES (?, ?)', ['proj', repo]);
112
+
113
+ expect(await resolveRoot(repo, '/fallback')).toBe(repo);
114
+ expect(await resolveRoot(join(workRoot, 'gone'), '/fallback')).toBe('/fallback');
115
+ });
116
+ });
@@ -14,6 +14,8 @@
14
14
  import path from 'path';
15
15
  import { getDb } from '@/server/db.server';
16
16
  import { pathExists } from '@/server/lib/omp/core/paths';
17
+ import { canonicalPath } from '@/server/lib/fs/canonical';
18
+ import { resolveProjectRoot } from '@/server/lib/omp/core/worktree';
17
19
 
18
20
  const APP_ROOT = process.cwd();
19
21
 
@@ -124,8 +126,18 @@ export async function getDefaultFsRoot(mock: boolean): Promise<string> {
124
126
  * the server is allowed to operate on.
125
127
  *
126
128
  * Returns `fallback` when `rawRoot` is missing, does not exist, or is not on
127
- * the allow-list (app root / subpath / registered project_path), so existing
128
- * callers keep their previous behavior for unscoped requests.
129
+ * the allow-list (app root / subpath / registered project_path / **worktree of
130
+ * a registered project**), so existing callers keep their previous behavior for
131
+ * unscoped requests.
132
+ *
133
+ * The worktree branch is why a worktree session's panels can point anywhere:
134
+ * a chat started in a linked checkout runs there, and `resolveRoot` answered the
135
+ * fallback for it — measured, so Files/Search/Git/Terminal silently showed the
136
+ * app root while the agent wrote to the worktree. The rule is deliberately
137
+ * narrow: the candidate's own REPOSITORY ROOT must be a registered project, so a
138
+ * registered project never becomes a filesystem grant. A SUBDIRECTORY is still
139
+ * refused — the allow-list stays exact roots, and a panel is pointed at the
140
+ * worktree root, never at a folder inside it.
129
141
  */
130
142
  export async function resolveRoot(
131
143
  rawRoot: string | null | undefined,
@@ -134,22 +146,39 @@ export async function resolveRoot(
134
146
  if (!rawRoot || !rawRoot.trim()) return fallback;
135
147
 
136
148
  const resolved = path.resolve(rawRoot.trim());
149
+ if (!(await pathExists(resolved))) return fallback;
137
150
 
138
151
  // Fast path: the app root and anything beneath it are always allowed.
139
152
  if (resolved === APP_ROOT || resolved.startsWith(APP_ROOT + path.sep)) {
140
- return (await pathExists(resolved)) ? resolved : fallback;
153
+ return resolved;
141
154
  }
142
155
 
143
- // Registered workspaces are user-opted project roots — allow exact matches.
156
+ // Registered workspaces are user-opted project roots — allow exact matches,
157
+ // compared by canonical path so a `/tmp` vs `/private/tmp` spelling of one
158
+ // directory is not read as two.
144
159
  try {
145
160
  const db = await getDb();
146
- const row = await db.get(
147
- 'SELECT project_path FROM workspace_folders WHERE project_path IS NOT NULL AND project_path = ? LIMIT 1',
148
- [resolved]
149
- );
150
- if (row && (await pathExists(resolved))) return resolved;
161
+ const rows = (await db.all(
162
+ 'SELECT project_path FROM workspace_folders WHERE project_path IS NOT NULL'
163
+ )) as { project_path: string }[];
164
+ const canonical = await canonicalPath(resolved);
165
+ for (const row of rows) {
166
+ if ((await canonicalPath(row.project_path)) === canonical) return resolved;
167
+ }
168
+
169
+ // A worktree ROOT: `resolveProjectRoot` only asks git when the candidate
170
+ // holds a `.git` entry, so a linked checkout resolves to the main repository
171
+ // while an ordinary subdirectory resolves to ITSELF and stays refused. That
172
+ // is what keeps this rule at "the roots of registered repositories" instead
173
+ // of "anything beneath one".
174
+ if (rows.length > 0) {
175
+ const projectRoot = await canonicalPath(await resolveProjectRoot(resolved));
176
+ for (const row of rows) {
177
+ if ((await canonicalPath(row.project_path)) === projectRoot) return resolved;
178
+ }
179
+ }
151
180
  } catch {
152
- // Database unavailable — fall through to the safe fallback.
181
+ // Database or git unavailable — fall through to the safe fallback.
153
182
  }
154
183
 
155
184
  return fallback;