@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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `isSpawnArrival` / `isOwnSpawn` — the two questions a session-id change asks.
|
|
8
|
+
*
|
|
9
|
+
* Both are pure so every ordering can be stated directly. The orderings are the
|
|
10
|
+
* point: the browser is where they were found (three CI specs at once showed an
|
|
11
|
+
* answer on screen and then an empty timeline), but reproducing a child that
|
|
12
|
+
* answers before the URL flips is not something a browser spec can be asked to
|
|
13
|
+
* do on demand.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, test } from 'bun:test';
|
|
17
|
+
import { isOwnSpawn, isSpawnArrival } from '@/client/hooks/chat/timeline/session-switch';
|
|
18
|
+
|
|
19
|
+
describe('isSpawnArrival', () => {
|
|
20
|
+
test('the id the send path recorded is an arrival, whatever the previous id was', () => {
|
|
21
|
+
// A chat opened from a WORKSPACE: no `?sessionId=` at all, so the previous
|
|
22
|
+
// id is null and the URL moves straight to the child's UUID. This is the
|
|
23
|
+
// shape that used to clear a finished turn.
|
|
24
|
+
expect(
|
|
25
|
+
isSpawnArrival({ sessionId: 'uuid-1', previousSessionId: null, adoptedSessionId: 'uuid-1' }),
|
|
26
|
+
).toBe(true);
|
|
27
|
+
// A pending chat, mid-run or after it: same fact.
|
|
28
|
+
expect(
|
|
29
|
+
isSpawnArrival({ sessionId: 'uuid-1', previousSessionId: 'new-9', adoptedSessionId: 'uuid-1' }),
|
|
30
|
+
).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('`new-…` → UUID is still an arrival when the record was already consumed', () => {
|
|
34
|
+
// The pending store hands the adoption to the session-state provider once,
|
|
35
|
+
// so the URL's own shape is the only evidence left.
|
|
36
|
+
expect(
|
|
37
|
+
isSpawnArrival({ sessionId: 'uuid-1', previousSessionId: 'new-9', adoptedSessionId: null }),
|
|
38
|
+
).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('opening another session is not an arrival', () => {
|
|
42
|
+
expect(
|
|
43
|
+
isSpawnArrival({ sessionId: 'uuid-2', previousSessionId: 'uuid-1', adoptedSessionId: 'uuid-1' }),
|
|
44
|
+
).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a null or unchanged id is not an arrival', () => {
|
|
48
|
+
expect(isSpawnArrival({ sessionId: null, previousSessionId: 'uuid-1', adoptedSessionId: null })).toBe(false);
|
|
49
|
+
expect(
|
|
50
|
+
isSpawnArrival({ sessionId: 'uuid-1', previousSessionId: 'uuid-1', adoptedSessionId: 'uuid-1' }),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('isOwnSpawn', () => {
|
|
56
|
+
test('a pending id and the adopted UUID are both own spawns', () => {
|
|
57
|
+
expect(isOwnSpawn('new-9', null)).toBe(true);
|
|
58
|
+
expect(isOwnSpawn('uuid-1', 'uuid-1')).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a session this page did not spawn is not one', () => {
|
|
62
|
+
expect(isOwnSpawn('uuid-2', 'uuid-1')).toBe(false);
|
|
63
|
+
expect(isOwnSpawn(null, 'uuid-1')).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which rows a session-switch keeps, and which a committed payload may replace.
|
|
8
|
+
*
|
|
9
|
+
* Split out of `session-load.ts` for the file-size ceiling, and it is one
|
|
10
|
+
* subject rather than an arbitrary cut: both functions answer "does this arrival
|
|
11
|
+
* belong to the session the timeline is already showing?".
|
|
12
|
+
*
|
|
13
|
+
* The distinction they encode was paid for twice, in opposite directions.
|
|
14
|
+
*
|
|
15
|
+
* - **A spawn's arrival is not a session switch.** The optimistic bubbles are
|
|
16
|
+
* the operator's own turn and the row the stream is filling; clearing them
|
|
17
|
+
* deleted a turn that had already rendered. Two shapes reach here and only one
|
|
18
|
+
* was ever recognised: `new-…` → UUID, and a chat opened from a WORKSPACE
|
|
19
|
+
* (`?folderId=` and no `?sessionId=`), whose previous id is `null` and whose
|
|
20
|
+
* URL moves straight to the child's UUID. The second was keyed on the
|
|
21
|
+
* generating flag, so a FAST child — the scripted one answers in milliseconds
|
|
22
|
+
* — finished before the URL flipped and the reset ran anyway. Measured in CI
|
|
23
|
+
* on three specs at once: the answer was on screen, then the timeline was
|
|
24
|
+
* empty. The id the send path recorded is the fact that survives either
|
|
25
|
+
* ordering.
|
|
26
|
+
* - **A committed payload is a lagging snapshot** while a run this page started
|
|
27
|
+
* owns the rows, and an own spawn's transcript may legitimately be empty (omp
|
|
28
|
+
* buffers its writes until the first assistant message settles). Either way it
|
|
29
|
+
* must not empty the timeline.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The ids a session-switch decision needs, all of them refs. */
|
|
33
|
+
export interface SessionSwitchFacts {
|
|
34
|
+
sessionId: string | null;
|
|
35
|
+
previousSessionId: string | null;
|
|
36
|
+
/** The id a spawn answered with, recorded by the send path. */
|
|
37
|
+
adoptedSessionId: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether this change is a spawn's arrival rather than the user opening another
|
|
42
|
+
* session — in which case the optimistic rows stay.
|
|
43
|
+
*/
|
|
44
|
+
export function isSpawnArrival({ sessionId, previousSessionId, adoptedSessionId }: SessionSwitchFacts): boolean {
|
|
45
|
+
if (!sessionId) return false;
|
|
46
|
+
// The recorded id is checked FIRST and covers both orderings: it is written
|
|
47
|
+
// before the URL moves, so a child that answered mid-run and one that answered
|
|
48
|
+
// after the run ended are the same fact here.
|
|
49
|
+
if (adoptedSessionId === sessionId) return true;
|
|
50
|
+
// `new-…` → UUID. Kept as a fallback for a spawn whose record was already
|
|
51
|
+
// consumed (the pending store hands the adoption to the session-state
|
|
52
|
+
// provider once), where the URL's own shape is the only evidence left.
|
|
53
|
+
return previousSessionId?.startsWith('new-') === true && !sessionId.startsWith('new-');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether `sessionId` is one THIS PAGE spawned, so an empty committed payload
|
|
58
|
+
* means "not written yet" rather than "nothing happened".
|
|
59
|
+
*/
|
|
60
|
+
export function isOwnSpawn(sessionId: string | null, adoptedSessionId: string | null): boolean {
|
|
61
|
+
if (!sessionId) return false;
|
|
62
|
+
return sessionId.startsWith('new-') || adoptedSessionId === sessionId;
|
|
63
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The deferred-highlight hook, and the idle scheduler under it.
|
|
8
|
+
*
|
|
9
|
+
* The property that matters is the one the user feels: the FIRST render after a
|
|
10
|
+
* tap must not run the tokenizer. A card that appeared only after 220–300 ms of
|
|
11
|
+
* grammar work (measured at 4× CPU; 730–1484 ms at 20×) is the delay this
|
|
12
|
+
* exists to remove, and a hook that highlighted synchronously "sometimes" would
|
|
13
|
+
* pass a markup assertion and fail the point.
|
|
14
|
+
*
|
|
15
|
+
* So the scheduler is injectable here — the same seam the warm-up and the
|
|
16
|
+
* grammar loader use — and the assertions are about WHEN the compute ran, not
|
|
17
|
+
* only about what it produced. No wall-clock waits: the fallback path is driven
|
|
18
|
+
* by fake timers.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'bun:test';
|
|
22
|
+
import { Window } from 'happy-dom';
|
|
23
|
+
import { h, render } from 'preact';
|
|
24
|
+
import { act } from 'preact/test-utils';
|
|
25
|
+
|
|
26
|
+
import { useDeferredHighlight } from '@/client/hooks/code/deferred-highlight';
|
|
27
|
+
import { scheduleIdle } from '@/shared/lib/code/idle';
|
|
28
|
+
import { clearHighlightMemo, highlightMemoSize } from '@/shared/lib/code/highlight-memo';
|
|
29
|
+
import { resetTokenizerWarmup, warmSyntaxTokenizers } from '@/shared/lib/code/highlighter-warmup';
|
|
30
|
+
|
|
31
|
+
const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'MouseEvent'] as const;
|
|
32
|
+
const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
|
|
33
|
+
let container: HTMLElement;
|
|
34
|
+
|
|
35
|
+
beforeAll(() => {
|
|
36
|
+
const win = new Window({ url: 'http://localhost' });
|
|
37
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
38
|
+
for (const key of DOM_GLOBALS) {
|
|
39
|
+
nativeGlobals[key] = target[key];
|
|
40
|
+
target[key] = (win as unknown as Record<string, unknown>)[key];
|
|
41
|
+
}
|
|
42
|
+
container = document.createElement('div');
|
|
43
|
+
document.body.appendChild(container);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(() => {
|
|
47
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
48
|
+
for (const key of DOM_GLOBALS) {
|
|
49
|
+
if (nativeGlobals[key] === undefined) delete target[key];
|
|
50
|
+
else target[key] = nativeGlobals[key];
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
clearHighlightMemo();
|
|
56
|
+
resetTokenizerWarmup();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('scheduleIdle', () => {
|
|
60
|
+
test('a runtime without requestIdleCallback still runs the work', () => {
|
|
61
|
+
const host = globalThis as unknown as Record<string, unknown>;
|
|
62
|
+
const saved = host.requestIdleCallback;
|
|
63
|
+
delete host.requestIdleCallback;
|
|
64
|
+
vi.useFakeTimers();
|
|
65
|
+
try {
|
|
66
|
+
let runs = 0;
|
|
67
|
+
scheduleIdle(() => { runs += 1; });
|
|
68
|
+
vi.advanceTimersByTime(1);
|
|
69
|
+
expect(runs).toBe(1);
|
|
70
|
+
} finally {
|
|
71
|
+
vi.useRealTimers();
|
|
72
|
+
if (saved !== undefined) host.requestIdleCallback = saved;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('cancelling before the slice fires skips the work', () => {
|
|
77
|
+
const host = globalThis as unknown as Record<string, unknown>;
|
|
78
|
+
const saved = host.requestIdleCallback;
|
|
79
|
+
delete host.requestIdleCallback;
|
|
80
|
+
vi.useFakeTimers();
|
|
81
|
+
try {
|
|
82
|
+
let runs = 0;
|
|
83
|
+
const cancel = scheduleIdle(() => { runs += 1; });
|
|
84
|
+
cancel();
|
|
85
|
+
vi.advanceTimersByTime(1);
|
|
86
|
+
expect(runs).toBe(0);
|
|
87
|
+
} finally {
|
|
88
|
+
vi.useRealTimers();
|
|
89
|
+
if (saved !== undefined) host.requestIdleCallback = saved;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('warmSyntaxTokenizers', () => {
|
|
95
|
+
test('tokenizes one sample per language, one slice at a time', () => {
|
|
96
|
+
const calls: Array<{ language: string; code: string }> = [];
|
|
97
|
+
const queue: Array<() => void> = [];
|
|
98
|
+
warmSyntaxTokenizers({
|
|
99
|
+
languages: ['typescript', 'bash'],
|
|
100
|
+
ready: () => true,
|
|
101
|
+
highlight: (code, language) => calls.push({ language, code }),
|
|
102
|
+
schedule: (run) => {
|
|
103
|
+
queue.push(run);
|
|
104
|
+
return () => {};
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// One slice is armed, not the whole set: a slow grammar must not become the
|
|
109
|
+
// long task this exists to prevent.
|
|
110
|
+
expect(queue).toHaveLength(1);
|
|
111
|
+
act(() => queue.shift()!());
|
|
112
|
+
expect(calls.map((c) => c.language)).toEqual(['typescript']);
|
|
113
|
+
expect(queue).toHaveLength(1);
|
|
114
|
+
act(() => queue.shift()!());
|
|
115
|
+
expect(calls.map((c) => c.language)).toEqual(['typescript', 'bash']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('is a no-op the second time, so a re-evaluated entry cannot re-warm', () => {
|
|
119
|
+
let calls = 0;
|
|
120
|
+
const options = {
|
|
121
|
+
languages: ['typescript'],
|
|
122
|
+
ready: () => true,
|
|
123
|
+
highlight: () => { calls += 1; },
|
|
124
|
+
schedule: (run: () => void) => { run(); return () => {}; },
|
|
125
|
+
};
|
|
126
|
+
warmSyntaxTokenizers(options);
|
|
127
|
+
warmSyntaxTokenizers(options);
|
|
128
|
+
expect(calls).toBe(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a grammar that has not landed is retried rather than skipped', () => {
|
|
132
|
+
const calls: string[] = [];
|
|
133
|
+
let ready = false;
|
|
134
|
+
const queue: Array<() => void> = [];
|
|
135
|
+
warmSyntaxTokenizers({
|
|
136
|
+
languages: ['typescript'],
|
|
137
|
+
ready: () => ready,
|
|
138
|
+
highlight: (_code, language) => calls.push(language),
|
|
139
|
+
schedule: (run) => {
|
|
140
|
+
queue.push(run);
|
|
141
|
+
return () => {};
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
act(() => queue.shift()!());
|
|
146
|
+
expect(calls).toEqual([]);
|
|
147
|
+
ready = true;
|
|
148
|
+
act(() => queue.shift()!());
|
|
149
|
+
expect(calls).toEqual(['typescript']);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('the deferred render', () => {
|
|
154
|
+
test('draws the escaped source without tokenizing on the mounting tick', async () => {
|
|
155
|
+
let renders = 0;
|
|
156
|
+
function Card({ code }: { code: string }) {
|
|
157
|
+
const html = useDeferredHighlight(code, 'typescript');
|
|
158
|
+
renders += 1;
|
|
159
|
+
return h('pre', { dangerouslySetInnerHTML: { __html: html } });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await act(async () => {
|
|
163
|
+
render(h(Card, { code: 'const value = 1;' }), container);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Nothing has been tokenized on this tick — the tap's own task is clean.
|
|
167
|
+
expect(highlightMemoSize()).toBe(0);
|
|
168
|
+
expect(renders).toBeGreaterThan(0);
|
|
169
|
+
// What it DID draw is the escaped source, not an empty box.
|
|
170
|
+
expect(container.textContent).toContain('const value = 1;');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('empty input renders nothing and caches nothing', async () => {
|
|
174
|
+
function Card() {
|
|
175
|
+
return h('pre', { dangerouslySetInnerHTML: { __html: useDeferredHighlight('', 'typescript') } });
|
|
176
|
+
}
|
|
177
|
+
await act(async () => {
|
|
178
|
+
render(h(Card, {}), container);
|
|
179
|
+
});
|
|
180
|
+
expect(highlightMemoSize()).toBe(0);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Syntax-highlighted markup that never blocks the interaction that asked for it.
|
|
8
|
+
*
|
|
9
|
+
* A tool card renders its code the moment it is expanded, and the tokenizer used
|
|
10
|
+
* to run inside that render: measured on a throttled phone (4× CPU), a `read`
|
|
11
|
+
* card's expand was a 220–300 ms long task of which ~75% was the Shiki grammar
|
|
12
|
+
* engine, and at 20× the same card blocked for 730–1.484 ms. The card was on
|
|
13
|
+
* screen the whole time — it just could not paint until the last line had been
|
|
14
|
+
* tokenized.
|
|
15
|
+
*
|
|
16
|
+
* So the first render draws the text, escaped and correct, and the tokenized
|
|
17
|
+
* markup replaces it once the main thread is free (`scheduleIdle`). The two
|
|
18
|
+
* carry the same characters, so the swap costs colour and no layout.
|
|
19
|
+
*
|
|
20
|
+
* Three properties make that honest:
|
|
21
|
+
*
|
|
22
|
+
* - **A cached block renders highlighted on the FIRST paint.** `highlight-memo`
|
|
23
|
+
* is checked during render, so reopening a card the reader already opened —
|
|
24
|
+
* the common case while paging a transcript — has no plain-text frame at all.
|
|
25
|
+
* - **A stale block is never shown.** The value is tagged with the input it was
|
|
26
|
+
* computed from; a filter keystroke in the search panel therefore shows the
|
|
27
|
+
* new text, never the previous query's markup.
|
|
28
|
+
* - **Nothing is computed for empty input.** Call sites that gate a branch pass
|
|
29
|
+
* `''`, which is how `Edit` stops tokenizing the two panels it is not drawing.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { useEffect, useMemo, useState } from 'preact/hooks';
|
|
33
|
+
import { escapeCode } from '@/shared/lib/code/highlighter';
|
|
34
|
+
import {
|
|
35
|
+
cachedHighlightBlock,
|
|
36
|
+
cachedHighlightBlockLines,
|
|
37
|
+
highlightBlock,
|
|
38
|
+
highlightBlockLines,
|
|
39
|
+
} from '@/shared/lib/code/highlight-memo';
|
|
40
|
+
import { scheduleIdle } from '@/shared/lib/code/idle';
|
|
41
|
+
import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
|
|
42
|
+
|
|
43
|
+
interface Deferred<T> {
|
|
44
|
+
/** Identity of the input; a value computed for another input is not shown. */
|
|
45
|
+
key: object;
|
|
46
|
+
hasContent: boolean;
|
|
47
|
+
cached: T | undefined;
|
|
48
|
+
plain: T;
|
|
49
|
+
compute: () => T;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function useDeferred<T>(spec: Deferred<T>): T {
|
|
53
|
+
// A grammar arriving is what turns a plain fallback into a real highlight, so
|
|
54
|
+
// the effect re-runs on it even though the input did not move.
|
|
55
|
+
const syntaxReady = useSyntaxReady();
|
|
56
|
+
const [state, setState] = useState<{ key: object; value: T }>(() => ({
|
|
57
|
+
key: spec.key,
|
|
58
|
+
value: spec.cached ?? spec.plain,
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
/** Adopt a value only when it is a change, so a cached block does not cost an
|
|
62
|
+
* extra render on every mount. */
|
|
63
|
+
const adopt = (key: object, value: T): void => {
|
|
64
|
+
setState((previous) => (previous.key === key && previous.value === value ? previous : { key, value }));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!spec.hasContent) {
|
|
69
|
+
adopt(spec.key, spec.plain);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (spec.cached !== undefined) {
|
|
73
|
+
adopt(spec.key, spec.cached);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
let cancelled = false;
|
|
77
|
+
const cancel = scheduleIdle(() => {
|
|
78
|
+
if (cancelled) return;
|
|
79
|
+
let value: T;
|
|
80
|
+
try {
|
|
81
|
+
value = spec.compute();
|
|
82
|
+
} catch {
|
|
83
|
+
// A grammar that cannot tokenize this input leaves the escaped text,
|
|
84
|
+
// which is what the first paint already drew.
|
|
85
|
+
value = spec.plain;
|
|
86
|
+
}
|
|
87
|
+
if (!cancelled) adopt(spec.key, value);
|
|
88
|
+
});
|
|
89
|
+
return () => {
|
|
90
|
+
cancelled = true;
|
|
91
|
+
cancel();
|
|
92
|
+
};
|
|
93
|
+
// `spec` is rebuilt every render; its KEY (the input) and the grammar's
|
|
94
|
+
// readiness are what decide the work.
|
|
95
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
96
|
+
}, [spec.key, syntaxReady]);
|
|
97
|
+
|
|
98
|
+
if (state.key === spec.key) return state.value;
|
|
99
|
+
return spec.cached ?? spec.plain;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Tokenized markup for one block, deferred off the render that asked for it. */
|
|
103
|
+
export function useDeferredHighlight(code: string, language: string): string {
|
|
104
|
+
const key = useMemo(() => ({ code, language }), [code, language]);
|
|
105
|
+
const plain = useMemo(() => escapeCode(code), [code]);
|
|
106
|
+
return useDeferred<string>({
|
|
107
|
+
key,
|
|
108
|
+
hasContent: Boolean(code),
|
|
109
|
+
cached: code ? cachedHighlightBlock(code, language) : undefined,
|
|
110
|
+
plain,
|
|
111
|
+
compute: () => highlightBlock(code, language),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Tokenized markup per line, deferred the same way. */
|
|
116
|
+
export function useDeferredHighlightLines(code: string, language: string): string[] {
|
|
117
|
+
const key = useMemo(() => ({ code, language }), [code, language]);
|
|
118
|
+
const plain = useMemo(
|
|
119
|
+
() => (code ? code.split('\n').map(escapeCode) : []),
|
|
120
|
+
[code],
|
|
121
|
+
);
|
|
122
|
+
return useDeferred<string[]>({
|
|
123
|
+
key,
|
|
124
|
+
hasContent: Boolean(code),
|
|
125
|
+
cached: code ? cachedHighlightBlockLines(code, language) : undefined,
|
|
126
|
+
plain,
|
|
127
|
+
compute: () => highlightBlockLines(code, language),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { EditorCommand } from '@/shared/lib/code/editor/keymap';
|
|
17
|
-
import type { FindOptions } from '@/shared/lib/code/editor/find';
|
|
17
|
+
import type { FindOptions } from '@/shared/lib/code/editor/find-scan';
|
|
18
18
|
|
|
19
19
|
export interface UseEditorFindOptions {
|
|
20
20
|
/** The buffer on screen. */
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The editor-theme setting, and the two rules that make it safe to offer.
|
|
8
|
+
*
|
|
9
|
+
* A theme is a MODULE that is fetched on selection, so there are two failure
|
|
10
|
+
* modes a unit test can pin without a browser:
|
|
11
|
+
*
|
|
12
|
+
* 1. **The module has to be registered before Shiki can tokenize with it.**
|
|
13
|
+
* `loadTheme` is idempotent, and a second surface selecting the same theme
|
|
14
|
+
* must share the first fetch rather than start a second one.
|
|
15
|
+
* 2. **`auto` must not register anything**, and an id this build does not ship
|
|
16
|
+
* must resolve to no theme rather than leave the editor half-painted.
|
|
17
|
+
*
|
|
18
|
+
* Driven through happy-dom because the hook subscribes to a window event — the
|
|
19
|
+
* same shape the editor FONT uses, and the reason an already-open editor hears
|
|
20
|
+
* about a change at all (`appSettings` is a boot-time prop).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
24
|
+
import { Window } from 'happy-dom';
|
|
25
|
+
import { h, render } from 'preact';
|
|
26
|
+
import { act } from 'preact/test-utils';
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
EDITOR_THEME_CHANGED_EVENT,
|
|
30
|
+
currentEditorThemeId,
|
|
31
|
+
useEditorTheme,
|
|
32
|
+
} from '@/client/hooks/editor/theme';
|
|
33
|
+
import {
|
|
34
|
+
isEditorThemeReady,
|
|
35
|
+
loadEditorTheme,
|
|
36
|
+
onEditorThemeReady,
|
|
37
|
+
resetEditorThemeLoader,
|
|
38
|
+
} from '@/shared/lib/code/editor/theme-loader';
|
|
39
|
+
import { EDITOR_AUTO_THEME_ID, resolveEditorTheme } from '@/shared/lib/code/editor/themes';
|
|
40
|
+
import { bootSyntax, onLanguageReady, requestLanguage } from '@/shared/lib/code/highlighter';
|
|
41
|
+
import { primeKabmoSettings } from '@/shared/lib/settings/client';
|
|
42
|
+
|
|
43
|
+
const DOM_GLOBALS = [
|
|
44
|
+
'window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'CustomEvent',
|
|
45
|
+
'getComputedStyle', 'MutationObserver', 'requestAnimationFrame', 'cancelAnimationFrame',
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
/** The runner's own globals, put back once this file's DOM work is done. */
|
|
49
|
+
const native: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
|
|
50
|
+
|
|
51
|
+
/** happy-dom's `CustomEvent`, so a dispatch is an instance its EventTarget accepts. */
|
|
52
|
+
let Custom: typeof CustomEvent;
|
|
53
|
+
|
|
54
|
+
async function bootTokenizer(): Promise<void> {
|
|
55
|
+
// The highlighter only boots where `window` exists, and the Bun runner has
|
|
56
|
+
// none. Stub it for the boot alone, then hand the global to happy-dom — a
|
|
57
|
+
// `window` left pointing at `globalThis` would make the hook's
|
|
58
|
+
// `dispatchEvent` the runner's, which rejects a happy-dom CustomEvent.
|
|
59
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
60
|
+
const had = 'window' in target ? target.window : undefined;
|
|
61
|
+
target.window = globalThis;
|
|
62
|
+
await bootSyntax();
|
|
63
|
+
if (had === undefined) delete target.window;
|
|
64
|
+
else target.window = had;
|
|
65
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
66
|
+
const settle = () => {
|
|
67
|
+
if (requestLanguage('typescript')) {
|
|
68
|
+
off();
|
|
69
|
+
resolve();
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const off = onLanguageReady(settle);
|
|
73
|
+
settle();
|
|
74
|
+
await promise;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
beforeAll(async () => {
|
|
78
|
+
const win = new Window({ url: 'http://localhost' });
|
|
79
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
80
|
+
for (const key of DOM_GLOBALS) {
|
|
81
|
+
if (!(key in native)) native[key] = target[key];
|
|
82
|
+
const value = (win as unknown as Record<string, unknown>)[key];
|
|
83
|
+
if (value !== undefined) target[key] = value;
|
|
84
|
+
}
|
|
85
|
+
Custom = win.CustomEvent as unknown as typeof CustomEvent;
|
|
86
|
+
await bootTokenizer();
|
|
87
|
+
resetEditorThemeLoader();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
primeKabmoSettings({});
|
|
92
|
+
document.body.innerHTML = '';
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterAll(() => {
|
|
96
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
97
|
+
for (const key of DOM_GLOBALS) {
|
|
98
|
+
if (native[key] === undefined) delete target[key];
|
|
99
|
+
else target[key] = native[key];
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/** A probe that renders exactly what the hook says the surface should paint. */
|
|
104
|
+
function ThemeProbe() {
|
|
105
|
+
const theme = useEditorTheme();
|
|
106
|
+
return h(
|
|
107
|
+
'div',
|
|
108
|
+
{ id: 'probe' },
|
|
109
|
+
`${theme.theme?.id ?? 'auto'}|${theme.vars['--editor-bg'] ?? 'none'}|${theme.themes.light}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function mountProbe(): Promise<HTMLElement> {
|
|
114
|
+
const host = document.createElement('div');
|
|
115
|
+
document.body.appendChild(host);
|
|
116
|
+
await act(async () => {
|
|
117
|
+
render(h(ThemeProbe, {}), host);
|
|
118
|
+
});
|
|
119
|
+
return host.querySelector('#probe') as HTMLElement;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe('currentEditorThemeId', () => {
|
|
123
|
+
test('reads the stored value and falls back to auto', () => {
|
|
124
|
+
primeKabmoSettings({});
|
|
125
|
+
expect(currentEditorThemeId()).toBe(EDITOR_AUTO_THEME_ID);
|
|
126
|
+
|
|
127
|
+
primeKabmoSettings({ kabmo_settings: { editorTheme: 'nord' } });
|
|
128
|
+
expect(currentEditorThemeId()).toBe('nord');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('theme module loading', () => {
|
|
133
|
+
test('auto needs no module, and an unknown id is a no-op', async () => {
|
|
134
|
+
resetEditorThemeLoader();
|
|
135
|
+
await loadEditorTheme(EDITOR_AUTO_THEME_ID);
|
|
136
|
+
expect(isEditorThemeReady(EDITOR_AUTO_THEME_ID)).toBe(true);
|
|
137
|
+
|
|
138
|
+
await loadEditorTheme('no-such-theme');
|
|
139
|
+
expect(isEditorThemeReady('no-such-theme')).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('a shipped theme registers with the highlighter and announces itself', async () => {
|
|
143
|
+
resetEditorThemeLoader();
|
|
144
|
+
const seen: string[] = [];
|
|
145
|
+
const off = onEditorThemeReady((id) => seen.push(id));
|
|
146
|
+
try {
|
|
147
|
+
expect(isEditorThemeReady('nord')).toBe(false);
|
|
148
|
+
await loadEditorTheme('nord');
|
|
149
|
+
expect(isEditorThemeReady('nord')).toBe(true);
|
|
150
|
+
expect(seen).toEqual(['nord']);
|
|
151
|
+
} finally {
|
|
152
|
+
off();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('two requests for one theme share a single registration', async () => {
|
|
157
|
+
resetEditorThemeLoader();
|
|
158
|
+
let announcements = 0;
|
|
159
|
+
const off = onEditorThemeReady(() => announcements++);
|
|
160
|
+
try {
|
|
161
|
+
await Promise.all([loadEditorTheme('dracula'), loadEditorTheme('dracula')]);
|
|
162
|
+
expect(announcements).toBe(1);
|
|
163
|
+
// Already registered: a third call resolves without announcing again.
|
|
164
|
+
await loadEditorTheme('dracula');
|
|
165
|
+
expect(announcements).toBe(1);
|
|
166
|
+
} finally {
|
|
167
|
+
off();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('useEditorTheme', () => {
|
|
173
|
+
test('auto renders no variables and the app’s dual pair', async () => {
|
|
174
|
+
primeKabmoSettings({});
|
|
175
|
+
const probe = await mountProbe();
|
|
176
|
+
expect(probe.textContent).toBe('auto|none|one-light');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('an explicit theme renders its own surface and names itself in both slots', async () => {
|
|
180
|
+
primeKabmoSettings({ kabmo_settings: { editorTheme: 'dracula' } });
|
|
181
|
+
const probe = await mountProbe();
|
|
182
|
+
const theme = resolveEditorTheme('dracula');
|
|
183
|
+
expect(theme).not.toBeNull();
|
|
184
|
+
expect(probe.textContent).toBe(`dracula|${theme!.bg}|dracula`);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('a write reaches an ALREADY-MOUNTED editor without a reload', async () => {
|
|
188
|
+
primeKabmoSettings({});
|
|
189
|
+
const probe = await mountProbe();
|
|
190
|
+
expect(probe.textContent).toBe('auto|none|one-light');
|
|
191
|
+
|
|
192
|
+
// Exactly what the settings modal dispatches on a change.
|
|
193
|
+
await act(async () => {
|
|
194
|
+
window.dispatchEvent(new Custom(EDITOR_THEME_CHANGED_EVENT, { detail: 'nord' }));
|
|
195
|
+
});
|
|
196
|
+
const nord = resolveEditorTheme('nord');
|
|
197
|
+
expect(probe.textContent).toBe(`nord|${nord!.bg}|nord`);
|
|
198
|
+
|
|
199
|
+
// And back to auto, which must clear the variables again rather than leave
|
|
200
|
+
// the previous theme's surface behind.
|
|
201
|
+
await act(async () => {
|
|
202
|
+
window.dispatchEvent(new Custom(EDITOR_THEME_CHANGED_EVENT, { detail: EDITOR_AUTO_THEME_ID }));
|
|
203
|
+
});
|
|
204
|
+
expect(probe.textContent).toBe('auto|none|one-light');
|
|
205
|
+
});
|
|
206
|
+
});
|