@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.
- package/README.md +7 -0
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +314 -314
- package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
- package/dist/client/static/js/index-k315p1e8.css +1 -0
- package/package.json +9 -1
- package/src/client/components/common/code-surface/documents.test.ts +120 -0
- package/src/client/components/common/code-surface/documents.ts +201 -0
- package/src/client/components/common/code-surface/extensions.test.ts +110 -0
- package/src/client/components/common/code-surface/extensions.ts +162 -0
- package/src/client/components/common/code-surface/handle.ts +102 -0
- package/src/client/components/common/code-surface/index.tsx +253 -314
- package/src/client/components/common/code-surface/keymap.test.ts +133 -0
- package/src/client/components/common/code-surface/keymap.ts +267 -0
- package/src/client/components/common/code-surface/marks.ts +74 -0
- package/src/client/components/common/code-surface/shiki.ts +234 -0
- package/src/client/components/common/code-surface/sync.test.ts +185 -0
- package/src/client/components/common/code-surface/sync.ts +58 -0
- package/src/client/components/common/code-surface/theme.test.ts +202 -0
- package/src/client/components/common/code-surface/theme.ts +111 -0
- package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
- package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
- package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
- package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
- package/src/client/components/common/shell-update-button/index.tsx +125 -0
- package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
- package/src/client/components/common/toolbar-row/index.tsx +189 -0
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
- package/src/client/components/layout/desktop-layout/index.tsx +47 -67
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
- package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
- package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
- package/src/client/components/settings/Modal.tsx +8 -0
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
- package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
- package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
- package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
- package/src/client/components/workspace/diff-panel/index.tsx +76 -145
- package/src/client/components/workspace/diff-panel/merge.ts +315 -0
- package/src/client/components/workspace/editor/Body.tsx +4 -7
- package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
- package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
- package/src/client/components/workspace/editor/FilePane.tsx +4 -7
- package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
- package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
- package/src/client/components/workspace/editor/index.tsx +7 -9
- package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
- package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
- package/src/client/components/workspace/search-panel/Body.tsx +1 -1
- package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
- package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
- package/src/client/hooks/chat/omp/reconnect.ts +187 -0
- package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
- package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
- package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
- package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
- package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
- package/src/client/hooks/chat/omp/session-list.tsx +69 -9
- package/src/client/hooks/chat/timeline/generating.ts +19 -2
- package/src/client/hooks/chat/timeline/index.ts +19 -24
- package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
- package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
- package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
- package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
- package/src/client/hooks/chat/timeline/session-load.ts +69 -37
- package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
- package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
- package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
- package/src/client/hooks/code/deferred-highlight.ts +129 -0
- package/src/client/hooks/editor/find-state.ts +1 -1
- package/src/client/hooks/editor/theme.test.ts +206 -0
- package/src/client/hooks/editor/theme.ts +82 -0
- package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
- package/src/client/hooks/editor/use-editor-drop.ts +67 -14
- package/src/client/hooks/editor/use-editor-find.ts +7 -7
- package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
- package/src/client/hooks/editor/use-outline-view.ts +94 -7
- package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
- package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
- package/src/client/hooks/editor/use-search-reveal.ts +1 -1
- package/src/client/hooks/ui/settings-modal.ts +56 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
- package/src/client/hooks/ui/updates/shell-download.ts +55 -14
- package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
- package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
- package/src/client/hooks/ui/updates/shell-update.ts +119 -0
- package/src/client/hooks/workspace/diff-file.ts +29 -10
- package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
- package/src/client/hooks/workspace/editor-view.ts +61 -0
- package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
- package/src/client/hooks/workspace/worktree/index.ts +20 -5
- package/src/client/lib/desktop/bridge.ts +45 -0
- package/src/client/lib/search-reveal.ts +1 -1
- package/src/client/main.tsx +5 -1
- package/src/client/tailwind.css +37 -8
- package/src/server/lib/auth/guard.test.ts +28 -0
- package/src/server/lib/auth/guard.ts +10 -0
- package/src/server/lib/fs/worktree/create.ts +2 -3
- package/src/server/lib/fs/worktree/list.ts +13 -0
- package/src/server/lib/fs/worktree/remove.ts +3 -3
- package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
- package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
- package/src/server/lib/lifecycle/port-guard.ts +39 -7
- package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
- package/src/server/lib/lifecycle/probe.test.ts +77 -164
- package/src/server/lib/lifecycle/probe.ts +121 -13
- package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
- package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
- package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
- package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
- package/src/server/lib/omp/session/telemetry.test.ts +33 -0
- package/src/server/lib/realtime/signals.server.ts +10 -0
- package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
- package/src/server/lib/realtime/topics.server.ts +16 -0
- package/src/server/lib/updates/shell-assets.ts +94 -3
- package/src/server/lib/updates/shell-cache.ts +105 -0
- package/src/server/lib/updates/shell-pages.test.ts +33 -2
- package/src/server/lib/updates/shell-pages.ts +17 -5
- package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
- package/src/server/lib/updates/shell-release.test.ts +10 -27
- package/src/server/lib/updates/shell-release.ts +107 -111
- package/src/server/routes/fs/git-merge.test.ts +11 -1
- package/src/server/routes/fs/read.test.ts +24 -7
- package/src/server/routes/fs/scope.test.ts +15 -10
- package/src/server/routes/fs/scope.ts +3 -14
- package/src/server/routes/updates/android.ts +6 -1
- package/src/server/routes/updates/desktop.ts +29 -7
- package/src/server/routes/updates/ios.ts +5 -1
- package/src/server/routes/worktrees/list.ts +11 -1
- package/src/server/routes/worktrees/routes.test.ts +17 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
- package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
- package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
- package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
- package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
- package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
- package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
- package/src/shared/lib/code/editor/find-scan.ts +177 -0
- package/src/shared/lib/code/editor/keymap.ts +3 -3
- package/src/shared/lib/code/editor/lines.test.ts +7 -55
- package/src/shared/lib/code/editor/lines.ts +8 -48
- package/src/shared/lib/code/editor/marks.test.ts +4 -5
- package/src/shared/lib/code/editor/marks.ts +13 -25
- package/src/shared/lib/code/editor/theme-loader.ts +97 -0
- package/src/shared/lib/code/editor/themes.generated.ts +156 -0
- package/src/shared/lib/code/editor/themes.test.ts +165 -0
- package/src/shared/lib/code/editor/themes.ts +165 -0
- package/src/shared/lib/code/highlight-memo.test.ts +103 -0
- package/src/shared/lib/code/highlight-memo.ts +149 -0
- package/src/shared/lib/code/highlighter-warmup.ts +112 -0
- package/src/shared/lib/code/highlighter.ts +3 -1
- package/src/shared/lib/code/idle.ts +37 -0
- package/src/shared/lib/code/outline/rows.test.ts +96 -0
- package/src/shared/lib/code/outline/rows.ts +94 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
- package/src/shared/lib/code/syntax-highlight.ts +2 -3
- package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
- package/src/shared/lib/fs/file-kind.ts +47 -13
- package/src/shared/lib/fs/file-url.test.ts +52 -0
- package/src/shared/lib/fs/file-url.ts +37 -0
- package/src/shared/lib/fs/patch-sides.test.ts +114 -0
- package/src/shared/lib/fs/patch-sides.ts +139 -0
- package/src/shared/lib/realtime/protocol.ts +16 -0
- package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
- package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
- package/src/shared/lib/updates/shell-arch.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +4 -4
- package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
- package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
- package/src/shared/lib/workspace/panel-widths.ts +25 -5
- package/src/shared/lib/workspace/session-state/cache.ts +159 -0
- package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
- package/src/shared/lib/workspace/session-state/merge.ts +100 -0
- package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
- package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
- package/src/shared/lib/workspace/session-state/store.ts +92 -130
- package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
- package/src/shared/types/realtime.ts +29 -0
- package/src/shared/types/settings/state.ts +10 -0
- package/src/test-support/loopback.ts +183 -0
- package/dist/client/static/js/index-gtbpewr7.css +0 -1
- package/src/client/components/common/code-editor/handler.test.ts +0 -274
- package/src/client/components/common/code-editor/handler.ts +0 -175
- package/src/client/components/common/code-editor/index.tsx +0 -314
- package/src/client/components/common/code-surface/measure.ts +0 -148
- package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
- package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
- package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
- package/src/client/hooks/editor/scroll-container.test.ts +0 -102
- package/src/client/hooks/editor/scroll-container.ts +0 -26
- package/src/client/hooks/editor/use-code-window.ts +0 -100
- package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
- package/src/client/hooks/editor/use-editor-history.ts +0 -134
- package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
- package/src/client/hooks/editor/use-occurrences.ts +0 -98
- package/src/shared/lib/code/editor/commands.test.ts +0 -155
- package/src/shared/lib/code/editor/commands.ts +0 -260
- package/src/shared/lib/code/editor/edits.test.ts +0 -100
- package/src/shared/lib/code/editor/edits.ts +0 -122
- package/src/shared/lib/code/editor/history.test.ts +0 -118
- package/src/shared/lib/code/editor/history.ts +0 -101
- package/src/shared/lib/code/editor/layers.test.ts +0 -77
- package/src/shared/lib/code/editor/layers.ts +0 -67
- package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
- package/src/shared/lib/code/editor/line-edits.ts +0 -159
- package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
- package/src/shared/lib/code/editor/occurrences.ts +0 -111
- package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
- package/src/shared/lib/code/editor/reset-css.ts +0 -32
- package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
- package/src/shared/lib/code/editor/text-commands.ts +0 -154
- package/src/shared/lib/code/lazy-window.test.ts +0 -161
- package/src/shared/lib/code/lazy-window.ts +0 -156
- package/src/shared/lib/code/windowed-highlight.ts +0 -114
- package/src/shared/lib/fs/diff-parser.test.ts +0 -116
- package/src/shared/lib/fs/diff-parser.ts +0 -213
|
@@ -161,6 +161,19 @@ function storedOffsets(): ScrollPositions | undefined {
|
|
|
161
161
|
return getSessionValue<ScrollPositions>(SESSION, SCROLL_POSITIONS_KEY);
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* A READER's scroll: the input that causes it, then the movement.
|
|
166
|
+
*
|
|
167
|
+
* The hook distinguishes the two, and it has to: a `scroll` with no input behind
|
|
168
|
+
* it is the browser re-anchoring the document as its real height lands, which
|
|
169
|
+
* must NOT be read as the reader moving. `wheel` is the simplest input a code
|
|
170
|
+
* surface sees.
|
|
171
|
+
*/
|
|
172
|
+
function readerScroll(probe: ProbeHandle, top: number): void {
|
|
173
|
+
probe.el.dispatchEvent(new Event('wheel', { bubbles: true }));
|
|
174
|
+
probe.el.scrollTop = top;
|
|
175
|
+
}
|
|
176
|
+
|
|
164
177
|
describe('useScrollRestore', () => {
|
|
165
178
|
test('restores the stored offset for the tab and surface', async () => {
|
|
166
179
|
const probe = await mount('7', 'code', { '7': { code: 900 } });
|
|
@@ -181,7 +194,47 @@ describe('useScrollRestore', () => {
|
|
|
181
194
|
|
|
182
195
|
test("a reader's own scroll wins over a pending restore", async () => {
|
|
183
196
|
const probe = await mount('7', 'code', { '7': { code: 900 } });
|
|
184
|
-
probe
|
|
197
|
+
readerScroll(probe, 120);
|
|
198
|
+
await act(async () => {
|
|
199
|
+
StubResizeObserver.latest()?.fire();
|
|
200
|
+
});
|
|
201
|
+
expect(probe.el.scrollTop).toBe(120);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('the document re-anchoring is not the reader, and the restore re-applies', async () => {
|
|
205
|
+
// The bug the e2e spec found: the restore writes 900, the document's real
|
|
206
|
+
// height lands a frame later, and CodeMirror re-anchors to keep the visible
|
|
207
|
+
// content stable — `scrollTop` moves to 420 with nobody assigning. Reading
|
|
208
|
+
// that as the reader's own scroll dropped the pending restore, so the
|
|
209
|
+
// anchored value became where the reader stayed (measured: opening a tab
|
|
210
|
+
// moved them 29 lines) and the wrong offset was persisted.
|
|
211
|
+
const probe = await mount('7', 'code', { '7': { code: 900 } }, 600);
|
|
212
|
+
expect(probe.el.scrollTop).toBe(100);
|
|
213
|
+
|
|
214
|
+
// The browser's own correction: no input event behind it.
|
|
215
|
+
probe.el.scrollTop = 420;
|
|
216
|
+
probe.setHeight(5000);
|
|
217
|
+
await act(async () => {
|
|
218
|
+
StubResizeObserver.latest()?.fire();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// The restore is still in charge, so the reader lands where the session
|
|
222
|
+
// recorded — not where the anchoring put them.
|
|
223
|
+
expect(probe.el.scrollTop).toBe(900);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('a reader’s scroll is still theirs after an anchoring correction', async () => {
|
|
227
|
+
// The discriminator must not swallow the NEXT real scroll: the input that
|
|
228
|
+
// explains a movement is consumed by it, so a reader who scrolls after the
|
|
229
|
+
// document settles keeps their own position.
|
|
230
|
+
const probe = await mount('7', 'code', { '7': { code: 900 } });
|
|
231
|
+
probe.el.scrollTop = 420; // anchoring, no input
|
|
232
|
+
await act(async () => {
|
|
233
|
+
StubResizeObserver.latest()?.fire();
|
|
234
|
+
});
|
|
235
|
+
expect(probe.el.scrollTop).toBe(900);
|
|
236
|
+
|
|
237
|
+
readerScroll(probe, 120);
|
|
185
238
|
await act(async () => {
|
|
186
239
|
StubResizeObserver.latest()?.fire();
|
|
187
240
|
});
|
|
@@ -192,7 +245,7 @@ describe('useScrollRestore', () => {
|
|
|
192
245
|
const probe = await mount('7', 'code', {});
|
|
193
246
|
jest.useFakeTimers();
|
|
194
247
|
try {
|
|
195
|
-
probe
|
|
248
|
+
readerScroll(probe, 350);
|
|
196
249
|
jest.advanceTimersByTime(200);
|
|
197
250
|
await act(async () => {});
|
|
198
251
|
} finally {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
* overwrite the position this hook exists to keep.
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import { useCallback, useContext, useLayoutEffect, useRef } from 'preact/hooks';
|
|
35
|
+
import { useCallback, useContext, useEffect, useLayoutEffect, useRef } from 'preact/hooks';
|
|
36
36
|
import { SessionStateContext } from '@/client/hooks/workspace/session-state/context';
|
|
37
37
|
import { getSessionValue, setSessionKey } from '@/shared/lib/workspace/session-state/store';
|
|
38
38
|
import {
|
|
@@ -52,6 +52,29 @@ const SAVE_DEBOUNCE_MS = 150;
|
|
|
52
52
|
/** Scroll deltas below this are rounding, not a move. */
|
|
53
53
|
const NOISE_PX = 1;
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The cancel hook of every restore currently armed, by scroller.
|
|
57
|
+
*
|
|
58
|
+
* A pending restore REPLAYS while the document settles, and it re-applies
|
|
59
|
+
* through a `ResizeObserver` — which can fire BEFORE the scroll event a
|
|
60
|
+
* programmatic reveal causes. Measured: an outline jump scrolled a 2 520-line
|
|
61
|
+
* file to 34 695 px and the observer wrote the stored 0 back 375 ms later, so
|
|
62
|
+
* the caret moved and the view stayed at the top.
|
|
63
|
+
*
|
|
64
|
+
* An explicit reveal therefore has to CANCEL the restore rather than race it,
|
|
65
|
+
* and the surface that scrolls is the one that knows. This registry is that
|
|
66
|
+
* hand-off: keyed by the element, so a caller needs no reference to the hook
|
|
67
|
+
* instance that armed it (the surface owns the scroller; the panel owns the
|
|
68
|
+
* hook).
|
|
69
|
+
*/
|
|
70
|
+
const armedRestores = new Map<HTMLElement, () => void>();
|
|
71
|
+
|
|
72
|
+
/** Stop a pending restore of `element` — a reveal wins over a remembered offset. */
|
|
73
|
+
export function cancelScrollRestore(element: HTMLElement | null): void {
|
|
74
|
+
if (!element) return;
|
|
75
|
+
armedRestores.get(element)?.();
|
|
76
|
+
}
|
|
77
|
+
|
|
55
78
|
/** What a caller attaches to its scroll container. */
|
|
56
79
|
export interface ScrollRestoreBinding {
|
|
57
80
|
ref: (element: HTMLElement | null) => void;
|
|
@@ -67,6 +90,16 @@ export interface UseScrollRestoreOptions {
|
|
|
67
90
|
ready?: boolean;
|
|
68
91
|
/** The session-state slot to read/write; the editor column's by default. */
|
|
69
92
|
slot?: string;
|
|
93
|
+
/**
|
|
94
|
+
* The element to restore, for a caller that does not own a `ref` on it.
|
|
95
|
+
*
|
|
96
|
+
* The code surface's scroller belongs to CodeMirror (`view.scrollDOM`) and
|
|
97
|
+
* exists only after the view mounts, so the surface reports it through
|
|
98
|
+
* `onScroller` and the caller passes it here. `undefined` means "this caller
|
|
99
|
+
* uses `ref`"; `null` means "the element is not there yet", which re-arms the
|
|
100
|
+
* restore on the next render that has one.
|
|
101
|
+
*/
|
|
102
|
+
element?: HTMLElement | null;
|
|
70
103
|
}
|
|
71
104
|
|
|
72
105
|
export function useScrollRestore({
|
|
@@ -74,6 +107,7 @@ export function useScrollRestore({
|
|
|
74
107
|
surface,
|
|
75
108
|
ready = true,
|
|
76
109
|
slot = SCROLL_POSITIONS_KEY,
|
|
110
|
+
element: providedElement,
|
|
77
111
|
}: UseScrollRestoreOptions): ScrollRestoreBinding {
|
|
78
112
|
// Read OPTIONALLY: the shared diff views also draw a chat tool card, and a
|
|
79
113
|
// caller may mount one without the provider. A null context makes the whole
|
|
@@ -100,6 +134,37 @@ export function useScrollRestore({
|
|
|
100
134
|
* whose height is still growing then stayed where the clamp left it.
|
|
101
135
|
*/
|
|
102
136
|
const writingRef = useRef(false);
|
|
137
|
+
/**
|
|
138
|
+
* What the element's `scrollTop` was at the last observation, so a scroll
|
|
139
|
+
* event that moves it WITHOUT anyone assigning can be recognised as the
|
|
140
|
+
* browser's own anchoring rather than the reader's.
|
|
141
|
+
*
|
|
142
|
+
* This is the bug the e2e spec found on CI and could not explain: returning to
|
|
143
|
+
* a long file landed the reader on line 99 instead of line 70, and the wrong
|
|
144
|
+
* offset was then persisted. Measured in the browser: the restore wrote 1255,
|
|
145
|
+
* the document's real height arrived a frame later (5796 → 7232), and
|
|
146
|
+
* CodeMirror re-anchored — `scrollTop` became 1777 with no assignment from
|
|
147
|
+
* this hook. That produced a `scroll` event, the handler below compared it
|
|
148
|
+
* against `restoredTopRef` (1255), read a 522px delta, and treated it as the
|
|
149
|
+
* reader's own scroll: the pending restore was dropped, and the anchored
|
|
150
|
+
* value became what got stored. The reader moved 29 lines for opening a tab.
|
|
151
|
+
*
|
|
152
|
+
* An anchoring correction is not a reader. It is re-applied like any other
|
|
153
|
+
* height change, because the target offset is the one the session recorded.
|
|
154
|
+
*/
|
|
155
|
+
const observedTopRef = useRef(0);
|
|
156
|
+
/**
|
|
157
|
+
* Whether the reader has DONE something since the last scroll event — the
|
|
158
|
+
* only thing that separates their scroll from the browser's own.
|
|
159
|
+
*
|
|
160
|
+
* Every way a reader scrolls a code surface starts with an input event: a
|
|
161
|
+
* wheel, a key (arrows, PageDown, Home/End), a pointer press on the scrollbar
|
|
162
|
+
* or a drag-select, a touch. CodeMirror re-anchoring starts with none, so a
|
|
163
|
+
* `scroll` with no input behind it is the document settling. The flag is
|
|
164
|
+
* cleared by the scroll it explains, so the next unattributed move is read as
|
|
165
|
+
* anchoring again.
|
|
166
|
+
*/
|
|
167
|
+
const readerIntentRef = useRef(false);
|
|
103
168
|
/** The last offset the READER produced, persisted on the way out. */
|
|
104
169
|
const lastTopRef = useRef<number | null>(null);
|
|
105
170
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
|
@@ -113,13 +178,17 @@ export function useScrollRestore({
|
|
|
113
178
|
pendingRef.current = null;
|
|
114
179
|
return;
|
|
115
180
|
}
|
|
116
|
-
if (Math.abs(element.scrollTop - pending.top) < NOISE_PX)
|
|
181
|
+
if (Math.abs(element.scrollTop - pending.top) < NOISE_PX) {
|
|
182
|
+
observedTopRef.current = element.scrollTop;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
117
185
|
writingRef.current = true;
|
|
118
186
|
element.scrollTop = pending.top;
|
|
119
187
|
writingRef.current = false;
|
|
120
188
|
// Read back: the document may be shorter than the target, and the clamped
|
|
121
189
|
// value is what an asynchronous scroll event will report.
|
|
122
190
|
restoredTopRef.current = element.scrollTop;
|
|
191
|
+
observedTopRef.current = element.scrollTop;
|
|
123
192
|
}, []);
|
|
124
193
|
|
|
125
194
|
const attach = useCallback(
|
|
@@ -152,9 +221,23 @@ export function useScrollRestore({
|
|
|
152
221
|
// because the event may arrive synchronously (during the assignment) or
|
|
153
222
|
// on a later frame (against the value the write landed on).
|
|
154
223
|
if (writingRef.current) return;
|
|
224
|
+
// Moved with nobody assigning AND no reader input behind it: CodeMirror
|
|
225
|
+
// re-anchoring after the document's real height landed. NOT the reader —
|
|
226
|
+
// re-applying the recorded offset is what keeps them where they were. See
|
|
227
|
+
// `observedTopRef` for the measurement.
|
|
228
|
+
const moved = Math.abs(top - observedTopRef.current) >= NOISE_PX;
|
|
229
|
+
observedTopRef.current = top;
|
|
230
|
+
if (moved && !readerIntentRef.current) {
|
|
231
|
+
applyPending();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
155
234
|
if (Math.abs(top - restoredTopRef.current) < NOISE_PX * 2) return;
|
|
156
235
|
pendingRef.current = null;
|
|
157
236
|
}
|
|
237
|
+
observedTopRef.current = top;
|
|
238
|
+
// The input this scroll belongs to has now been accounted for; the next
|
|
239
|
+
// unattributed move is anchoring again.
|
|
240
|
+
readerIntentRef.current = false;
|
|
158
241
|
lastTopRef.current = top;
|
|
159
242
|
clearTimeout(saveTimerRef.current);
|
|
160
243
|
saveTimerRef.current = setTimeout(() => {
|
|
@@ -162,14 +245,47 @@ export function useScrollRestore({
|
|
|
162
245
|
const value = lastTopRef.current;
|
|
163
246
|
if (value !== null) persist(value);
|
|
164
247
|
}, SAVE_DEBOUNCE_MS);
|
|
165
|
-
}, [persist, tabKey]);
|
|
248
|
+
}, [applyPending, persist, tabKey]);
|
|
249
|
+
|
|
250
|
+
// A provided element is not a React-rendered node, so its scroll events have
|
|
251
|
+
// to be wired here: the caller has no `onScroll` prop to hang them on. The
|
|
252
|
+
// ref path keeps the caller's own `onScroll`.
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (!providedElement) return;
|
|
255
|
+
const listener = () => onScroll();
|
|
256
|
+
providedElement.addEventListener('scroll', listener, { passive: true });
|
|
257
|
+
return () => providedElement.removeEventListener('scroll', listener);
|
|
258
|
+
}, [providedElement, onScroll]);
|
|
259
|
+
|
|
260
|
+
// What counts as the reader having scrolled, for `readerIntentRef`. Wired on
|
|
261
|
+
// the element AND the window, because a keydown goes to the focused editor
|
|
262
|
+
// while a wheel over the gutter goes to the window's capture phase.
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
const element = providedElement ?? elementRef.current;
|
|
265
|
+
if (!element) return;
|
|
266
|
+
const note = () => {
|
|
267
|
+
readerIntentRef.current = true;
|
|
268
|
+
};
|
|
269
|
+
const types = ['wheel', 'keydown', 'pointerdown', 'touchstart'] as const;
|
|
270
|
+
for (const type of types) element.addEventListener(type, note, { passive: true });
|
|
271
|
+
window.addEventListener('keydown', note, { passive: true });
|
|
272
|
+
return () => {
|
|
273
|
+
for (const type of types) element.removeEventListener(type, note);
|
|
274
|
+
window.removeEventListener('keydown', note);
|
|
275
|
+
};
|
|
276
|
+
}, [providedElement]);
|
|
166
277
|
|
|
167
278
|
// A layout effect so the restore lands in the same frame the tab paints — a
|
|
168
279
|
// passive effect would show the document's head for one frame first.
|
|
169
280
|
useLayoutEffect(() => {
|
|
170
281
|
pendingRef.current = null;
|
|
171
282
|
lastTopRef.current = null;
|
|
283
|
+
readerIntentRef.current = false;
|
|
172
284
|
let observer: ResizeObserver | null = null;
|
|
285
|
+
// A provided element WINS over the ref: the caller handed us the element it
|
|
286
|
+
// wants restored, and a stale ref from the other surface of the same tab
|
|
287
|
+
// must not be restored instead.
|
|
288
|
+
if (providedElement !== undefined) elementRef.current = providedElement;
|
|
173
289
|
const element = elementRef.current;
|
|
174
290
|
|
|
175
291
|
if (ready && storeReady && tabKey && element) {
|
|
@@ -179,6 +295,9 @@ export function useScrollRestore({
|
|
|
179
295
|
// of it. "No stored offset" means the top, and the top is where they go.
|
|
180
296
|
const top = readScrollTop(getSessionValue<ScrollPositions>(sessionId, slot), tabKey, surface);
|
|
181
297
|
pendingRef.current = { top, until: performance.now() + RESTORE_WINDOW_MS };
|
|
298
|
+
armedRestores.set(element, () => {
|
|
299
|
+
pendingRef.current = null;
|
|
300
|
+
});
|
|
182
301
|
applyPending();
|
|
183
302
|
// The document's own height is what moves after this commit (measurement,
|
|
184
303
|
// a width animation, spacers landing), so its growth is what re-applies.
|
|
@@ -190,6 +309,7 @@ export function useScrollRestore({
|
|
|
190
309
|
}
|
|
191
310
|
|
|
192
311
|
return () => {
|
|
312
|
+
if (element) armedRestores.delete(element);
|
|
193
313
|
observer?.disconnect();
|
|
194
314
|
clearTimeout(saveTimerRef.current);
|
|
195
315
|
saveTimerRef.current = undefined;
|
|
@@ -203,7 +323,7 @@ export function useScrollRestore({
|
|
|
203
323
|
};
|
|
204
324
|
// `applyPending`/`persist` are stable, and re-running this effect restarts
|
|
205
325
|
// the restore — which is exactly what a tab or surface change needs.
|
|
206
|
-
}, [ready, storeReady, tabKey, surface, sessionId, slot, applyPending, persist]);
|
|
326
|
+
}, [ready, storeReady, tabKey, surface, sessionId, slot, applyPending, persist, providedElement]);
|
|
207
327
|
|
|
208
328
|
return { ref: attach, onScroll };
|
|
209
329
|
}
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
|
30
30
|
import { useSyncExternalStore } from 'preact/compat';
|
|
31
31
|
|
|
32
|
-
import { findMatches, lineStartOffset, matchIndexAtOrAfter } from '@/shared/lib/code/editor/find';
|
|
32
|
+
import { findMatches, lineStartOffset, matchIndexAtOrAfter } from '@/shared/lib/code/editor/find-scan';
|
|
33
33
|
import {
|
|
34
34
|
getSearchReveal,
|
|
35
35
|
setSearchReveal,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The desktop layout's settings modal: whether it is open, which category it
|
|
8
|
+
* opens on, and whether a provider dialog follows.
|
|
9
|
+
*
|
|
10
|
+
* A hook because two different triggers drive it — the sidebar footer's button
|
|
11
|
+
* and the `omp:open-settings` window event (dispatched by the model dropdown,
|
|
12
|
+
* the phone's footer and any plugin) — and the event carries the category it
|
|
13
|
+
* wants. Stated once, the two paths cannot open the same modal on different
|
|
14
|
+
* screens.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useState } from 'preact/hooks';
|
|
18
|
+
import { useKabmoEvent } from '@/client/hooks/ui/window-event';
|
|
19
|
+
import type { SettingsCategoryId } from '@/shared/types';
|
|
20
|
+
|
|
21
|
+
export interface SettingsModalState {
|
|
22
|
+
isOpen: boolean;
|
|
23
|
+
category: SettingsCategoryId;
|
|
24
|
+
/** The "add provider" dialog opens on top of the modal's Providers screen. */
|
|
25
|
+
autoOpenAddProvider: boolean;
|
|
26
|
+
open: (category?: SettingsCategoryId, autoOpenAdd?: boolean) => void;
|
|
27
|
+
close: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useSettingsModal(): SettingsModalState {
|
|
31
|
+
const [isOpen, setOpen] = useState(false);
|
|
32
|
+
const [category, setCategory] = useState<SettingsCategoryId>('appearance');
|
|
33
|
+
const [autoOpenAddProvider, setAutoOpenAddProvider] = useState(false);
|
|
34
|
+
|
|
35
|
+
const open = (next?: SettingsCategoryId, autoOpenAdd = false) => {
|
|
36
|
+
setCategory(next ?? 'appearance');
|
|
37
|
+
setAutoOpenAddProvider(autoOpenAdd);
|
|
38
|
+
setOpen(true);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
useKabmoEvent('omp:open-settings', (event) => {
|
|
42
|
+
const detail = (event as CustomEvent<{ category?: SettingsCategoryId; autoOpenAdd?: boolean }>).detail;
|
|
43
|
+
open(detail?.category, Boolean(detail?.autoOpenAdd));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
isOpen,
|
|
48
|
+
category,
|
|
49
|
+
autoOpenAddProvider,
|
|
50
|
+
open,
|
|
51
|
+
close: () => {
|
|
52
|
+
setOpen(false);
|
|
53
|
+
setAutoOpenAddProvider(false);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -21,6 +21,7 @@ const RELEASE = {
|
|
|
21
21
|
url: 'https://github.com/kabmo-labs/kabmo/releases/download/v1.2.3/Kabmo-1.2.3-android.apk',
|
|
22
22
|
latest: '1.2.3',
|
|
23
23
|
releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v1.2.3',
|
|
24
|
+
direct: true,
|
|
24
25
|
};
|
|
25
26
|
|
|
26
27
|
const UPDATE: ShellDownload = { ...RELEASE, kind: 'update', platform: 'android' };
|
|
@@ -209,6 +210,7 @@ describe('desktopShellFromIdentity', () => {
|
|
|
209
210
|
expect(desktopShellFromIdentity({ platform: 'macos', shellVersion: '0.2.0' })).toEqual({
|
|
210
211
|
platform: 'macos',
|
|
211
212
|
version: '0.2.0',
|
|
213
|
+
arch: null,
|
|
212
214
|
});
|
|
213
215
|
expect(desktopShellFromIdentity({ platform: 'windows', shellVersion: '0.2.0' })?.platform).toBe('windows');
|
|
214
216
|
expect(desktopShellFromIdentity({ platform: 'linux', shellVersion: '0.2.0' })?.platform).toBe('linux');
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import { useEffect, useState } from 'preact/hooks';
|
|
31
31
|
import { isNewer } from '@/shared/lib/updates/semver';
|
|
32
|
+
import { parseShellArch, type ShellArch } from '@/shared/lib/updates/shell-arch';
|
|
32
33
|
import {
|
|
33
34
|
isDesktopShellKind,
|
|
34
35
|
type DesktopShellKind,
|
|
@@ -43,11 +44,13 @@ export type ShellOfferKind = 'update' | 'install';
|
|
|
43
44
|
|
|
44
45
|
export interface ShellDownload {
|
|
45
46
|
/**
|
|
46
|
-
* What the control
|
|
47
|
+
* What the control points at.
|
|
47
48
|
*
|
|
48
49
|
* The artifact itself for the mobile pair (an APK the shell installs, an IPA
|
|
49
|
-
* the release page explains), and
|
|
50
|
-
*
|
|
50
|
+
* the release page explains), and for a desktop shell EITHER its own
|
|
51
|
+
* per-architecture artifact (when it reported `arch`) or the RELEASE PAGE (when
|
|
52
|
+
* it did not). `direct` says which, and that is what decides between a button
|
|
53
|
+
* and a link.
|
|
51
54
|
*/
|
|
52
55
|
url: string;
|
|
53
56
|
/** The version that artifact ships. */
|
|
@@ -58,12 +61,22 @@ export interface ShellDownload {
|
|
|
58
61
|
kind: ShellOfferKind;
|
|
59
62
|
/** Which shell this offer is for. */
|
|
60
63
|
platform: ShellPlatform;
|
|
64
|
+
/**
|
|
65
|
+
* True when `url` is the artifact itself, so the SHELL can install it.
|
|
66
|
+
*
|
|
67
|
+
* False means `url` is the release page: the console cannot name one file
|
|
68
|
+
* (a desktop shell that did not report its architecture), so the offer is a
|
|
69
|
+
* link a human follows. That distinction is what decides between an `<a>` and
|
|
70
|
+
* a button that drives `update_apply`.
|
|
71
|
+
*/
|
|
72
|
+
direct: boolean;
|
|
61
73
|
}
|
|
62
74
|
|
|
63
75
|
interface ReleasePayload {
|
|
64
76
|
url?: unknown;
|
|
65
77
|
latest?: unknown;
|
|
66
78
|
releaseUrl?: unknown;
|
|
79
|
+
direct?: unknown;
|
|
67
80
|
}
|
|
68
81
|
|
|
69
82
|
/** The platform's own name, as the label says it. */
|
|
@@ -91,6 +104,10 @@ export function parseShellRelease(payload: unknown): Omit<ShellDownload, 'kind'
|
|
|
91
104
|
url: body.url,
|
|
92
105
|
latest: body.latest,
|
|
93
106
|
releaseUrl: typeof body.releaseUrl === 'string' ? body.releaseUrl : null,
|
|
107
|
+
// Absent means "not installable": a shell that cannot install the bytes must
|
|
108
|
+
// fall back to the link rather than to a button that would download into
|
|
109
|
+
// nowhere.
|
|
110
|
+
direct: body.direct === true,
|
|
94
111
|
};
|
|
95
112
|
}
|
|
96
113
|
|
|
@@ -147,8 +164,14 @@ export function shellDownloadLabel(download: ShellDownload): string {
|
|
|
147
164
|
* The desktop trio shares ONE route with an explicit `kind`, so no absence can
|
|
148
165
|
* silently mean a platform — the reason the mobile pair has two.
|
|
149
166
|
*/
|
|
150
|
-
export function shellReleasePath(platform: ShellPlatform): string {
|
|
151
|
-
if (isDesktopShellKind(platform))
|
|
167
|
+
export function shellReleasePath(platform: ShellPlatform, arch: ShellArch | null = null): string {
|
|
168
|
+
if (isDesktopShellKind(platform)) {
|
|
169
|
+
// The arch is what makes the answer a FILE rather than a page. A shell that
|
|
170
|
+
// did not report one still asks, and still gets the page that lists every
|
|
171
|
+
// architecture — the offer is then a link, which is the honest fallback.
|
|
172
|
+
const base = `/api/updates/desktop?kind=${platform}`;
|
|
173
|
+
return arch ? `${base}&arch=${arch}` : base;
|
|
174
|
+
}
|
|
152
175
|
return `/api/updates/${platform}`;
|
|
153
176
|
}
|
|
154
177
|
|
|
@@ -182,10 +205,17 @@ export function shellFromBootstrap(bootstrap: {
|
|
|
182
205
|
export function desktopShellFromIdentity(identity: {
|
|
183
206
|
platform?: string;
|
|
184
207
|
shellVersion?: string;
|
|
185
|
-
|
|
208
|
+
arch?: string;
|
|
209
|
+
} | null | undefined): { platform: DesktopShellKind; version: string; arch: ShellArch | null } | null {
|
|
186
210
|
if (!identity?.platform || !identity.shellVersion) return null;
|
|
187
211
|
if (!isDesktopShellKind(identity.platform)) return null;
|
|
188
|
-
return {
|
|
212
|
+
return {
|
|
213
|
+
platform: identity.platform,
|
|
214
|
+
version: identity.shellVersion,
|
|
215
|
+
// A shell too old to report one keeps working: the offer degrades to the
|
|
216
|
+
// release page instead of disappearing.
|
|
217
|
+
arch: parseShellArch(identity.arch),
|
|
218
|
+
};
|
|
189
219
|
}
|
|
190
220
|
|
|
191
221
|
/**
|
|
@@ -200,6 +230,7 @@ export function useShellDownload(
|
|
|
200
230
|
version: string | null | undefined,
|
|
201
231
|
onAndroid: boolean | null | undefined,
|
|
202
232
|
fetchImpl: typeof fetch = fetch,
|
|
233
|
+
arch: ShellArch | null = null,
|
|
203
234
|
): ShellDownload | null {
|
|
204
235
|
/**
|
|
205
236
|
* The answer, TAGGED with the platform it was read for.
|
|
@@ -210,10 +241,14 @@ export function useShellDownload(
|
|
|
210
241
|
* — offering an Android APK to a Mac for one render.
|
|
211
242
|
*/
|
|
212
243
|
const [answer, setAnswer] = useState<{
|
|
213
|
-
|
|
244
|
+
key: string;
|
|
214
245
|
release: Omit<ShellDownload, 'kind' | 'platform'> | null;
|
|
215
246
|
} | null>(null);
|
|
216
247
|
const wanted = Boolean(version) || (platform === 'android' && Boolean(onAndroid));
|
|
248
|
+
// The question this hook is asking, arch included: two windows on one console
|
|
249
|
+
// differ only by architecture, and an answer read for the other one is not an
|
|
250
|
+
// answer.
|
|
251
|
+
const key = `${platform}:${arch ?? ''}`;
|
|
217
252
|
|
|
218
253
|
useEffect(() => {
|
|
219
254
|
if (!wanted) return;
|
|
@@ -221,10 +256,10 @@ export function useShellDownload(
|
|
|
221
256
|
let cancelled = false;
|
|
222
257
|
void (async () => {
|
|
223
258
|
try {
|
|
224
|
-
const response = await fetchImpl(shellReleasePath(platform));
|
|
259
|
+
const response = await fetchImpl(shellReleasePath(platform, arch));
|
|
225
260
|
if (!response.ok) return;
|
|
226
261
|
const payload: unknown = await response.json();
|
|
227
|
-
if (!cancelled) setAnswer({
|
|
262
|
+
if (!cancelled) setAnswer({ key, release: parseShellRelease(payload) });
|
|
228
263
|
} catch {
|
|
229
264
|
// A probe that cannot answer is a button that must not appear.
|
|
230
265
|
}
|
|
@@ -233,10 +268,10 @@ export function useShellDownload(
|
|
|
233
268
|
return () => {
|
|
234
269
|
cancelled = true;
|
|
235
270
|
};
|
|
236
|
-
}, [wanted, platform, fetchImpl]);
|
|
271
|
+
}, [wanted, key, platform, arch, fetchImpl]);
|
|
237
272
|
|
|
238
|
-
// A stale answer belongs to the
|
|
239
|
-
const release = answer?.
|
|
273
|
+
// A stale answer belongs to the question it was read for, not this one.
|
|
274
|
+
const release = answer?.key === key ? answer.release : null;
|
|
240
275
|
return shouldOfferShellDownload(platform, version, onAndroid, release);
|
|
241
276
|
}
|
|
242
277
|
|
|
@@ -285,7 +320,13 @@ export function useShellOffer(
|
|
|
285
320
|
const platform = fromBridge?.platform ?? mobile?.platform ?? 'android';
|
|
286
321
|
const version = fromBridge?.version ?? mobile?.version ?? null;
|
|
287
322
|
|
|
288
|
-
return useShellDownload(
|
|
323
|
+
return useShellDownload(
|
|
324
|
+
platform,
|
|
325
|
+
version,
|
|
326
|
+
fromBridge ? null : bootstrap?.isAndroid,
|
|
327
|
+
fetchImpl,
|
|
328
|
+
fromBridge?.arch ?? null,
|
|
329
|
+
);
|
|
289
330
|
}
|
|
290
331
|
|
|
291
332
|
/**
|
|
@@ -35,6 +35,7 @@ const RELEASE = {
|
|
|
35
35
|
url: 'https://github.com/kabmo-labs/kabmo/releases/download/v0.7.0/Kabmo-0.6.0-android.apk',
|
|
36
36
|
latest: '0.6.0',
|
|
37
37
|
releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v0.7.0',
|
|
38
|
+
direct: true,
|
|
38
39
|
};
|
|
39
40
|
|
|
40
41
|
function Harness({ fetchImpl }: { fetchImpl: typeof fetch }) {
|
|
@@ -169,6 +170,31 @@ describe('the desktop shells', () => {
|
|
|
169
170
|
expect(await mount(stubFetch([]))).toBe('update:linux:0.6.0');
|
|
170
171
|
});
|
|
171
172
|
|
|
173
|
+
/**
|
|
174
|
+
* A shell that reports its architecture asks for THAT machine's artifact.
|
|
175
|
+
*
|
|
176
|
+
* Without it the console can only answer the release page — every desktop
|
|
177
|
+
* artifact is per-architecture — so the offer is a link a human follows instead
|
|
178
|
+
* of an update the shell installs.
|
|
179
|
+
*/
|
|
180
|
+
test('reports the arch, so the answer is a file rather than a page', async () => {
|
|
181
|
+
shell({ desktop: { platform: 'macos', shellVersion: '0.1.0', arch: 'arm64' } });
|
|
182
|
+
const asked: string[] = [];
|
|
183
|
+
|
|
184
|
+
await mount(stubFetch(asked));
|
|
185
|
+
expect(asked).toEqual(['/api/updates/desktop?kind=macos&arch=arm64']);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('a shell with no arch still asks, and gets the page', async () => {
|
|
189
|
+
// An older shell must keep working: the offer degrades to the release page
|
|
190
|
+
// instead of disappearing.
|
|
191
|
+
shell({ desktop: { platform: 'macos', shellVersion: '0.1.0' } });
|
|
192
|
+
const asked: string[] = [];
|
|
193
|
+
|
|
194
|
+
await mount(stubFetch(asked));
|
|
195
|
+
expect(asked).toEqual(['/api/updates/desktop?kind=macos']);
|
|
196
|
+
});
|
|
197
|
+
|
|
172
198
|
test('a refused probe draws no button', async () => {
|
|
173
199
|
shell({ desktop: { platform: 'macos', shellVersion: '0.1.0' } });
|
|
174
200
|
const refusing = (async () => ({ ok: false, json: async () => ({}) }) as Response) as unknown as typeof fetch;
|