@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,140 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The desktop self-update's client half.
8
+ *
9
+ * Two facts are pinned here and both were decisions rather than accidents:
10
+ *
11
+ * 1. **Which offers the shell can install itself** — a FILE (`direct`) in a
12
+ * DESKTOP shell. An Android offer installs through `PackageInstaller` (its own
13
+ * path), an iOS one cannot install at all, and a browser has no shell to ask.
14
+ * 2. **A failure is reported, never swallowed.** The shell's own sentence is what
15
+ * a user can act on, and a run that reported success before the shell said so
16
+ * would be a lie about an app that is still on the old build.
17
+ */
18
+
19
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
20
+ import { Window } from 'happy-dom';
21
+ import { h, render } from 'preact';
22
+ import { act } from 'preact/test-utils';
23
+ import { canSelfUpdate, useShellUpdate } from '@/client/hooks/ui/updates/shell-update';
24
+ import type { ShellDownload } from '@/client/hooks/ui/updates/shell-download';
25
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
26
+
27
+ const DESKTOP: ShellDownload = {
28
+ url: 'https://x/Kabmo-1.2.3-mac-arm64.zip',
29
+ latest: '1.2.3',
30
+ releaseUrl: 'https://x/app',
31
+ direct: true,
32
+ kind: 'update',
33
+ platform: 'macos',
34
+ };
35
+
36
+ const PAGE: ShellDownload = { ...DESKTOP, url: 'https://x/app', direct: false };
37
+
38
+ let container: HTMLElement;
39
+
40
+ /** Install (or remove) a bridge on the page. */
41
+ function bridge(call: ((method: string, params?: Record<string, unknown>) => Promise<unknown>) | null): void {
42
+ const page = globalThis as unknown as { window: { __kabmoCall?: unknown } };
43
+ if (call) page.window.__kabmoCall = call;
44
+ else delete page.window.__kabmoCall;
45
+ }
46
+
47
+ beforeAll(() => {
48
+ installDomGlobals(new Window({ url: 'http://localhost' }));
49
+ container = document.createElement('div');
50
+ document.body.appendChild(container);
51
+ });
52
+
53
+ afterEach(() => {
54
+ act(() => render(null, container));
55
+ bridge(null);
56
+ });
57
+
58
+ afterAll(() => {
59
+ container.remove();
60
+ restoreDomGlobals();
61
+ });
62
+
63
+ describe('canSelfUpdate', () => {
64
+ test('a direct desktop offer inside the shell is installable', () => {
65
+ bridge(async () => null);
66
+ expect(canSelfUpdate(DESKTOP)).toBe(true);
67
+ expect(canSelfUpdate({ ...DESKTOP, platform: 'windows' })).toBe(true);
68
+ expect(canSelfUpdate({ ...DESKTOP, platform: 'linux' })).toBe(true);
69
+ });
70
+
71
+ test('a release PAGE is not installable, whatever the platform', () => {
72
+ bridge(async () => null);
73
+ // The console could not name a file, so there is nothing to hand the shell.
74
+ expect(canSelfUpdate(PAGE)).toBe(false);
75
+ });
76
+
77
+ test('the mobile shells keep their own paths', () => {
78
+ bridge(async () => null);
79
+ // Android installs through PackageInstaller, iOS cannot install at all.
80
+ expect(canSelfUpdate({ ...DESKTOP, platform: 'android' })).toBe(false);
81
+ expect(canSelfUpdate({ ...DESKTOP, platform: 'ios' })).toBe(false);
82
+ });
83
+
84
+ test('a browser has no shell to ask', () => {
85
+ bridge(null);
86
+ expect(canSelfUpdate(DESKTOP)).toBe(false);
87
+ });
88
+
89
+ test('an absent offer is not installable', () => {
90
+ bridge(async () => null);
91
+ expect(canSelfUpdate(null)).toBe(false);
92
+ expect(canSelfUpdate(undefined)).toBe(false);
93
+ });
94
+ });
95
+
96
+ describe('useShellUpdate', () => {
97
+ /** A harness that exposes the hook's state and its one action. */
98
+ function Harness({ run }: { run: boolean }) {
99
+ const state = useShellUpdate();
100
+ if (run && state.phase === 'idle') void state.apply(DESKTOP);
101
+ return h('span', { id: 'state' }, `${state.phase}:${state.message ?? ''}`);
102
+ }
103
+
104
+ async function mount(run: boolean): Promise<string> {
105
+ await act(async () => {
106
+ render(h(Harness, { run }), container);
107
+ });
108
+ for (let i = 0; i < 20; i += 1) await act(async () => {});
109
+ return container.querySelector('#state')?.textContent ?? '';
110
+ }
111
+
112
+ test('passes the offer to the shell, and reports its own sentence', async () => {
113
+ const calls: { method: string; params?: Record<string, unknown> }[] = [];
114
+ bridge(async (method, params) => {
115
+ calls.push({ method, params });
116
+ return { success: true, version: '1.2.3', message: 'Kabmo 1.2.3 installed — restarting.', relaunching: true };
117
+ });
118
+
119
+ expect(await mount(true)).toBe('done:Kabmo 1.2.3 installed — restarting.');
120
+ expect(calls).toEqual([
121
+ { method: 'update_apply', params: { url: DESKTOP.url, version: DESKTOP.latest } },
122
+ ]);
123
+ });
124
+
125
+ test('a refused update keeps the shell’s reason', async () => {
126
+ // The shell verified the bytes and declined: "the downloaded file is not a
127
+ // signed Kabmo build" is the only thing that tells a user what to do next.
128
+ bridge(async () => ({ success: false, message: 'The downloaded file is not a signed Kabmo build.' }));
129
+
130
+ expect(await mount(true)).toBe('error:The downloaded file is not a signed Kabmo build.');
131
+ });
132
+
133
+ test('a shell that cannot answer is reported rather than assumed to have worked', async () => {
134
+ bridge(async () => {
135
+ throw new Error('Unknown shell command: update_apply');
136
+ });
137
+
138
+ expect(await mount(true)).toBe('error:Unknown shell command: update_apply');
139
+ });
140
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Installing a desktop shell's own update, from the console.
8
+ *
9
+ * ## Why the shell does the work
10
+ *
11
+ * A page cannot replace the executable it runs inside, and a browser download
12
+ * leaves a file in a folder the user has to find and open. So the button asks
13
+ * the SHELL (`update_apply` over the bridge), and the shell downloads, verifies,
14
+ * replaces and relaunches. What this hook owns is only the part a page can own:
15
+ * the run's state, and reporting the outcome where the user clicked.
16
+ *
17
+ * ## Why it is a hook rather than a function at the call site
18
+ *
19
+ * Both sidebars draw this control and each one unmounts at a different moment (a
20
+ * phone closes the drawer, a desktop swaps sessions). The state has to live with
21
+ * the control that shows it, and the two must report the SAME vocabulary — so the
22
+ * phases and their sentences are here, not in either footer.
23
+ *
24
+ * ## A failure is always a sentence
25
+ *
26
+ * The shell sends its own reason ("the downloaded file is not a signed Kabmo
27
+ * build", "the installer refused with code 5"), which is the only thing that can
28
+ * tell a user what to do next. Nothing here replaces it with a generic failure,
29
+ * and nothing reports success before the shell said so.
30
+ */
31
+
32
+ import { useCallback, useRef, useState } from 'preact/hooks';
33
+ import {
34
+ applyShellUpdate,
35
+ bridgeError,
36
+ isDesktopShell,
37
+ type ShellUpdateOutcome,
38
+ } from '@/client/lib/desktop/bridge';
39
+ import type { ShellDownload } from '@/client/hooks/ui/updates/shell-download';
40
+
41
+ /** What the control is doing right now. */
42
+ export type ShellUpdatePhase = 'idle' | 'working' | 'done' | 'error';
43
+
44
+ export interface ShellUpdateState {
45
+ phase: ShellUpdatePhase;
46
+ /** The shell's own sentence, for `done` and `error`. */
47
+ message: string | null;
48
+ }
49
+
50
+ export interface UseShellUpdateResult extends ShellUpdateState {
51
+ /** Run the update this offer names. */
52
+ apply: (download: ShellDownload) => Promise<void>;
53
+ /**
54
+ * Forget the last run, back to `idle`.
55
+ *
56
+ * The dialog calls this when it OPENS: a run that failed last time must not greet
57
+ * the user with its old refusal the next time they press the control.
58
+ */
59
+ reset: () => void;
60
+ /** True while a run is in flight, so the control can disable itself. */
61
+ working: boolean;
62
+ }
63
+
64
+ /**
65
+ * Whether this offer is something the shell can install by itself.
66
+ *
67
+ * Two facts, and both are required. The artifact must be a FILE (`direct`), and
68
+ * the page must be inside the desktop shell that can install it — an Android
69
+ * shell installs through `PackageInstaller` and iOS cannot install at all, so
70
+ * neither belongs to this path. A browser on a desktop gets neither, because
71
+ * there is no shell to ask.
72
+ */
73
+ export function canSelfUpdate(download: ShellDownload | null | undefined): boolean {
74
+ if (!download) return false;
75
+ if (!download.direct) return false;
76
+ if (download.platform === 'android' || download.platform === 'ios') return false;
77
+ return isDesktopShell();
78
+ }
79
+
80
+ export function useShellUpdate(): UseShellUpdateResult {
81
+ const [state, setState] = useState<ShellUpdateState>({ phase: 'idle', message: null });
82
+ // A second click while the shell is replacing itself would be a second
83
+ // installer beside the first, so the run is claimed by a ref rather than by
84
+ // the render state — a fast double click lands before the state is read.
85
+ const running = useRef(false);
86
+
87
+ const apply = useCallback(async (download: ShellDownload) => {
88
+ if (running.current) return;
89
+ running.current = true;
90
+ setState({ phase: 'working', message: null });
91
+
92
+ try {
93
+ const outcome: ShellUpdateOutcome = await applyShellUpdate(download.url, download.latest);
94
+ setState({
95
+ phase: outcome.success ? 'done' : 'error',
96
+ // The shell's sentence is the report. `?? null` rather than a fallback
97
+ // string: a shell that answered nothing has said nothing, and inventing
98
+ // "Update failed" over it would hide that.
99
+ message: outcome.message ?? null,
100
+ });
101
+ } catch (cause) {
102
+ // A refused command (an older shell with no `update_apply`) lands here,
103
+ // and its own message says so.
104
+ setState({ phase: 'error', message: bridgeError(cause) });
105
+ } finally {
106
+ running.current = false;
107
+ }
108
+ }, []);
109
+
110
+ const reset = useCallback(() => {
111
+ // A run in flight is NOT resettable: the shell is already replacing itself, and
112
+ // reporting `idle` over it would re-arm a control whose second press would start
113
+ // a second installer.
114
+ if (running.current) return;
115
+ setState({ phase: 'idle', message: null });
116
+ }, []);
117
+
118
+ return { ...state, apply, reset, working: state.phase === 'working' };
119
+ }
@@ -1,5 +1,4 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
- import type { DiffContentMode } from '@/client/components/workspace/diff-panel/Toolbar';
3
2
 
4
3
  interface UseFileDiffOptions {
5
4
  filePath: string;
@@ -7,11 +6,14 @@ interface UseFileDiffOptions {
7
6
  isStaged: boolean;
8
7
  repo: string;
9
8
  root?: string;
10
- contentMode: DiffContentMode;
11
9
  }
12
10
 
13
11
  export interface FileDiffState {
14
12
  rawDiff: string;
13
+ /** The document before the change (HEAD, or the index for an unstaged read). */
14
+ oldContent: string;
15
+ /** The document after it (the working file). */
16
+ newContent: string;
15
17
  currentStatus: string;
16
18
  currentStaged: boolean;
17
19
  isLoading: boolean;
@@ -31,8 +33,10 @@ export interface FileDiffState {
31
33
  * earlier read overwriting a newer one, and a dependency on the staged/status
32
34
  * copies re-keying the callback and firing a fetch per render.
33
35
  */
34
- export function useFileDiff({ filePath, status, isStaged, repo, root, contentMode }: UseFileDiffOptions): FileDiffState {
36
+ export function useFileDiff({ filePath, status, isStaged, repo, root }: UseFileDiffOptions): FileDiffState {
35
37
  const [rawDiff, setRawDiff] = useState<string>('');
38
+ const [oldContent, setOldContent] = useState<string>('');
39
+ const [newContent, setNewContent] = useState<string>('');
36
40
  const [currentStatus, setCurrentStatus] = useState<string>(status);
37
41
  const [currentStaged, setCurrentStaged] = useState<boolean>(isStaged);
38
42
  const [isLoading, setIsLoading] = useState<boolean>(true);
@@ -62,11 +66,9 @@ export function useFileDiff({ filePath, status, isStaged, repo, root, contentMod
62
66
  staged: staged ? '1' : '0',
63
67
  repo: repo || '.',
64
68
  });
65
- // `fullContext=1` makes git emit the whole file rather than the hunks.
66
- // Only the user's own toggle changes it, so it is safe as a callback
67
- // dependency (unlike the staged/status copies above, which the action
68
- // path updates while a fetch of its own is already in flight).
69
- if (contentMode === 'full') params.set('fullContext', '1');
69
+ // No `fullContext`: the panel renders both SIDES of the file now, and
70
+ // how much of them is shown is a client-side collapse. Asking the server
71
+ // for the whole file as a patch would be a second answer to that question.
70
72
  if (statusForFetch) params.set('status', statusForFetch);
71
73
  if (root) params.set('root', root);
72
74
 
@@ -74,6 +76,8 @@ export function useFileDiff({ filePath, status, isStaged, repo, root, contentMod
74
76
  const data = (await res.json().catch(() => null)) as {
75
77
  success?: boolean;
76
78
  diff?: string;
79
+ oldContent?: string;
80
+ newContent?: string;
77
81
  status?: string;
78
82
  staged?: boolean;
79
83
  error?: string;
@@ -83,6 +87,11 @@ export function useFileDiff({ filePath, status, isStaged, repo, root, contentMod
83
87
  if (seq !== fetchSeqRef.current) return;
84
88
  if (data?.success) {
85
89
  setRawDiff(data.diff || '');
90
+ // Both sides travel on every read: the merge view compares documents,
91
+ // and a whitespace fold has to be applied to the ORIGINAL, not to the
92
+ // patch (see `foldWhitespaceOnly`).
93
+ setOldContent(data.oldContent ?? '');
94
+ setNewContent(data.newContent ?? '');
86
95
  if (data.status) {
87
96
  statusRef.current = data.status;
88
97
  setCurrentStatus(data.status);
@@ -100,7 +109,7 @@ export function useFileDiff({ filePath, status, isStaged, repo, root, contentMod
100
109
  } finally {
101
110
  if (seq === fetchSeqRef.current) setIsLoading(false);
102
111
  }
103
- }, [filePath, repo, root, contentMode]);
112
+ }, [filePath, repo, root]);
104
113
 
105
114
  useEffect(() => {
106
115
  setCurrentStatus(status);
@@ -124,5 +133,15 @@ export function useFileDiff({ filePath, status, isStaged, repo, root, contentMod
124
133
  }
125
134
  }, []);
126
135
 
127
- return { rawDiff, currentStatus, currentStaged, isLoading, errorMessage, fetchDiff, adoptState };
136
+ return {
137
+ rawDiff,
138
+ oldContent,
139
+ newContent,
140
+ currentStatus,
141
+ currentStaged,
142
+ isLoading,
143
+ errorMessage,
144
+ fetchDiff,
145
+ adoptState,
146
+ };
128
147
  }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The diff panel's git writes, and the conflict-marker read behind its banner.
8
+ *
9
+ * Split out of the panel because these are the only parts of it that talk to
10
+ * the server: the panel used to fire and forget (`fetcher.submit` + an
11
+ * optimistic flip + a 300ms timer), which made a rejected action
12
+ * indistinguishable from a successful one — the badge flipped and the diff
13
+ * silently stayed put. Every write here AWAITS its answer and reports the
14
+ * server's own reason.
15
+ *
16
+ * `revert` is the server's name for "restore this file from the index", and it
17
+ * is the one that handles the untracked case (there the file is removed).
18
+ * Sending `discard`, which no handler matched, returned `{success: true}` for
19
+ * doing nothing at all.
20
+ */
21
+
22
+ import { useCallback, useEffect, useState } from 'preact/hooks';
23
+ import type { ConflictMarkers } from '@/shared/lib/fs/git-unmerged';
24
+
25
+ export interface DiffGitActions {
26
+ /** A write is in flight; the toolbar locks its actions. */
27
+ isBusy: boolean;
28
+ /** The server's refusal, or a transport failure, as a sentence. */
29
+ error: string | null;
30
+ /** The conflict sides' labels from the file's own markers (`{}` when none). */
31
+ markers: ConflictMarkers;
32
+ dismissError: () => void;
33
+ /** Stage or unstage this path, then re-read the diff at the new side. */
34
+ stageUnstage: () => Promise<void>;
35
+ /** Take one side of a conflict (or only stage a resolved file with `''`). */
36
+ resolve: (side: 'ours' | 'theirs' | '') => Promise<void>;
37
+ /** Restore this file from the index — the untracked case removes it. */
38
+ revert: () => Promise<void>;
39
+ }
40
+
41
+ export interface DiffGitActionsArgs {
42
+ filePath: string;
43
+ repo: string;
44
+ root?: string;
45
+ /** The status the panel currently believes (`UU`, `M`, …). */
46
+ status: string;
47
+ staged: boolean;
48
+ /** The panel is showing a conflicted file, so its marker labels are read. */
49
+ conflicted: boolean;
50
+ /** Adopt a state change the server confirmed. */
51
+ adoptState: (patch: { staged: boolean }) => void;
52
+ /** Re-read the diff, optionally at the other side of the index. */
53
+ fetchDiff: (options?: { staged: boolean }) => Promise<void>;
54
+ /** Something on disk changed, so the workspace panels should refresh. */
55
+ onFileSaved?: () => void;
56
+ }
57
+
58
+ export function useDiffGitActions(args: DiffGitActionsArgs): DiffGitActions {
59
+ const { filePath, repo, root, staged, conflicted, adoptState, fetchDiff, onFileSaved } = args;
60
+ const [isBusy, setIsBusy] = useState(false);
61
+ const [error, setError] = useState<string | null>(null);
62
+ const [markers, setMarkers] = useState<ConflictMarkers>({});
63
+
64
+ const run = useCallback(
65
+ async (fields: Record<string, string>): Promise<boolean> => {
66
+ const fd = new FormData();
67
+ for (const [key, value] of Object.entries(fields)) fd.set(key, value);
68
+ fd.set('repo', repo || '.');
69
+ if (root) fd.set('root', root);
70
+ try {
71
+ const res = await fetch('/api/fs/git', { method: 'POST', body: fd });
72
+ const data = (await res.json().catch(() => null)) as { success?: boolean; error?: string } | null;
73
+ if (!res.ok || !data?.success) {
74
+ setError(data?.error || `Request failed (HTTP ${res.status})`);
75
+ return false;
76
+ }
77
+ setError(null);
78
+ return true;
79
+ } catch (err: unknown) {
80
+ setError(err instanceof Error ? err.message : 'Request failed');
81
+ return false;
82
+ }
83
+ },
84
+ [repo, root],
85
+ );
86
+
87
+ /** One write, its busy flag, and the refresh every action ends with. */
88
+ const write = useCallback(
89
+ async (fields: Record<string, string>, after: () => Promise<void>): Promise<void> => {
90
+ setIsBusy(true);
91
+ try {
92
+ if (!(await run(fields))) return;
93
+ await after();
94
+ } finally {
95
+ setIsBusy(false);
96
+ }
97
+ },
98
+ [run],
99
+ );
100
+
101
+ // The marker labels are fetched when the file turns out to be conflicted, and
102
+ // re-fetched when the path changes. Keyed on the path and the status so a
103
+ // resolve (which changes the status away from `UU`) clears the banner.
104
+ useEffect(() => {
105
+ if (!conflicted) {
106
+ setMarkers({});
107
+ return;
108
+ }
109
+ let cancelled = false;
110
+ const params = new URLSearchParams({ conflictMarkers: '1', file: filePath, repo: repo || '.' });
111
+ if (root) params.set('root', root);
112
+ void fetch(`/api/fs/git?${params.toString()}`)
113
+ .then((res) => res.json() as Promise<{ markers?: ConflictMarkers }>)
114
+ .then((body) => {
115
+ if (!cancelled) setMarkers(body.markers ?? {});
116
+ })
117
+ .catch(() => {
118
+ if (!cancelled) setMarkers({});
119
+ });
120
+ return () => {
121
+ cancelled = true;
122
+ };
123
+ }, [conflicted, filePath, repo, root]);
124
+
125
+ return {
126
+ isBusy,
127
+ error,
128
+ markers,
129
+ dismissError: () => setError(null),
130
+ stageUnstage: () =>
131
+ write({ actionType: staged ? 'unstage' : 'stage', file: filePath }, async () => {
132
+ adoptState({ staged: !staged });
133
+ onFileSaved?.();
134
+ await fetchDiff({ staged: !staged });
135
+ }),
136
+ resolve: (side) =>
137
+ write({ actionType: 'resolve_conflict', file: filePath, ...(side ? { side } : {}) }, async () => {
138
+ onFileSaved?.();
139
+ await fetchDiff();
140
+ }),
141
+ revert: () =>
142
+ write({ actionType: 'revert', file: filePath }, async () => {
143
+ onFileSaved?.();
144
+ await fetchDiff();
145
+ }),
146
+ };
147
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The desktop editor column's WIDTH mode, read and written through the session
8
+ * slot every surface follows.
9
+ *
10
+ * Two surfaces read it — the layout (which collapses the columns) and both
11
+ * toolbars (which draw the mode buttons) — so it goes through
12
+ * `useSharedSessionState`: `useSessionState` hands each caller a private copy,
13
+ * and a copy would leave the diff toolbar showing `normal` after the editor
14
+ * toolbar entered compact view. A private copy is right for state one component
15
+ * owns; this one is a decision two toolbars and the layout share.
16
+ *
17
+ * The stored mode only means anything while the editor column is OPEN. With it
18
+ * closed the chat is the only thing on screen, and honouring the mode would
19
+ * leave a blank workspace; the effective mode is therefore DERIVED rather than
20
+ * written back, so closing and reopening the column returns the user to the
21
+ * width they chose.
22
+ */
23
+
24
+ import { useSharedSessionState } from '@/client/hooks/workspace/session-state';
25
+ import {
26
+ DEFAULT_EDITOR_VIEW_MODE,
27
+ EDITOR_VIEW_MODE_KEY,
28
+ editorViewHides,
29
+ isEditorViewMode,
30
+ type EditorViewMode,
31
+ } from '@/shared/lib/workspace/editor-view-mode';
32
+
33
+ export interface EditorViewLayout {
34
+ /** The mode in force, after the editor-open guard. */
35
+ mode: EditorViewMode;
36
+ /** The chat column is collapsed and the editor takes its width. */
37
+ chatHidden: boolean;
38
+ /** The session sidebar is collapsed as well (full view only). */
39
+ sidebarHidden: boolean;
40
+ /** Enter a mode, or return to `normal` — see `toggledEditorViewMode`. */
41
+ setMode: (next: EditorViewMode) => void;
42
+ /** Leave `full` for `compact`: the chat stays hidden, the sidebar returns. */
43
+ revealSidebar: () => void;
44
+ }
45
+
46
+ export function useEditorViewLayout(editorOpen: boolean): EditorViewLayout {
47
+ const [stored, setMode] = useSharedSessionState<EditorViewMode>(EDITOR_VIEW_MODE_KEY, DEFAULT_EDITOR_VIEW_MODE);
48
+ const mode: EditorViewMode = editorOpen && isEditorViewMode(stored) ? stored : DEFAULT_EDITOR_VIEW_MODE;
49
+
50
+ return {
51
+ mode,
52
+ chatHidden: editorViewHides(mode, 'chat'),
53
+ sidebarHidden: editorViewHides(mode, 'sidebar'),
54
+ setMode,
55
+ // Only `full` hides the sidebar, so only `full` has anything to step back
56
+ // from. A caller that asks for the sidebar (the navbar's own toggle) must be
57
+ // able to call this unconditionally: turning `normal` into `compact` would
58
+ // hide the chat for a user who only wanted their sidebar back.
59
+ revealSidebar: () => setMode((previous) => (previous === 'full' ? 'compact' : previous)),
60
+ };
61
+ }
@@ -26,6 +26,8 @@
26
26
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
27
27
  import { Window } from 'happy-dom';
28
28
  import type { h as PreactH, render as PreactRender } from 'preact';
29
+ import type { ComponentChildren } from 'preact';
30
+ import type { ComponentType } from 'preact/compat';
29
31
  import type { act as PreactAct } from 'preact/test-utils';
30
32
  import type { useFileTabs as UseFileTabsHook } from '@/client/hooks/workspace/file-tabs';
31
33
  import type { OpenFileInput } from '@/client/hooks/workspace/file-tabs';
@@ -44,7 +46,11 @@ const SESSION = 'new-file-tabs';
44
46
  const nativeFetch = Bun.fetch;
45
47
 
46
48
  let container: HTMLElement;
49
+ /** The provider component, kept so a case can re-render with a different root. */
50
+ let SessionStateProvider: ComponentType<{ sessionId: string; children: ComponentChildren }>;
47
51
  let handleOpenFile: ((file: OpenFileInput) => void) | null = null;
52
+ /** The mounted probe, so a re-render can hand it another root. */
53
+ let Probe: ComponentType<{ rootPath: string | null }>;
48
54
  let openedFiles: OpenedFile[] = [];
49
55
  let activeFileId: number | string | null = null;
50
56
 
@@ -56,23 +62,35 @@ async function settle() {
56
62
  }
57
63
  }
58
64
 
65
+ /** Re-render the mounted tree with a different workspace root. */
66
+ async function setRoot(rootPath: string | null): Promise<void> {
67
+ await act(async () => {
68
+ render(
69
+ h(SessionStateProvider, { sessionId: SESSION, children: h(Probe, { rootPath }) }),
70
+ container,
71
+ );
72
+ });
73
+ await settle();
74
+ }
75
+
59
76
  /** Mount the hook inside the provider, with `stored` as the session's blob. */
60
- async function mount(stored: Record<string, unknown> = {}) {
77
+ async function mount(stored: Record<string, unknown> = {}, root: string | null = ROOT) {
61
78
  globalThis.fetch = (async () => ({
62
79
  ok: true,
63
80
  json: async () => ({ sessionId: SESSION, state: stored }),
64
81
  text: async () => JSON.stringify({ sessionId: SESSION, state: stored }),
65
82
  })) as unknown as typeof fetch;
66
- const { SessionStateProvider } = await import('@/client/components/common/session-state-provider/index');
67
- const Probe = () => {
68
- const tabs = useFileTabs(ROOT, SESSION);
83
+ const provider = await import('@/client/components/common/session-state-provider/index');
84
+ SessionStateProvider = provider.SessionStateProvider as typeof SessionStateProvider;
85
+ Probe = ({ rootPath }: { rootPath: string | null }) => {
86
+ const tabs = useFileTabs(rootPath, SESSION);
69
87
  handleOpenFile = tabs.handleOpenFile;
70
88
  openedFiles = tabs.openedFiles;
71
89
  activeFileId = tabs.activeFileId;
72
90
  return null;
73
91
  };
74
92
  await act(async () => {
75
- render(h(SessionStateProvider, { sessionId: SESSION, children: h(Probe, {}) }), container);
93
+ render(h(SessionStateProvider, { sessionId: SESSION, children: h(Probe, { rootPath: root }) }), container);
76
94
  });
77
95
  await settle();
78
96
  }
@@ -147,6 +165,45 @@ describe('useFileTabs open resolution', () => {
147
165
  expect(activeFileId).toBe(fileTabId('src/a.ts'));
148
166
  });
149
167
 
168
+ /**
169
+ * A root that is not known yet must not close the tabs.
170
+ *
171
+ * `activeProjectPath` is derived from the sidebar's folder list, which is empty
172
+ * for a beat on every load and again whenever the structure topic
173
+ * re-subscribes. Treating that `null` as a root CHANGE wiped the file the user
174
+ * had just clicked, and the editor drew "Select a file to open" over it — the
175
+ * rare e2e failure this pins.
176
+ */
177
+ test('a transiently unknown root does not wipe the open tabs', async () => {
178
+ await mount();
179
+ await act(async () => {
180
+ handleOpenFile?.(explorerPayload('src/a.ts'));
181
+ });
182
+ await settle();
183
+ expect(openedFiles).toHaveLength(1);
184
+
185
+ // The folder list drops out and comes back — the same workspace.
186
+ await setRoot(null);
187
+ await setRoot(ROOT);
188
+
189
+ expect(openedFiles).toHaveLength(1);
190
+ expect(activeFileId).toBe('src/a.ts');
191
+ });
192
+
193
+ test('a DIFFERENT root closes the previous workspace’s tabs', async () => {
194
+ await mount();
195
+ await act(async () => {
196
+ handleOpenFile?.(explorerPayload('src/a.ts'));
197
+ });
198
+ await settle();
199
+ expect(openedFiles).toHaveLength(1);
200
+
201
+ await setRoot('/other');
202
+
203
+ expect(openedFiles).toHaveLength(0);
204
+ expect(activeFileId).toBeNull();
205
+ });
206
+
150
207
  test('reopening under the computed id activates the tab rather than adding one', async () => {
151
208
  await mount();
152
209