@kabmo-labs/kabmo 0.8.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 (254) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +314 -314
  4. package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +9 -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/shell-update-button/Modal.tsx +163 -0
  24. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  25. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  26. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  27. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  28. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  29. package/src/client/components/layout/desktop-layout/index.tsx +47 -67
  30. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  31. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  32. package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
  33. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  34. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  35. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  36. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  37. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  38. package/src/client/components/settings/Modal.tsx +8 -0
  39. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  40. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  41. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  42. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
  43. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
  44. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  45. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  46. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  47. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  48. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  49. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  57. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  58. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  59. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  65. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  66. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  67. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  68. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  69. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  70. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  71. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  72. package/src/client/components/workspace/editor/Body.tsx +4 -7
  73. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  74. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  75. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  76. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  77. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  78. package/src/client/components/workspace/editor/index.tsx +7 -9
  79. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  80. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  81. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  82. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  83. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  84. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  85. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  86. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  87. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  88. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  89. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  90. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  91. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  92. package/src/client/hooks/chat/timeline/index.ts +19 -24
  93. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  94. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  95. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  96. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  97. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  98. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  99. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  100. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  101. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  102. package/src/client/hooks/editor/find-state.ts +1 -1
  103. package/src/client/hooks/editor/theme.test.ts +206 -0
  104. package/src/client/hooks/editor/theme.ts +82 -0
  105. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  106. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  107. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  108. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  109. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  110. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  111. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  112. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  113. package/src/client/hooks/ui/settings-modal.ts +56 -0
  114. package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
  115. package/src/client/hooks/ui/updates/shell-download.ts +55 -14
  116. package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
  117. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  118. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  119. package/src/client/hooks/workspace/diff-file.ts +29 -10
  120. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  121. package/src/client/hooks/workspace/editor-view.ts +61 -0
  122. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  123. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  124. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  125. package/src/client/hooks/workspace/worktree/index.ts +20 -5
  126. package/src/client/lib/desktop/bridge.ts +45 -0
  127. package/src/client/lib/search-reveal.ts +1 -1
  128. package/src/client/main.tsx +5 -1
  129. package/src/client/tailwind.css +37 -8
  130. package/src/server/lib/auth/guard.test.ts +28 -0
  131. package/src/server/lib/auth/guard.ts +10 -0
  132. package/src/server/lib/fs/worktree/create.ts +2 -3
  133. package/src/server/lib/fs/worktree/list.ts +13 -0
  134. package/src/server/lib/fs/worktree/remove.ts +3 -3
  135. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  136. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  137. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  138. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  139. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  140. package/src/server/lib/lifecycle/probe.ts +121 -13
  141. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  143. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  144. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  145. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  146. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  147. package/src/server/lib/realtime/signals.server.ts +10 -0
  148. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  149. package/src/server/lib/realtime/topics.server.ts +16 -0
  150. package/src/server/lib/updates/shell-assets.ts +94 -3
  151. package/src/server/lib/updates/shell-cache.ts +105 -0
  152. package/src/server/lib/updates/shell-pages.test.ts +33 -2
  153. package/src/server/lib/updates/shell-pages.ts +17 -5
  154. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  155. package/src/server/lib/updates/shell-release.test.ts +10 -27
  156. package/src/server/lib/updates/shell-release.ts +107 -111
  157. package/src/server/routes/fs/git-merge.test.ts +11 -1
  158. package/src/server/routes/fs/read.test.ts +24 -7
  159. package/src/server/routes/fs/scope.test.ts +15 -10
  160. package/src/server/routes/fs/scope.ts +3 -14
  161. package/src/server/routes/updates/android.ts +6 -1
  162. package/src/server/routes/updates/desktop.ts +29 -7
  163. package/src/server/routes/updates/ios.ts +5 -1
  164. package/src/server/routes/worktrees/list.ts +11 -1
  165. package/src/server/routes/worktrees/routes.test.ts +17 -0
  166. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  167. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  168. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  169. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  170. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  171. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  172. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  173. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  174. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  175. package/src/shared/lib/code/editor/keymap.ts +3 -3
  176. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  177. package/src/shared/lib/code/editor/lines.ts +8 -48
  178. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  179. package/src/shared/lib/code/editor/marks.ts +13 -25
  180. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  181. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  182. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  183. package/src/shared/lib/code/editor/themes.ts +165 -0
  184. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  185. package/src/shared/lib/code/highlight-memo.ts +149 -0
  186. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  187. package/src/shared/lib/code/highlighter.ts +3 -1
  188. package/src/shared/lib/code/idle.ts +37 -0
  189. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  190. package/src/shared/lib/code/outline/rows.ts +94 -0
  191. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  192. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  193. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  194. package/src/shared/lib/fs/file-kind.ts +47 -13
  195. package/src/shared/lib/fs/file-url.test.ts +52 -0
  196. package/src/shared/lib/fs/file-url.ts +37 -0
  197. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  198. package/src/shared/lib/fs/patch-sides.ts +139 -0
  199. package/src/shared/lib/realtime/protocol.ts +16 -0
  200. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  201. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  202. package/src/shared/lib/updates/shell-arch.ts +54 -0
  203. package/src/shared/lib/updates/shell-ua.ts +4 -4
  204. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  205. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  206. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  207. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  208. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  209. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  210. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  211. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  212. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  213. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  214. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  215. package/src/shared/types/realtime.ts +29 -0
  216. package/src/shared/types/settings/state.ts +10 -0
  217. package/src/test-support/loopback.ts +183 -0
  218. package/dist/client/static/js/index-gtbpewr7.css +0 -1
  219. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  220. package/src/client/components/common/code-editor/handler.ts +0 -175
  221. package/src/client/components/common/code-editor/index.tsx +0 -314
  222. package/src/client/components/common/code-surface/measure.ts +0 -148
  223. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
  224. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
  225. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  226. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  227. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  228. package/src/client/hooks/editor/scroll-container.ts +0 -26
  229. package/src/client/hooks/editor/use-code-window.ts +0 -100
  230. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  231. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  232. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  233. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  234. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  235. package/src/shared/lib/code/editor/commands.ts +0 -260
  236. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  237. package/src/shared/lib/code/editor/edits.ts +0 -122
  238. package/src/shared/lib/code/editor/history.test.ts +0 -118
  239. package/src/shared/lib/code/editor/history.ts +0 -101
  240. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  241. package/src/shared/lib/code/editor/layers.ts +0 -67
  242. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  243. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  244. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  245. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  246. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  247. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  248. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  249. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  250. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  251. package/src/shared/lib/code/lazy-window.ts +0 -156
  252. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  253. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  254. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -0,0 +1,251 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The footer control is a CONTROL; every word a run says is in the dialog.
8
+ *
9
+ * That split is the whole point of this suite. The footer is a 24px row of glyphs,
10
+ * and a status sentence pushed into it moved the neighbouring controls and read as
11
+ * a status line for the SIDEBAR rather than for the update the user just asked for.
12
+ * So the cases below assert the RENDERED tree in both states, not the module's
13
+ * constants: a table in a module would pass while the footer rendered something
14
+ * else.
15
+ *
16
+ * Three behaviours are load-bearing and each was a decision:
17
+ *
18
+ * 1. **A confirmation comes FIRST.** The click replaces the app and restarts it, so
19
+ * a 24px glyph must not do that on a single press with no words.
20
+ * 2. **`done` and `error` print the SHELL's sentence**, inside the dialog. Nothing
21
+ * here invents "Update failed": a shell that verified the download and declined
22
+ * knows why, and that reason is the only thing a user can act on.
23
+ * 3. **A shell that cannot install it gets a LINK**, and the dialog never opens.
24
+ */
25
+
26
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
27
+ import { Window } from 'happy-dom';
28
+ import { h, render } from 'preact';
29
+ import { act } from 'preact/test-utils';
30
+ import { ShellUpdateButton } from '@/client/components/common/shell-update-button';
31
+ import type { ShellDownload } from '@/client/hooks/ui/updates/shell-download';
32
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
33
+
34
+ const DESKTOP: ShellDownload = {
35
+ url: 'https://github.com/kabmo-labs/kabmo/releases/download/v0.3.2/Kabmo-0.3.2-mac-arm64.zip',
36
+ latest: '0.3.2',
37
+ releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.8.0',
38
+ direct: true,
39
+ kind: 'update',
40
+ platform: 'macos',
41
+ };
42
+
43
+ const PAGE: ShellDownload = { ...DESKTOP, url: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.8.0', direct: false };
44
+
45
+ let container: HTMLElement;
46
+
47
+ /** Install (or remove) a bridge on the page. */
48
+ function bridge(call: ((method: string, params?: Record<string, unknown>) => Promise<unknown>) | null): void {
49
+ const page = globalThis as unknown as { window: { __kabmoCall?: unknown } };
50
+ if (call) page.window.__kabmoCall = call;
51
+ else delete page.window.__kabmoCall;
52
+ }
53
+
54
+ async function mount(download: ShellDownload): Promise<void> {
55
+ await act(async () => {
56
+ render(h(ShellUpdateButton, { download, variant: 'icon' }), container);
57
+ });
58
+ for (let i = 0; i < 5; i += 1) await act(async () => {});
59
+ }
60
+
61
+ /**
62
+ * The dialog, by its role — the same locator a browser spec would use.
63
+ *
64
+ * Queried on `document.body`, not on the render container: the dialog is a PORTAL
65
+ * (see the component), which is what keeps it out of the footer row's own text.
66
+ */
67
+ function dialog(): HTMLElement | null {
68
+ return document.body.querySelector('[role="dialog"]');
69
+ }
70
+
71
+ /**
72
+ * Both helpers search the whole body, because the dialog is a PORTAL: its controls
73
+ * live beside the render container, not inside it. The footer control is inside the
74
+ * container, which is itself in the body, so one query covers both.
75
+ */
76
+ function byName(name: string): HTMLElement | null {
77
+ return document.body.querySelector(`[aria-label="${name}"]`);
78
+ }
79
+
80
+ function buttonByText(text: string): HTMLElement | null {
81
+ const buttons = [...document.body.querySelectorAll('button')];
82
+ return (buttons.find((button) => button.textContent?.trim() === text) as HTMLElement | undefined) ?? null;
83
+ }
84
+
85
+ async function click(element: HTMLElement | null): Promise<void> {
86
+ if (!element) throw new Error('nothing to click');
87
+ await act(async () => {
88
+ element.click();
89
+ });
90
+ for (let i = 0; i < 10; i += 1) await act(async () => {});
91
+ }
92
+
93
+ beforeAll(() => {
94
+ installDomGlobals(new Window({ url: 'http://localhost' }));
95
+ container = document.createElement('div');
96
+ document.body.appendChild(container);
97
+ });
98
+
99
+ afterEach(() => {
100
+ act(() => render(null, container));
101
+ bridge(null);
102
+ });
103
+
104
+ afterAll(() => {
105
+ container.remove();
106
+ restoreDomGlobals();
107
+ });
108
+
109
+ describe('a shell that can install its own update', () => {
110
+ test('the dialog is OUTSIDE the footer row, so the row says nothing about a run', async () => {
111
+ // A dialog rendered inside the footer would put its sentence into the row's own
112
+ // text content (measured in the browser before the portal: the row read as the
113
+ // dialog's body). It also has to be a portal for a second reason — the row is a
114
+ // 24px flex strip, and any `overflow`/`transform` added to it would clip a
115
+ // `fixed` overlay.
116
+ bridge(async () => ({ success: true, message: 'ok' }));
117
+ await mount(DESKTOP);
118
+ await click(byName('Update Kabmo 0.3.2 for macOS'));
119
+
120
+ const modal = document.body.querySelector('[role="dialog"]');
121
+ expect(modal).not.toBeNull();
122
+ expect(container.contains(modal)).toBe(false);
123
+ // The control itself carries no status text in any phase.
124
+ expect(byName('Update Kabmo 0.3.2 for macOS')?.textContent?.trim()).toBe('');
125
+ });
126
+
127
+ test('draws a button, and opens a CONFIRMATION rather than installing on the press', async () => {
128
+ const calls: string[] = [];
129
+ bridge(async (method) => {
130
+ calls.push(method);
131
+ return { success: true, version: '0.3.2', message: 'Kabmo 0.3.2 installed — restarting.', relaunching: true };
132
+ });
133
+ await mount(DESKTOP);
134
+
135
+ const control = byName('Update Kabmo 0.3.2 for macOS');
136
+ expect(control?.tagName).toBe('BUTTON');
137
+ // Nothing has been asked of the shell yet — that is the confirmation's job.
138
+ expect(calls).toEqual([]);
139
+
140
+ await click(control);
141
+ expect(dialog()).not.toBeNull();
142
+ expect(dialog()?.textContent).toContain('Version 0.3.2');
143
+ expect(dialog()?.textContent).toContain('replaces itself');
144
+ // Still nothing: the press opened a dialog, it did not install.
145
+ expect(calls).toEqual([]);
146
+ });
147
+
148
+ test('installing happens on the dialog’s own button, and the report stays IN the dialog', async () => {
149
+ const calls: { method: string; params?: Record<string, unknown> }[] = [];
150
+ bridge(async (method, params) => {
151
+ calls.push({ method, params });
152
+ return { success: true, version: '0.3.2', message: 'Kabmo 0.3.2 installed — restarting.', relaunching: true };
153
+ });
154
+ await mount(DESKTOP);
155
+ await click(byName('Update Kabmo 0.3.2 for macOS'));
156
+ await click(buttonByText('Install update'));
157
+
158
+ expect(calls).toEqual([
159
+ { method: 'update_apply', params: { url: DESKTOP.url, version: '0.3.2' } },
160
+ ]);
161
+
162
+ // The sentence is the SHELL's, and it is inside the dialog.
163
+ expect(dialog()?.textContent).toContain('Kabmo 0.3.2 installed — restarting.');
164
+ // …and the footer carries no status line of its own: the control is still a
165
+ // control, with its own label.
166
+ expect(byName('Update Kabmo 0.3.2 for macOS')).not.toBeNull();
167
+ });
168
+
169
+ test('a refusal keeps the shell’s reason in the dialog, and offers another try', async () => {
170
+ bridge(async () => ({ success: false, message: 'The downloaded file is not a signed Kabmo build.' }));
171
+ await mount(DESKTOP);
172
+ await click(byName('Update Kabmo 0.3.2 for macOS'));
173
+ await click(buttonByText('Install update'));
174
+
175
+ expect(dialog()?.textContent).toContain('The downloaded file is not a signed Kabmo build.');
176
+ expect(buttonByText('Try again')).not.toBeNull();
177
+ expect(buttonByText('Close')).not.toBeNull();
178
+ });
179
+
180
+ test('closing after a failure, then reopening, starts at the confirmation again', async () => {
181
+ // A dismissed run is not a run: the next press must not greet the user with the
182
+ // previous attempt's refusal.
183
+ bridge(async () => ({ success: false, message: 'The download could not be downloaded.' }));
184
+ await mount(DESKTOP);
185
+ await click(byName('Update Kabmo 0.3.2 for macOS'));
186
+ await click(buttonByText('Install update'));
187
+ await click(buttonByText('Close'));
188
+ expect(dialog()).toBeNull();
189
+
190
+ await click(byName('Update Kabmo 0.3.2 for macOS'));
191
+ expect(dialog()?.textContent).toContain('Version 0.3.2');
192
+ expect(dialog()?.textContent).not.toContain('could not be downloaded');
193
+ });
194
+
195
+ test('Cancel closes without asking the shell anything', async () => {
196
+ const calls: string[] = [];
197
+ bridge(async (method) => {
198
+ calls.push(method);
199
+ return { success: true, message: 'ok' };
200
+ });
201
+ await mount(DESKTOP);
202
+ await click(byName('Update Kabmo 0.3.2 for macOS'));
203
+ await click(buttonByText('Cancel'));
204
+
205
+ expect(dialog()).toBeNull();
206
+ expect(calls).toEqual([]);
207
+ });
208
+ });
209
+
210
+ describe('the phone footer’s variant', () => {
211
+ test('draws the short label on the same button, and opens the same dialog', async () => {
212
+ // The two layouts pass different `variant`s because a 24px glyph and a word are
213
+ // different rows — but they must be the same CONTROL: a link on one layout and a
214
+ // button on the other is the drift this component exists to prevent.
215
+ bridge(async () => ({ success: true, version: '0.3.2', message: 'Kabmo 0.3.2 installed — restarting.' }));
216
+ await act(async () => {
217
+ render(h(ShellUpdateButton, { download: DESKTOP, variant: 'label' }), container);
218
+ });
219
+ for (let i = 0; i < 5; i += 1) await act(async () => {});
220
+
221
+ const control = byName('Update Kabmo 0.3.2 for macOS');
222
+ expect(control?.tagName).toBe('BUTTON');
223
+ expect(control?.textContent?.trim()).toBe('Update macOS');
224
+
225
+ await click(control);
226
+ expect(dialog()?.textContent).toContain('Version 0.3.2');
227
+ });
228
+ });
229
+
230
+ describe('a shell that cannot install it', () => {
231
+ test('draws a link to the release page, and no dialog', async () => {
232
+ bridge(async () => null);
233
+ await mount(PAGE);
234
+
235
+ const control = byName('Update Kabmo 0.3.2 for macOS');
236
+ expect(control?.tagName).toBe('A');
237
+ expect(control?.getAttribute('href')).toBe(PAGE.url);
238
+ expect(control?.getAttribute('target')).toBe('_blank');
239
+
240
+ await click(control);
241
+ expect(dialog()).toBeNull();
242
+ });
243
+
244
+ test('a browser draws the link too, because there is no shell to ask', async () => {
245
+ bridge(null);
246
+ await mount(DESKTOP);
247
+
248
+ expect(byName('Update Kabmo 0.3.2 for macOS')?.tagName).toBe('A');
249
+ expect(dialog()).toBeNull();
250
+ });
251
+ });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The native shell's update/install control, shared by both sidebar layouts.
8
+ *
9
+ * ## Two controls, because there are two different jobs
10
+ *
11
+ * - **A link** when the console cannot name a file: a desktop shell that did not
12
+ * report its architecture, or any offer whose artifact is not installable from
13
+ * inside. Following it is the browser's (or the WebView's) job.
14
+ * - **A button** when the shell CAN install it: the click opens a CONFIRMATION, and
15
+ * the confirmation asks the shell to download, verify and replace itself. That is
16
+ * the desktop self-update, and it is the whole reason this component exists.
17
+ *
18
+ * The decision is `canSelfUpdate`, never a check on the platform at the call site:
19
+ * Android installs through `PackageInstaller` (its own path, unchanged), iOS cannot
20
+ * install at all, and a browser has no shell to ask.
21
+ *
22
+ * ## The control is ONLY a control
23
+ *
24
+ * Every word a run has to say — the confirmation, the progress, the shell's own
25
+ * refusal — is in `Modal.tsx`. The footer is a 24px row of glyphs, and a status
26
+ * sentence pushed into it moved the neighbouring controls and read as a status
27
+ * line for the SIDEBAR rather than for the update.
28
+ *
29
+ * ## The state lives here, not in either footer
30
+ *
31
+ * Both layouts draw this control and each unmounts at a different moment (a phone
32
+ * closes its drawer, a desktop swaps sessions). One hook, one vocabulary of
33
+ * phases, so the two cannot report the same run differently.
34
+ */
35
+
36
+ import { useState } from 'preact/hooks';
37
+ import { createPortal } from 'preact/compat';
38
+ import { Download, Loader2 } from 'lucide-preact';
39
+ import {
40
+ shellDownloadLabel,
41
+ shellDownloadShortLabel,
42
+ type ShellDownload,
43
+ } from '@/client/hooks/ui/updates/shell-download';
44
+ import { canSelfUpdate, useShellUpdate } from '@/client/hooks/ui/updates/shell-update';
45
+ import { ShellUpdateModal } from '@/client/components/common/shell-update-button/Modal';
46
+
47
+ interface ShellUpdateButtonProps {
48
+ download: ShellDownload;
49
+ /** `icon` for the desktop footer's glyph row, `label` for the phone's. */
50
+ variant: 'icon' | 'label';
51
+ }
52
+
53
+ const ICON_CLASS = 'p-1 rounded hover:text-ink hover:bg-ink/5 cursor-pointer transition-colors';
54
+
55
+ export function ShellUpdateButton({ download, variant }: ShellUpdateButtonProps) {
56
+ const { phase, message, apply, reset, working } = useShellUpdate();
57
+ // The dialog is open from the CLICK until it is dismissed, and the run's phases
58
+ // are drawn inside it. A closed dialog whose run is still `working` is not a
59
+ // state this component can be in: `confirm` is what opens it.
60
+ const [open, setOpen] = useState(false);
61
+ const label = shellDownloadLabel(download);
62
+
63
+ if (!canSelfUpdate(download)) {
64
+ // A link, and `target="_blank"` hands it to the system browser so the console
65
+ // is not replaced by a file download.
66
+ return (
67
+ <a
68
+ href={download.url}
69
+ target="_blank"
70
+ rel="noopener noreferrer"
71
+ title={label}
72
+ aria-label={label}
73
+ className={ICON_CLASS}
74
+ >
75
+ {variant === 'icon' ? <Download size={16} /> : <span>{shellDownloadShortLabel(download)}</span>}
76
+ </a>
77
+ );
78
+ }
79
+
80
+ return (
81
+ <>
82
+ <button
83
+ type="button"
84
+ // A dismissed run is not a run: reopening starts from the confirmation, not
85
+ // from the previous attempt's refusal.
86
+ onClick={() => {
87
+ reset();
88
+ setOpen(true);
89
+ }}
90
+ disabled={working}
91
+ title={label}
92
+ aria-label={label}
93
+ className={`${ICON_CLASS} disabled:cursor-not-allowed disabled:opacity-60 inline-flex items-center gap-1`}
94
+ >
95
+ {working ? (
96
+ <Loader2 size={variant === 'icon' ? 16 : 13} className="animate-spin motion-reduce:animate-none" />
97
+ ) : (
98
+ <Download size={variant === 'icon' ? 16 : 13} />
99
+ )}
100
+ {variant === 'label' && <span>{shellDownloadShortLabel(download)}</span>}
101
+ </button>
102
+
103
+ {open &&
104
+ // A PORTAL, not a child of this row. The footer is a 24px flex row of
105
+ // glyphs, so a dialog rendered inside it is nested in a control strip: its
106
+ // text joins the row's own text content (measured in the browser — the row
107
+ // read as the dialog's body), and any `overflow`/`transform` added to that
108
+ // row later would clip a `fixed` overlay. The portal is what makes "the
109
+ // footer says nothing about a run" true rather than merely visually true.
110
+ createPortal(
111
+ <ShellUpdateModal
112
+ download={download}
113
+ phase={phase === 'idle' ? 'confirm' : phase}
114
+ message={message}
115
+ onConfirm={() => void apply(download)}
116
+ // Closing mid-run hides the dialog but does not cancel the shell: it is
117
+ // already replacing itself, and a Cancel that could not stop it would be
118
+ // a button that lies. The control stays disabled until the run answers.
119
+ onClose={() => setOpen(false)}
120
+ />,
121
+ document.body,
122
+ )}
123
+ </>
124
+ );
125
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The toolbar stays ONE row, and what does not fit is one click away.
8
+ *
9
+ * The failure this pins is a row that becomes two: the editor panel is 320px
10
+ * wide on a 1440px screen, and the width-mode buttons pushed ten controls past
11
+ * it. Wrapping moved the document down by the toolbar's own height and read as
12
+ * a second toolbar; a horizontal scroll clipped the tail invisibly. Neither is
13
+ * visible to a state-only assertion, so the cases below are about what is
14
+ * RENDERED and what the `⋯` menu offers.
15
+ *
16
+ * happy-dom lays nothing out, so `getBoundingClientRect` and `clientWidth` are
17
+ * stubbed at the PROTOTYPE level — the rule reads exactly those two values, and
18
+ * stubbing them before the first render is what lets the component's own layout
19
+ * effect measure a real row instead of a row of zeros.
20
+ */
21
+
22
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
23
+ import { Window } from 'happy-dom';
24
+ import { h, render } from 'preact';
25
+ import { act } from 'preact/test-utils';
26
+ import { Save, Search, WrapText } from 'lucide-preact';
27
+ import { ToolbarRow, type ToolbarCluster } from '@/client/components/common/toolbar-row';
28
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
29
+
30
+ let container: HTMLElement;
31
+ /** What the stubbed layout reports for the row, and for each cluster in order. */
32
+ let rowWidth = 0;
33
+ let clusterWidths: number[] = [];
34
+
35
+ /** Captured inside `beforeAll`: the DOM globals do not exist at module load. */
36
+ let originalRect: typeof Element.prototype.getBoundingClientRect;
37
+ let originalClientWidth: PropertyDescriptor | undefined;
38
+
39
+ /** The row is the flex container the component renders; clusters carry a marker. */
40
+ function isRow(el: HTMLElement): boolean {
41
+ return el.className.includes('flex-nowrap');
42
+ }
43
+
44
+ function rectOf(width: number): DOMRect {
45
+ return { width, height: 0, top: 0, left: 0, right: width, bottom: 0, x: 0, y: 0, toJSON: () => ({}) } as DOMRect;
46
+ }
47
+
48
+ beforeAll(() => {
49
+ installDomGlobals(new Window({ url: 'http://localhost' }));
50
+ originalRect = Element.prototype.getBoundingClientRect;
51
+ originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth');
52
+ Element.prototype.getBoundingClientRect = function (this: Element) {
53
+ const el = this as HTMLElement;
54
+ if (el.hasAttribute?.('data-toolbar-cluster')) {
55
+ const siblings = [...(el.parentElement?.querySelectorAll('[data-toolbar-cluster]') ?? [])];
56
+ return rectOf(clusterWidths[siblings.indexOf(el)] ?? 0);
57
+ }
58
+ if (isRow(el)) return rectOf(rowWidth);
59
+ return originalRect.call(this);
60
+ };
61
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
62
+ configurable: true,
63
+ get(this: HTMLElement) {
64
+ if (isRow(this)) return rowWidth;
65
+ return originalClientWidth?.get?.call(this) ?? 0;
66
+ },
67
+ });
68
+ });
69
+
70
+ afterEach(() => {
71
+ if (container) {
72
+ render(null, container);
73
+ container.remove();
74
+ }
75
+ rowWidth = 0;
76
+ clusterWidths = [];
77
+ });
78
+
79
+ afterAll(() => {
80
+ Element.prototype.getBoundingClientRect = originalRect;
81
+ if (originalClientWidth) Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth);
82
+ restoreDomGlobals();
83
+ });
84
+
85
+ /** Three clusters, the first pinned — the editor toolbar in miniature. */
86
+ function clusters(onSelect: string[] = []): ToolbarCluster[] {
87
+ const make = (id: string, label: string, icon: typeof Save, pinned?: boolean): ToolbarCluster => ({
88
+ id,
89
+ pinned,
90
+ actions: [{ label, icon, onSelect: () => onSelect.push(label) }],
91
+ children: h('button', { type: 'button', 'aria-label': label }, label),
92
+ });
93
+ return [
94
+ make('save', 'Save file', Save, true),
95
+ make('find', 'Find and replace', Search),
96
+ make('wrap', 'Word wrap on', WrapText),
97
+ ];
98
+ }
99
+
100
+ async function mount(width: number, widths: number[], onSelect: string[] = []): Promise<void> {
101
+ rowWidth = width;
102
+ clusterWidths = widths;
103
+ container = document.createElement('div');
104
+ document.body.appendChild(container);
105
+ await act(async () => {
106
+ render(h(ToolbarRow, { clusters: clusters(onSelect) }), container);
107
+ });
108
+ // The layout effect commits its measurement in a second pass, the way the
109
+ // browser gives one before paint.
110
+ await act(async () => {});
111
+ }
112
+
113
+ function rowButtons(): HTMLButtonElement[] {
114
+ const host = container.firstElementChild as HTMLElement;
115
+ return [...host.querySelectorAll<HTMLButtonElement>(':scope > div > button')];
116
+ }
117
+
118
+ async function openMenu(): Promise<void> {
119
+ const trigger = rowButtons().find((button) => button.getAttribute('aria-label') === 'More actions');
120
+ if (!trigger) throw new Error('no overflow trigger rendered');
121
+ await act(async () => {
122
+ trigger.dispatchEvent(new window.MouseEvent('click', { bubbles: true }) as never);
123
+ });
124
+ }
125
+
126
+ describe('ToolbarRow overflow', () => {
127
+ test('a wide row draws every cluster and no trigger', async () => {
128
+ await mount(400, [60, 60, 60]);
129
+ expect(rowButtons().map((button) => button.getAttribute('aria-label'))).toEqual([
130
+ 'Save file',
131
+ 'Find and replace',
132
+ 'Word wrap on',
133
+ ]);
134
+ });
135
+
136
+ test('a narrow row keeps the pinned cluster and moves the rest into `⋯`', async () => {
137
+ await mount(120, [60, 60, 60]);
138
+ const labels = rowButtons().map((button) => button.getAttribute('aria-label'));
139
+ expect(labels).toContain('Save file');
140
+ expect(labels).toContain('More actions');
141
+ expect(labels).not.toContain('Word wrap on');
142
+ });
143
+
144
+ test('the menu offers the hidden controls, named in words', async () => {
145
+ await mount(120, [60, 60, 60]);
146
+ await openMenu();
147
+ const items = [...container.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent?.trim());
148
+ expect(items).toContain('Word wrap on');
149
+ // The pinned control is on the row, so it is NOT in the menu.
150
+ expect(items).not.toContain('Save file');
151
+ });
152
+
153
+ test('a menu row runs the same action the button would', async () => {
154
+ const ran: string[] = [];
155
+ await mount(120, [60, 60, 60], ran);
156
+ await openMenu();
157
+ const item = [...container.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')].find(
158
+ (row) => row.textContent?.trim() === 'Word wrap on',
159
+ );
160
+ if (!item) throw new Error('the wrap row is missing from the menu');
161
+ await act(async () => {
162
+ item.dispatchEvent(new window.MouseEvent('click', { bubbles: true }) as never);
163
+ });
164
+ expect(ran).toEqual(['Word wrap on']);
165
+ });
166
+ });