@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,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shiki colours for the CodeMirror surface, as a decoration plugin.
|
|
8
|
+
*
|
|
9
|
+
* The app has ONE tokenizer. The chat timeline, the diff views, the wiki reader
|
|
10
|
+
* and the markdown preview all render through `highlightCode`, and the editor
|
|
11
|
+
* must not disagree with them about what a keyword looks like — which is why
|
|
12
|
+
* this is Shiki over CM6's viewport rather than a lezer grammar. A lezer build
|
|
13
|
+
* would cover 48 of the 59 ids `shiki-langs` knows and paint a second palette.
|
|
14
|
+
*
|
|
15
|
+
* Two rules make it affordable on a large file:
|
|
16
|
+
*
|
|
17
|
+
* - **Only the viewport is tokenized**, plus `CONTEXT_LINES` above it so a
|
|
18
|
+
* window that opens inside a block comment or a template literal still
|
|
19
|
+
* tokenizes as one. Measured: 2.1 ms per keystroke at 20 000 lines, flat,
|
|
20
|
+
* because the window is ~100 lines whatever the document is.
|
|
21
|
+
* - **A line at or past `MAX_LINE_CHARS` is left plain.** A minified bundle is
|
|
22
|
+
* one line of hundreds of KB, and tokenizing it on every scroll is what makes
|
|
23
|
+
* an editor feel dead.
|
|
24
|
+
*
|
|
25
|
+
* The colours are NOT inline. Every token carries Shiki's two custom properties
|
|
26
|
+
* (`--shiki-light` / `--shiki-dark`) and `theme.ts` picks one through
|
|
27
|
+
* `[data-theme-variant]`, so a theme switch repaints with zero re-tokenizing —
|
|
28
|
+
* the same property every other surface relies on.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { RangeSetBuilder, type Extension } from '@codemirror/state';
|
|
32
|
+
import { Decoration, ViewPlugin, type DecorationSet, type EditorView, type ViewUpdate } from '@codemirror/view';
|
|
33
|
+
|
|
34
|
+
import { getHighlighterSync, onLanguageReady, onSyntaxReady, requestLanguage } from '@/shared/lib/code/highlighter';
|
|
35
|
+
import { loadEditorTheme, onEditorThemeReady } from '@/shared/lib/code/editor/theme-loader';
|
|
36
|
+
import { SHIKI_THEMES } from '@/shared/lib/code/shiki-themes';
|
|
37
|
+
|
|
38
|
+
/** Lines tokenized above the viewport, to seed the grammar's own state. */
|
|
39
|
+
const CONTEXT_LINES = 40;
|
|
40
|
+
|
|
41
|
+
/** Lines at or above this length render as plain text (see the module note). */
|
|
42
|
+
const MAX_LINE_CHARS = 12_000;
|
|
43
|
+
|
|
44
|
+
/** Marks are shared by their style string: a file has a few dozen distinct ones. */
|
|
45
|
+
const markCache = new Map<string, Decoration>();
|
|
46
|
+
|
|
47
|
+
/** Ceiling on cached marks; a pathological file cannot grow the map forever. */
|
|
48
|
+
const MARK_CACHE_LIMIT = 2_000;
|
|
49
|
+
|
|
50
|
+
function markFor(style: string): Decoration {
|
|
51
|
+
const cached = markCache.get(style);
|
|
52
|
+
if (cached) return cached;
|
|
53
|
+
const mark = Decoration.mark({ class: 'shiki-tok', attributes: { style } });
|
|
54
|
+
if (markCache.size >= MARK_CACHE_LIMIT) markCache.clear();
|
|
55
|
+
markCache.set(style, mark);
|
|
56
|
+
return mark;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `{ '--shiki-light': '#…', '--shiki-dark': '#…' }` as a declaration list. */
|
|
60
|
+
function styleOf(htmlStyle: Record<string, string> | undefined): string | null {
|
|
61
|
+
if (!htmlStyle) return null;
|
|
62
|
+
let style = '';
|
|
63
|
+
for (const key of Object.keys(htmlStyle)) {
|
|
64
|
+
const value = htmlStyle[key];
|
|
65
|
+
if (!value) continue;
|
|
66
|
+
style += `${style ? ';' : ''}${key}:${value}`;
|
|
67
|
+
}
|
|
68
|
+
return style || null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The two theme names a surface tokenizes with. */
|
|
72
|
+
export interface ShikiThemeNames {
|
|
73
|
+
light: string;
|
|
74
|
+
dark: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The app's dual pair, used when a caller names none. */
|
|
78
|
+
const APP_THEMES: ShikiThemeNames = SHIKI_THEMES;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Tokenize the viewport and emit one mark per coloured token.
|
|
82
|
+
*
|
|
83
|
+
* `RangeSetBuilder` requires ascending ranges, which the line walk gives for
|
|
84
|
+
* free: tokens arrive in document order because they are read line by line.
|
|
85
|
+
*/
|
|
86
|
+
function buildDecorations(
|
|
87
|
+
view: EditorView,
|
|
88
|
+
language: string,
|
|
89
|
+
themes: ShikiThemeNames,
|
|
90
|
+
): DecorationSet {
|
|
91
|
+
const highlighter = getHighlighterSync();
|
|
92
|
+
// Not booted yet, or the grammar is still in flight: plain text for this
|
|
93
|
+
// pass, and `subscribe` re-runs the moment either lands.
|
|
94
|
+
if (!highlighter || language === 'text' || !requestLanguage(language)) return Decoration.none;
|
|
95
|
+
|
|
96
|
+
const doc = view.state.doc;
|
|
97
|
+
const firstLine = doc.lineAt(view.viewport.from).number;
|
|
98
|
+
const lastLine = doc.lineAt(view.viewport.to).number;
|
|
99
|
+
const contextStart = Math.max(1, firstLine - CONTEXT_LINES);
|
|
100
|
+
|
|
101
|
+
const lines: string[] = [];
|
|
102
|
+
for (let number = contextStart; number <= lastLine; number++) lines.push(doc.line(number).text);
|
|
103
|
+
const source = lines.join('\n');
|
|
104
|
+
|
|
105
|
+
let tokenized: { content: string; htmlStyle?: Record<string, string> }[][];
|
|
106
|
+
try {
|
|
107
|
+
tokenized = highlighter.codeToTokens(source, {
|
|
108
|
+
lang: language,
|
|
109
|
+
// Shiki's own type is `Partial<Record<string, ThemeRegistrationAny|string>>`;
|
|
110
|
+
// the pair is a plain string map, which is what it accepts at runtime.
|
|
111
|
+
themes: themes as unknown as Record<string, string>,
|
|
112
|
+
defaultColor: false,
|
|
113
|
+
tokenizeMaxLineLength: MAX_LINE_CHARS,
|
|
114
|
+
}).tokens as typeof tokenized;
|
|
115
|
+
} catch {
|
|
116
|
+
// A grammar the engine refuses (or a Shiki internal): plain text is the
|
|
117
|
+
// honest fallback, never a crash that takes the editor down.
|
|
118
|
+
return Decoration.none;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const builder = new RangeSetBuilder<Decoration>();
|
|
122
|
+
for (let index = 0; index < tokenized.length && index < lines.length; index++) {
|
|
123
|
+
const line = doc.line(contextStart + index);
|
|
124
|
+
if (line.length >= MAX_LINE_CHARS) continue;
|
|
125
|
+
let offset = 0;
|
|
126
|
+
for (const token of tokenized[index]) {
|
|
127
|
+
const length = token.content.length;
|
|
128
|
+
const style = styleOf(token.htmlStyle);
|
|
129
|
+
if (style && length > 0) {
|
|
130
|
+
const from = line.from + offset;
|
|
131
|
+
const to = Math.min(line.to, from + length);
|
|
132
|
+
if (to > from) builder.add(from, to, markFor(style));
|
|
133
|
+
}
|
|
134
|
+
offset += length;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return builder.finish();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Re-run when the highlighter boots, a grammar lands, or the theme's module
|
|
142
|
+
* arrives.
|
|
143
|
+
*
|
|
144
|
+
* The theme is the third case and it is not optional: an explicit editor theme
|
|
145
|
+
* is a module that is fetched on selection, and `codeToTokens` throws for a
|
|
146
|
+
* theme Shiki has not been given — which the catch below turns into plain text.
|
|
147
|
+
* Without this subscription a freshly picked theme would leave the surface
|
|
148
|
+
* uncoloured until something else forced a rebuild.
|
|
149
|
+
*/
|
|
150
|
+
function subscribe(themes: ShikiThemeNames, onReady: () => void): () => void {
|
|
151
|
+
const offBoot = onSyntaxReady(onReady);
|
|
152
|
+
const offLanguage = onLanguageReady(onReady);
|
|
153
|
+
const wanted = new Set([themes.light, themes.dark]);
|
|
154
|
+
const offTheme = onEditorThemeReady((id) => {
|
|
155
|
+
if (wanted.has(id)) onReady();
|
|
156
|
+
});
|
|
157
|
+
// A theme the app has never loaded: ask now, and the subscription above
|
|
158
|
+
// rebuilds once it lands.
|
|
159
|
+
for (const id of wanted) void loadEditorTheme(id);
|
|
160
|
+
return () => {
|
|
161
|
+
offBoot();
|
|
162
|
+
offLanguage();
|
|
163
|
+
offTheme();
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The decoration plugin for one language and theme.
|
|
169
|
+
*
|
|
170
|
+
* Both are baked in because neither changes under a live state: `documentKey`
|
|
171
|
+
* carries the language (a `.ts` tab and a `.py` tab are two cached states, and
|
|
172
|
+
* two states cannot share decorations built from different grammars), and a
|
|
173
|
+
* theme switch is a CSS repaint — the marks carry both palettes as custom
|
|
174
|
+
* properties, so the values are identical when one theme names both slots.
|
|
175
|
+
*
|
|
176
|
+
* `themes` is optional so a caller that has no editor-theme opinion (a diff
|
|
177
|
+
* card inside a chat message) keeps the app's dual pair.
|
|
178
|
+
*/
|
|
179
|
+
export function shikiHighlight(language: string, themes: ShikiThemeNames = APP_THEMES): Extension {
|
|
180
|
+
return ViewPlugin.fromClass(
|
|
181
|
+
class {
|
|
182
|
+
decorations: DecorationSet;
|
|
183
|
+
private readonly view: EditorView;
|
|
184
|
+
private dirty = false;
|
|
185
|
+
private destroyed = false;
|
|
186
|
+
private readonly unsubscribe: () => void;
|
|
187
|
+
|
|
188
|
+
constructor(view: EditorView) {
|
|
189
|
+
this.view = view;
|
|
190
|
+
this.decorations = buildDecorations(view, language, themes);
|
|
191
|
+
this.unsubscribe = subscribe(themes, () => this.invalidate());
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
update(update: ViewUpdate): void {
|
|
195
|
+
// A composition is a document in flux: tokenizing mid-IME paints
|
|
196
|
+
// decorations over text the browser is still composing, and Chrome
|
|
197
|
+
// reacts by moving the caret out from under the user. The rebuild lands
|
|
198
|
+
// on the first update after it ends.
|
|
199
|
+
if (update.view.composing) {
|
|
200
|
+
this.dirty = true;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!update.docChanged && !update.viewportChanged && !this.dirty) return;
|
|
204
|
+
this.dirty = false;
|
|
205
|
+
this.decorations = buildDecorations(update.view, language, themes);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
destroy(): void {
|
|
209
|
+
this.destroyed = true;
|
|
210
|
+
this.unsubscribe();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A grammar landed: force one rebuild.
|
|
215
|
+
*
|
|
216
|
+
* The empty transaction is the only way a plugin can ask for an update it
|
|
217
|
+
* did not get from a document change — and the `dirty` flag is what makes
|
|
218
|
+
* the rebuild happen even though neither `docChanged` nor
|
|
219
|
+
* `viewportChanged` is set. Guarded, because a chunk can land while the
|
|
220
|
+
* view is being torn down.
|
|
221
|
+
*/
|
|
222
|
+
private invalidate(): void {
|
|
223
|
+
if (this.destroyed) return;
|
|
224
|
+
this.dirty = true;
|
|
225
|
+
try {
|
|
226
|
+
this.view.dispatch({});
|
|
227
|
+
} catch {
|
|
228
|
+
// The view went away between the check and the dispatch.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
{ decorations: (plugin) => plugin.decorations },
|
|
233
|
+
);
|
|
234
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The external-sync guard, which is the difference between a large file being
|
|
8
|
+
* usable and not.
|
|
9
|
+
*
|
|
10
|
+
* `syncExternal` runs on EVERY render, and the parent re-renders on every
|
|
11
|
+
* keystroke, so its fast path has to be a reference comparison: the old version
|
|
12
|
+
* compared against `view.state.doc.toString()` unconditionally, which is 7.2 ms
|
|
13
|
+
* at 20 MB and 13.7 ms at 30 MB (measured in Bun) — twice per character, since
|
|
14
|
+
* the write and the read each cost one.
|
|
15
|
+
*
|
|
16
|
+
* The cases below pin both halves: the echo must be free, and a REAL external
|
|
17
|
+
* replacement must still land. A guard that skipped both would look correct in
|
|
18
|
+
* the fast-path test and silently stop a file re-read from reaching the editor.
|
|
19
|
+
*
|
|
20
|
+
* Driven through a real `EditorView` in happy-dom, because the thing under test
|
|
21
|
+
* dispatches transactions — a stub view would test the stub.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
25
|
+
import { Window } from 'happy-dom';
|
|
26
|
+
import { EditorState } from '@codemirror/state';
|
|
27
|
+
import { EditorView } from '@codemirror/view';
|
|
28
|
+
import { syncExternal, type MutableRef } from '@/client/components/common/code-surface/sync';
|
|
29
|
+
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
30
|
+
|
|
31
|
+
let observer: unknown;
|
|
32
|
+
|
|
33
|
+
beforeAll(() => {
|
|
34
|
+
const win = new Window({ url: 'http://localhost' });
|
|
35
|
+
installDomGlobals(win);
|
|
36
|
+
// `installDomGlobals` covers what a COMPONENT reads; mounting a real
|
|
37
|
+
// `EditorView` additionally needs `MutationObserver` and
|
|
38
|
+
// `requestAnimationFrame`, which CM6's DOM observer and its measure cycle
|
|
39
|
+
// call directly on `globalThis`.
|
|
40
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
41
|
+
observer = target.MutationObserver;
|
|
42
|
+
target.MutationObserver = (win as unknown as Record<string, unknown>).MutationObserver;
|
|
43
|
+
target.requestAnimationFrame = (win as unknown as Record<string, unknown>).requestAnimationFrame;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(() => {
|
|
47
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
48
|
+
if (observer === undefined) delete target.MutationObserver;
|
|
49
|
+
else target.MutationObserver = observer;
|
|
50
|
+
restoreDomGlobals();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
interface Harness {
|
|
54
|
+
view: EditorView;
|
|
55
|
+
writingRef: MutableRef<boolean>;
|
|
56
|
+
lastValueRef: MutableRef<string>;
|
|
57
|
+
/** Every string the sync was asked to write, in order. */
|
|
58
|
+
writes: string[];
|
|
59
|
+
destroy(): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A view over `doc`, wired the way the component wires it: the update listener
|
|
64
|
+
* keeps `lastValueRef` in step and records the writes it observes.
|
|
65
|
+
*/
|
|
66
|
+
function mount(doc: string): Harness {
|
|
67
|
+
const writingRef: MutableRef<boolean> = { current: false };
|
|
68
|
+
const lastValueRef: MutableRef<string> = { current: doc };
|
|
69
|
+
const writes: string[] = [];
|
|
70
|
+
|
|
71
|
+
const view = new EditorView({
|
|
72
|
+
state: EditorState.create({
|
|
73
|
+
doc,
|
|
74
|
+
extensions: [
|
|
75
|
+
EditorView.updateListener.of((update) => {
|
|
76
|
+
if (!update.docChanged) return;
|
|
77
|
+
// Mirrors `documents.ts`'s `onDocChange`: recorded BEFORE the writing
|
|
78
|
+
// guard, because this is what the document holds either way.
|
|
79
|
+
lastValueRef.current = update.state.doc.toString();
|
|
80
|
+
if (writingRef.current) return;
|
|
81
|
+
writes.push(update.state.doc.toString());
|
|
82
|
+
}),
|
|
83
|
+
],
|
|
84
|
+
}),
|
|
85
|
+
parent: document.body,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return { view, writingRef, lastValueRef, writes, destroy: () => view.destroy() };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('syncExternal', () => {
|
|
92
|
+
test('the echo of the document costs one comparison and dispatches nothing', () => {
|
|
93
|
+
const harness = mount('const a = 1;');
|
|
94
|
+
try {
|
|
95
|
+
// What the parent hands back after a keystroke.
|
|
96
|
+
syncExternal(harness.view, 'const a = 1;', harness.writingRef, harness.lastValueRef);
|
|
97
|
+
|
|
98
|
+
expect(harness.view.state.doc.toString()).toBe('const a = 1;');
|
|
99
|
+
expect(harness.writes).toEqual([]);
|
|
100
|
+
} finally {
|
|
101
|
+
harness.destroy();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('the fast path does not stringify: a same-reference value is accepted', () => {
|
|
106
|
+
const harness = mount('x');
|
|
107
|
+
try {
|
|
108
|
+
// The document and `lastValueRef` agree, so the guard returns before the
|
|
109
|
+
// `doc.toString()` that used to run on every render. A spy is not needed:
|
|
110
|
+
// `lastValueRef` being the identity the caller passes is the contract, and
|
|
111
|
+
// an unchanged document proves nothing was dispatched.
|
|
112
|
+
const before = harness.view.state.doc;
|
|
113
|
+
syncExternal(harness.view, 'x', harness.writingRef, harness.lastValueRef);
|
|
114
|
+
|
|
115
|
+
expect(harness.view.state.doc).toBe(before);
|
|
116
|
+
} finally {
|
|
117
|
+
harness.destroy();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('a real external replacement lands, once, and is not reported as a user edit', () => {
|
|
122
|
+
const harness = mount('old text');
|
|
123
|
+
try {
|
|
124
|
+
syncExternal(harness.view, 'new text', harness.writingRef, harness.lastValueRef);
|
|
125
|
+
|
|
126
|
+
expect(harness.view.state.doc.toString()).toBe('new text');
|
|
127
|
+
// `writingRef` suppresses the report: a re-read is not something the user
|
|
128
|
+
// typed, and reporting it would arm an autosave for an unchanged file.
|
|
129
|
+
expect(harness.writes).toEqual([]);
|
|
130
|
+
expect(harness.lastValueRef.current).toBe('new text');
|
|
131
|
+
expect(harness.writingRef.current).toBe(false);
|
|
132
|
+
} finally {
|
|
133
|
+
harness.destroy();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a value that differs from the ref but EQUALS the document is adopted without a dispatch', () => {
|
|
138
|
+
const harness = mount('same');
|
|
139
|
+
try {
|
|
140
|
+
// The parent re-serialized the same text: a new string, same content.
|
|
141
|
+
const fresh = ['same'].join('');
|
|
142
|
+
harness.lastValueRef.current = 'stale';
|
|
143
|
+
syncExternal(harness.view, fresh, harness.writingRef, harness.lastValueRef);
|
|
144
|
+
|
|
145
|
+
expect(harness.view.state.doc.toString()).toBe('same');
|
|
146
|
+
expect(harness.writes).toEqual([]);
|
|
147
|
+
// The ref is corrected, so the NEXT render takes the fast path again.
|
|
148
|
+
expect(harness.lastValueRef.current).toBe('same');
|
|
149
|
+
} finally {
|
|
150
|
+
harness.destroy();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('an empty document is a real value, not "nothing to do"', () => {
|
|
155
|
+
const harness = mount('was here');
|
|
156
|
+
try {
|
|
157
|
+
syncExternal(harness.view, '', harness.writingRef, harness.lastValueRef);
|
|
158
|
+
|
|
159
|
+
expect(harness.view.state.doc.length).toBe(0);
|
|
160
|
+
expect(harness.lastValueRef.current).toBe('');
|
|
161
|
+
} finally {
|
|
162
|
+
harness.destroy();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('the writing flag is released even when the dispatch throws', () => {
|
|
167
|
+
const harness = mount('text');
|
|
168
|
+
try {
|
|
169
|
+
const broken = {
|
|
170
|
+
state: harness.view.state,
|
|
171
|
+
dispatch: () => {
|
|
172
|
+
throw new Error('dispatch failed');
|
|
173
|
+
},
|
|
174
|
+
} as unknown as EditorView;
|
|
175
|
+
|
|
176
|
+
expect(() => syncExternal(broken, 'other', harness.writingRef, harness.lastValueRef)).toThrow(
|
|
177
|
+
'dispatch failed',
|
|
178
|
+
);
|
|
179
|
+
// A stuck flag would silently swallow every later user edit.
|
|
180
|
+
expect(harness.writingRef.current).toBe(false);
|
|
181
|
+
} finally {
|
|
182
|
+
harness.destroy();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Writing the parent's `value` into the view — and, more importantly, NOT
|
|
8
|
+
* writing it.
|
|
9
|
+
*
|
|
10
|
+
* The parent re-renders on every keystroke, so an unguarded write would dispatch
|
|
11
|
+
* a full-document replace on each one: a second edit and a fresh undo entry per
|
|
12
|
+
* character.
|
|
13
|
+
*
|
|
14
|
+
* The guard used to be a single comparison against `view.state.doc.toString()`,
|
|
15
|
+
* which is a full stringify of the document — and since the parent re-renders on
|
|
16
|
+
* every keystroke, a large file paid it twice per character. Measured in Bun:
|
|
17
|
+
* 7.2 ms at 20 MB and 13.7 ms at 30 MB; in the browser the knee was visible as
|
|
18
|
+
* 67 ms per keystroke at 30 MB and 83 ms at 40 MB. Both drop by roughly a third
|
|
19
|
+
* once the common case is a reference check.
|
|
20
|
+
*
|
|
21
|
+
* `lastValueRef` is what makes that possible, and its definition is the whole
|
|
22
|
+
* correctness argument: it describes what the DOCUMENT holds — written by the
|
|
23
|
+
* update listener for every change (user or programmatic) and by this function
|
|
24
|
+
* when it writes — never what the parent last asked for. So an external
|
|
25
|
+
* replacement still falls through to the real comparison, while the echo of the
|
|
26
|
+
* parent's own value is rejected in one string comparison.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { EditorView } from '@codemirror/view';
|
|
30
|
+
|
|
31
|
+
export interface MutableRef<T> {
|
|
32
|
+
current: T;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function syncExternal(
|
|
36
|
+
view: EditorView,
|
|
37
|
+
value: string,
|
|
38
|
+
writingRef: MutableRef<boolean>,
|
|
39
|
+
lastValueRef: MutableRef<string>,
|
|
40
|
+
): void {
|
|
41
|
+
// The common case: `value` is the string the update listener reported a
|
|
42
|
+
// moment ago, so this costs nothing whatever the document's size.
|
|
43
|
+
if (value === lastValueRef.current) return;
|
|
44
|
+
// A genuinely different string. It may still equal the document (a parent that
|
|
45
|
+
// re-serialized the same text), in which case nothing needs dispatching — and
|
|
46
|
+
// the stringify is affordable here precisely because it is the rare path.
|
|
47
|
+
if (value === view.state.doc.toString()) {
|
|
48
|
+
lastValueRef.current = value;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
writingRef.current = true;
|
|
52
|
+
try {
|
|
53
|
+
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
|
|
54
|
+
lastValueRef.current = value;
|
|
55
|
+
} finally {
|
|
56
|
+
writingRef.current = false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The code surface's colours, and the two mechanisms that carry them.
|
|
8
|
+
*
|
|
9
|
+
* An editor theme is applied in TWO places, and confusing them is the bug this
|
|
10
|
+
* file exists to catch:
|
|
11
|
+
*
|
|
12
|
+
* - **The chrome** (surface, gutter, caret, selection, find, diff bands) is a
|
|
13
|
+
* set of `--editor-*` custom properties on the surface's own host. Writing
|
|
14
|
+
* them repaints the editor with no transaction at all, which is what makes a
|
|
15
|
+
* theme switch a colour change rather than a rebuild.
|
|
16
|
+
* - **The tokens** are baked into the Shiki decoration plugin, because Shiki is
|
|
17
|
+
* handed theme NAMES at tokenize time. That one needs a compartment
|
|
18
|
+
* reconfigure — and the reconfigure must keep the document, the selection and
|
|
19
|
+
* the undo history, which is why it is a compartment and not a re-mount.
|
|
20
|
+
*
|
|
21
|
+
* Both are asserted through a real `EditorView` in happy-dom: the theme
|
|
22
|
+
* extension emits real CSS, and a stub would test the stub.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
26
|
+
import { Window } from 'happy-dom';
|
|
27
|
+
import { Compartment, EditorState } from '@codemirror/state';
|
|
28
|
+
import { EditorView } from '@codemirror/view';
|
|
29
|
+
|
|
30
|
+
import { createDocumentCache } from '@/client/components/common/code-surface/documents';
|
|
31
|
+
import { surfaceTheme } from '@/client/components/common/code-surface/theme';
|
|
32
|
+
import { editorThemeVars, resolveEditorTheme } from '@/shared/lib/code/editor/themes';
|
|
33
|
+
import { SHIKI_THEMES } from '@/shared/lib/code/shiki-themes';
|
|
34
|
+
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
35
|
+
|
|
36
|
+
const EXTRA_GLOBALS = ['MutationObserver', 'requestAnimationFrame', 'cancelAnimationFrame'] as const;
|
|
37
|
+
const native: Partial<Record<(typeof EXTRA_GLOBALS)[number], unknown>> = {};
|
|
38
|
+
|
|
39
|
+
let host: HTMLElement;
|
|
40
|
+
|
|
41
|
+
beforeAll(() => {
|
|
42
|
+
const win = new Window({ url: 'http://localhost' });
|
|
43
|
+
installDomGlobals(win);
|
|
44
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
45
|
+
for (const key of EXTRA_GLOBALS) {
|
|
46
|
+
native[key] = target[key];
|
|
47
|
+
target[key] = (win as unknown as Record<string, unknown>)[key];
|
|
48
|
+
}
|
|
49
|
+
host = document.body.appendChild(document.createElement('div'));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterAll(() => {
|
|
53
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
54
|
+
for (const key of EXTRA_GLOBALS) {
|
|
55
|
+
if (native[key] === undefined) delete target[key];
|
|
56
|
+
else target[key] = native[key];
|
|
57
|
+
}
|
|
58
|
+
restoreDomGlobals();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** The stylesheet CM6 generated for the theme extension. */
|
|
62
|
+
function themeCss(): string {
|
|
63
|
+
return Array.from(document.head.querySelectorAll('style'))
|
|
64
|
+
.map((node) => node.textContent ?? '')
|
|
65
|
+
.join('\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('surface theme', () => {
|
|
69
|
+
/** CM6 injects a theme's stylesheet when a VIEW mounts, not when the
|
|
70
|
+
* extension is built — so one view is mounted for the CSS cases below. */
|
|
71
|
+
beforeAll(() => {
|
|
72
|
+
const view = new EditorView({
|
|
73
|
+
parent: host,
|
|
74
|
+
state: EditorState.create({ doc: 'x', extensions: [surfaceTheme()] }),
|
|
75
|
+
});
|
|
76
|
+
view.destroy();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('every chrome colour is a variable with the app palette as its fallback', () => {
|
|
80
|
+
const css = themeCss();
|
|
81
|
+
// The editor's own variable first, the app's palette behind it — which is
|
|
82
|
+
// what makes `auto` a no-op and an explicit theme a pure repaint.
|
|
83
|
+
expect(css).toContain('background-color: var(--editor-bg, transparent)');
|
|
84
|
+
expect(css).toContain('color: var(--editor-fg, var(--theme-ink))');
|
|
85
|
+
expect(css).toContain('var(--editor-gutter-fg,');
|
|
86
|
+
expect(css).toContain('var(--editor-selection,');
|
|
87
|
+
expect(css).toContain('var(--editor-cursor, var(--theme-ink))');
|
|
88
|
+
expect(css).toContain('var(--editor-find,');
|
|
89
|
+
expect(css).toContain('var(--editor-border,');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a token paints from the app variant when the editor theme is auto', () => {
|
|
93
|
+
const css = themeCss();
|
|
94
|
+
expect(css).toContain('.cm-content .shiki-tok {color: var(--shiki-light)');
|
|
95
|
+
// The dark rule is scoped to the app's variant attribute, so an app theme
|
|
96
|
+
// flip recolours tokens with no re-tokenize.
|
|
97
|
+
expect(css).toContain('html[data-theme-variant="dark"]');
|
|
98
|
+
expect(css).toContain('var(--shiki-dark)');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('writing the theme variables on the host repaints without a transaction', () => {
|
|
102
|
+
const view = new EditorView({ parent: host, state: EditorState.create({ doc: 'const a = 1;', extensions: [surfaceTheme()] }) });
|
|
103
|
+
try {
|
|
104
|
+
const theme = resolveEditorTheme('dracula');
|
|
105
|
+
expect(theme).not.toBeNull();
|
|
106
|
+
const before = view.state.doc.length;
|
|
107
|
+
for (const [key, value] of Object.entries(editorThemeVars(theme))) {
|
|
108
|
+
host.style.setProperty(key, value);
|
|
109
|
+
}
|
|
110
|
+
// The document and its history are untouched: a colour change is CSS.
|
|
111
|
+
expect(view.state.doc.length).toBe(before);
|
|
112
|
+
expect(host.style.getPropertyValue('--editor-bg')).toBe(theme!.bg);
|
|
113
|
+
expect(host.style.getPropertyValue('--editor-gutter-fg')).toBe(theme!.gutterFg);
|
|
114
|
+
} finally {
|
|
115
|
+
view.destroy();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('document cache theme reconfigure', () => {
|
|
121
|
+
function cache() {
|
|
122
|
+
return createDocumentCache({
|
|
123
|
+
wordWrap: false,
|
|
124
|
+
readOnly: false,
|
|
125
|
+
themes: SHIKI_THEMES,
|
|
126
|
+
onDocChange: () => {},
|
|
127
|
+
onState: () => {},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
test('a theme change is reported for the document it applies to', () => {
|
|
132
|
+
const documents = cache();
|
|
133
|
+
documents.stateFor('a', 'typescript', 'const a = 1;');
|
|
134
|
+
|
|
135
|
+
// Nothing moved: no effects, which is what keeps a re-render free.
|
|
136
|
+
expect(documents.optionsEffects({ wordWrap: false, readOnly: false, themes: SHIKI_THEMES }, 'a')).toEqual([]);
|
|
137
|
+
|
|
138
|
+
const effects = documents.optionsEffects(
|
|
139
|
+
{ wordWrap: false, readOnly: false, themes: { light: 'dracula', dark: 'dracula' } },
|
|
140
|
+
'a',
|
|
141
|
+
);
|
|
142
|
+
expect(effects).toHaveLength(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('a wrap toggle and a theme change are independent effects', () => {
|
|
146
|
+
const documents = cache();
|
|
147
|
+
documents.stateFor('a', 'typescript', 'x');
|
|
148
|
+
const effects = documents.optionsEffects(
|
|
149
|
+
{ wordWrap: true, readOnly: false, themes: { light: 'nord', dark: 'nord' } },
|
|
150
|
+
'a',
|
|
151
|
+
);
|
|
152
|
+
// Two compartments: one for the wrap, one for the tokenizer.
|
|
153
|
+
expect(effects).toHaveLength(2);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('a reconfigure keeps the document, the caret and the history', () => {
|
|
157
|
+
const documents = cache();
|
|
158
|
+
const first = documents.stateFor('a', 'typescript', 'const a = 1;');
|
|
159
|
+
const edited = first.update({ changes: { from: 0, to: 5, insert: 'let' } }).state;
|
|
160
|
+
documents.remember('a', edited);
|
|
161
|
+
|
|
162
|
+
const themeCompartment = new Compartment();
|
|
163
|
+
const view = new EditorView({ parent: host, state: edited });
|
|
164
|
+
try {
|
|
165
|
+
// A reconfigure of a live view is what a theme switch does.
|
|
166
|
+
view.dispatch({ effects: themeCompartment.reconfigure([]) });
|
|
167
|
+
expect(view.state.doc.toString()).toBe('let a = 1;');
|
|
168
|
+
expect(view.state.selection.main.head).toBe(edited.selection.main.head);
|
|
169
|
+
} finally {
|
|
170
|
+
view.destroy();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('a document switched back gets the CURRENT theme, not the one it was created with', () => {
|
|
175
|
+
const documents = cache();
|
|
176
|
+
documents.stateFor('a', 'typescript', 'a');
|
|
177
|
+
documents.optionsEffects(
|
|
178
|
+
{ wordWrap: false, readOnly: false, themes: { light: 'dracula', dark: 'dracula' } },
|
|
179
|
+
'a',
|
|
180
|
+
);
|
|
181
|
+
// `currentEffects` is the re-apply `view.setState` owes: a cached state
|
|
182
|
+
// carries the configuration it was CREATED with.
|
|
183
|
+
const effects = documents.currentEffects('a');
|
|
184
|
+
expect(effects.length).toBeGreaterThan(0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('a switch back to a document that already holds the theme does not re-tokenize', () => {
|
|
188
|
+
const documents = cache();
|
|
189
|
+
documents.stateFor('a', 'typescript', 'a');
|
|
190
|
+
documents.stateFor('b', 'typescript', 'b');
|
|
191
|
+
const dracula = { light: 'dracula', dark: 'dracula' };
|
|
192
|
+
|
|
193
|
+
// Pick the theme while `a` is the live document, then switch to `b` — the
|
|
194
|
+
// apply path reconfigures BOTH, which is what makes them agree.
|
|
195
|
+
documents.optionsEffects({ wordWrap: false, readOnly: false, themes: dracula }, 'a');
|
|
196
|
+
documents.currentEffects('b');
|
|
197
|
+
// ... and back: `a` already holds the theme, so there is nothing to rebuild.
|
|
198
|
+
const effects = documents.currentEffects('a');
|
|
199
|
+
// Wrap + read-only only; the tokenizer's reconfigure is absent.
|
|
200
|
+
expect(effects).toHaveLength(2);
|
|
201
|
+
});
|
|
202
|
+
});
|