@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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +403 -384
- package/dist/client/static/js/index-gtbpewr7.css +1 -0
- package/dist/client/static/js/{index-0wvbca0b.js → index-qwpvd9fv.js} +779 -779
- package/package.json +2 -1
- package/src/client/components/common/header-controls/index.tsx +24 -1
- package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
- package/src/client/components/layout/desktop-layout/index.tsx +8 -2
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +22 -16
- package/src/client/components/mobile/RightSidebar.tsx +21 -10
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
- package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
- package/src/client/components/mobile/worktree-session.ts +51 -0
- package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +29 -56
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +24 -6
- package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
- package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
- package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
- package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
- package/src/client/components/workspace/file-explorer/filter.ts +47 -0
- package/src/client/components/workspace/file-explorer/index.tsx +77 -64
- package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
- package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
- package/src/client/hooks/chat/timeline/index.ts +9 -5
- package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.ts +11 -1
- package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
- package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
- package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
- package/src/client/hooks/ui/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +73 -0
- package/src/client/hooks/ui/updates/shell-download.ts +112 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -0
- package/src/client/hooks/workspace/file-create.ts +41 -11
- package/src/client/hooks/workspace/file-listing.ts +56 -1
- package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/worktree/index.ts +150 -0
- package/src/client/tailwind.css +19 -0
- package/src/server/index.ts +7 -2
- package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/worktree-registry.server.ts +91 -0
- package/src/server/lib/fs/canonical.ts +52 -0
- package/src/server/lib/fs/relative-path.ts +48 -0
- package/src/server/lib/fs/root-worktree.test.ts +116 -0
- package/src/server/lib/fs/root.ts +39 -10
- package/src/server/lib/fs/worktree/carry.ts +134 -0
- package/src/server/lib/fs/worktree/create.test.ts +249 -0
- package/src/server/lib/fs/worktree/create.ts +263 -0
- package/src/server/lib/fs/worktree/errors.ts +43 -0
- package/src/server/lib/fs/worktree/list.test.ts +207 -0
- package/src/server/lib/fs/worktree/list.ts +129 -0
- package/src/server/lib/fs/worktree/location.test.ts +206 -0
- package/src/server/lib/fs/worktree/location.ts +114 -0
- package/src/server/lib/fs/worktree/project-id.ts +53 -0
- package/src/server/lib/fs/worktree/remove.test.ts +242 -0
- package/src/server/lib/fs/worktree/remove.ts +152 -0
- package/src/server/lib/fs/worktree/settings.server.ts +56 -0
- package/src/server/lib/fs/worktree/topology.ts +142 -0
- package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
- package/src/server/lib/panels/local-edits.server.ts +33 -0
- package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
- package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
- package/src/server/lib/panels/sync.server.test.ts +125 -147
- package/src/server/lib/panels/sync.server.ts +153 -48
- package/src/server/lib/updates/shell-assets.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +109 -0
- package/src/server/lib/updates/shell-pages.test.ts +156 -0
- package/src/server/lib/updates/shell-pages.ts +145 -0
- package/src/server/lib/updates/shell-release.test.ts +75 -70
- package/src/server/lib/updates/shell-release.ts +120 -136
- package/src/server/routes/fs/action-create.test.ts +18 -13
- package/src/server/routes/fs/action-nested.test.ts +184 -0
- package/src/server/routes/fs/action.ts +47 -16
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/updates/desktop.ts +37 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/worktrees/create.ts +138 -0
- package/src/server/routes/worktrees/index.ts +25 -0
- package/src/server/routes/worktrees/list.ts +124 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +309 -0
- package/src/server/routes/worktrees/shared.ts +90 -0
- package/src/shared/lib/fs/file-tree.test.ts +183 -0
- package/src/shared/lib/fs/file-tree.ts +159 -0
- package/src/shared/lib/fs/paths.test.ts +24 -1
- package/src/shared/lib/fs/paths.ts +17 -0
- package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
- package/src/shared/lib/omp/session/prewarm.ts +7 -0
- package/src/shared/lib/updates/shell-ua.ts +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
- package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
- package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
- package/src/shared/lib/workspace/worktree/types.ts +104 -0
- package/src/shared/types/fs.ts +10 -0
- package/src/shared/types/workspace.ts +14 -0
- package/src/test-support/file-explorer-disk.test-util.ts +76 -0
- package/src/test-support/file-explorer.test-util.ts +71 -40
- 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
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
20
|
-
* app
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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)
|
|
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
|
|
195
|
-
*
|
|
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(
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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 ||
|
|
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' });
|