@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
@@ -1,12 +1,11 @@
1
1
  /**
2
- * What the sidebar should offer for a native shell, and why.
2
+ * What the console should offer for a native shell, and why.
3
3
  *
4
4
  * TWO DIFFERENT OFFERS, from two different facts:
5
5
  *
6
- * - **`update`** — this page is running inside the shell (`shellVersion` from
7
- * the bootstrap) and a newer build is published. An up-to-date shell offers
8
- * nothing: a button that downloads the build already installed is worse than
9
- * no button.
6
+ * - **`update`** — this page is running inside the shell and a newer build is
7
+ * published. An up-to-date shell offers nothing: a button that downloads the
8
+ * build already installed is worse than no button.
10
9
  * - **`install`** — this page is an Android BROWSER (Chrome, Brave, Firefox) and
11
10
  * an APK is published. The reader may not know a native shell exists, and
12
11
  * telling them is the whole point of the offer; there is no installed version
@@ -16,18 +15,26 @@
16
15
  * an APK can — iOS has no sideload-by-URL path — so a browser on an iPhone is
17
16
  * offered nothing, and only an installed iOS shell ever sees this control.
18
17
  *
19
- * A desktop browser gets neither: an APK cannot be installed there, and the
20
- * app's own update badge already covers the server.
18
+ * There is NO desktop BROWSER offer either, but there IS a desktop SHELL one: a
19
+ * desktop app is installed by hand from the release page, so nothing in a tab
20
+ * should advertise it — while an installed app that is behind has exactly the
21
+ * same problem the mobile pair had, and used to have no way to learn it.
21
22
  *
22
- * The probe is lazy: it runs only when a shell token or an Android device is
23
- * present, so a console that can never show the button makes no request for it.
24
- * ONE hook for both shells, because the decision table is identical and only the
25
- * artifact, the endpoint and the platform word differ.
23
+ * The probe is lazy: it runs only when an installed shell (a bootstrap token, or
24
+ * a bridge identity) or an Android device is present, so a console that can
25
+ * never show the button makes no request for it. ONE hook for every shell,
26
+ * because the decision table is identical and only the artifact, the endpoint
27
+ * and the platform word differ.
26
28
  */
27
29
 
28
30
  import { useEffect, useState } from 'preact/hooks';
29
31
  import { isNewer } from '@/shared/lib/updates/semver';
30
- import type { ShellKind } from '@/shared/lib/updates/shell-ua';
32
+ import { parseShellArch, type ShellArch } from '@/shared/lib/updates/shell-arch';
33
+ import {
34
+ isDesktopShellKind,
35
+ type DesktopShellKind,
36
+ type ShellKind,
37
+ } from '@/shared/lib/updates/shell-ua';
31
38
 
32
39
  /** Which shell the offer is for. */
33
40
  export type ShellPlatform = ShellKind;
@@ -36,7 +43,15 @@ export type ShellPlatform = ShellKind;
36
43
  export type ShellOfferKind = 'update' | 'install';
37
44
 
38
45
  export interface ShellDownload {
39
- /** The artifact to download. */
46
+ /**
47
+ * What the control points at.
48
+ *
49
+ * The artifact itself for the mobile pair (an APK the shell installs, an IPA
50
+ * the release page explains), and for a desktop shell EITHER its own
51
+ * per-architecture artifact (when it reported `arch`) or the RELEASE PAGE (when
52
+ * it did not). `direct` says which, and that is what decides between a button
53
+ * and a link.
54
+ */
40
55
  url: string;
41
56
  /** The version that artifact ships. */
42
57
  latest: string;
@@ -46,18 +61,31 @@ export interface ShellDownload {
46
61
  kind: ShellOfferKind;
47
62
  /** Which shell this offer is for. */
48
63
  platform: ShellPlatform;
64
+ /**
65
+ * True when `url` is the artifact itself, so the SHELL can install it.
66
+ *
67
+ * False means `url` is the release page: the console cannot name one file
68
+ * (a desktop shell that did not report its architecture), so the offer is a
69
+ * link a human follows. That distinction is what decides between an `<a>` and
70
+ * a button that drives `update_apply`.
71
+ */
72
+ direct: boolean;
49
73
  }
50
74
 
51
75
  interface ReleasePayload {
52
76
  url?: unknown;
53
77
  latest?: unknown;
54
78
  releaseUrl?: unknown;
79
+ direct?: unknown;
55
80
  }
56
81
 
57
82
  /** The platform's own name, as the label says it. */
58
83
  const PLATFORM_NAMES: Record<ShellPlatform, string> = {
59
84
  android: 'Android',
60
85
  ios: 'iOS',
86
+ macos: 'macOS',
87
+ windows: 'Windows',
88
+ linux: 'Linux',
61
89
  };
62
90
 
63
91
  /**
@@ -76,6 +104,10 @@ export function parseShellRelease(payload: unknown): Omit<ShellDownload, 'kind'
76
104
  url: body.url,
77
105
  latest: body.latest,
78
106
  releaseUrl: typeof body.releaseUrl === 'string' ? body.releaseUrl : null,
107
+ // Absent means "not installable": a shell that cannot install the bytes must
108
+ // fall back to the link rather than to a button that would download into
109
+ // nowhere.
110
+ direct: body.direct === true,
79
111
  };
80
112
  }
81
113
 
@@ -126,8 +158,20 @@ export function shellDownloadLabel(download: ShellDownload): string {
126
158
  return `${verb} Kabmo ${download.latest} for ${PLATFORM_NAMES[download.platform]}`;
127
159
  }
128
160
 
129
- /** The endpoint that answers for one shell. */
130
- export function shellReleasePath(platform: ShellPlatform): string {
161
+ /**
162
+ * The endpoint that answers for one shell.
163
+ *
164
+ * The desktop trio shares ONE route with an explicit `kind`, so no absence can
165
+ * silently mean a platform — the reason the mobile pair has two.
166
+ */
167
+ export function shellReleasePath(platform: ShellPlatform, arch: ShellArch | null = null): string {
168
+ if (isDesktopShellKind(platform)) {
169
+ // The arch is what makes the answer a FILE rather than a page. A shell that
170
+ // did not report one still asks, and still gets the page that lists every
171
+ // architecture — the offer is then a link, which is the honest fallback.
172
+ const base = `/api/updates/desktop?kind=${platform}`;
173
+ return arch ? `${base}&arch=${arch}` : base;
174
+ }
131
175
  return `/api/updates/${platform}`;
132
176
  }
133
177
 
@@ -146,6 +190,34 @@ export function shellFromBootstrap(bootstrap: {
146
190
  return null;
147
191
  }
148
192
 
193
+ /**
194
+ * Which DESKTOP shell this page is running inside, from the bridge identity.
195
+ *
196
+ * The desktop trio reports itself over `window.__kabmoDesktop` rather than the
197
+ * User-Agent, because a desktop app is not downloaded from the console and the
198
+ * server has nothing to parse per request. The identity is injected at document
199
+ * start, before the page's own scripts, so this answers on the FIRST paint.
200
+ *
201
+ * An identity whose `platform` is not one of the three is not a desktop shell
202
+ * this build knows how to update — a newer shell reporting a fourth platform
203
+ * must not be compared against an artifact nobody publishes.
204
+ */
205
+ export function desktopShellFromIdentity(identity: {
206
+ platform?: string;
207
+ shellVersion?: string;
208
+ arch?: string;
209
+ } | null | undefined): { platform: DesktopShellKind; version: string; arch: ShellArch | null } | null {
210
+ if (!identity?.platform || !identity.shellVersion) return null;
211
+ if (!isDesktopShellKind(identity.platform)) return null;
212
+ return {
213
+ platform: identity.platform,
214
+ version: identity.shellVersion,
215
+ // A shell too old to report one keeps working: the offer degrades to the
216
+ // release page instead of disappearing.
217
+ arch: parseShellArch(identity.arch),
218
+ };
219
+ }
220
+
149
221
  /**
150
222
  * Fetch the published artifact and decide what to offer.
151
223
  *
@@ -158,9 +230,25 @@ export function useShellDownload(
158
230
  version: string | null | undefined,
159
231
  onAndroid: boolean | null | undefined,
160
232
  fetchImpl: typeof fetch = fetch,
233
+ arch: ShellArch | null = null,
161
234
  ): ShellDownload | null {
162
- const [release, setRelease] = useState<Omit<ShellDownload, 'kind' | 'platform'> | null>(null);
235
+ /**
236
+ * The answer, TAGGED with the platform it was read for.
237
+ *
238
+ * A bare payload is not enough: this hook is called with a different platform
239
+ * when a page's shell changes (a desktop identity arriving, a spawn adoption),
240
+ * and an untagged answer would be compared against the new platform's version
241
+ * — offering an Android APK to a Mac for one render.
242
+ */
243
+ const [answer, setAnswer] = useState<{
244
+ key: string;
245
+ release: Omit<ShellDownload, 'kind' | 'platform'> | null;
246
+ } | null>(null);
163
247
  const wanted = Boolean(version) || (platform === 'android' && Boolean(onAndroid));
248
+ // The question this hook is asking, arch included: two windows on one console
249
+ // differ only by architecture, and an answer read for the other one is not an
250
+ // answer.
251
+ const key = `${platform}:${arch ?? ''}`;
164
252
 
165
253
  useEffect(() => {
166
254
  if (!wanted) return;
@@ -168,10 +256,10 @@ export function useShellDownload(
168
256
  let cancelled = false;
169
257
  void (async () => {
170
258
  try {
171
- const response = await fetchImpl(shellReleasePath(platform));
259
+ const response = await fetchImpl(shellReleasePath(platform, arch));
172
260
  if (!response.ok) return;
173
261
  const payload: unknown = await response.json();
174
- if (!cancelled) setRelease(parseShellRelease(payload));
262
+ if (!cancelled) setAnswer({ key, release: parseShellRelease(payload) });
175
263
  } catch {
176
264
  // A probe that cannot answer is a button that must not appear.
177
265
  }
@@ -180,29 +268,64 @@ export function useShellDownload(
180
268
  return () => {
181
269
  cancelled = true;
182
270
  };
183
- }, [wanted, platform, fetchImpl]);
271
+ }, [wanted, key, platform, arch, fetchImpl]);
184
272
 
273
+ // A stale answer belongs to the question it was read for, not this one.
274
+ const release = answer?.key === key ? answer.release : null;
185
275
  return shouldOfferShellDownload(platform, version, onAndroid, release);
186
276
  }
187
277
 
278
+ /** The bridge identity a desktop shell injects before the page's first script. */
279
+ export interface DesktopIdentity {
280
+ platform?: string;
281
+ shellVersion?: string;
282
+ }
283
+
284
+ function readDesktopIdentity(): DesktopIdentity | null {
285
+ if (typeof window === 'undefined') return null;
286
+ // `window.__kabmoDesktop` is declared by `lib/desktop/bridge` — the same
287
+ // object the shell injects, so this reads the shell's own contract rather
288
+ // than re-declaring its shape here.
289
+ return window.__kabmoDesktop ?? null;
290
+ }
291
+
188
292
  /**
189
293
  * The offer for whichever shell this page is running inside — or the Android
190
294
  * install offer for a browser on that platform.
191
295
  *
192
296
  * The single entry point both sidebar layouts call, so the two cannot disagree
193
297
  * about which shell they are in or which endpoint they read. It reads the
194
- * bootstrap the SERVER wrote for this request rather than sniffing the UA
195
- * client-side, because the server already parsed it for the very response that
196
- * carried the page.
298
+ * bootstrap the SERVER wrote for this request (and, for a desktop shell, the
299
+ * identity the shell injected) rather than sniffing the UA client-side, because
300
+ * the server already parsed it for the very response that carried the page.
301
+ *
302
+ * `desktop` is a parameter so the tests can drive the bridge identity without a
303
+ * real shell; production reads it from `window.__kabmoDesktop`.
197
304
  */
198
- export function useShellOffer(fetchImpl: typeof fetch = fetch): ShellDownload | null {
305
+ export function useShellOffer(
306
+ fetchImpl: typeof fetch = fetch,
307
+ desktop: DesktopIdentity | null = readDesktopIdentity(),
308
+ ): ShellDownload | null {
199
309
  const bootstrap = typeof window === 'undefined' ? undefined : window.__OMP_BOOTSTRAP__;
200
- const shell = shellFromBootstrap(bootstrap);
310
+ const mobile = shellFromBootstrap(bootstrap);
311
+ const fromBridge = desktopShellFromIdentity(desktop);
312
+
313
+ // The desktop identity is checked FIRST and is authoritative when present: a
314
+ // desktop shell never carries a mobile UA token, and a page that somehow had
315
+ // both is in the desktop app, whose version is the one being compared.
316
+ //
317
+ // ONE call, with the resolved platform — never two conditional calls. A hook
318
+ // behind an `if` changes the hook order between renders, which React/Preact
319
+ // treats as a different component.
320
+ const platform = fromBridge?.platform ?? mobile?.platform ?? 'android';
321
+ const version = fromBridge?.version ?? mobile?.version ?? null;
322
+
201
323
  return useShellDownload(
202
- shell?.platform ?? 'android',
203
- shell?.version ?? null,
204
- bootstrap?.isAndroid,
324
+ platform,
325
+ version,
326
+ fromBridge ? null : bootstrap?.isAndroid,
205
327
  fetchImpl,
328
+ fromBridge?.arch ?? null,
206
329
  );
207
330
  }
208
331
 
@@ -212,9 +335,11 @@ export function useShellOffer(fetchImpl: typeof fetch = fetch): ShellDownload |
212
335
  * It names the OFFER, and for Android it names the file type because that is
213
336
  * what a reader recognises (`APK` is the thing they will see in Downloads). An
214
337
  * iOS update is an IPA, which nobody installs by hand — the shell finishes the
215
- * install itself — so the platform word is the honest one there.
338
+ * install itself — so the platform word is the honest one there. A desktop
339
+ * update opens the release page, so it names the platform too.
216
340
  */
217
341
  export function shellDownloadShortLabel(download: ShellDownload): string {
218
342
  if (download.platform === 'ios') return 'Update app';
343
+ if (isDesktopShellKind(download.platform)) return `Update ${PLATFORM_NAMES[download.platform]}`;
219
344
  return download.kind === 'update' ? 'Update APK' : 'Get APK';
220
345
  }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `useShellOffer` — the one hook both sidebars call.
8
+ *
9
+ * The pure decision table is pinned next door; what this file covers is the
10
+ * WIRING, which is where the reported bug lived: an APK two versions behind a
11
+ * running server drew no button, and no unit test of `shouldOfferShellDownload`
12
+ * could have seen it, because the hook never asked the server at all.
13
+ *
14
+ * Three facts it reads, and each has been wrong at some point:
15
+ *
16
+ * 1. the MOBILE shell's version, from the bootstrap the server wrote for this
17
+ * request (the `KabmoAndroid/<v>` token);
18
+ * 2. the DESKTOP shell's version, from the bridge identity injected before the
19
+ * first script — a desktop app carries no UA token;
20
+ * 3. the release itself, fetched from the shell's own route, with the desktop
21
+ * trio sharing one endpoint and an explicit `kind`.
22
+ */
23
+
24
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
25
+ import { Window } from 'happy-dom';
26
+ import { h, render } from 'preact';
27
+ import { act } from 'preact/test-utils';
28
+ import { useShellOffer } from '@/client/hooks/ui/updates/shell-download';
29
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
30
+
31
+ let container: HTMLElement;
32
+
33
+ /** The payload every route answers in these cases. */
34
+ const RELEASE = {
35
+ url: 'https://github.com/kabmo-labs/kabmo/releases/download/v0.7.0/Kabmo-0.6.0-android.apk',
36
+ latest: '0.6.0',
37
+ releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
38
+ direct: true,
39
+ };
40
+
41
+ function Harness({ fetchImpl }: { fetchImpl: typeof fetch }) {
42
+ const offer = useShellOffer(fetchImpl);
43
+ return h('span', { id: 'offer' }, offer ? `${offer.kind}:${offer.platform}:${offer.latest}` : 'none');
44
+ }
45
+
46
+ /** A fetch double that answers the release payload and records the URLs asked. */
47
+ function stubFetch(asked: string[]) {
48
+ return (async (input: string | URL | Request) => {
49
+ asked.push(String(input));
50
+ return { ok: true, json: async () => RELEASE } as Response;
51
+ }) as unknown as typeof fetch;
52
+ }
53
+
54
+ async function mount(fetchImpl: typeof fetch): Promise<string> {
55
+ await act(async () => {
56
+ render(h(Harness, { fetchImpl }), container);
57
+ });
58
+ // The probe is an effect: let its promise settle and re-render.
59
+ for (let i = 0; i < 20; i += 1) await act(async () => {});
60
+ return container.querySelector('#offer')?.textContent ?? '';
61
+ }
62
+
63
+ /** Install a bootstrap and (optionally) a bridge identity on the page. */
64
+ function shell(options: { bootstrap?: Record<string, unknown>; desktop?: Record<string, unknown> }): void {
65
+ const page = globalThis as unknown as {
66
+ window: {
67
+ __OMP_BOOTSTRAP__?: Record<string, unknown>;
68
+ __kabmoDesktop?: Record<string, unknown>;
69
+ };
70
+ };
71
+ delete page.window.__OMP_BOOTSTRAP__;
72
+ delete page.window.__kabmoDesktop;
73
+ if (options.bootstrap) page.window.__OMP_BOOTSTRAP__ = options.bootstrap;
74
+ if (options.desktop) page.window.__kabmoDesktop = options.desktop;
75
+ }
76
+
77
+ beforeAll(() => {
78
+ installDomGlobals(new Window({ url: 'http://localhost' }));
79
+ container = document.createElement('div');
80
+ document.body.appendChild(container);
81
+ });
82
+
83
+ afterEach(() => {
84
+ act(() => render(null, container));
85
+ shell({});
86
+ });
87
+
88
+ afterAll(() => {
89
+ container.remove();
90
+ restoreDomGlobals();
91
+ });
92
+
93
+ describe('the Android shell', () => {
94
+ test('offers an update when the APK is behind the published asset', async () => {
95
+ // The reported incident: server v0.7.0, APK v0.5.0, published 0.6.0, and
96
+ // the sidebar drew nothing because the probe answered `null`.
97
+ shell({ bootstrap: { shellVersion: '0.5.0', isAndroid: true } });
98
+ const asked: string[] = [];
99
+
100
+ expect(await mount(stubFetch(asked))).toBe('update:android:0.6.0');
101
+ expect(asked, 'the Android route').toEqual(['/api/updates/android']);
102
+ });
103
+
104
+ test('offers nothing when the APK is the published build', async () => {
105
+ shell({ bootstrap: { shellVersion: '0.6.0', isAndroid: true } });
106
+ expect(await mount(stubFetch([]))).toBe('none');
107
+ });
108
+
109
+ test('offers an INSTALL to a browser on Android, and nothing on a desktop browser', async () => {
110
+ shell({ bootstrap: { isAndroid: true } });
111
+ expect(await mount(stubFetch([]))).toBe('install:android:0.6.0');
112
+
113
+ shell({ bootstrap: { isAndroid: false } });
114
+ expect(await mount(stubFetch([]))).toBe('none');
115
+ });
116
+ });
117
+
118
+ describe('the iOS shell', () => {
119
+ test('reads its own token and its own route', async () => {
120
+ shell({ bootstrap: { iosShellVersion: '0.1.0' } });
121
+ const asked: string[] = [];
122
+
123
+ expect(await mount(stubFetch(asked))).toBe('update:ios:0.6.0');
124
+ expect(asked).toEqual(['/api/updates/ios']);
125
+ });
126
+
127
+ test('an iOS browser is offered nothing, because an IPA cannot be installed from a page', async () => {
128
+ shell({ bootstrap: {} });
129
+ expect(await mount(stubFetch([]))).toBe('none');
130
+ });
131
+ });
132
+
133
+ describe('the desktop shells', () => {
134
+ test('reads the version from the bridge and the route from the kind', async () => {
135
+ // No bootstrap token at all: a desktop app reports itself over the bridge,
136
+ // and the console had no way to read it before this.
137
+ shell({ desktop: { platform: 'macos', shellVersion: '0.1.0', overlayTitlebar: true } });
138
+ const asked: string[] = [];
139
+
140
+ expect(await mount(stubFetch(asked))).toBe('update:macos:0.6.0');
141
+ expect(asked).toEqual(['/api/updates/desktop?kind=macos']);
142
+ });
143
+
144
+ test('each desktop shell asks for its own kind', async () => {
145
+ for (const platform of ['windows', 'linux'] as const) {
146
+ shell({ desktop: { platform, shellVersion: '0.1.0' } });
147
+ const asked: string[] = [];
148
+ await mount(stubFetch(asked));
149
+ expect(asked, platform).toEqual([`/api/updates/desktop?kind=${platform}`]);
150
+ }
151
+ });
152
+
153
+ test('a current desktop build offers nothing', async () => {
154
+ shell({ desktop: { platform: 'windows', shellVersion: '0.6.0' } });
155
+ expect(await mount(stubFetch([]))).toBe('none');
156
+ });
157
+
158
+ test('a browser on a desktop is offered nothing', async () => {
159
+ shell({ bootstrap: { isAndroid: false } });
160
+ expect(await mount(stubFetch([]))).toBe('none');
161
+ });
162
+
163
+ test('the desktop identity wins over a mobile token, because that is the shell asking', async () => {
164
+ // A page cannot carry both in practice, and if it somehow did the desktop
165
+ // app is the process whose version is being compared.
166
+ shell({
167
+ bootstrap: { shellVersion: '0.1.0', isAndroid: true },
168
+ desktop: { platform: 'linux', shellVersion: '0.1.0' },
169
+ });
170
+ expect(await mount(stubFetch([]))).toBe('update:linux:0.6.0');
171
+ });
172
+
173
+ /**
174
+ * A shell that reports its architecture asks for THAT machine's artifact.
175
+ *
176
+ * Without it the console can only answer the release page — every desktop
177
+ * artifact is per-architecture — so the offer is a link a human follows instead
178
+ * of an update the shell installs.
179
+ */
180
+ test('reports the arch, so the answer is a file rather than a page', async () => {
181
+ shell({ desktop: { platform: 'macos', shellVersion: '0.1.0', arch: 'arm64' } });
182
+ const asked: string[] = [];
183
+
184
+ await mount(stubFetch(asked));
185
+ expect(asked).toEqual(['/api/updates/desktop?kind=macos&arch=arm64']);
186
+ });
187
+
188
+ test('a shell with no arch still asks, and gets the page', async () => {
189
+ // An older shell must keep working: the offer degrades to the release page
190
+ // instead of disappearing.
191
+ shell({ desktop: { platform: 'macos', shellVersion: '0.1.0' } });
192
+ const asked: string[] = [];
193
+
194
+ await mount(stubFetch(asked));
195
+ expect(asked).toEqual(['/api/updates/desktop?kind=macos']);
196
+ });
197
+
198
+ test('a refused probe draws no button', async () => {
199
+ shell({ desktop: { platform: 'macos', shellVersion: '0.1.0' } });
200
+ const refusing = (async () => ({ ok: false, json: async () => ({}) }) as Response) as unknown as typeof fetch;
201
+ expect(await mount(refusing)).toBe('none');
202
+ });
203
+
204
+ test('a payload with no artifact draws no button', async () => {
205
+ shell({ desktop: { platform: 'macos', shellVersion: '0.1.0' } });
206
+ const empty = (async () =>
207
+ ({
208
+ ok: true,
209
+ json: async () => ({ url: null, latest: null, releaseUrl: null }),
210
+ }) as Response) as unknown as typeof fetch;
211
+ expect(await mount(empty)).toBe('none');
212
+ });
213
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The desktop self-update's client half.
8
+ *
9
+ * Two facts are pinned here and both were decisions rather than accidents:
10
+ *
11
+ * 1. **Which offers the shell can install itself** — a FILE (`direct`) in a
12
+ * DESKTOP shell. An Android offer installs through `PackageInstaller` (its own
13
+ * path), an iOS one cannot install at all, and a browser has no shell to ask.
14
+ * 2. **A failure is reported, never swallowed.** The shell's own sentence is what
15
+ * a user can act on, and a run that reported success before the shell said so
16
+ * would be a lie about an app that is still on the old build.
17
+ */
18
+
19
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
20
+ import { Window } from 'happy-dom';
21
+ import { h, render } from 'preact';
22
+ import { act } from 'preact/test-utils';
23
+ import { canSelfUpdate, useShellUpdate } from '@/client/hooks/ui/updates/shell-update';
24
+ import type { ShellDownload } from '@/client/hooks/ui/updates/shell-download';
25
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
26
+
27
+ const DESKTOP: ShellDownload = {
28
+ url: 'https://x/Kabmo-1.2.3-mac-arm64.zip',
29
+ latest: '1.2.3',
30
+ releaseUrl: 'https://x/app',
31
+ direct: true,
32
+ kind: 'update',
33
+ platform: 'macos',
34
+ };
35
+
36
+ const PAGE: ShellDownload = { ...DESKTOP, url: 'https://x/app', direct: false };
37
+
38
+ let container: HTMLElement;
39
+
40
+ /** Install (or remove) a bridge on the page. */
41
+ function bridge(call: ((method: string, params?: Record<string, unknown>) => Promise<unknown>) | null): void {
42
+ const page = globalThis as unknown as { window: { __kabmoCall?: unknown } };
43
+ if (call) page.window.__kabmoCall = call;
44
+ else delete page.window.__kabmoCall;
45
+ }
46
+
47
+ beforeAll(() => {
48
+ installDomGlobals(new Window({ url: 'http://localhost' }));
49
+ container = document.createElement('div');
50
+ document.body.appendChild(container);
51
+ });
52
+
53
+ afterEach(() => {
54
+ act(() => render(null, container));
55
+ bridge(null);
56
+ });
57
+
58
+ afterAll(() => {
59
+ container.remove();
60
+ restoreDomGlobals();
61
+ });
62
+
63
+ describe('canSelfUpdate', () => {
64
+ test('a direct desktop offer inside the shell is installable', () => {
65
+ bridge(async () => null);
66
+ expect(canSelfUpdate(DESKTOP)).toBe(true);
67
+ expect(canSelfUpdate({ ...DESKTOP, platform: 'windows' })).toBe(true);
68
+ expect(canSelfUpdate({ ...DESKTOP, platform: 'linux' })).toBe(true);
69
+ });
70
+
71
+ test('a release PAGE is not installable, whatever the platform', () => {
72
+ bridge(async () => null);
73
+ // The console could not name a file, so there is nothing to hand the shell.
74
+ expect(canSelfUpdate(PAGE)).toBe(false);
75
+ });
76
+
77
+ test('the mobile shells keep their own paths', () => {
78
+ bridge(async () => null);
79
+ // Android installs through PackageInstaller, iOS cannot install at all.
80
+ expect(canSelfUpdate({ ...DESKTOP, platform: 'android' })).toBe(false);
81
+ expect(canSelfUpdate({ ...DESKTOP, platform: 'ios' })).toBe(false);
82
+ });
83
+
84
+ test('a browser has no shell to ask', () => {
85
+ bridge(null);
86
+ expect(canSelfUpdate(DESKTOP)).toBe(false);
87
+ });
88
+
89
+ test('an absent offer is not installable', () => {
90
+ bridge(async () => null);
91
+ expect(canSelfUpdate(null)).toBe(false);
92
+ expect(canSelfUpdate(undefined)).toBe(false);
93
+ });
94
+ });
95
+
96
+ describe('useShellUpdate', () => {
97
+ /** A harness that exposes the hook's state and its one action. */
98
+ function Harness({ run }: { run: boolean }) {
99
+ const state = useShellUpdate();
100
+ if (run && state.phase === 'idle') void state.apply(DESKTOP);
101
+ return h('span', { id: 'state' }, `${state.phase}:${state.message ?? ''}`);
102
+ }
103
+
104
+ async function mount(run: boolean): Promise<string> {
105
+ await act(async () => {
106
+ render(h(Harness, { run }), container);
107
+ });
108
+ for (let i = 0; i < 20; i += 1) await act(async () => {});
109
+ return container.querySelector('#state')?.textContent ?? '';
110
+ }
111
+
112
+ test('passes the offer to the shell, and reports its own sentence', async () => {
113
+ const calls: { method: string; params?: Record<string, unknown> }[] = [];
114
+ bridge(async (method, params) => {
115
+ calls.push({ method, params });
116
+ return { success: true, version: '1.2.3', message: 'Kabmo 1.2.3 installed — restarting.', relaunching: true };
117
+ });
118
+
119
+ expect(await mount(true)).toBe('done:Kabmo 1.2.3 installed — restarting.');
120
+ expect(calls).toEqual([
121
+ { method: 'update_apply', params: { url: DESKTOP.url, version: DESKTOP.latest } },
122
+ ]);
123
+ });
124
+
125
+ test('a refused update keeps the shell’s reason', async () => {
126
+ // The shell verified the bytes and declined: "the downloaded file is not a
127
+ // signed Kabmo build" is the only thing that tells a user what to do next.
128
+ bridge(async () => ({ success: false, message: 'The downloaded file is not a signed Kabmo build.' }));
129
+
130
+ expect(await mount(true)).toBe('error:The downloaded file is not a signed Kabmo build.');
131
+ });
132
+
133
+ test('a shell that cannot answer is reported rather than assumed to have worked', async () => {
134
+ bridge(async () => {
135
+ throw new Error('Unknown shell command: update_apply');
136
+ });
137
+
138
+ expect(await mount(true)).toBe('error:Unknown shell command: update_apply');
139
+ });
140
+ });