@kabmo-labs/kabmo 0.7.0 → 0.8.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 (130) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +403 -384
  3. package/dist/client/static/js/index-gtbpewr7.css +1 -0
  4. package/dist/client/static/js/{index-0wvbca0b.js → index-qwpvd9fv.js} +779 -779
  5. package/package.json +2 -1
  6. package/src/client/components/common/header-controls/index.tsx +24 -1
  7. package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
  8. package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
  9. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
  10. package/src/client/components/layout/desktop-layout/index.tsx +8 -2
  11. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
  12. package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
  13. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
  14. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
  15. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  16. package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
  17. package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
  18. package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
  19. package/src/client/components/mobile/LayoutWrapper.tsx +22 -16
  20. package/src/client/components/mobile/RightSidebar.tsx +21 -10
  21. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  22. package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
  23. package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
  24. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
  25. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
  26. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
  27. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
  28. package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
  29. package/src/client/components/mobile/worktree-session.ts +51 -0
  30. package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
  31. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -0
  32. package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
  33. package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
  34. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +29 -56
  35. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
  36. package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
  37. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
  38. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
  39. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
  40. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +24 -6
  41. package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
  42. package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
  43. package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
  44. package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
  45. package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
  46. package/src/client/components/workspace/file-explorer/filter.ts +47 -0
  47. package/src/client/components/workspace/file-explorer/index.tsx +77 -64
  48. package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
  49. package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
  50. package/src/client/hooks/chat/timeline/index.ts +9 -5
  51. package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
  52. package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
  53. package/src/client/hooks/chat/timeline/send.test.ts +2 -0
  54. package/src/client/hooks/chat/timeline/send.ts +11 -1
  55. package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
  56. package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
  57. package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
  58. package/src/client/hooks/ui/long-press.test.ts +174 -0
  59. package/src/client/hooks/ui/long-press.ts +112 -0
  60. package/src/client/hooks/ui/updates/shell-download.test.ts +73 -0
  61. package/src/client/hooks/ui/updates/shell-download.ts +112 -28
  62. package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -0
  63. package/src/client/hooks/workspace/file-create.ts +41 -11
  64. package/src/client/hooks/workspace/file-listing.ts +56 -1
  65. package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
  66. package/src/client/hooks/workspace/worktree/index.ts +150 -0
  67. package/src/client/tailwind.css +19 -0
  68. package/src/server/index.ts +7 -2
  69. package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
  70. package/src/server/lib/db/schema.ts +4 -0
  71. package/src/server/lib/db/worktree-registry.server.ts +91 -0
  72. package/src/server/lib/fs/canonical.ts +52 -0
  73. package/src/server/lib/fs/relative-path.ts +48 -0
  74. package/src/server/lib/fs/root-worktree.test.ts +116 -0
  75. package/src/server/lib/fs/root.ts +39 -10
  76. package/src/server/lib/fs/worktree/carry.ts +134 -0
  77. package/src/server/lib/fs/worktree/create.test.ts +249 -0
  78. package/src/server/lib/fs/worktree/create.ts +263 -0
  79. package/src/server/lib/fs/worktree/errors.ts +43 -0
  80. package/src/server/lib/fs/worktree/list.test.ts +207 -0
  81. package/src/server/lib/fs/worktree/list.ts +129 -0
  82. package/src/server/lib/fs/worktree/location.test.ts +206 -0
  83. package/src/server/lib/fs/worktree/location.ts +114 -0
  84. package/src/server/lib/fs/worktree/project-id.ts +53 -0
  85. package/src/server/lib/fs/worktree/remove.test.ts +242 -0
  86. package/src/server/lib/fs/worktree/remove.ts +152 -0
  87. package/src/server/lib/fs/worktree/settings.server.ts +56 -0
  88. package/src/server/lib/fs/worktree/topology.ts +142 -0
  89. package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
  90. package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
  91. package/src/server/lib/panels/local-edits.server.ts +33 -0
  92. package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
  93. package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
  94. package/src/server/lib/panels/sync.server.test.ts +125 -147
  95. package/src/server/lib/panels/sync.server.ts +153 -48
  96. package/src/server/lib/updates/shell-assets.test.ts +114 -0
  97. package/src/server/lib/updates/shell-assets.ts +109 -0
  98. package/src/server/lib/updates/shell-pages.test.ts +156 -0
  99. package/src/server/lib/updates/shell-pages.ts +145 -0
  100. package/src/server/lib/updates/shell-release.test.ts +75 -70
  101. package/src/server/lib/updates/shell-release.ts +120 -136
  102. package/src/server/routes/fs/action-create.test.ts +18 -13
  103. package/src/server/routes/fs/action-nested.test.ts +184 -0
  104. package/src/server/routes/fs/action.ts +47 -16
  105. package/src/server/routes/index.ts +2 -0
  106. package/src/server/routes/updates/desktop.ts +37 -0
  107. package/src/server/routes/updates/index.ts +2 -0
  108. package/src/server/routes/worktrees/create.ts +138 -0
  109. package/src/server/routes/worktrees/index.ts +25 -0
  110. package/src/server/routes/worktrees/list.ts +124 -0
  111. package/src/server/routes/worktrees/remove.ts +102 -0
  112. package/src/server/routes/worktrees/routes.test.ts +309 -0
  113. package/src/server/routes/worktrees/shared.ts +90 -0
  114. package/src/shared/lib/fs/file-tree.test.ts +183 -0
  115. package/src/shared/lib/fs/file-tree.ts +159 -0
  116. package/src/shared/lib/fs/paths.test.ts +24 -1
  117. package/src/shared/lib/fs/paths.ts +17 -0
  118. package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
  119. package/src/shared/lib/omp/session/prewarm.ts +7 -0
  120. package/src/shared/lib/updates/shell-ua.ts +24 -4
  121. package/src/shared/lib/workspace/active-project.ts +23 -0
  122. package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
  123. package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
  124. package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
  125. package/src/shared/lib/workspace/worktree/types.ts +104 -0
  126. package/src/shared/types/fs.ts +10 -0
  127. package/src/shared/types/workspace.ts +14 -0
  128. package/src/test-support/file-explorer-disk.test-util.ts +76 -0
  129. package/src/test-support/file-explorer.test-util.ts +71 -40
  130. package/dist/client/static/js/index-nzyjf92m.css +0 -1
@@ -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,
@@ -155,3 +157,74 @@ describe('shellReleasePath', () => {
155
157
  expect(shellReleasePath('ios')).toBe('/api/updates/ios');
156
158
  });
157
159
  });
160
+
161
+ describe('the desktop shells', () => {
162
+ test('each desktop shell reads its own route, with an explicit kind', () => {
163
+ // ONE route with a required `kind`, so no absence can silently mean a
164
+ // platform — the reason the mobile pair has two routes.
165
+ expect(shellReleasePath('macos')).toBe('/api/updates/desktop?kind=macos');
166
+ expect(shellReleasePath('windows')).toBe('/api/updates/desktop?kind=windows');
167
+ expect(shellReleasePath('linux')).toBe('/api/updates/desktop?kind=linux');
168
+ });
169
+
170
+ test('a desktop shell offers an update when it is behind', () => {
171
+ // The reported gap: an installed desktop app that is behind had no way to
172
+ // learn it, because nothing in the console read its version.
173
+ expect(shouldOfferShellDownload('macos', '0.1.0', null, RELEASE)).toEqual({
174
+ ...RELEASE,
175
+ kind: 'update',
176
+ platform: 'macos',
177
+ });
178
+ expect(shouldOfferShellDownload('windows', '0.1.0', null, RELEASE)?.platform).toBe('windows');
179
+ expect(shouldOfferShellDownload('linux', '0.1.0', null, RELEASE)?.platform).toBe('linux');
180
+ });
181
+
182
+ test('a desktop shell that is current, or ahead, offers nothing', () => {
183
+ expect(shouldOfferShellDownload('macos', '1.2.3', null, RELEASE)).toBeNull();
184
+ expect(shouldOfferShellDownload('macos', '1.3.0', null, RELEASE)).toBeNull();
185
+ });
186
+
187
+ test('a desktop BROWSER is offered nothing', () => {
188
+ // A desktop app is installed by hand from the release page; a tab that
189
+ // advertised one would be a button for a product the reader did not ask for.
190
+ expect(shouldOfferShellDownload('macos', null, false, RELEASE)).toBeNull();
191
+ expect(shouldOfferShellDownload('windows', undefined, undefined, RELEASE)).toBeNull();
192
+ });
193
+
194
+ test('the label names the desktop platform', () => {
195
+ const update: ShellDownload = { ...RELEASE, kind: 'update', platform: 'linux' };
196
+ expect(shellDownloadLabel(update)).toBe('Update Kabmo 1.2.3 for Linux');
197
+ expect(shellDownloadShortLabel(update)).toBe('Update Linux');
198
+ });
199
+
200
+ test('the short label keeps the mobile spellings', () => {
201
+ expect(shellDownloadShortLabel(UPDATE)).toBe('Update APK');
202
+ expect(shellDownloadShortLabel(INSTALL)).toBe('Get APK');
203
+ expect(shellDownloadShortLabel({ ...UPDATE, platform: 'ios' })).toBe('Update app');
204
+ });
205
+ });
206
+
207
+ describe('desktopShellFromIdentity', () => {
208
+ test('reads the bridge identity the shell injects', () => {
209
+ expect(desktopShellFromIdentity({ platform: 'macos', shellVersion: '0.2.0' })).toEqual({
210
+ platform: 'macos',
211
+ version: '0.2.0',
212
+ });
213
+ expect(desktopShellFromIdentity({ platform: 'windows', shellVersion: '0.2.0' })?.platform).toBe('windows');
214
+ expect(desktopShellFromIdentity({ platform: 'linux', shellVersion: '0.2.0' })?.platform).toBe('linux');
215
+ });
216
+
217
+ test('is null in a browser, and for an identity with no version', () => {
218
+ expect(desktopShellFromIdentity(null)).toBeNull();
219
+ expect(desktopShellFromIdentity(undefined)).toBeNull();
220
+ expect(desktopShellFromIdentity({ platform: 'macos' })).toBeNull();
221
+ expect(desktopShellFromIdentity({ shellVersion: '0.2.0' })).toBeNull();
222
+ });
223
+
224
+ test('an unknown platform is not compared against a published artifact', () => {
225
+ // A newer shell reporting a fourth platform must not be matched to a file
226
+ // nobody publishes.
227
+ expect(desktopShellFromIdentity({ platform: 'beos', shellVersion: '1.0.0' })).toBeNull();
228
+ expect(desktopShellFromIdentity({ platform: 'android', shellVersion: '1.0.0' })).toBeNull();
229
+ });
230
+ });
@@ -1,12 +1,11 @@
1
1
  /**
2
- * What the sidebar should offer for a native shell, and why.
2
+ * What the console should offer for a native shell, and why.
3
3
  *
4
4
  * TWO DIFFERENT OFFERS, from two different facts:
5
5
  *
6
- * - **`update`** — this page is running inside the shell (`shellVersion` from
7
- * the bootstrap) and a newer build is published. An up-to-date shell offers
8
- * nothing: a button that downloads the build already installed is worse than
9
- * no button.
6
+ * - **`update`** — this page is running inside the shell and a newer build is
7
+ * published. An up-to-date shell offers nothing: a button that downloads the
8
+ * build already installed is worse than no button.
10
9
  * - **`install`** — this page is an Android BROWSER (Chrome, Brave, Firefox) and
11
10
  * an APK is published. The reader may not know a native shell exists, and
12
11
  * telling them is the whole point of the offer; there is no installed version
@@ -16,18 +15,25 @@
16
15
  * an APK can — iOS has no sideload-by-URL path — so a browser on an iPhone is
17
16
  * offered nothing, and only an installed iOS shell ever sees this control.
18
17
  *
19
- * A desktop browser gets neither: an APK cannot be installed there, and the
20
- * app's own update badge already covers the server.
18
+ * There is NO desktop BROWSER offer either, but there IS a desktop SHELL one: a
19
+ * desktop app is installed by hand from the release page, so nothing in a tab
20
+ * should advertise it — while an installed app that is behind has exactly the
21
+ * same problem the mobile pair had, and used to have no way to learn it.
21
22
  *
22
- * The probe is lazy: it runs only when a shell token or an Android device is
23
- * present, so a console that can never show the button makes no request for it.
24
- * ONE hook for both shells, because the decision table is identical and only the
25
- * artifact, the endpoint and the platform word differ.
23
+ * The probe is lazy: it runs only when an installed shell (a bootstrap token, or
24
+ * a bridge identity) or an Android device is present, so a console that can
25
+ * never show the button makes no request for it. ONE hook for every shell,
26
+ * because the decision table is identical and only the artifact, the endpoint
27
+ * and the platform word differ.
26
28
  */
27
29
 
28
30
  import { useEffect, useState } from 'preact/hooks';
29
31
  import { isNewer } from '@/shared/lib/updates/semver';
30
- import type { ShellKind } from '@/shared/lib/updates/shell-ua';
32
+ import {
33
+ isDesktopShellKind,
34
+ type DesktopShellKind,
35
+ type ShellKind,
36
+ } from '@/shared/lib/updates/shell-ua';
31
37
 
32
38
  /** Which shell the offer is for. */
33
39
  export type ShellPlatform = ShellKind;
@@ -36,7 +42,13 @@ export type ShellPlatform = ShellKind;
36
42
  export type ShellOfferKind = 'update' | 'install';
37
43
 
38
44
  export interface ShellDownload {
39
- /** The artifact to download. */
45
+ /**
46
+ * What the control links to.
47
+ *
48
+ * The artifact itself for the mobile pair (an APK the shell installs, an IPA
49
+ * the release page explains), and the RELEASE PAGE for a desktop shell: every
50
+ * desktop build is per-architecture, so the console cannot name one file.
51
+ */
40
52
  url: string;
41
53
  /** The version that artifact ships. */
42
54
  latest: string;
@@ -58,6 +70,9 @@ interface ReleasePayload {
58
70
  const PLATFORM_NAMES: Record<ShellPlatform, string> = {
59
71
  android: 'Android',
60
72
  ios: 'iOS',
73
+ macos: 'macOS',
74
+ windows: 'Windows',
75
+ linux: 'Linux',
61
76
  };
62
77
 
63
78
  /**
@@ -126,8 +141,14 @@ export function shellDownloadLabel(download: ShellDownload): string {
126
141
  return `${verb} Kabmo ${download.latest} for ${PLATFORM_NAMES[download.platform]}`;
127
142
  }
128
143
 
129
- /** The endpoint that answers for one shell. */
144
+ /**
145
+ * The endpoint that answers for one shell.
146
+ *
147
+ * The desktop trio shares ONE route with an explicit `kind`, so no absence can
148
+ * silently mean a platform — the reason the mobile pair has two.
149
+ */
130
150
  export function shellReleasePath(platform: ShellPlatform): string {
151
+ if (isDesktopShellKind(platform)) return `/api/updates/desktop?kind=${platform}`;
131
152
  return `/api/updates/${platform}`;
132
153
  }
133
154
 
@@ -146,6 +167,27 @@ export function shellFromBootstrap(bootstrap: {
146
167
  return null;
147
168
  }
148
169
 
170
+ /**
171
+ * Which DESKTOP shell this page is running inside, from the bridge identity.
172
+ *
173
+ * The desktop trio reports itself over `window.__kabmoDesktop` rather than the
174
+ * User-Agent, because a desktop app is not downloaded from the console and the
175
+ * server has nothing to parse per request. The identity is injected at document
176
+ * start, before the page's own scripts, so this answers on the FIRST paint.
177
+ *
178
+ * An identity whose `platform` is not one of the three is not a desktop shell
179
+ * this build knows how to update — a newer shell reporting a fourth platform
180
+ * must not be compared against an artifact nobody publishes.
181
+ */
182
+ export function desktopShellFromIdentity(identity: {
183
+ platform?: string;
184
+ shellVersion?: string;
185
+ } | null | undefined): { platform: DesktopShellKind; version: string } | null {
186
+ if (!identity?.platform || !identity.shellVersion) return null;
187
+ if (!isDesktopShellKind(identity.platform)) return null;
188
+ return { platform: identity.platform, version: identity.shellVersion };
189
+ }
190
+
149
191
  /**
150
192
  * Fetch the published artifact and decide what to offer.
151
193
  *
@@ -159,7 +201,18 @@ export function useShellDownload(
159
201
  onAndroid: boolean | null | undefined,
160
202
  fetchImpl: typeof fetch = fetch,
161
203
  ): ShellDownload | null {
162
- const [release, setRelease] = useState<Omit<ShellDownload, 'kind' | 'platform'> | null>(null);
204
+ /**
205
+ * The answer, TAGGED with the platform it was read for.
206
+ *
207
+ * A bare payload is not enough: this hook is called with a different platform
208
+ * when a page's shell changes (a desktop identity arriving, a spawn adoption),
209
+ * and an untagged answer would be compared against the new platform's version
210
+ * — offering an Android APK to a Mac for one render.
211
+ */
212
+ const [answer, setAnswer] = useState<{
213
+ platform: ShellPlatform;
214
+ release: Omit<ShellDownload, 'kind' | 'platform'> | null;
215
+ } | null>(null);
163
216
  const wanted = Boolean(version) || (platform === 'android' && Boolean(onAndroid));
164
217
 
165
218
  useEffect(() => {
@@ -171,7 +224,7 @@ export function useShellDownload(
171
224
  const response = await fetchImpl(shellReleasePath(platform));
172
225
  if (!response.ok) return;
173
226
  const payload: unknown = await response.json();
174
- if (!cancelled) setRelease(parseShellRelease(payload));
227
+ if (!cancelled) setAnswer({ platform, release: parseShellRelease(payload) });
175
228
  } catch {
176
229
  // A probe that cannot answer is a button that must not appear.
177
230
  }
@@ -182,28 +235,57 @@ export function useShellDownload(
182
235
  };
183
236
  }, [wanted, platform, fetchImpl]);
184
237
 
238
+ // A stale answer belongs to the platform it was read for, not this one.
239
+ const release = answer?.platform === platform ? answer.release : null;
185
240
  return shouldOfferShellDownload(platform, version, onAndroid, release);
186
241
  }
187
242
 
243
+ /** The bridge identity a desktop shell injects before the page's first script. */
244
+ export interface DesktopIdentity {
245
+ platform?: string;
246
+ shellVersion?: string;
247
+ }
248
+
249
+ function readDesktopIdentity(): DesktopIdentity | null {
250
+ if (typeof window === 'undefined') return null;
251
+ // `window.__kabmoDesktop` is declared by `lib/desktop/bridge` — the same
252
+ // object the shell injects, so this reads the shell's own contract rather
253
+ // than re-declaring its shape here.
254
+ return window.__kabmoDesktop ?? null;
255
+ }
256
+
188
257
  /**
189
258
  * The offer for whichever shell this page is running inside — or the Android
190
259
  * install offer for a browser on that platform.
191
260
  *
192
261
  * The single entry point both sidebar layouts call, so the two cannot disagree
193
262
  * about which shell they are in or which endpoint they read. It reads the
194
- * bootstrap the SERVER wrote for this request rather than sniffing the UA
195
- * client-side, because the server already parsed it for the very response that
196
- * carried the page.
263
+ * bootstrap the SERVER wrote for this request (and, for a desktop shell, the
264
+ * identity the shell injected) rather than sniffing the UA client-side, because
265
+ * the server already parsed it for the very response that carried the page.
266
+ *
267
+ * `desktop` is a parameter so the tests can drive the bridge identity without a
268
+ * real shell; production reads it from `window.__kabmoDesktop`.
197
269
  */
198
- export function useShellOffer(fetchImpl: typeof fetch = fetch): ShellDownload | null {
270
+ export function useShellOffer(
271
+ fetchImpl: typeof fetch = fetch,
272
+ desktop: DesktopIdentity | null = readDesktopIdentity(),
273
+ ): ShellDownload | null {
199
274
  const bootstrap = typeof window === 'undefined' ? undefined : window.__OMP_BOOTSTRAP__;
200
- const shell = shellFromBootstrap(bootstrap);
201
- return useShellDownload(
202
- shell?.platform ?? 'android',
203
- shell?.version ?? null,
204
- bootstrap?.isAndroid,
205
- fetchImpl,
206
- );
275
+ const mobile = shellFromBootstrap(bootstrap);
276
+ const fromBridge = desktopShellFromIdentity(desktop);
277
+
278
+ // The desktop identity is checked FIRST and is authoritative when present: a
279
+ // desktop shell never carries a mobile UA token, and a page that somehow had
280
+ // both is in the desktop app, whose version is the one being compared.
281
+ //
282
+ // ONE call, with the resolved platform — never two conditional calls. A hook
283
+ // behind an `if` changes the hook order between renders, which React/Preact
284
+ // treats as a different component.
285
+ const platform = fromBridge?.platform ?? mobile?.platform ?? 'android';
286
+ const version = fromBridge?.version ?? mobile?.version ?? null;
287
+
288
+ return useShellDownload(platform, version, fromBridge ? null : bootstrap?.isAndroid, fetchImpl);
207
289
  }
208
290
 
209
291
  /**
@@ -212,9 +294,11 @@ export function useShellOffer(fetchImpl: typeof fetch = fetch): ShellDownload |
212
294
  * It names the OFFER, and for Android it names the file type because that is
213
295
  * what a reader recognises (`APK` is the thing they will see in Downloads). An
214
296
  * iOS update is an IPA, which nobody installs by hand — the shell finishes the
215
- * install itself — so the platform word is the honest one there.
297
+ * install itself — so the platform word is the honest one there. A desktop
298
+ * update opens the release page, so it names the platform too.
216
299
  */
217
300
  export function shellDownloadShortLabel(download: ShellDownload): string {
218
301
  if (download.platform === 'ios') return 'Update app';
302
+ if (isDesktopShellKind(download.platform)) return `Update ${PLATFORM_NAMES[download.platform]}`;
219
303
  return download.kind === 'update' ? 'Update APK' : 'Get APK';
220
304
  }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `useShellOffer` — the one hook both sidebars call.
8
+ *
9
+ * The pure decision table is pinned next door; what this file covers is the
10
+ * WIRING, which is where the reported bug lived: an APK two versions behind a
11
+ * running server drew no button, and no unit test of `shouldOfferShellDownload`
12
+ * could have seen it, because the hook never asked the server at all.
13
+ *
14
+ * Three facts it reads, and each has been wrong at some point:
15
+ *
16
+ * 1. the MOBILE shell's version, from the bootstrap the server wrote for this
17
+ * request (the `KabmoAndroid/<v>` token);
18
+ * 2. the DESKTOP shell's version, from the bridge identity injected before the
19
+ * first script — a desktop app carries no UA token;
20
+ * 3. the release itself, fetched from the shell's own route, with the desktop
21
+ * trio sharing one endpoint and an explicit `kind`.
22
+ */
23
+
24
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
25
+ import { Window } from 'happy-dom';
26
+ import { h, render } from 'preact';
27
+ import { act } from 'preact/test-utils';
28
+ import { useShellOffer } from '@/client/hooks/ui/updates/shell-download';
29
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
30
+
31
+ let container: HTMLElement;
32
+
33
+ /** The payload every route answers in these cases. */
34
+ const RELEASE = {
35
+ url: 'https://github.com/kabmo-labs/kabmo/releases/download/v0.7.0/Kabmo-0.6.0-android.apk',
36
+ latest: '0.6.0',
37
+ releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
38
+ };
39
+
40
+ function Harness({ fetchImpl }: { fetchImpl: typeof fetch }) {
41
+ const offer = useShellOffer(fetchImpl);
42
+ return h('span', { id: 'offer' }, offer ? `${offer.kind}:${offer.platform}:${offer.latest}` : 'none');
43
+ }
44
+
45
+ /** A fetch double that answers the release payload and records the URLs asked. */
46
+ function stubFetch(asked: string[]) {
47
+ return (async (input: string | URL | Request) => {
48
+ asked.push(String(input));
49
+ return { ok: true, json: async () => RELEASE } as Response;
50
+ }) as unknown as typeof fetch;
51
+ }
52
+
53
+ async function mount(fetchImpl: typeof fetch): Promise<string> {
54
+ await act(async () => {
55
+ render(h(Harness, { fetchImpl }), container);
56
+ });
57
+ // The probe is an effect: let its promise settle and re-render.
58
+ for (let i = 0; i < 20; i += 1) await act(async () => {});
59
+ return container.querySelector('#offer')?.textContent ?? '';
60
+ }
61
+
62
+ /** Install a bootstrap and (optionally) a bridge identity on the page. */
63
+ function shell(options: { bootstrap?: Record<string, unknown>; desktop?: Record<string, unknown> }): void {
64
+ const page = globalThis as unknown as {
65
+ window: {
66
+ __OMP_BOOTSTRAP__?: Record<string, unknown>;
67
+ __kabmoDesktop?: Record<string, unknown>;
68
+ };
69
+ };
70
+ delete page.window.__OMP_BOOTSTRAP__;
71
+ delete page.window.__kabmoDesktop;
72
+ if (options.bootstrap) page.window.__OMP_BOOTSTRAP__ = options.bootstrap;
73
+ if (options.desktop) page.window.__kabmoDesktop = options.desktop;
74
+ }
75
+
76
+ beforeAll(() => {
77
+ installDomGlobals(new Window({ url: 'http://localhost' }));
78
+ container = document.createElement('div');
79
+ document.body.appendChild(container);
80
+ });
81
+
82
+ afterEach(() => {
83
+ act(() => render(null, container));
84
+ shell({});
85
+ });
86
+
87
+ afterAll(() => {
88
+ container.remove();
89
+ restoreDomGlobals();
90
+ });
91
+
92
+ describe('the Android shell', () => {
93
+ test('offers an update when the APK is behind the published asset', async () => {
94
+ // The reported incident: server v0.7.0, APK v0.5.0, published 0.6.0, and
95
+ // the sidebar drew nothing because the probe answered `null`.
96
+ shell({ bootstrap: { shellVersion: '0.5.0', isAndroid: true } });
97
+ const asked: string[] = [];
98
+
99
+ expect(await mount(stubFetch(asked))).toBe('update:android:0.6.0');
100
+ expect(asked, 'the Android route').toEqual(['/api/updates/android']);
101
+ });
102
+
103
+ test('offers nothing when the APK is the published build', async () => {
104
+ shell({ bootstrap: { shellVersion: '0.6.0', isAndroid: true } });
105
+ expect(await mount(stubFetch([]))).toBe('none');
106
+ });
107
+
108
+ test('offers an INSTALL to a browser on Android, and nothing on a desktop browser', async () => {
109
+ shell({ bootstrap: { isAndroid: true } });
110
+ expect(await mount(stubFetch([]))).toBe('install:android:0.6.0');
111
+
112
+ shell({ bootstrap: { isAndroid: false } });
113
+ expect(await mount(stubFetch([]))).toBe('none');
114
+ });
115
+ });
116
+
117
+ describe('the iOS shell', () => {
118
+ test('reads its own token and its own route', async () => {
119
+ shell({ bootstrap: { iosShellVersion: '0.1.0' } });
120
+ const asked: string[] = [];
121
+
122
+ expect(await mount(stubFetch(asked))).toBe('update:ios:0.6.0');
123
+ expect(asked).toEqual(['/api/updates/ios']);
124
+ });
125
+
126
+ test('an iOS browser is offered nothing, because an IPA cannot be installed from a page', async () => {
127
+ shell({ bootstrap: {} });
128
+ expect(await mount(stubFetch([]))).toBe('none');
129
+ });
130
+ });
131
+
132
+ describe('the desktop shells', () => {
133
+ test('reads the version from the bridge and the route from the kind', async () => {
134
+ // No bootstrap token at all: a desktop app reports itself over the bridge,
135
+ // and the console had no way to read it before this.
136
+ shell({ desktop: { platform: 'macos', shellVersion: '0.1.0', overlayTitlebar: true } });
137
+ const asked: string[] = [];
138
+
139
+ expect(await mount(stubFetch(asked))).toBe('update:macos:0.6.0');
140
+ expect(asked).toEqual(['/api/updates/desktop?kind=macos']);
141
+ });
142
+
143
+ test('each desktop shell asks for its own kind', async () => {
144
+ for (const platform of ['windows', 'linux'] as const) {
145
+ shell({ desktop: { platform, shellVersion: '0.1.0' } });
146
+ const asked: string[] = [];
147
+ await mount(stubFetch(asked));
148
+ expect(asked, platform).toEqual([`/api/updates/desktop?kind=${platform}`]);
149
+ }
150
+ });
151
+
152
+ test('a current desktop build offers nothing', async () => {
153
+ shell({ desktop: { platform: 'windows', shellVersion: '0.6.0' } });
154
+ expect(await mount(stubFetch([]))).toBe('none');
155
+ });
156
+
157
+ test('a browser on a desktop is offered nothing', async () => {
158
+ shell({ bootstrap: { isAndroid: false } });
159
+ expect(await mount(stubFetch([]))).toBe('none');
160
+ });
161
+
162
+ test('the desktop identity wins over a mobile token, because that is the shell asking', async () => {
163
+ // A page cannot carry both in practice, and if it somehow did the desktop
164
+ // app is the process whose version is being compared.
165
+ shell({
166
+ bootstrap: { shellVersion: '0.1.0', isAndroid: true },
167
+ desktop: { platform: 'linux', shellVersion: '0.1.0' },
168
+ });
169
+ expect(await mount(stubFetch([]))).toBe('update:linux:0.6.0');
170
+ });
171
+
172
+ test('a refused probe draws no button', async () => {
173
+ shell({ desktop: { platform: 'macos', shellVersion: '0.1.0' } });
174
+ const refusing = (async () => ({ ok: false, json: async () => ({}) }) as Response) as unknown as typeof fetch;
175
+ expect(await mount(refusing)).toBe('none');
176
+ });
177
+
178
+ test('a payload with no artifact draws no button', async () => {
179
+ shell({ desktop: { platform: 'macos', shellVersion: '0.1.0' } });
180
+ const empty = (async () =>
181
+ ({
182
+ ok: true,
183
+ json: async () => ({ url: null, latest: null, releaseUrl: null }),
184
+ }) as Response) as unknown as typeof fetch;
185
+ expect(await mount(empty)).toBe('none');
186
+ });
187
+ });
@@ -4,7 +4,8 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Creating one empty file in a named folder of the Files panel, and opening it.
7
+ * Creating one entry in a named folder of the Files panel, and opening it when
8
+ * it is a file.
8
9
  *
9
10
  * Extracted from `useFileActions` because the Files panel needs the SAME flow
10
11
  * from two places that share no target: a row's context menu (create beside the
@@ -12,6 +13,12 @@
12
13
  * root). A second copy would drift on the two things that matter — a refusal
13
14
  * keeps the dialog open with the server's reason beside the field, and a success
14
15
  * OPENS the file, because the user asked for something to work on.
16
+ *
17
+ * The name may carry SEGMENTS (`lib/parse.ts`), so one dialog creates a nested
18
+ * path and every directory that leads to it. The grammar that keeps the result
19
+ * inside the named folder is the server's (`normalizeRelativePath`); this side
20
+ * only reports its refusal, because a second copy of that rule is how the field
21
+ * accepts something the route then rejects.
15
22
  */
16
23
 
17
24
  import { useEffect, useRef, useState } from 'preact/hooks';
@@ -21,30 +28,46 @@ import { useFetcher } from '@/client/lib/router/fetcher';
21
28
  /** The shape `/api/fs/action` answers a create with. */
22
29
  interface CreateResponse {
23
30
  success?: boolean;
24
- /** Relative path of the file that was created. */
31
+ /** `file` or `folder` — what the request asked for. */
32
+ kind?: 'file' | 'folder';
33
+ /** Relative path of the entry that was created. */
25
34
  path?: string;
26
35
  error?: string;
27
36
  }
28
37
 
38
+ /** What a create produced, for the caller to place in its tree. */
39
+ export interface CreatedEntry {
40
+ path: string;
41
+ name: string;
42
+ kind: 'file' | 'folder';
43
+ }
44
+
29
45
  export interface FileCreateDeps {
30
- /** Folder the file goes in, relative to the listed root (`.` for the root). */
46
+ /** Folder the entry goes in, relative to the listed root (`.` for the root). */
31
47
  folderPath: string;
32
48
  rootPath?: string;
33
49
  repo?: string;
34
- /** Fired after the file exists, so the tree re-reads. */
35
- onCreated: () => void;
36
- /** Fired with the new file's relative path, so the panel can open its tab. */
50
+ /**
51
+ * Fired after the entry exists. The tree inserts it directly rather than
52
+ * re-reading: a create answers with the whole result, and a reload would cost
53
+ * one request per open folder to learn what this call already knows.
54
+ */
55
+ onCreated: (entry: CreatedEntry) => void;
56
+ /** Fired with a created FILE's relative path, so the panel can open its tab. */
37
57
  onOpenCreated?: (file: { path: string; name: string }) => void;
38
58
  }
39
59
 
40
60
  export interface FileCreate {
41
61
  open: boolean;
62
+ /** Whether the open dialog makes a folder; a row's menu offers both. */
63
+ kind: 'file' | 'folder';
42
64
  name: string;
43
65
  setName: (value: string) => void;
44
66
  /** The server's refusal, rendered inside the dialog. */
45
67
  error: string | null;
46
68
  busy: boolean;
47
- openDialog: () => void;
69
+ /** Open the dialog; `kind` defaults to a file. */
70
+ openDialog: (kind?: 'file' | 'folder') => void;
48
71
  closeDialog: () => void;
49
72
  submit: (event: FormEvent) => void;
50
73
  }
@@ -52,6 +75,7 @@ export interface FileCreate {
52
75
  export function useFileCreate({ folderPath, rootPath, repo, onCreated, onOpenCreated }: FileCreateDeps): FileCreate {
53
76
  const fetcher = useFetcher<CreateResponse>();
54
77
  const [open, setOpen] = useState(false);
78
+ const [kind, setKind] = useState<'file' | 'folder'>('file');
55
79
  const [name, setName] = useState('');
56
80
  const [error, setError] = useState<string | null>(null);
57
81
 
@@ -71,24 +95,29 @@ export function useFileCreate({ folderPath, rootPath, repo, onCreated, onOpenCre
71
95
  // The fetcher parses the body whatever the status, so a refusal arrives as
72
96
  // `data.error` rather than as a throw — which is what keeps the dialog open.
73
97
  if (data.success && data.path) {
98
+ const createdKind = data.kind === 'folder' ? 'folder' : 'file';
74
99
  setOpen(false);
75
100
  setName('');
76
101
  setError(null);
77
- onCreated();
78
- onOpenCreated?.({ path: data.path, name: data.path.split('/').pop() || data.path });
102
+ const name = data.path.split('/').pop() || data.path;
103
+ onCreated({ path: data.path, name, kind: createdKind });
104
+ // A folder has nothing to open: the user is about to put something in it.
105
+ if (createdKind === 'file') onOpenCreated?.({ path: data.path, name });
79
106
  } else {
80
- setError(data.error || 'Could not create the file');
107
+ setError(data.error || `Could not create the ${kind}`);
81
108
  }
82
109
  // eslint-disable-next-line react-hooks/exhaustive-deps
83
110
  }, [fetcher.state, fetcher.data]);
84
111
 
85
112
  return {
86
113
  open,
114
+ kind,
87
115
  name,
88
116
  setName,
89
117
  error,
90
118
  busy: fetcher.state !== 'idle',
91
- openDialog: () => {
119
+ openDialog: (nextKind: 'file' | 'folder' = 'file') => {
120
+ setKind(nextKind);
92
121
  setName('');
93
122
  setError(null);
94
123
  setOpen(true);
@@ -103,6 +132,7 @@ export function useFileCreate({ folderPath, rootPath, repo, onCreated, onOpenCre
103
132
  formData.append('actionType', 'create');
104
133
  formData.append('path', folderPath);
105
134
  formData.append('name', trimmed);
135
+ if (kind === 'folder') formData.append('kind', 'folder');
106
136
  if (repo && repo !== '.') formData.append('repo', repo);
107
137
  if (rootPath) formData.append('root', rootPath);
108
138
  void fetcher.submit(formData, { method: 'post', action: '/api/fs/action' });