@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,200 @@
1
+ /**
2
+ * How each native shell's release asset is NAMED, and what version it declares.
3
+ *
4
+ * Split from `shell-release.ts` because that file owns the two READS (the REST
5
+ * API and the public-pages fallback) and this one owns the naming rules, and
6
+ * because the repo's ceiling is 350 lines per file — the two concerns together
7
+ * crossed it.
8
+ *
9
+ * The naming is the load-bearing half: the asset NAME is the only place a shell
10
+ * artifact's version exists. The release TAG names the SERVER version, and the
11
+ * two move independently, so a parser that read the tag would compare a server
12
+ * version against a shell version.
13
+ */
14
+
15
+ import type { ShellArch } from '@/shared/lib/updates/shell-arch';
16
+ import type { ShellKind } from '@/shared/lib/updates/shell-ua';
17
+
18
+ /**
19
+ * One shell's asset scheme, plus the spelling it carried BEFORE the rename.
20
+ *
21
+ * ONE scheme for every shell in the project — `<App>-<version>-<platform>…`:
22
+ * `Kabmo-0.5.0-android.apk`, `Kabmo-0.1.0-ios.ipa`, and the desktop trio's
23
+ * `Kabmo-0.2.0-mac-arm64.dmg` / `-win-x64-setup.exe` / `-linux-x86_64.tar.gz`.
24
+ * The mobile pair carries no architecture because neither artifact is per-arch:
25
+ * the APK is one universal build (the shell is pure Kotlin with no native
26
+ * libraries, so there is nothing to split), and the IPA contains only the device
27
+ * slice (`arm64-apple-ios`).
28
+ *
29
+ * The desktop entries match on the segment AFTER the version rather than on the
30
+ * whole name, because one release carries several files per platform
31
+ * (`-mac-arm64.zip` and `-mac-arm64.dmg`, four Linux formats, two arches). Any
32
+ * one of them answers the same version, which is all `pattern` is for.
33
+ *
34
+ * WHICH file the console hands back is a second question, and `selfUpdate` is
35
+ * the answer: a desktop shell that knows its architecture installs one exact
36
+ * artifact, and a probe that does not know it still gets the release page. Both
37
+ * live in this table so the route, the pages fallback and the tests read one
38
+ * spelling.
39
+ *
40
+ * **`legacy` is not dead code.** These names are PARSED to answer "is a newer
41
+ * shell published", and every release already on GitHub carries the old mobile
42
+ * spelling. Dropping it would make the console answer "no update" for an
43
+ * installed shell the moment this ships — silently, because a name that does not
44
+ * match reads exactly like a release with no artifact. The desktop trio has no
45
+ * legacy spelling: they were born under the current scheme.
46
+ */
47
+ export interface AssetScheme {
48
+ /** The whole-name pattern for the mobile pair, the segment for the desktops. */
49
+ pattern: RegExp;
50
+ /** The prefix that marks the CURRENT scheme, for "prefer the new spelling". */
51
+ current: RegExp;
52
+ /** The old mobile spelling, or null for a shell that never had one. */
53
+ legacy: { prefix: string; extension: string } | null;
54
+ /**
55
+ * Whether the payload's `url` should be the artifact itself. True for the
56
+ * mobile pair, whose artifact is the same file on every device.
57
+ */
58
+ directDownload: boolean;
59
+ /**
60
+ * The ONE file a desktop shell installs ITSELF from, per architecture.
61
+ *
62
+ * `null` for the mobile pair: an APK is installed by `PackageInstaller` and an
63
+ * IPA cannot be installed at all, so neither is this shell's own business.
64
+ *
65
+ * The desktop entry is deliberately narrower than `pattern`: one release
66
+ * carries several files per platform (`.zip` and `.dmg`, `.tar.gz`/`.deb`/
67
+ * `.rpm`/`.AppImage`), and only ONE of each is something the shell can install
68
+ * without a human opening it. The choice is stated here rather than at the
69
+ * call site so the route, the pages fallback and the tests cannot disagree
70
+ * about which file "the update" means.
71
+ */
72
+ selfUpdate: SelfUpdateScheme | null;
73
+ }
74
+
75
+ /**
76
+ * How one desktop shell's self-update artifact is named.
77
+ *
78
+ * `pattern` captures the version AND the architecture, because the two answers
79
+ * come from the same string: the version is what the console compares against
80
+ * the installed build, and the architecture is what makes this file the right
81
+ * one for the machine asking.
82
+ */
83
+ export interface SelfUpdateScheme {
84
+ /** `Kabmo-<version>-<platform>-<arch><suffix>` → `[name, version, arch]`. */
85
+ pattern: RegExp;
86
+ /** How each canonical arch is spelled in the ASSET name. */
87
+ assetArch: Record<ShellArch, string>;
88
+ }
89
+
90
+ const VERSION = String.raw`(\d+\.\d+\.\d+)`;
91
+
92
+ /** `arm64`/`x64` as macOS and Windows spell them. */
93
+ const SAME_ARCH: Record<ShellArch, string> = { arm64: 'arm64', x64: 'x64' };
94
+
95
+ /** Linux says `x86_64` where the other two say `x64`. */
96
+ const LINUX_ARCH: Record<ShellArch, string> = { arm64: 'arm64', x64: 'x86_64' };
97
+
98
+ export const SHELL_ASSETS: Record<ShellKind, AssetScheme> = {
99
+ android: {
100
+ pattern: new RegExp(`^Kabmo-${VERSION}-android\\.apk$`),
101
+ current: /^Kabmo-\d/,
102
+ legacy: { prefix: 'kabmo-android-v', extension: '.apk' },
103
+ directDownload: true,
104
+ selfUpdate: null,
105
+ },
106
+ ios: {
107
+ pattern: new RegExp(`^Kabmo-${VERSION}-ios\\.ipa$`),
108
+ current: /^Kabmo-\d/,
109
+ legacy: { prefix: 'kabmo-ios-v', extension: '.ipa' },
110
+ directDownload: true,
111
+ selfUpdate: null,
112
+ },
113
+ macos: {
114
+ pattern: new RegExp(`^Kabmo-${VERSION}-mac-`),
115
+ current: /^Kabmo-\d/,
116
+ legacy: null,
117
+ directDownload: false,
118
+ // The `.zip`, not the `.dmg`: `ditto -x -k` extracts it without mounting a
119
+ // volume, and the shell has to be able to do that with no human present.
120
+ // Both shapes are published; only one is installable by the app itself.
121
+ selfUpdate: {
122
+ pattern: new RegExp(`^Kabmo-${VERSION}-mac-(arm64|x64)\\.zip$`),
123
+ assetArch: SAME_ARCH,
124
+ },
125
+ },
126
+ windows: {
127
+ pattern: new RegExp(`^Kabmo-${VERSION}-win-`),
128
+ current: /^Kabmo-\d/,
129
+ legacy: null,
130
+ directDownload: false,
131
+ // The INSTALLER, not the `.zip`-shaped publish output: Inno already owns the
132
+ // upgrade semantics (a stable `AppId` is what makes an install an upgrade
133
+ // rather than a second copy), and `/CLOSEAPPLICATIONS` is how it replaces a
134
+ // running exe.
135
+ selfUpdate: {
136
+ pattern: new RegExp(`^Kabmo-${VERSION}-win-(arm64|x64)-setup\\.exe$`),
137
+ assetArch: SAME_ARCH,
138
+ },
139
+ },
140
+ linux: {
141
+ pattern: new RegExp(`^Kabmo-${VERSION}-linux-`),
142
+ current: /^Kabmo-\d/,
143
+ legacy: null,
144
+ directDownload: false,
145
+ // The AppImage, and only it: `.deb`/`.rpm` need root and a package manager,
146
+ // so a shell cannot install them — it says so instead. An AppImage is ONE
147
+ // file the shell can replace atomically and re-exec.
148
+ selfUpdate: {
149
+ pattern: new RegExp(`^Kabmo-${VERSION}-linux-(arm64|x86_64)\\.AppImage$`),
150
+ assetArch: LINUX_ARCH,
151
+ },
152
+ },
153
+ };
154
+
155
+ /**
156
+ * The self-update artifact for one architecture, if this name is it.
157
+ *
158
+ * Null for a shell that has no self-update, for an arch it does not publish, and
159
+ * for every other file in the release (a `.dmg`, a `.deb`, the OTHER
160
+ * architecture's build). Callers treat null as "not the file this machine
161
+ * installs", which is why the route falls back to the release page rather than
162
+ * guessing.
163
+ */
164
+ export function selfUpdateArtifact(
165
+ kind: ShellKind,
166
+ arch: ShellArch,
167
+ name: string,
168
+ ): { version: string } | null {
169
+ const scheme = SHELL_ASSETS[kind].selfUpdate;
170
+ if (!scheme) return null;
171
+
172
+ const match = scheme.pattern.exec(name);
173
+ if (!match) return null;
174
+ // The captured arch is compared against the ASSET spelling, so a Linux build
175
+ // reporting `x64` matches `…-linux-x86_64.AppImage` and nothing else.
176
+ if (match[2] !== scheme.assetArch[arch]) return null;
177
+ return { version: match[1] ?? '' };
178
+ }
179
+
180
+ /**
181
+ * `Kabmo-0.5.0-android.apk` → `0.5.0`, the `.ipa` twin, every desktop spelling,
182
+ * plus the legacy `kabmo-android-v0.5.0.apk` the older releases still carry.
183
+ */
184
+ export function versionFromAssetName(kind: ShellKind, name: string): string | null {
185
+ const scheme = SHELL_ASSETS[kind];
186
+
187
+ const current = scheme.pattern.exec(name);
188
+ if (current) return current[1] ?? null;
189
+
190
+ // Legacy: `kabmo-android-v<version>.apk`. Kept for the releases already
191
+ // published; no new release writes it.
192
+ const legacy = scheme.legacy;
193
+ if (legacy && name.startsWith(legacy.prefix) && name.endsWith(legacy.extension)) {
194
+ const middle = name.slice(legacy.prefix.length, -legacy.extension.length);
195
+ const version = middle.startsWith('v') ? middle.slice(1) : middle;
196
+ return /^\d+\.\d+\.\d+$/.test(version) ? version : null;
197
+ }
198
+
199
+ return null;
200
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The shell-release answer cache, and where the GitHub reads go.
8
+ *
9
+ * Split from `shell-release.ts` for two reasons that are not cosmetic.
10
+ *
11
+ * 1. **The key is per SHELL AND per ARCHITECTURE.** A desktop shell asks for the
12
+ * artifact of its own machine, so a single per-kind slot would answer an
13
+ * arm64 window with the x64 installer the moment both hit one console — and
14
+ * the console is routinely shared by a whole team.
15
+ * 2. **The cache outlives a test file.** It lives on `globalThis` so `bun --hot`
16
+ * cannot orphan it, which is also why `resetShellReleaseCache` exists: without
17
+ * that seam the second case in a suite reads the first case's answer and
18
+ * passes for the wrong reason (measured — the "malformed body" case returned
19
+ * the previous test's release).
20
+ */
21
+
22
+ import type { ShellArch } from '@/shared/lib/updates/shell-arch';
23
+ import type { ShellKind } from '@/shared/lib/updates/shell-ua';
24
+
25
+ /** How long an answer is reused. */
26
+ export const CACHE_MS = 5 * 60_000;
27
+
28
+ /** One shell's (and, for a desktop, one architecture's) cached answer. */
29
+ export interface CacheSlot<T> {
30
+ entry: T | null;
31
+ at: number;
32
+ }
33
+
34
+ type ShellCacheHost = typeof globalThis & {
35
+ __kabmoShellRelease?: Record<string, CacheSlot<unknown>>;
36
+ };
37
+
38
+ /**
39
+ * The cache key for one question.
40
+ *
41
+ * The arch is part of the KEY rather than a field on the value, so a stale entry
42
+ * can never be read by the wrong machine: a miss is a miss, and there is no
43
+ * comparison left to get wrong.
44
+ */
45
+ export function shellCacheKey(kind: ShellKind, arch: ShellArch | null): string {
46
+ return arch ? `${kind}:${arch}` : kind;
47
+ }
48
+
49
+ /** The cache slot for one key, created on first use. */
50
+ export function cacheSlot<T>(key: string): CacheSlot<T> {
51
+ const host = globalThis as ShellCacheHost;
52
+ host.__kabmoShellRelease ??= {};
53
+ host.__kabmoShellRelease[key] ??= { entry: null, at: 0 };
54
+ return host.__kabmoShellRelease[key] as CacheSlot<T>;
55
+ }
56
+
57
+ /**
58
+ * Drop the cached answer for one shell (or every shell, with no argument).
59
+ *
60
+ * Production has no caller; it is the test seam described above.
61
+ */
62
+ export function resetShellReleaseCache(kind?: ShellKind): void {
63
+ const host = globalThis as ShellCacheHost;
64
+ if (!kind) {
65
+ host.__kabmoShellRelease = {};
66
+ return;
67
+ }
68
+ const prefix = `${kind}:`;
69
+ const slots = host.__kabmoShellRelease ?? {};
70
+ for (const key of Object.keys(slots)) {
71
+ if (key === kind || key.startsWith(prefix)) delete slots[key];
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Where the release read goes.
77
+ *
78
+ * Overridable for the same reason the npm probe honours `NPM_CONFIG_REGISTRY`: a
79
+ * test and a rehearsal need to exercise the real parsing against a stub instead
80
+ * of a live GitHub call, and hardcoding the host makes that impossible without
81
+ * intercepting `fetch` globally.
82
+ */
83
+ export function githubApiBase(): string {
84
+ const configured = Bun.env.KABMO_GITHUB_API ?? '';
85
+ return configured.trim().replace(/\/+$/, '') || 'https://api.github.com';
86
+ }
87
+
88
+ /**
89
+ * A token, when the operator has one, which lifts the API quota from 60 an hour
90
+ * to 5000 — the difference between the precise read working on a shared address
91
+ * and falling through to the pages every time.
92
+ *
93
+ * Read from the environment rather than a setting: it is a deployment fact, it
94
+ * must never reach the browser, and `kabmo serve` already passes its own
95
+ * environment through.
96
+ */
97
+ export function gitHubHeaders(): Record<string, string> {
98
+ const token = (Bun.env.KABMO_GITHUB_TOKEN ?? Bun.env.GITHUB_TOKEN ?? '').trim();
99
+ const headers: Record<string, string> = {
100
+ // GitHub rejects a request with no User-Agent.
101
+ 'user-agent': 'kabmo-console',
102
+ };
103
+ if (token) headers.authorization = `Bearer ${token}`;
104
+ return headers;
105
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { beforeEach, describe, expect, test } from 'bun:test';
7
+
8
+ import { fetchShellRelease, resetShellReleaseCache } from '@/server/lib/updates/shell-release';
9
+ import { releaseTags } from '@/server/lib/updates/shell-pages';
10
+
11
+
12
+ /**
13
+ * A double that routes by URL, for the two-path probe.
14
+ *
15
+ * `api` answers the REST read (or fails, which is what a spent quota does) and
16
+ * the pages answer the Atom feed and each release's asset fragment. Returning
17
+ * `undefined` for a route means "this request fails", which is how the fallback
18
+ * is entered at all.
19
+ */
20
+ function routedFetch(routes: {
21
+ api?: unknown;
22
+ atom?: string;
23
+ assets?: Record<string, string>;
24
+ }) {
25
+ const calls: string[] = [];
26
+ const impl = (async (input: string | URL | Request) => {
27
+ const url = String(input);
28
+ calls.push(url);
29
+
30
+ if (url.includes('/repos/')) {
31
+ if (routes.api === undefined) return { ok: false, json: async () => ({}), text: async () => '' } as Response;
32
+ return { ok: true, json: async () => routes.api, text: async () => JSON.stringify(routes.api) } as Response;
33
+ }
34
+ if (url.endsWith('/releases.atom')) {
35
+ const body = routes.atom ?? '';
36
+ return { ok: Boolean(routes.atom), json: async () => ({}), text: async () => body } as Response;
37
+ }
38
+ const tag = url.slice(url.lastIndexOf('/') + 1);
39
+ const body = routes.assets?.[decodeURIComponent(tag)];
40
+ return { ok: body !== undefined, json: async () => ({}), text: async () => body ?? '' } as Response;
41
+ }) as unknown as typeof fetch;
42
+ return { impl, calls };
43
+ }
44
+
45
+ /**
46
+ * The public-pages fallback, and the reason it exists.
47
+ *
48
+ * The REST read is unauthenticated at 60 requests an hour per source IP, and on a
49
+ * mobile network that IP is a carrier NAT shared with an entire subscriber base.
50
+ * Measured on the author's machine: `remaining: 0`, so the probe answered `null`
51
+ * and the console drew no update button for an APK two versions behind the
52
+ * published one — a missing button being indistinguishable from "you are up to
53
+ * date". These cases pin the second path, which is what the report needed.
54
+ */
55
+ describe('the fallback when the API is unavailable', () => {
56
+ beforeEach(() => resetShellReleaseCache());
57
+
58
+ const ATOM = `<?xml version="1.0" encoding="UTF-8"?>
59
+ <feed xmlns="http://www.w3.org/2005/Atom">
60
+ <title>Release notes from kabmo</title>
61
+ <entry>
62
+ <id>tag:github.com,2008:Repository/1/v0.7.0</id>
63
+ <link rel="alternate" type="text/html" href="https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0"/>
64
+ <title>v0.7.0</title>
65
+ </entry>
66
+ <entry>
67
+ <link rel="alternate" type="text/html" href="https://github.com/kabmo-labs/kabmo/releases/tag/kabmo-sdk/v0.1.0"/>
68
+ <title>kabmo-sdk/v0.1.0</title>
69
+ </entry>
70
+ <entry>
71
+ <link rel="alternate" type="text/html" href="https://github.com/kabmo-labs/kabmo/releases/tag/v0.6.1"/>
72
+ <title>v0.6.1</title>
73
+ </entry>
74
+ </feed>`;
75
+
76
+ /** The fragment GitHub's release page loads, trimmed to its asset links. */
77
+ const ASSETS_V070 = `
78
+ <div class="Box">
79
+ <li><a href="/kabmo-labs/kabmo/releases/download/v0.7.0/Kabmo-0.6.0-android.apk"><span class="text-bold">Kabmo-0.6.0-android.apk</span></a></li>
80
+ <li><a href="/kabmo-labs/kabmo/releases/download/v0.7.0/Kabmo-0.2.0-mac-arm64.zip"><span class="text-bold">Kabmo-0.2.0-mac-arm64.zip</span></a></li>
81
+ </div>`;
82
+
83
+ test('resolves the APK when the API read is refused', async () => {
84
+ // `api: undefined` is a spent quota: the route answers 403 with a body that
85
+ // is not a release list.
86
+ const { impl, calls } = routedFetch({ atom: ATOM, assets: { 'v0.7.0': ASSETS_V070 } });
87
+
88
+ expect(await fetchShellRelease('android', impl)).toEqual({
89
+ url: 'https://github.com/kabmo-labs/kabmo/releases/download/v0.7.0/Kabmo-0.6.0-android.apk',
90
+ latest: '0.6.0',
91
+ releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
92
+ direct: true,
93
+ });
94
+ expect(calls.some((url) => url.includes('/repos/'))).toBe(true);
95
+ expect(calls.some((url) => url.endsWith('/releases.atom'))).toBe(true);
96
+ });
97
+
98
+ test('walks past a release that carries no artifact of this shell', async () => {
99
+ // The packages are released FIRST, so the newest tag is often a
100
+ // `kabmo-sdk/v*` release with no shell in it — the same shape that made
101
+ // `/releases/latest` wrong.
102
+ const { impl } = routedFetch({
103
+ atom: ATOM,
104
+ assets: { 'kabmo-sdk/v0.1.0': '<div>no assets</div>', 'v0.7.0': ASSETS_V070 },
105
+ });
106
+
107
+ expect((await fetchShellRelease('android', impl))?.latest).toBe('0.6.0');
108
+ });
109
+
110
+ test('answers the release page for a desktop shell with no arch', async () => {
111
+ const { impl } = routedFetch({ atom: ATOM, assets: { 'v0.7.0': ASSETS_V070 } });
112
+
113
+ expect(await fetchShellRelease('macos', impl)).toEqual({
114
+ url: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
115
+ latest: '0.2.0',
116
+ releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
117
+ direct: false,
118
+ });
119
+ });
120
+
121
+ test('answers that arch’s own artifact when the fragment lists it', async () => {
122
+ // The pages fallback carries the same arch rule as the API read: a desktop
123
+ // shell that named its machine gets that machine's installer, and a build it
124
+ // does not publish is never substituted.
125
+ const { impl } = routedFetch({ atom: ATOM, assets: { 'v0.7.0': ASSETS_V070 } });
126
+
127
+ expect(await fetchShellRelease('macos', impl, 'arm64')).toEqual({
128
+ url: 'https://github.com/kabmo-labs/kabmo/releases/download/v0.7.0/Kabmo-0.2.0-mac-arm64.zip',
129
+ latest: '0.2.0',
130
+ releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
131
+ direct: true,
132
+ });
133
+
134
+ // x64 is not in the fragment, so the page is the answer rather than the
135
+ // arm64 file.
136
+ const again = routedFetch({ atom: ATOM, assets: { 'v0.7.0': ASSETS_V070 } });
137
+ expect(await fetchShellRelease('macos', again.impl, 'x64')).toEqual({
138
+ url: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
139
+ latest: '0.2.0',
140
+ releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
141
+ direct: false,
142
+ });
143
+ });
144
+
145
+ test('a release with no artifact anywhere answers empty, not null', async () => {
146
+ // "The read worked and there is nothing" and "the read failed" must stay
147
+ // distinguishable: the first is cacheable and the second keeps the last good
148
+ // answer.
149
+ const { impl } = routedFetch({ atom: ATOM, assets: { 'v0.7.0': '<div></div>' } });
150
+ expect(await fetchShellRelease('android', impl)).toEqual({
151
+ url: null,
152
+ latest: null,
153
+ releaseUrl: null,
154
+ direct: false,
155
+ });
156
+ });
157
+
158
+ test('an unreachable feed answers null rather than an empty result', async () => {
159
+ const { impl } = routedFetch({});
160
+ expect(await fetchShellRelease('android', impl)).toBeNull();
161
+ });
162
+
163
+ test('the API answer wins when it works, so the fallback costs nothing', async () => {
164
+ const { impl, calls } = routedFetch({
165
+ api: [
166
+ {
167
+ html_url: 'https://x/api',
168
+ assets: [{ name: 'Kabmo-1.0.0-android.apk', browser_download_url: 'https://x/a.apk' }],
169
+ },
170
+ ],
171
+ atom: ATOM,
172
+ assets: { 'v0.7.0': ASSETS_V070 },
173
+ });
174
+
175
+ expect((await fetchShellRelease('android', impl))?.latest).toBe('1.0.0');
176
+ expect(calls.some((url) => url.endsWith('/releases.atom'))).toBe(false);
177
+ });
178
+
179
+ test('releaseTags reads the feed in order, with each page', () => {
180
+ expect(releaseTags(ATOM)).toEqual([
181
+ { name: 'v0.7.0', url: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0' },
182
+ { name: 'kabmo-sdk/v0.1.0', url: 'https://github.com/kabmo-labs/kabmo/releases/tag/kabmo-sdk/v0.1.0' },
183
+ { name: 'v0.6.1', url: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.6.1' },
184
+ ]);
185
+ expect(releaseTags('')).toEqual([]);
186
+ });
187
+ });
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The PUBLIC release pages, for when the GitHub API is unavailable.
3
+ *
4
+ * Split from `shell-release.ts` because it is a second, independent read: the API
5
+ * path parses JSON, this one parses an Atom feed and an HTML fragment, and the two
6
+ * share nothing but the asset-naming rules in `shell-assets`.
7
+ *
8
+ * ## Why it exists
9
+ *
10
+ * The REST API is unauthenticated at 60 requests an hour **per source IP** — and
11
+ * on a mobile network that IP is shared with an entire carrier NAT. Measured on
12
+ * the author's machine: `remaining: 0`, so the probe answered `null` and the
13
+ * console drew no update button for an APK two versions behind the published one.
14
+ * A missing button is indistinguishable from "you are up to date", which is why
15
+ * that failed silently for as long as it did.
16
+ *
17
+ * `github.com/<repo>/releases.atom` is a plain Atom feed of published releases
18
+ * (newest first) and `github.com/<repo>/releases/expanded_assets/<tag>` is the
19
+ * HTML fragment the release page itself loads. Both are served without an API
20
+ * budget — verified live: 200 on both, and the fragment carries every asset link.
21
+ */
22
+
23
+ import { REPO } from '@/server/lib/updates/release-url';
24
+ import { SHELL_ASSETS, selfUpdateArtifact, versionFromAssetName } from '@/server/lib/updates/shell-assets';
25
+ import type { ShellArch } from '@/shared/lib/updates/shell-arch';
26
+ import type { ShellKind } from '@/shared/lib/updates/shell-ua';
27
+
28
+ /** How many tags to walk before giving up. */
29
+ const PAGE_SCAN = 6;
30
+
31
+ /** A tag from the Atom feed, with the release page it names. */
32
+ export interface ReleaseTag {
33
+ name: string;
34
+ url: string | null;
35
+ }
36
+
37
+ /** One asset found in a release's fragment. */
38
+ export interface PageAsset {
39
+ name: string;
40
+ downloadUrl: string;
41
+ }
42
+
43
+ /**
44
+ * What the pages read concluded.
45
+ *
46
+ * THREE cases, not two, because "the feed could not be read" and "the feed was
47
+ * read and carries no artifact of this shell" are different facts: the first
48
+ * keeps the last good answer, the second is a cacheable empty result. Collapsing
49
+ * them makes a momentary outage look like "no update published", which is the
50
+ * same silence the API path's `null` exists to avoid.
51
+ */
52
+ export type PagesResult =
53
+ | { kind: 'found'; asset: PageAsset; releaseUrl: string | null }
54
+ | { kind: 'none' }
55
+ | { kind: 'unreachable' };
56
+
57
+ /**
58
+ * The newest tag that carries this shell's artifact.
59
+ *
60
+ * The FIRST tag with a matching asset wins, because the feed is newest-first and
61
+ * a release that carries no artifact of this shell (a `kabmo-sdk/v*` package
62
+ * release, which the pipeline publishes before the app) must not stop the walk.
63
+ */
64
+ export async function findAssetInPages(
65
+ kind: ShellKind,
66
+ fetchImpl: typeof fetch,
67
+ headers: Record<string, string>,
68
+ arch: ShellArch | null = null,
69
+ ): Promise<PagesResult> {
70
+ const feed = await fetchImpl(`https://github.com/${REPO}/releases.atom`, {
71
+ headers: { accept: 'application/atom+xml, text/xml', ...headers },
72
+ });
73
+ if (!feed.ok) return { kind: 'unreachable' };
74
+
75
+ const tags = releaseTags(await feed.text());
76
+ if (tags.length === 0) return { kind: 'none' };
77
+
78
+ for (const tag of tags.slice(0, PAGE_SCAN)) {
79
+ const page = await fetchImpl(
80
+ `https://github.com/${REPO}/releases/expanded_assets/${encodeURIComponent(tag.name)}`,
81
+ { headers: { accept: 'text/html', ...headers } },
82
+ );
83
+ if (!page.ok) continue;
84
+
85
+ const asset = shellAssetFromPage(kind, await page.text(), arch);
86
+ if (!asset) continue;
87
+
88
+ return { kind: 'found', asset, releaseUrl: tag.url };
89
+ }
90
+
91
+ return { kind: 'none' };
92
+ }
93
+
94
+ /**
95
+ * The feed's entries, in the order GitHub lists them (newest first).
96
+ *
97
+ * Parsed with two regexes rather than an XML parser: the feed is a fixed shape
98
+ * GitHub emits, and a dependency for two fields would be the larger risk. The
99
+ * entity decode covers the one escape that appears in practice.
100
+ */
101
+ export function releaseTags(xml: string): ReleaseTag[] {
102
+ const tags: ReleaseTag[] = [];
103
+ const entries = xml.split('<entry>').slice(1);
104
+
105
+ for (const entry of entries) {
106
+ const title = /<title>([^<]*)<\/title>/.exec(entry)?.[1];
107
+ if (!title) continue;
108
+ const href = /<link[^>]*rel="alternate"[^>]*href="([^"]+)"/.exec(entry)?.[1] ?? null;
109
+ tags.push({ name: decodeEntities(title.trim()), url: href ? decodeEntities(href) : null });
110
+ }
111
+
112
+ return tags;
113
+ }
114
+
115
+ /**
116
+ * The asset links inside a release's `expanded_assets` fragment.
117
+ *
118
+ * The name is read out of the DOWNLOAD URL rather than the link's text: the
119
+ * fragment wraps it in a `<span class="text-bold">` whose markup has changed
120
+ * before, while the URL is the release's own path and carries the same name.
121
+ *
122
+ * The CURRENT naming scheme wins over the legacy one, for the reason
123
+ * `shell-release`'s API path documents: a release carrying both spellings must
124
+ * not resolve to the older version.
125
+ */
126
+ export function shellAssetFromPage(kind: ShellKind, html: string, arch: ShellArch | null = null): PageAsset | null {
127
+ const found: PageAsset[] = [];
128
+
129
+ for (const match of html.matchAll(ASSET_HREF)) {
130
+ const path = decodeEntities(match[1] ?? '');
131
+ const name = decodeURIComponent(path.slice(path.lastIndexOf('/') + 1));
132
+ if (versionFromAssetName(kind, name) !== null) found.push({ name, downloadUrl: `https://github.com${path}` });
133
+ }
134
+
135
+ // The architecture decides FIRST, for the reason the API path documents: a
136
+ // desktop shell that named its own machine must not be handed the other
137
+ // architecture's installer just because the fragment lists it first.
138
+ if (arch) {
139
+ const exact = found.find((asset) => selfUpdateArtifact(kind, arch, asset.name) !== null);
140
+ if (exact) return exact;
141
+ }
142
+
143
+ return found.find((asset) => SHELL_ASSETS[kind].current.test(asset.name)) ?? found[0] ?? null;
144
+ }
145
+
146
+ /** One asset link in a release fragment. Stated once: the loop and the regex must agree. */
147
+ const ASSET_HREF = /href="(\/[^"]+\/releases\/download\/[^"]+)"/g;
148
+
149
+ /** The entities that appear in a feed title or a link. */
150
+ function decodeEntities(text: string): string {
151
+ return text
152
+ .replace(/&lt;/g, '<')
153
+ .replace(/&gt;/g, '>')
154
+ .replace(/&quot;/g, '"')
155
+ .replace(/&#39;/g, "'")
156
+ .replace(/&amp;/g, '&');
157
+ }