@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
@@ -32,7 +32,7 @@
32
32
  * overwrite the position this hook exists to keep.
33
33
  */
34
34
 
35
- import { useCallback, useContext, useLayoutEffect, useRef } from 'preact/hooks';
35
+ import { useCallback, useContext, useEffect, useLayoutEffect, useRef } from 'preact/hooks';
36
36
  import { SessionStateContext } from '@/client/hooks/workspace/session-state/context';
37
37
  import { getSessionValue, setSessionKey } from '@/shared/lib/workspace/session-state/store';
38
38
  import {
@@ -52,6 +52,29 @@ const SAVE_DEBOUNCE_MS = 150;
52
52
  /** Scroll deltas below this are rounding, not a move. */
53
53
  const NOISE_PX = 1;
54
54
 
55
+ /**
56
+ * The cancel hook of every restore currently armed, by scroller.
57
+ *
58
+ * A pending restore REPLAYS while the document settles, and it re-applies
59
+ * through a `ResizeObserver` — which can fire BEFORE the scroll event a
60
+ * programmatic reveal causes. Measured: an outline jump scrolled a 2 520-line
61
+ * file to 34 695 px and the observer wrote the stored 0 back 375 ms later, so
62
+ * the caret moved and the view stayed at the top.
63
+ *
64
+ * An explicit reveal therefore has to CANCEL the restore rather than race it,
65
+ * and the surface that scrolls is the one that knows. This registry is that
66
+ * hand-off: keyed by the element, so a caller needs no reference to the hook
67
+ * instance that armed it (the surface owns the scroller; the panel owns the
68
+ * hook).
69
+ */
70
+ const armedRestores = new Map<HTMLElement, () => void>();
71
+
72
+ /** Stop a pending restore of `element` — a reveal wins over a remembered offset. */
73
+ export function cancelScrollRestore(element: HTMLElement | null): void {
74
+ if (!element) return;
75
+ armedRestores.get(element)?.();
76
+ }
77
+
55
78
  /** What a caller attaches to its scroll container. */
56
79
  export interface ScrollRestoreBinding {
57
80
  ref: (element: HTMLElement | null) => void;
@@ -67,6 +90,16 @@ export interface UseScrollRestoreOptions {
67
90
  ready?: boolean;
68
91
  /** The session-state slot to read/write; the editor column's by default. */
69
92
  slot?: string;
93
+ /**
94
+ * The element to restore, for a caller that does not own a `ref` on it.
95
+ *
96
+ * The code surface's scroller belongs to CodeMirror (`view.scrollDOM`) and
97
+ * exists only after the view mounts, so the surface reports it through
98
+ * `onScroller` and the caller passes it here. `undefined` means "this caller
99
+ * uses `ref`"; `null` means "the element is not there yet", which re-arms the
100
+ * restore on the next render that has one.
101
+ */
102
+ element?: HTMLElement | null;
70
103
  }
71
104
 
72
105
  export function useScrollRestore({
@@ -74,6 +107,7 @@ export function useScrollRestore({
74
107
  surface,
75
108
  ready = true,
76
109
  slot = SCROLL_POSITIONS_KEY,
110
+ element: providedElement,
77
111
  }: UseScrollRestoreOptions): ScrollRestoreBinding {
78
112
  // Read OPTIONALLY: the shared diff views also draw a chat tool card, and a
79
113
  // caller may mount one without the provider. A null context makes the whole
@@ -100,6 +134,37 @@ export function useScrollRestore({
100
134
  * whose height is still growing then stayed where the clamp left it.
101
135
  */
102
136
  const writingRef = useRef(false);
137
+ /**
138
+ * What the element's `scrollTop` was at the last observation, so a scroll
139
+ * event that moves it WITHOUT anyone assigning can be recognised as the
140
+ * browser's own anchoring rather than the reader's.
141
+ *
142
+ * This is the bug the e2e spec found on CI and could not explain: returning to
143
+ * a long file landed the reader on line 99 instead of line 70, and the wrong
144
+ * offset was then persisted. Measured in the browser: the restore wrote 1255,
145
+ * the document's real height arrived a frame later (5796 → 7232), and
146
+ * CodeMirror re-anchored — `scrollTop` became 1777 with no assignment from
147
+ * this hook. That produced a `scroll` event, the handler below compared it
148
+ * against `restoredTopRef` (1255), read a 522px delta, and treated it as the
149
+ * reader's own scroll: the pending restore was dropped, and the anchored
150
+ * value became what got stored. The reader moved 29 lines for opening a tab.
151
+ *
152
+ * An anchoring correction is not a reader. It is re-applied like any other
153
+ * height change, because the target offset is the one the session recorded.
154
+ */
155
+ const observedTopRef = useRef(0);
156
+ /**
157
+ * Whether the reader has DONE something since the last scroll event — the
158
+ * only thing that separates their scroll from the browser's own.
159
+ *
160
+ * Every way a reader scrolls a code surface starts with an input event: a
161
+ * wheel, a key (arrows, PageDown, Home/End), a pointer press on the scrollbar
162
+ * or a drag-select, a touch. CodeMirror re-anchoring starts with none, so a
163
+ * `scroll` with no input behind it is the document settling. The flag is
164
+ * cleared by the scroll it explains, so the next unattributed move is read as
165
+ * anchoring again.
166
+ */
167
+ const readerIntentRef = useRef(false);
103
168
  /** The last offset the READER produced, persisted on the way out. */
104
169
  const lastTopRef = useRef<number | null>(null);
105
170
  const saveTimerRef = useRef<ReturnType<typeof setTimeout>>();
@@ -113,13 +178,17 @@ export function useScrollRestore({
113
178
  pendingRef.current = null;
114
179
  return;
115
180
  }
116
- if (Math.abs(element.scrollTop - pending.top) < NOISE_PX) return;
181
+ if (Math.abs(element.scrollTop - pending.top) < NOISE_PX) {
182
+ observedTopRef.current = element.scrollTop;
183
+ return;
184
+ }
117
185
  writingRef.current = true;
118
186
  element.scrollTop = pending.top;
119
187
  writingRef.current = false;
120
188
  // Read back: the document may be shorter than the target, and the clamped
121
189
  // value is what an asynchronous scroll event will report.
122
190
  restoredTopRef.current = element.scrollTop;
191
+ observedTopRef.current = element.scrollTop;
123
192
  }, []);
124
193
 
125
194
  const attach = useCallback(
@@ -152,9 +221,23 @@ export function useScrollRestore({
152
221
  // because the event may arrive synchronously (during the assignment) or
153
222
  // on a later frame (against the value the write landed on).
154
223
  if (writingRef.current) return;
224
+ // Moved with nobody assigning AND no reader input behind it: CodeMirror
225
+ // re-anchoring after the document's real height landed. NOT the reader —
226
+ // re-applying the recorded offset is what keeps them where they were. See
227
+ // `observedTopRef` for the measurement.
228
+ const moved = Math.abs(top - observedTopRef.current) >= NOISE_PX;
229
+ observedTopRef.current = top;
230
+ if (moved && !readerIntentRef.current) {
231
+ applyPending();
232
+ return;
233
+ }
155
234
  if (Math.abs(top - restoredTopRef.current) < NOISE_PX * 2) return;
156
235
  pendingRef.current = null;
157
236
  }
237
+ observedTopRef.current = top;
238
+ // The input this scroll belongs to has now been accounted for; the next
239
+ // unattributed move is anchoring again.
240
+ readerIntentRef.current = false;
158
241
  lastTopRef.current = top;
159
242
  clearTimeout(saveTimerRef.current);
160
243
  saveTimerRef.current = setTimeout(() => {
@@ -162,14 +245,47 @@ export function useScrollRestore({
162
245
  const value = lastTopRef.current;
163
246
  if (value !== null) persist(value);
164
247
  }, SAVE_DEBOUNCE_MS);
165
- }, [persist, tabKey]);
248
+ }, [applyPending, persist, tabKey]);
249
+
250
+ // A provided element is not a React-rendered node, so its scroll events have
251
+ // to be wired here: the caller has no `onScroll` prop to hang them on. The
252
+ // ref path keeps the caller's own `onScroll`.
253
+ useEffect(() => {
254
+ if (!providedElement) return;
255
+ const listener = () => onScroll();
256
+ providedElement.addEventListener('scroll', listener, { passive: true });
257
+ return () => providedElement.removeEventListener('scroll', listener);
258
+ }, [providedElement, onScroll]);
259
+
260
+ // What counts as the reader having scrolled, for `readerIntentRef`. Wired on
261
+ // the element AND the window, because a keydown goes to the focused editor
262
+ // while a wheel over the gutter goes to the window's capture phase.
263
+ useEffect(() => {
264
+ const element = providedElement ?? elementRef.current;
265
+ if (!element) return;
266
+ const note = () => {
267
+ readerIntentRef.current = true;
268
+ };
269
+ const types = ['wheel', 'keydown', 'pointerdown', 'touchstart'] as const;
270
+ for (const type of types) element.addEventListener(type, note, { passive: true });
271
+ window.addEventListener('keydown', note, { passive: true });
272
+ return () => {
273
+ for (const type of types) element.removeEventListener(type, note);
274
+ window.removeEventListener('keydown', note);
275
+ };
276
+ }, [providedElement]);
166
277
 
167
278
  // A layout effect so the restore lands in the same frame the tab paints — a
168
279
  // passive effect would show the document's head for one frame first.
169
280
  useLayoutEffect(() => {
170
281
  pendingRef.current = null;
171
282
  lastTopRef.current = null;
283
+ readerIntentRef.current = false;
172
284
  let observer: ResizeObserver | null = null;
285
+ // A provided element WINS over the ref: the caller handed us the element it
286
+ // wants restored, and a stale ref from the other surface of the same tab
287
+ // must not be restored instead.
288
+ if (providedElement !== undefined) elementRef.current = providedElement;
173
289
  const element = elementRef.current;
174
290
 
175
291
  if (ready && storeReady && tabKey && element) {
@@ -179,6 +295,9 @@ export function useScrollRestore({
179
295
  // of it. "No stored offset" means the top, and the top is where they go.
180
296
  const top = readScrollTop(getSessionValue<ScrollPositions>(sessionId, slot), tabKey, surface);
181
297
  pendingRef.current = { top, until: performance.now() + RESTORE_WINDOW_MS };
298
+ armedRestores.set(element, () => {
299
+ pendingRef.current = null;
300
+ });
182
301
  applyPending();
183
302
  // The document's own height is what moves after this commit (measurement,
184
303
  // a width animation, spacers landing), so its growth is what re-applies.
@@ -190,6 +309,7 @@ export function useScrollRestore({
190
309
  }
191
310
 
192
311
  return () => {
312
+ if (element) armedRestores.delete(element);
193
313
  observer?.disconnect();
194
314
  clearTimeout(saveTimerRef.current);
195
315
  saveTimerRef.current = undefined;
@@ -203,7 +323,7 @@ export function useScrollRestore({
203
323
  };
204
324
  // `applyPending`/`persist` are stable, and re-running this effect restarts
205
325
  // the restore — which is exactly what a tab or surface change needs.
206
- }, [ready, storeReady, tabKey, surface, sessionId, slot, applyPending, persist]);
326
+ }, [ready, storeReady, tabKey, surface, sessionId, slot, applyPending, persist, providedElement]);
207
327
 
208
328
  return { ref: attach, onScroll };
209
329
  }
@@ -29,7 +29,7 @@
29
29
  import { useEffect, useMemo, useState } from 'preact/hooks';
30
30
  import { useSyncExternalStore } from 'preact/compat';
31
31
 
32
- import { findMatches, lineStartOffset, matchIndexAtOrAfter } from '@/shared/lib/code/editor/find';
32
+ import { findMatches, lineStartOffset, matchIndexAtOrAfter } from '@/shared/lib/code/editor/find-scan';
33
33
  import {
34
34
  getSearchReveal,
35
35
  setSearchReveal,
@@ -0,0 +1,174 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Long press: the only way a finger reaches a Files-panel row's menu.
8
+ *
9
+ * Three rules are load-bearing and each was a way the gesture would misbehave
10
+ * on a phone:
11
+ *
12
+ * 1. **A press that MOVES is a scroll.** The tree is a scroller, and a hold that
13
+ * begins a flick must cancel — otherwise every scroll that starts on a row
14
+ * opens a menu.
15
+ * 2. **The click the same gesture produces is swallowed.** Lifting the finger
16
+ * after the menu opened would otherwise also toggle the folder it opened for;
17
+ * the platform raises both events for one gesture and marks neither.
18
+ * 3. **It is inert without a finger.** A desktop holding its primary button down
19
+ * is a drag, and a mouse already has a right button.
20
+ *
21
+ * Time is driven, never slept through: a 450 ms hold is a real wait, and the
22
+ * hook's decisions are all about WHICH event arrives, never about how long the
23
+ * test took.
24
+ */
25
+
26
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'bun:test';
27
+ import { Window } from 'happy-dom';
28
+ import { h, render } from 'preact';
29
+ import { act } from 'preact/test-utils';
30
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
31
+ import { useLongPress, type LongPressHandlers } from '@/client/hooks/ui/long-press';
32
+
33
+ let win: Window;
34
+ let container: HTMLElement;
35
+ let handlers: LongPressHandlers | null = null;
36
+ const fired: Array<{ x: number; y: number }> = [];
37
+
38
+ beforeEach(() => {
39
+ vi.useFakeTimers();
40
+ win = new Window({ url: 'http://localhost' });
41
+ installDomGlobals(win);
42
+ container = document.createElement('div');
43
+ document.body.appendChild(container);
44
+ fired.length = 0;
45
+ handlers = null;
46
+ });
47
+
48
+ afterEach(() => {
49
+ render(null, container);
50
+ container.remove();
51
+ restoreDomGlobals();
52
+ vi.useRealTimers();
53
+ });
54
+
55
+ /** Mount the hook; `enabled` is what a touch device decides. */
56
+ async function mount(enabled: boolean): Promise<void> {
57
+ const Probe = () => {
58
+ handlers = useLongPress({ enabled, onLongPress: (x, y) => fired.push({ x, y }) });
59
+ return null;
60
+ };
61
+ await act(async () => {
62
+ render(h(Probe, {}), container);
63
+ });
64
+ }
65
+
66
+ /** A touch event carrying one finger at `(x, y)`. */
67
+ function touch(type: string, x: number, y: number): globalThis.TouchEvent {
68
+ const event = new win.Event(type, { bubbles: true, cancelable: true }) as unknown as globalThis.TouchEvent;
69
+ Object.defineProperty(event, 'touches', { value: type === 'touchend' || type === 'touchcancel' ? [] : [{ clientX: x, clientY: y }] });
70
+ return event;
71
+ }
72
+
73
+ /** Advance the hook's own timer, without waiting on a real clock. */
74
+ async function holdFor(ms: number): Promise<void> {
75
+ await act(async () => {
76
+ vi.advanceTimersByTime(ms);
77
+ });
78
+ }
79
+
80
+ describe('useLongPress', () => {
81
+ test('a hold fires at the press point', async () => {
82
+ await mount(true);
83
+
84
+ await act(async () => {
85
+ handlers!.onTouchStart(touch('touchstart', 40, 80));
86
+ });
87
+ await holdFor(520);
88
+
89
+ expect(fired).toEqual([{ x: 40, y: 80 }]);
90
+ });
91
+
92
+ test('a press released before the threshold never fires', async () => {
93
+ await mount(true);
94
+
95
+ await act(async () => {
96
+ handlers!.onTouchStart(touch('touchstart', 40, 80));
97
+ });
98
+ await act(async () => {
99
+ handlers!.onTouchEnd();
100
+ });
101
+ await holdFor(520);
102
+
103
+ expect(fired).toEqual([]);
104
+ });
105
+
106
+ test('a press that MOVES is a scroll, not a press', async () => {
107
+ await mount(true);
108
+
109
+ await act(async () => {
110
+ handlers!.onTouchStart(touch('touchstart', 40, 80));
111
+ // Past the slop: the first few pixels of a flick arrive immediately, and
112
+ // a menu that opened on them would make the tree unscrollable.
113
+ handlers!.onTouchMove(touch('touchmove', 40, 140));
114
+ });
115
+ await holdFor(520);
116
+
117
+ expect(fired).toEqual([]);
118
+ });
119
+
120
+ test('a small drift inside the slop still counts as a hold', async () => {
121
+ await mount(true);
122
+
123
+ await act(async () => {
124
+ handlers!.onTouchStart(touch('touchstart', 40, 80));
125
+ handlers!.onTouchMove(touch('touchmove', 42, 83));
126
+ });
127
+ await holdFor(520);
128
+
129
+ expect(fired).toHaveLength(1);
130
+ });
131
+
132
+ test('a cancelled press never fires', async () => {
133
+ await mount(true);
134
+
135
+ await act(async () => {
136
+ handlers!.onTouchStart(touch('touchstart', 40, 80));
137
+ handlers!.onTouchCancel();
138
+ });
139
+ await holdFor(520);
140
+
141
+ expect(fired).toEqual([]);
142
+ });
143
+
144
+ test('the click after a fired press is consumed exactly once', async () => {
145
+ await mount(true);
146
+
147
+ await act(async () => {
148
+ handlers!.onTouchStart(touch('touchstart', 40, 80));
149
+ });
150
+ await holdFor(520);
151
+
152
+ // The first click belongs to the gesture that opened the menu; a second
153
+ // click is the user's own and must work.
154
+ expect(handlers!.consume()).toBe(true);
155
+ expect(handlers!.consume()).toBe(false);
156
+ });
157
+
158
+ test('no press means nothing to consume', async () => {
159
+ await mount(true);
160
+
161
+ expect(handlers!.consume()).toBe(false);
162
+ });
163
+
164
+ test('it is inert without a finger', async () => {
165
+ await mount(false);
166
+
167
+ await act(async () => {
168
+ handlers!.onTouchStart(touch('touchstart', 40, 80));
169
+ });
170
+ await holdFor(520);
171
+
172
+ expect(fired).toEqual([]);
173
+ });
174
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Long press, for the surfaces that have no right-click.
8
+ *
9
+ * A phone's Files panel offered no way to reach a row's context menu: the menu
10
+ * opens on `contextmenu`, which a touch device only raises after a press that
11
+ * the browser has already claimed for something else — a native drag (the row
12
+ * is a drag source) or a text selection of the whole panel. Measured on a
13
+ * phone: holding a file row selected the tree's text and started a drag ghost,
14
+ * and no menu ever appeared.
15
+ *
16
+ * Three rules make this usable rather than a second way to break the panel:
17
+ *
18
+ * - **A press that MOVES is a scroll, not a press.** The tree is a scroller, and
19
+ * a hold that begins a scroll must cancel — otherwise every flick that starts
20
+ * on a row opens a menu. The threshold is small (`SLOP_PX`) because the first
21
+ * few pixels of a scroll arrive within the same tick.
22
+ * - **A fired press suppresses the CLICK it would otherwise produce.** Lifting
23
+ * the finger after the menu opened would otherwise also toggle the folder the
24
+ * menu was opened for. The caller asks `consume()` from its click handler,
25
+ * which is the only place the two can be told apart — the platform raises both
26
+ * events for one gesture and does not mark either.
27
+ * - **It is inert without a touch device.** A mouse already has a right button,
28
+ * and a desktop holding its primary button down over a row is a drag.
29
+ *
30
+ * The handler is deliberately not `onContextMenu`-based: some Android builds DO
31
+ * raise `contextmenu` after a hold, and the browser's own press has already
32
+ * started a selection by then, so the menu would open over a half-selected tree.
33
+ */
34
+
35
+ import { useCallback, useEffect, useRef } from 'preact/hooks';
36
+
37
+ /** How long a press must be held before it counts. */
38
+ const LONG_PRESS_MS = 450;
39
+ /** Movement that turns a press into a scroll, in CSS pixels. */
40
+ const SLOP_PX = 8;
41
+ /** How long a fired press keeps suppressing the click it produces. */
42
+ const CLICK_SUPPRESS_MS = 700;
43
+
44
+ export interface LongPressOptions {
45
+ /** False on a device with a pointer: the right button already does this. */
46
+ enabled: boolean;
47
+ /** Where the press landed, in viewport coordinates — a menu anchor. */
48
+ onLongPress: (x: number, y: number) => void;
49
+ }
50
+
51
+ export interface LongPressHandlers {
52
+ onTouchStart: (event: globalThis.TouchEvent) => void;
53
+ onTouchMove: (event: globalThis.TouchEvent) => void;
54
+ onTouchEnd: () => void;
55
+ onTouchCancel: () => void;
56
+ /**
57
+ * True exactly once after a press fired, so the caller's click handler can
58
+ * ignore the click the same gesture produces.
59
+ */
60
+ consume: () => boolean;
61
+ }
62
+
63
+ export function useLongPress({ enabled, onLongPress }: LongPressOptions): LongPressHandlers {
64
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
65
+ const originRef = useRef<{ x: number; y: number } | null>(null);
66
+ const firedAtRef = useRef(0);
67
+ /** The latest callback, so a re-render never leaves a stale one armed. */
68
+ const handlerRef = useRef(onLongPress);
69
+ handlerRef.current = onLongPress;
70
+
71
+ const cancel = useCallback(() => {
72
+ if (timerRef.current !== null) {
73
+ clearTimeout(timerRef.current);
74
+ timerRef.current = null;
75
+ }
76
+ originRef.current = null;
77
+ }, []);
78
+
79
+ useEffect(() => cancel, [cancel]);
80
+
81
+ return {
82
+ onTouchStart: (event) => {
83
+ if (!enabled) return;
84
+ const touch = event.touches[0];
85
+ if (!touch) return;
86
+ cancel();
87
+ originRef.current = { x: touch.clientX, y: touch.clientY };
88
+ timerRef.current = setTimeout(() => {
89
+ timerRef.current = null;
90
+ const origin = originRef.current;
91
+ originRef.current = null;
92
+ if (!origin) return;
93
+ firedAtRef.current = Date.now();
94
+ handlerRef.current(origin.x, origin.y);
95
+ }, LONG_PRESS_MS);
96
+ },
97
+ onTouchMove: (event) => {
98
+ const origin = originRef.current;
99
+ const touch = event.touches[0];
100
+ if (!origin || !touch) return;
101
+ if (Math.abs(touch.clientX - origin.x) > SLOP_PX || Math.abs(touch.clientY - origin.y) > SLOP_PX) cancel();
102
+ },
103
+ onTouchEnd: cancel,
104
+ onTouchCancel: cancel,
105
+ consume: () => {
106
+ if (firedAtRef.current === 0) return false;
107
+ const recent = Date.now() - firedAtRef.current < CLICK_SUPPRESS_MS;
108
+ firedAtRef.current = 0;
109
+ return recent;
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The desktop layout's settings modal: whether it is open, which category it
8
+ * opens on, and whether a provider dialog follows.
9
+ *
10
+ * A hook because two different triggers drive it — the sidebar footer's button
11
+ * and the `omp:open-settings` window event (dispatched by the model dropdown,
12
+ * the phone's footer and any plugin) — and the event carries the category it
13
+ * wants. Stated once, the two paths cannot open the same modal on different
14
+ * screens.
15
+ */
16
+
17
+ import { useState } from 'preact/hooks';
18
+ import { useKabmoEvent } from '@/client/hooks/ui/window-event';
19
+ import type { SettingsCategoryId } from '@/shared/types';
20
+
21
+ export interface SettingsModalState {
22
+ isOpen: boolean;
23
+ category: SettingsCategoryId;
24
+ /** The "add provider" dialog opens on top of the modal's Providers screen. */
25
+ autoOpenAddProvider: boolean;
26
+ open: (category?: SettingsCategoryId, autoOpenAdd?: boolean) => void;
27
+ close: () => void;
28
+ }
29
+
30
+ export function useSettingsModal(): SettingsModalState {
31
+ const [isOpen, setOpen] = useState(false);
32
+ const [category, setCategory] = useState<SettingsCategoryId>('appearance');
33
+ const [autoOpenAddProvider, setAutoOpenAddProvider] = useState(false);
34
+
35
+ const open = (next?: SettingsCategoryId, autoOpenAdd = false) => {
36
+ setCategory(next ?? 'appearance');
37
+ setAutoOpenAddProvider(autoOpenAdd);
38
+ setOpen(true);
39
+ };
40
+
41
+ useKabmoEvent('omp:open-settings', (event) => {
42
+ const detail = (event as CustomEvent<{ category?: SettingsCategoryId; autoOpenAdd?: boolean }>).detail;
43
+ open(detail?.category, Boolean(detail?.autoOpenAdd));
44
+ });
45
+
46
+ return {
47
+ isOpen,
48
+ category,
49
+ autoOpenAddProvider,
50
+ open,
51
+ close: () => {
52
+ setOpen(false);
53
+ setAutoOpenAddProvider(false);
54
+ },
55
+ };
56
+ }
@@ -6,8 +6,10 @@
6
6
  import { describe, expect, test } from 'bun:test';
7
7
 
8
8
  import {
9
+ desktopShellFromIdentity,
9
10
  parseShellRelease,
10
11
  shellDownloadLabel,
12
+ shellDownloadShortLabel,
11
13
  shellFromBootstrap,
12
14
  shellReleasePath,
13
15
  shouldOfferShellDownload,
@@ -19,6 +21,7 @@ const RELEASE = {
19
21
  url: 'https://github.com/kabmo-labs/kabmo/releases/download/v1.2.3/Kabmo-1.2.3-android.apk',
20
22
  latest: '1.2.3',
21
23
  releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v1.2.3',
24
+ direct: true,
22
25
  };
23
26
 
24
27
  const UPDATE: ShellDownload = { ...RELEASE, kind: 'update', platform: 'android' };
@@ -155,3 +158,75 @@ describe('shellReleasePath', () => {
155
158
  expect(shellReleasePath('ios')).toBe('/api/updates/ios');
156
159
  });
157
160
  });
161
+
162
+ describe('the desktop shells', () => {
163
+ test('each desktop shell reads its own route, with an explicit kind', () => {
164
+ // ONE route with a required `kind`, so no absence can silently mean a
165
+ // platform — the reason the mobile pair has two routes.
166
+ expect(shellReleasePath('macos')).toBe('/api/updates/desktop?kind=macos');
167
+ expect(shellReleasePath('windows')).toBe('/api/updates/desktop?kind=windows');
168
+ expect(shellReleasePath('linux')).toBe('/api/updates/desktop?kind=linux');
169
+ });
170
+
171
+ test('a desktop shell offers an update when it is behind', () => {
172
+ // The reported gap: an installed desktop app that is behind had no way to
173
+ // learn it, because nothing in the console read its version.
174
+ expect(shouldOfferShellDownload('macos', '0.1.0', null, RELEASE)).toEqual({
175
+ ...RELEASE,
176
+ kind: 'update',
177
+ platform: 'macos',
178
+ });
179
+ expect(shouldOfferShellDownload('windows', '0.1.0', null, RELEASE)?.platform).toBe('windows');
180
+ expect(shouldOfferShellDownload('linux', '0.1.0', null, RELEASE)?.platform).toBe('linux');
181
+ });
182
+
183
+ test('a desktop shell that is current, or ahead, offers nothing', () => {
184
+ expect(shouldOfferShellDownload('macos', '1.2.3', null, RELEASE)).toBeNull();
185
+ expect(shouldOfferShellDownload('macos', '1.3.0', null, RELEASE)).toBeNull();
186
+ });
187
+
188
+ test('a desktop BROWSER is offered nothing', () => {
189
+ // A desktop app is installed by hand from the release page; a tab that
190
+ // advertised one would be a button for a product the reader did not ask for.
191
+ expect(shouldOfferShellDownload('macos', null, false, RELEASE)).toBeNull();
192
+ expect(shouldOfferShellDownload('windows', undefined, undefined, RELEASE)).toBeNull();
193
+ });
194
+
195
+ test('the label names the desktop platform', () => {
196
+ const update: ShellDownload = { ...RELEASE, kind: 'update', platform: 'linux' };
197
+ expect(shellDownloadLabel(update)).toBe('Update Kabmo 1.2.3 for Linux');
198
+ expect(shellDownloadShortLabel(update)).toBe('Update Linux');
199
+ });
200
+
201
+ test('the short label keeps the mobile spellings', () => {
202
+ expect(shellDownloadShortLabel(UPDATE)).toBe('Update APK');
203
+ expect(shellDownloadShortLabel(INSTALL)).toBe('Get APK');
204
+ expect(shellDownloadShortLabel({ ...UPDATE, platform: 'ios' })).toBe('Update app');
205
+ });
206
+ });
207
+
208
+ describe('desktopShellFromIdentity', () => {
209
+ test('reads the bridge identity the shell injects', () => {
210
+ expect(desktopShellFromIdentity({ platform: 'macos', shellVersion: '0.2.0' })).toEqual({
211
+ platform: 'macos',
212
+ version: '0.2.0',
213
+ arch: null,
214
+ });
215
+ expect(desktopShellFromIdentity({ platform: 'windows', shellVersion: '0.2.0' })?.platform).toBe('windows');
216
+ expect(desktopShellFromIdentity({ platform: 'linux', shellVersion: '0.2.0' })?.platform).toBe('linux');
217
+ });
218
+
219
+ test('is null in a browser, and for an identity with no version', () => {
220
+ expect(desktopShellFromIdentity(null)).toBeNull();
221
+ expect(desktopShellFromIdentity(undefined)).toBeNull();
222
+ expect(desktopShellFromIdentity({ platform: 'macos' })).toBeNull();
223
+ expect(desktopShellFromIdentity({ shellVersion: '0.2.0' })).toBeNull();
224
+ });
225
+
226
+ test('an unknown platform is not compared against a published artifact', () => {
227
+ // A newer shell reporting a fourth platform must not be matched to a file
228
+ // nobody publishes.
229
+ expect(desktopShellFromIdentity({ platform: 'beos', shellVersion: '1.0.0' })).toBeNull();
230
+ expect(desktopShellFromIdentity({ platform: 'android', shellVersion: '1.0.0' })).toBeNull();
231
+ });
232
+ });