@kabmo-labs/kabmo 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (351) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +403 -384
  4. package/dist/client/static/js/{index-0wvbca0b.js → index-bpptfkz2.js} +905 -875
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +10 -1
  7. package/src/client/components/common/code-surface/documents.test.ts +120 -0
  8. package/src/client/components/common/code-surface/documents.ts +201 -0
  9. package/src/client/components/common/code-surface/extensions.test.ts +110 -0
  10. package/src/client/components/common/code-surface/extensions.ts +162 -0
  11. package/src/client/components/common/code-surface/handle.ts +102 -0
  12. package/src/client/components/common/code-surface/index.tsx +253 -314
  13. package/src/client/components/common/code-surface/keymap.test.ts +133 -0
  14. package/src/client/components/common/code-surface/keymap.ts +267 -0
  15. package/src/client/components/common/code-surface/marks.ts +74 -0
  16. package/src/client/components/common/code-surface/shiki.ts +234 -0
  17. package/src/client/components/common/code-surface/sync.test.ts +185 -0
  18. package/src/client/components/common/code-surface/sync.ts +58 -0
  19. package/src/client/components/common/code-surface/theme.test.ts +202 -0
  20. package/src/client/components/common/code-surface/theme.ts +111 -0
  21. package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
  22. package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
  23. package/src/client/components/common/header-controls/index.tsx +24 -1
  24. package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
  25. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  26. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  27. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  28. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  29. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  30. package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
  31. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
  32. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  33. package/src/client/components/layout/desktop-layout/index.tsx +55 -69
  34. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  35. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
  36. package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
  37. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
  38. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  39. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
  40. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  41. package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
  42. package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
  43. package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
  44. package/src/client/components/mobile/LayoutWrapper.tsx +23 -16
  45. package/src/client/components/mobile/RightSidebar.tsx +21 -10
  46. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  47. package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
  48. package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
  49. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  50. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  51. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  52. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  53. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  54. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
  55. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
  56. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
  57. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
  58. package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
  59. package/src/client/components/mobile/worktree-session.ts +51 -0
  60. package/src/client/components/settings/Modal.tsx +8 -0
  61. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  62. package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
  63. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  64. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  65. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +146 -0
  66. package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
  67. package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
  68. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +36 -55
  69. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
  70. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  71. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  72. package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
  73. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  74. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  75. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  76. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  77. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  78. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  79. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  80. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  81. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  82. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
  83. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +32 -17
  84. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  85. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  86. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  87. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  88. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  89. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  90. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  91. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  92. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  93. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  94. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  95. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  96. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  97. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  98. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  99. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  100. package/src/client/components/workspace/editor/Body.tsx +4 -7
  101. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  102. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  103. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  104. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  105. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  106. package/src/client/components/workspace/editor/index.tsx +7 -9
  107. package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
  108. package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
  109. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  110. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  111. package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
  112. package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
  113. package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
  114. package/src/client/components/workspace/file-explorer/filter.ts +47 -0
  115. package/src/client/components/workspace/file-explorer/index.tsx +77 -64
  116. package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
  117. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  118. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  119. package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
  120. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  121. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  122. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  123. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  124. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  125. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  126. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  127. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  128. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  129. package/src/client/hooks/chat/timeline/index.ts +27 -28
  130. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  131. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  132. package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
  133. package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
  134. package/src/client/hooks/chat/timeline/send.test.ts +2 -0
  135. package/src/client/hooks/chat/timeline/send.ts +11 -1
  136. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  137. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  138. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  139. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  140. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  141. package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
  142. package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
  143. package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
  144. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  145. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  146. package/src/client/hooks/editor/find-state.ts +1 -1
  147. package/src/client/hooks/editor/theme.test.ts +206 -0
  148. package/src/client/hooks/editor/theme.ts +82 -0
  149. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  150. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  151. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  152. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  153. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  154. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  155. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  156. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  157. package/src/client/hooks/ui/long-press.test.ts +174 -0
  158. package/src/client/hooks/ui/long-press.ts +112 -0
  159. package/src/client/hooks/ui/settings-modal.ts +56 -0
  160. package/src/client/hooks/ui/updates/shell-download.test.ts +75 -0
  161. package/src/client/hooks/ui/updates/shell-download.ts +153 -28
  162. package/src/client/hooks/ui/updates/shell-offer.test.ts +213 -0
  163. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  164. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  165. package/src/client/hooks/workspace/diff-file.ts +29 -10
  166. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  167. package/src/client/hooks/workspace/editor-view.ts +61 -0
  168. package/src/client/hooks/workspace/file-create.ts +41 -11
  169. package/src/client/hooks/workspace/file-listing.ts +56 -1
  170. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  171. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  172. package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
  173. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  174. package/src/client/hooks/workspace/worktree/index.ts +165 -0
  175. package/src/client/lib/desktop/bridge.ts +45 -0
  176. package/src/client/lib/search-reveal.ts +1 -1
  177. package/src/client/main.tsx +5 -1
  178. package/src/client/tailwind.css +56 -8
  179. package/src/server/index.ts +7 -2
  180. package/src/server/lib/auth/guard.test.ts +28 -0
  181. package/src/server/lib/auth/guard.ts +10 -0
  182. package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
  183. package/src/server/lib/db/schema.ts +4 -0
  184. package/src/server/lib/db/worktree-registry.server.ts +91 -0
  185. package/src/server/lib/fs/canonical.ts +52 -0
  186. package/src/server/lib/fs/relative-path.ts +48 -0
  187. package/src/server/lib/fs/root-worktree.test.ts +116 -0
  188. package/src/server/lib/fs/root.ts +39 -10
  189. package/src/server/lib/fs/worktree/carry.ts +134 -0
  190. package/src/server/lib/fs/worktree/create.test.ts +249 -0
  191. package/src/server/lib/fs/worktree/create.ts +262 -0
  192. package/src/server/lib/fs/worktree/errors.ts +43 -0
  193. package/src/server/lib/fs/worktree/list.test.ts +207 -0
  194. package/src/server/lib/fs/worktree/list.ts +142 -0
  195. package/src/server/lib/fs/worktree/location.test.ts +206 -0
  196. package/src/server/lib/fs/worktree/location.ts +114 -0
  197. package/src/server/lib/fs/worktree/project-id.ts +53 -0
  198. package/src/server/lib/fs/worktree/remove.test.ts +242 -0
  199. package/src/server/lib/fs/worktree/remove.ts +152 -0
  200. package/src/server/lib/fs/worktree/settings.server.ts +56 -0
  201. package/src/server/lib/fs/worktree/topology.ts +142 -0
  202. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  203. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  204. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  205. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  206. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  207. package/src/server/lib/lifecycle/probe.ts +121 -13
  208. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  209. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  210. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  211. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  212. package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
  213. package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
  214. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  215. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  216. package/src/server/lib/panels/local-edits.server.ts +33 -0
  217. package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
  218. package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
  219. package/src/server/lib/panels/sync.server.test.ts +125 -147
  220. package/src/server/lib/panels/sync.server.ts +153 -48
  221. package/src/server/lib/realtime/signals.server.ts +10 -0
  222. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  223. package/src/server/lib/realtime/topics.server.ts +16 -0
  224. package/src/server/lib/updates/shell-assets.test.ts +114 -0
  225. package/src/server/lib/updates/shell-assets.ts +200 -0
  226. package/src/server/lib/updates/shell-cache.ts +105 -0
  227. package/src/server/lib/updates/shell-pages.test.ts +187 -0
  228. package/src/server/lib/updates/shell-pages.ts +157 -0
  229. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  230. package/src/server/lib/updates/shell-release.test.ts +59 -71
  231. package/src/server/lib/updates/shell-release.ts +178 -198
  232. package/src/server/routes/fs/action-create.test.ts +18 -13
  233. package/src/server/routes/fs/action-nested.test.ts +184 -0
  234. package/src/server/routes/fs/action.ts +47 -16
  235. package/src/server/routes/fs/git-merge.test.ts +11 -1
  236. package/src/server/routes/fs/read.test.ts +24 -7
  237. package/src/server/routes/fs/scope.test.ts +15 -10
  238. package/src/server/routes/fs/scope.ts +3 -14
  239. package/src/server/routes/index.ts +2 -0
  240. package/src/server/routes/updates/android.ts +6 -1
  241. package/src/server/routes/updates/desktop.ts +59 -0
  242. package/src/server/routes/updates/index.ts +2 -0
  243. package/src/server/routes/updates/ios.ts +5 -1
  244. package/src/server/routes/worktrees/create.ts +138 -0
  245. package/src/server/routes/worktrees/index.ts +25 -0
  246. package/src/server/routes/worktrees/list.ts +134 -0
  247. package/src/server/routes/worktrees/remove.ts +102 -0
  248. package/src/server/routes/worktrees/routes.test.ts +326 -0
  249. package/src/server/routes/worktrees/shared.ts +90 -0
  250. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  251. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  252. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  253. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  254. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  255. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  256. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  257. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  258. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  259. package/src/shared/lib/code/editor/keymap.ts +3 -3
  260. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  261. package/src/shared/lib/code/editor/lines.ts +8 -48
  262. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  263. package/src/shared/lib/code/editor/marks.ts +13 -25
  264. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  265. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  266. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  267. package/src/shared/lib/code/editor/themes.ts +165 -0
  268. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  269. package/src/shared/lib/code/highlight-memo.ts +149 -0
  270. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  271. package/src/shared/lib/code/highlighter.ts +3 -1
  272. package/src/shared/lib/code/idle.ts +37 -0
  273. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  274. package/src/shared/lib/code/outline/rows.ts +94 -0
  275. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  276. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  277. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  278. package/src/shared/lib/fs/file-kind.ts +47 -13
  279. package/src/shared/lib/fs/file-tree.test.ts +183 -0
  280. package/src/shared/lib/fs/file-tree.ts +159 -0
  281. package/src/shared/lib/fs/file-url.test.ts +52 -0
  282. package/src/shared/lib/fs/file-url.ts +37 -0
  283. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  284. package/src/shared/lib/fs/patch-sides.ts +139 -0
  285. package/src/shared/lib/fs/paths.test.ts +24 -1
  286. package/src/shared/lib/fs/paths.ts +17 -0
  287. package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
  288. package/src/shared/lib/omp/session/prewarm.ts +7 -0
  289. package/src/shared/lib/realtime/protocol.ts +16 -0
  290. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  291. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  292. package/src/shared/lib/updates/shell-arch.ts +54 -0
  293. package/src/shared/lib/updates/shell-ua.ts +24 -4
  294. package/src/shared/lib/workspace/active-project.ts +23 -0
  295. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  296. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  297. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  298. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  299. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  300. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  301. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  302. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  303. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  304. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  305. package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
  306. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  307. package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
  308. package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
  309. package/src/shared/lib/workspace/worktree/types.ts +104 -0
  310. package/src/shared/types/fs.ts +10 -0
  311. package/src/shared/types/realtime.ts +29 -0
  312. package/src/shared/types/settings/state.ts +10 -0
  313. package/src/shared/types/workspace.ts +14 -0
  314. package/src/test-support/file-explorer-disk.test-util.ts +76 -0
  315. package/src/test-support/file-explorer.test-util.ts +71 -40
  316. package/src/test-support/loopback.ts +183 -0
  317. package/dist/client/static/js/index-nzyjf92m.css +0 -1
  318. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  319. package/src/client/components/common/code-editor/handler.ts +0 -175
  320. package/src/client/components/common/code-editor/index.tsx +0 -314
  321. package/src/client/components/common/code-surface/measure.ts +0 -148
  322. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  323. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  324. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  325. package/src/client/hooks/editor/scroll-container.ts +0 -26
  326. package/src/client/hooks/editor/use-code-window.ts +0 -100
  327. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  328. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  329. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  330. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  331. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  332. package/src/shared/lib/code/editor/commands.ts +0 -260
  333. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  334. package/src/shared/lib/code/editor/edits.ts +0 -122
  335. package/src/shared/lib/code/editor/history.test.ts +0 -118
  336. package/src/shared/lib/code/editor/history.ts +0 -101
  337. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  338. package/src/shared/lib/code/editor/layers.ts +0 -67
  339. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  340. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  341. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  342. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  343. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  344. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  345. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  346. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  347. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  348. package/src/shared/lib/code/lazy-window.ts +0 -156
  349. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  350. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  351. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -0,0 +1,184 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Nested paths at the route: a create and a rename that name SEGMENTS.
8
+ *
9
+ * The Files panel's dialogs used to accept one segment, so `lib/parse.ts` was
10
+ * unreachable from the UI — the user had to create `lib`, expand it, then the
11
+ * file. Both actions now take a relative path, and the rules that keep that
12
+ * inside the folder the user named are asserted here because they are the whole
13
+ * reason a nested path is safe:
14
+ *
15
+ * - a `..` SEGMENT is refused even when it would land back inside the root
16
+ * (`a/../b.ts` resolves there), so the refusal is about the path the user
17
+ * typed rather than about where it happened to land;
18
+ * - an ABSOLUTE path is refused, because `path.join(dir, abs)` discards the
19
+ * folder the dialog showed;
20
+ * - a rename resolves against the LISTED root, not the workspace root — a
21
+ * rename inside a nested repo used to resolve to a path outside the repo;
22
+ * - a rename never overwrites an existing destination, and never moves a folder
23
+ * into its own subtree.
24
+ */
25
+
26
+ import { afterEach, describe, expect, test } from 'bun:test';
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { action } from '@/server/routes/fs/action';
30
+
31
+ /** Probes live in the repo root — the one directory the root allow-list always accepts. */
32
+ const DIR = path.join(process.cwd(), 'tmp-nested-probe');
33
+
34
+ function reset(files: Record<string, string> = {}): void {
35
+ fs.rmSync(DIR, { recursive: true, force: true });
36
+ fs.mkdirSync(DIR, { recursive: true });
37
+ for (const [rel, content] of Object.entries(files)) {
38
+ const target = path.join(DIR, rel);
39
+ fs.mkdirSync(path.dirname(target), { recursive: true });
40
+ fs.writeFileSync(target, content);
41
+ }
42
+ }
43
+
44
+ afterEach(() => {
45
+ fs.rmSync(DIR, { recursive: true, force: true });
46
+ });
47
+
48
+ async function post(fields: Record<string, string>) {
49
+ const form = new FormData();
50
+ form.append('root', process.cwd());
51
+ for (const [key, value] of Object.entries(fields)) form.append(key, value);
52
+ const res = await action({ request: new Request('http://localhost/api/fs/action', { method: 'POST', body: form }) } as never);
53
+ return { status: res.status, body: (await res.json()) as { success?: boolean; kind?: string; path?: string; previousPath?: string; error?: string } };
54
+ }
55
+
56
+ describe('fs create with a nested path', () => {
57
+ test('creates every directory that leads to the file', async () => {
58
+ reset();
59
+
60
+ const { status, body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'lib/deep/parse.ts' });
61
+
62
+ expect(status).toBe(200);
63
+ expect(body).toEqual({ success: true, kind: 'file', path: 'tmp-nested-probe/lib/deep/parse.ts' });
64
+ expect(fs.readFileSync(path.join(DIR, 'lib/deep/parse.ts'), 'utf8')).toBe('');
65
+ });
66
+
67
+ test('creates a FOLDER when asked, and reports the kind back', async () => {
68
+ reset();
69
+
70
+ const { status, body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'a/b', kind: 'folder' });
71
+
72
+ expect(status).toBe(200);
73
+ expect(body).toEqual({ success: true, kind: 'folder', path: 'tmp-nested-probe/a/b' });
74
+ expect(fs.statSync(path.join(DIR, 'a/b')).isDirectory()).toBe(true);
75
+ });
76
+
77
+ test('normalizes a backslash and repeated separators into one path', async () => {
78
+ reset();
79
+
80
+ const { body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'x\\\\y//z.ts' });
81
+
82
+ expect(body.path).toBe('tmp-nested-probe/x/y/z.ts');
83
+ expect(fs.existsSync(path.join(DIR, 'x/y/z.ts'))).toBe(true);
84
+ });
85
+
86
+ test('refuses a .. segment even when it would land inside the root', async () => {
87
+ reset({ 'keep/.gitkeep': '' });
88
+
89
+ // `keep/../escape.ts` resolves inside the probe directory, so a containment
90
+ // check alone would accept it — the refusal is about the path as typed.
91
+ const { status, body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'keep/../escape.ts' });
92
+
93
+ expect(status).toBe(400);
94
+ expect(body.error).toContain('..');
95
+ expect(fs.existsSync(path.join(DIR, 'escape.ts'))).toBe(false);
96
+ });
97
+
98
+ test('refuses an absolute path', async () => {
99
+ reset();
100
+
101
+ for (const name of ['/tmp/absolute.ts', 'C:/absolute.ts', 'C:relative.ts']) {
102
+ expect((await post({ actionType: 'create', path: 'tmp-nested-probe', name })).status).toBe(400);
103
+ }
104
+ });
105
+
106
+ test('refuses a nested path whose final entry already exists', async () => {
107
+ reset({ 'lib/parse.ts': 'keep me' });
108
+
109
+ const { status, body } = await post({ actionType: 'create', path: 'tmp-nested-probe', name: 'lib/parse.ts' });
110
+
111
+ expect(status).toBe(409);
112
+ expect(body.error).toContain('already exists');
113
+ expect(fs.readFileSync(path.join(DIR, 'lib/parse.ts'), 'utf8')).toBe('keep me');
114
+ });
115
+ });
116
+
117
+ describe('fs rename with a nested path', () => {
118
+ test('renames into a new nested folder, creating its parents', async () => {
119
+ reset({ 'a.ts': 'A' });
120
+
121
+ const { status, body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: 'tmp-nested-probe/lib/deep/a.ts' });
122
+
123
+ expect(status).toBe(200);
124
+ expect(body).toEqual({ success: true, path: 'tmp-nested-probe/lib/deep/a.ts', previousPath: 'tmp-nested-probe/a.ts' });
125
+ expect(fs.existsSync(path.join(DIR, 'a.ts'))).toBe(false);
126
+ expect(fs.readFileSync(path.join(DIR, 'lib/deep/a.ts'), 'utf8')).toBe('A');
127
+ });
128
+
129
+ test('a plain rename inside one folder keeps its siblings', async () => {
130
+ reset({ 'src/a.ts': 'A', 'src/b.ts': 'B' });
131
+
132
+ const { body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/src/a.ts', newPath: 'tmp-nested-probe/src/c.ts' });
133
+
134
+ expect(body.path).toBe('tmp-nested-probe/src/c.ts');
135
+ expect(fs.existsSync(path.join(DIR, 'src/b.ts'))).toBe(true);
136
+ });
137
+
138
+ test('refuses an existing destination instead of clobbering it', async () => {
139
+ reset({ 'a.ts': 'A', 'b.ts': 'B' });
140
+
141
+ const { status, body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: 'tmp-nested-probe/b.ts' });
142
+
143
+ expect(status).toBe(409);
144
+ expect(body.error).toContain('already exists');
145
+ expect(fs.readFileSync(path.join(DIR, 'b.ts'), 'utf8')).toBe('B');
146
+ });
147
+
148
+ test('refuses moving a folder into its own subtree', async () => {
149
+ reset({ 'src/a.ts': 'A' });
150
+
151
+ const { status, body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/src', newPath: 'tmp-nested-probe/src/deep' });
152
+
153
+ expect(status).toBe(400);
154
+ expect(body.error).toContain('inside itself');
155
+ expect(fs.existsSync(path.join(DIR, 'src/a.ts'))).toBe(true);
156
+ });
157
+
158
+ test('refuses a .. segment and an absolute destination', async () => {
159
+ reset({ 'a.ts': 'A' });
160
+
161
+ expect((await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: 'tmp-nested-probe/../out.ts' })).status).toBe(400);
162
+ expect((await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: '/tmp/out.ts' })).status).toBe(400);
163
+ expect(fs.existsSync(path.join(DIR, 'a.ts'))).toBe(true);
164
+ });
165
+
166
+ test('renaming to the same path is a no-op that still reports both ends', async () => {
167
+ reset({ 'a.ts': 'A' });
168
+
169
+ const { status, body } = await post({ actionType: 'rename', path: 'tmp-nested-probe/a.ts', newPath: 'tmp-nested-probe/a.ts' });
170
+
171
+ expect(status).toBe(200);
172
+ expect(body.path).toBe('tmp-nested-probe/a.ts');
173
+ expect(fs.readFileSync(path.join(DIR, 'a.ts'), 'utf8')).toBe('A');
174
+ });
175
+
176
+ test('a missing source is refused rather than creating an empty entry', async () => {
177
+ reset();
178
+
179
+ const { status } = await post({ actionType: 'rename', path: 'tmp-nested-probe/gone.ts', newPath: 'tmp-nested-probe/here.ts' });
180
+
181
+ expect(status).toBe(404);
182
+ expect(fs.existsSync(path.join(DIR, 'here.ts'))).toBe(false);
183
+ });
184
+ });
@@ -3,10 +3,11 @@ import { errorResponse } from '@/server/lib/route-adapter';
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
5
  import { isMockMode } from '@/server/mock.server';
6
- import { getDefaultFsRoot, resolveRoot, resolveWithinRoot } from '@/server/lib/fs/root';
6
+ import { getDefaultFsRoot, isWithinRoot, resolveRoot, resolveWithinRoot } from '@/server/lib/fs/root';
7
7
  import { runShell } from '@/server/lib/fs/shell';
8
8
  import { revealInFileManager } from '@/server/lib/fs/reveal';
9
- import { duplicateEntry, nextDuplicateName, pasteEntry } from '@/server/lib/fs/transfer';
9
+ import { duplicateEntry, nextDuplicateName, pasteEntry, toRelPath } from '@/server/lib/fs/transfer';
10
+ import { normalizeRelativePath } from '@/server/lib/fs/relative-path';
10
11
  import { transferRefusal } from '@/shared/lib/fs/clipboard';
11
12
  import { toDiskText } from '@/shared/lib/code/line-endings';
12
13
 
@@ -104,13 +105,15 @@ export async function action({ request }: ActionFunctionArgs) {
104
105
  await Bun.write(fullPath, content);
105
106
  return json({ success: true });
106
107
  } else if (actionType === 'create') {
107
- // The Files panel's "New file": an empty file beside the folder that was
108
- // right-clicked. Only a NAME is accepted, never a path — a separator
109
- // would let the panel write anywhere under the root, and the folder is
110
- // already named by `path`.
111
- const name = ((formData.get('name') as string) ?? '').trim();
112
- if (!name || name === '.' || name === '..' || /[/\\\0]/.test(name)) {
113
- return json({ error: 'Enter a file name without slashes' }, { status: 400 });
108
+ // The Files panel's "New file"/"New folder": an empty entry beside the
109
+ // folder that was right-clicked. The NAME may carry segments
110
+ // (`lib/parse.ts`), so one dialog creates a nested path and every
111
+ // intermediate directory with it — the grammar that keeps it inside the
112
+ // named folder lives in `normalizeRelativePath`, not in a slash test here.
113
+ const kind = formData.get('kind') === 'folder' ? 'folder' : 'file';
114
+ const name = normalizeRelativePath((formData.get('name') as string) ?? '');
115
+ if (!name) {
116
+ return json({ error: 'Enter a name without .. or a leading slash' }, { status: 400 });
114
117
  }
115
118
  const target = resolveWithinRoot(scopedRoot, path.join(filePath || '.', name));
116
119
  if (!target) {
@@ -122,21 +125,49 @@ export async function action({ request }: ActionFunctionArgs) {
122
125
  if ((await Bun.file(target).stat().catch(() => null)) !== null) {
123
126
  return json({ error: `${name} already exists` }, { status: 409 });
124
127
  }
125
- await fs.promises.mkdir(path.dirname(target), { recursive: true });
126
- await Bun.write(target, '');
128
+ if (kind === 'folder') await fs.promises.mkdir(target, { recursive: true });
129
+ else {
130
+ await fs.promises.mkdir(path.dirname(target), { recursive: true });
131
+ await Bun.write(target, '');
132
+ }
127
133
  // Relative to the LISTED root, which is the identity the tree renders by.
128
- return json({ success: true, path: path.relative(scopedRoot, target).split(path.sep).join('/') });
134
+ return json({ success: true, kind, path: toRelPath(scopedRoot, target) });
129
135
  } else if (actionType === 'delete') {
130
136
  await fs.promises.rm(fullPath, { recursive: true, force: true });
131
137
  return json({ success: true });
132
138
  } else if (actionType === 'rename') {
133
- const newPath = formData.get('newPath') as string;
134
- const fullNewPath = path.resolve(rootDir, newPath);
135
- if (fullNewPath !== rootDir && !fullNewPath.startsWith(rootDir + path.sep)) {
139
+ // `newPath` is relative to the LISTED root, exactly like `path` is. It
140
+ // used to be resolved against `rootDir`, so renaming inside a nested repo
141
+ // resolved to a path under the workspace root instead of the repo — a
142
+ // move out of the tree the row belongs to.
143
+ const newPath = normalizeRelativePath((formData.get('newPath') as string) ?? '');
144
+ if (!newPath) {
145
+ return json({ error: 'Enter a name without .. or a leading slash' }, { status: 400 });
146
+ }
147
+ const fullNewPath = resolveWithinRoot(scopedRoot, newPath);
148
+ if (!fullNewPath) {
136
149
  return json({ error: 'Invalid new path' }, { status: 403 });
137
150
  }
151
+ if (fullNewPath === fullPath) return json({ success: true, path: newPath, previousPath: filePath });
152
+ const source = await Bun.file(fullPath).stat().catch(() => null);
153
+ if (!source) return json({ error: 'That entry no longer exists' }, { status: 404 });
154
+ // A folder cannot be moved into itself: `rename(a, a/b)` fails with
155
+ // EINVAL on every platform, but with an errno the panel cannot explain —
156
+ // and the destination directory may not exist yet, so the check has to be
157
+ // on the PATHS rather than on the filesystem.
158
+ if (source.isDirectory() && isWithinRoot(fullPath, fullNewPath)) {
159
+ return json({ error: 'A folder cannot be moved inside itself' }, { status: 400 });
160
+ }
161
+ // `rename` clobbers silently, so an existing destination is refused: the
162
+ // entry the user meant to keep would otherwise disappear without a word.
163
+ if ((await Bun.file(fullNewPath).stat().catch(() => null)) !== null) {
164
+ return json({ error: `${newPath} already exists` }, { status: 409 });
165
+ }
166
+ // The destination's parents are created, which is what makes a rename
167
+ // INTO a new folder work — the same nested grammar the create accepts.
168
+ await fs.promises.mkdir(path.dirname(fullNewPath), { recursive: true });
138
169
  await fs.promises.rename(fullPath, fullNewPath);
139
- return json({ success: true });
170
+ return json({ success: true, path: newPath, previousPath: filePath });
140
171
  } else if (actionType === 'open_explorer') {
141
172
  const isDir = (await Bun.file(fullPath).stat().catch(() => null))?.isDirectory() ?? false;
142
173
  // A file reveals its containing directory — there is no portable "select
@@ -19,7 +19,7 @@
19
19
  * answers "no <op> in progress" and only `reset --merge HEAD` clears it.
20
20
  */
21
21
 
22
- import { afterAll, describe, expect, test } from 'bun:test';
22
+ import { afterAll, describe, expect, setDefaultTimeout, test } from 'bun:test';
23
23
  import fs from 'node:fs';
24
24
  import path from 'node:path';
25
25
  import { action } from '@/server/routes/fs/git';
@@ -36,6 +36,16 @@ import { removeDir } from '@/test-support/remove-dir';
36
36
  * action ran against THE Kabmo REPOSITORY.
37
37
  */
38
38
  const FIXTURE_ROOT = path.join(process.cwd(), 'tmp-test-git-merge');
39
+
40
+ /**
41
+ * Every case here builds a real repository and drives several `git` processes
42
+ * through it, so Bun's 5s per-test default is a budget for the RUNNER rather
43
+ * than for the code. Measured on a loaded CI runner: the cherry-pick case alone
44
+ * spent 5.0s and timed out, while the same commit passed the `Checks` job and
45
+ * failed the `Release` one minutes later — the same suite, two machines, one
46
+ * answer each. `git-actions-sync.test.ts` sets the same 30s for the same reason.
47
+ */
48
+ setDefaultTimeout(30_000);
39
49
  const repos: string[] = [];
40
50
 
41
51
  function git(cwd: string, ...args: string[]): string {
@@ -21,6 +21,7 @@ import fs from 'fs';
21
21
  import path from 'path';
22
22
  import { readFile } from '@/server/routes/fs/read';
23
23
  import { EDITOR_MAX_FILE_BYTES, PDF_MAX_FILE_BYTES } from '@/shared/lib/fs/file-kind';
24
+ import { formatBytes } from '@/shared/lib/format/number';
24
25
 
25
26
  const DIR = path.join(process.cwd(), 'tmp-read-gate');
26
27
 
@@ -88,16 +89,23 @@ describe('fs read gate', () => {
88
89
 
89
90
  expect(status).toBe(415);
90
91
  expect(body.kind).toBe('too-large');
91
- expect(body.error).toContain('5.00 MB');
92
+ // Derived from the constant, not spelled out: the sentence and the gate must
93
+ // name the same number, and a literal here would pass while they drifted.
94
+ expect(body.error).toContain(formatBytes(EDITOR_MAX_FILE_BYTES));
92
95
  });
93
96
 
94
97
  // A PDF is streamed to Chrome's own viewer through `/api/fs/raw`, so the text
95
- // ceiling does not apply to it — the route must still point at the raw route
96
- // for a PDF well past 5 MB rather than refusing it. Refusing it was the bug:
97
- // the size test ran before the document branch, so a 6 MB PDF was answered
98
- // "larger than the 5.00 MB the editor opens".
99
- test('a PDF past the text ceiling is still served by the raw route', async () => {
100
- write('report.pdf', new Uint8Array(EDITOR_MAX_FILE_BYTES + 1).fill(0x25));
98
+ // ceiling has no bearing on it — the route must point at the raw route rather
99
+ // than refuse it. Refusing it was the bug: the size test ran before the
100
+ // document branch, so a 6 MB PDF was answered "larger than the 5.00 MB the
101
+ // editor opens". 6 MB is the size that was over the old text ceiling and is
102
+ // still a perfectly ordinary PDF.
103
+ //
104
+ // It is written at 6 MB rather than at `EDITOR_MAX_FILE_BYTES + 1` because the
105
+ // two ceilings are independent: the text one is now ABOVE the PDF one, so
106
+ // "past text, under PDF" is no longer a size that exists.
107
+ test('a PDF past the old text ceiling is still served by the raw route', async () => {
108
+ write('report.pdf', new Uint8Array(6 * 1024 * 1024).fill(0x25));
101
109
  const { status, body } = await read('report.pdf');
102
110
 
103
111
  expect(status).toBe(400);
@@ -105,6 +113,15 @@ describe('fs read gate', () => {
105
113
  expect(body.kind).toBeUndefined();
106
114
  });
107
115
 
116
+ test('a PDF past its own ceiling is refused before the document branch', async () => {
117
+ write('enormous.pdf', new Uint8Array(PDF_MAX_FILE_BYTES + 1).fill(0x25));
118
+ const { status, body } = await read('enormous.pdf');
119
+
120
+ expect(status).toBe(415);
121
+ expect(body.kind).toBe('too-large');
122
+ expect(body.error).toContain(formatBytes(PDF_MAX_FILE_BYTES));
123
+ });
124
+
108
125
  test('a PDF past the PDF ceiling is refused for size, naming 30 MB', async () => {
109
126
  write('huge.pdf', new Uint8Array(PDF_MAX_FILE_BYTES + 1).fill(0x25));
110
127
  const { status, body } = await read('huge.pdf');
@@ -20,7 +20,8 @@ import fs from 'fs';
20
20
  import path from 'path';
21
21
  import { testPosix } from '@/test-support/platform';
22
22
  import { resolveFileScope } from '@/server/routes/fs/scope';
23
- import { DOCUMENT_MAX_FILE_BYTES, PDF_MAX_FILE_BYTES } from '@/shared/lib/fs/file-kind';
23
+ import { DOCUMENT_MAX_FILE_BYTES, EDITOR_MAX_FILE_BYTES, PDF_MAX_FILE_BYTES } from '@/shared/lib/fs/file-kind';
24
+ import { formatBytes } from '@/shared/lib/format/number';
24
25
 
25
26
  const APP = process.cwd();
26
27
  const DIR = path.join(APP, 'tmp-scope-probe');
@@ -158,14 +159,18 @@ describe('fs scope route', () => {
158
159
  expect(body.error).toContain('20.00 MB');
159
160
  });
160
161
 
161
- // Plain text keeps the text ceiling: a textarea holds that buffer for the
162
- // life of the tab, which is why its limit did not move with the documents'.
163
- test('a text file past the text ceiling is still refused', async () => {
164
- const target = write('big.log', new Uint8Array(6 * 1024 * 1024).fill(0x61));
165
- const { status, body } = await scope(target);
166
-
167
- expect(status).toBe(200);
168
- expect(body.kind).toBe('too-large');
169
- expect(body.error).toContain('5.00 MB');
162
+ // Plain text keeps the text ceiling, which is its own number: the buffer is
163
+ // held for the life of the tab and re-posted on every save, where a document
164
+ // is converted once. 6 MB used to be over it; it is a normal generated file
165
+ // and must open.
166
+ test('a text file over the text ceiling is refused, and one under it is not', async () => {
167
+ const ok = await scope(write('big.log', new Uint8Array(6 * 1024 * 1024).fill(0x61)));
168
+ expect(ok.status).toBe(200);
169
+ expect(ok.body.kind).toBe('text');
170
+
171
+ const over = await scope(write('huge.log', new Uint8Array(EDITOR_MAX_FILE_BYTES + 1).fill(0x61)));
172
+ expect(over.status).toBe(200);
173
+ expect(over.body.kind).toBe('too-large');
174
+ expect(over.body.error).toContain(formatBytes(EDITOR_MAX_FILE_BYTES));
170
175
  });
171
176
  });
@@ -26,25 +26,14 @@
26
26
  import { json, type LoaderFunctionArgs } from '@/server/lib/remix-compat';
27
27
  import { resolveScopeForAbsolutePath } from '@/server/lib/fs/root';
28
28
  import { BINARY_SNIFF_BYTES, classifyEditorFile, unsupportedEditorMessage } from '@/shared/lib/fs/file-kind';
29
+ import { pathFromFileUrl } from '@/shared/lib/fs/file-url';
29
30
  import fs from 'fs';
30
31
  import path from 'path';
31
32
 
32
- /**
33
- * A `file://` URL to the path it names.
34
- *
35
- * The pathname is percent-encoded (a space is `%20`, and a Finder drop of
36
- * `My Notes.md` is exactly that), so decoding is not optional. `decodeURIComponent`
37
- * can throw on a malformed escape, which is answered as "not a path" rather
38
- * than surfacing as a 500.
39
- */
33
+ /** A `file://` URL to the path it names; see `pathFromFileUrl` for the rules. */
40
34
  function pathFromReference(reference: string): string | null {
41
35
  if (!/^file:\/\//i.test(reference)) return reference;
42
- try {
43
- const url = new URL(reference);
44
- return decodeURIComponent(url.pathname);
45
- } catch {
46
- return null;
47
- }
36
+ return pathFromFileUrl(reference);
48
37
  }
49
38
 
50
39
  export async function resolveFileScope({ request }: LoaderFunctionArgs) {
@@ -12,6 +12,7 @@ import { waitingBindings } from '@/server/routes/waiting/bindings';
12
12
  import { settingsBindings } from '@/server/routes/settings';
13
13
  import { fsBindings } from '@/server/routes/fs';
14
14
  import { foldersBindings } from '@/server/routes/folders';
15
+ import { worktreesBindings } from '@/server/routes/worktrees';
15
16
  import { filesBindings } from '@/server/routes/files';
16
17
  import { telemetryBindings } from '@/server/routes/telemetry';
17
18
  import { terminalBindings } from '@/server/routes/terminal';
@@ -42,6 +43,7 @@ const allBindings: HandlerBinding[] = [
42
43
  ...settingsBindings,
43
44
  ...fsBindings,
44
45
  ...foldersBindings,
46
+ ...worktreesBindings,
45
47
  ...filesBindings,
46
48
  ...telemetryBindings,
47
49
  ...terminalBindings,
@@ -12,11 +12,16 @@ import { fetchShellRelease, type ShellRelease } from '@/server/lib/updates/shell
12
12
  *
13
13
  * A failed probe is a valid answer (`url: null`), never a 500: the sidebar's job
14
14
  * is to omit a button it cannot back with a link.
15
+ *
16
+ * PUBLIC, unlike its siblings: the Android shell asks this from native code
17
+ * (`AppUpdateCheck`), which has no session cookie, and its "Check for update"
18
+ * button is for a shell too stale to load the console. See `PUBLIC_EXACT` in
19
+ * `lib/auth/guard.ts`.
15
20
  */
16
21
  export async function loader() {
17
22
  const release: ShellRelease | null = await fetchShellRelease('android');
18
23
  return json(
19
- release ?? { url: null, latest: null, releaseUrl: null },
24
+ release ?? { url: null, latest: null, releaseUrl: null, direct: false },
20
25
  { headers: NO_STORE_HEADERS },
21
26
  );
22
27
  }
@@ -0,0 +1,59 @@
1
+ import { json, NO_STORE_HEADERS } from '@/server/lib/remix-compat';
2
+ import { fetchShellRelease, type ShellRelease } from '@/server/lib/updates/shell-release';
3
+ import { isDesktopShellKind, type DesktopShellKind } from '@/shared/lib/updates/shell-ua';
4
+ import { parseShellArch, type ShellArch } from '@/shared/lib/updates/shell-arch';
5
+
6
+ /**
7
+ * GET /api/updates/desktop?kind=macos|windows|linux[&arch=arm64|x64]
8
+ *
9
+ * ONE route with an explicit `kind`, unlike the mobile pair's two routes, and
10
+ * the difference is deliberate. `?kind` here is REQUIRED and refused when it is
11
+ * missing or names no desktop shell, so no absence can silently mean a platform:
12
+ * the mobile pair's split exists precisely because a defaulted parameter would
13
+ * have made "the newest shell" mean Android without anyone saying so.
14
+ *
15
+ * ## `arch` is what makes the offer installable
16
+ *
17
+ * Only an INSTALLED desktop shell asks for this — the page reports its own
18
+ * version over the shell bridge (`__kabmoDesktop.shellVersion`), because a
19
+ * desktop app carries no User-Agent token. Without `arch` the answer is the
20
+ * RELEASE PAGE: every desktop artifact is per-architecture, and a console served
21
+ * to another machine cannot guess which one this window runs on.
22
+ *
23
+ * WITH `arch` the answer is that machine's own installer, and `direct: true`
24
+ * says so — which is what lets the sidebar offer an in-app update instead of a
25
+ * link. The shell still verifies the bytes before installing them; this route
26
+ * only decides WHICH file, never whether it is trusted.
27
+ *
28
+ * An `arch` that names nothing this product publishes is a REFUSAL rather than a
29
+ * silent fallback: a caller that asked for a build we do not make should be told,
30
+ * not handed the other architecture's installer.
31
+ *
32
+ * A failed probe is a valid answer (`url: null`), never a 500: the sidebar's job
33
+ * is to omit a button it cannot back with a link.
34
+ */
35
+ export async function loader({ request }: { request: Request }) {
36
+ const params = new URL(request.url).searchParams;
37
+ const kind = params.get('kind');
38
+ if (!isDesktopShellKind(kind ?? '')) {
39
+ return json(
40
+ { error: 'kind must be one of macos, windows, linux' },
41
+ { status: 400, headers: NO_STORE_HEADERS },
42
+ );
43
+ }
44
+
45
+ const requested = params.get('arch');
46
+ const arch: ShellArch | null = requested ? parseShellArch(requested) : null;
47
+ if (requested && !arch) {
48
+ return json(
49
+ { error: 'arch must be one of arm64, x64' },
50
+ { status: 400, headers: NO_STORE_HEADERS },
51
+ );
52
+ }
53
+
54
+ const release: ShellRelease | null = await fetchShellRelease(kind as DesktopShellKind, fetch, arch);
55
+ return json(
56
+ release ?? { url: null, latest: null, releaseUrl: null, direct: false },
57
+ { headers: NO_STORE_HEADERS },
58
+ );
59
+ }
@@ -3,6 +3,7 @@ import * as updatesAndroid from '@/server/routes/updates/android';
3
3
  import * as updatesApply from '@/server/routes/updates/apply';
4
4
  import * as updatesChangelog from '@/server/routes/updates/changelog';
5
5
  import * as updatesCheck from '@/server/routes/updates/check';
6
+ import * as updatesDesktop from '@/server/routes/updates/desktop';
6
7
  import * as updatesIos from '@/server/routes/updates/ios';
7
8
 
8
9
  export const updatesBindings: HandlerBinding[] = [
@@ -10,5 +11,6 @@ export const updatesBindings: HandlerBinding[] = [
10
11
  ...bindingsFor(updatesApply, '/api/updates/apply'),
11
12
  ...bindingsFor(updatesChangelog, '/api/updates/changelog'),
12
13
  ...bindingsFor(updatesCheck, '/api/updates/check'),
14
+ ...bindingsFor(updatesDesktop, '/api/updates/desktop'),
13
15
  ...bindingsFor(updatesIos, '/api/updates/ios'),
14
16
  ];
@@ -16,11 +16,15 @@ import { fetchShellRelease, type ShellRelease } from '@/server/lib/updates/shell
16
16
  *
17
17
  * A failed probe is a valid answer (`url: null`), never a 500: the sidebar's job
18
18
  * is to omit a button it cannot back with a link.
19
+ *
20
+ * PUBLIC, for the same reason as `/api/updates/android`: the installed shell
21
+ * asks it from native code with no session cookie, and the check has to work
22
+ * before the console can load.
19
23
  */
20
24
  export async function loader() {
21
25
  const release: ShellRelease | null = await fetchShellRelease('ios');
22
26
  return json(
23
- release ?? { url: null, latest: null, releaseUrl: null },
27
+ release ?? { url: null, latest: null, releaseUrl: null, direct: false },
24
28
  { headers: NO_STORE_HEADERS },
25
29
  );
26
30
  }