@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,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A bounded memo in front of the syntax highlighter, for BLOCK highlights — the
|
|
8
|
+
* code a tool card draws when it opens.
|
|
9
|
+
*
|
|
10
|
+
* Shiki tokenization is the expensive half of rendering a tool card: profiled on
|
|
11
|
+
* a throttled phone, a `read` expand spent 75% of its long task in
|
|
12
|
+
* `findNextMatchSync`/the grammar engine, and the same card re-expanded in 33 ms
|
|
13
|
+
* once its grammar was warm. Two consequences follow, and this module is the
|
|
14
|
+
* second half of both (the first is `idle.ts`, which moves the work off the tap):
|
|
15
|
+
*
|
|
16
|
+
* 1. A card that is closed and reopened is the same code and the same language,
|
|
17
|
+
* so its markup is the same string. Tokenizing it again is pure waste, and a
|
|
18
|
+
* reader paging through a transcript does exactly that repeatedly.
|
|
19
|
+
* 2. `Edit` asked for three highlights per render (old, new, preview) when at
|
|
20
|
+
* most one of them is ever drawn. Caching per `(language, code)` makes the
|
|
21
|
+
* duplicates free even before the call sites are gated.
|
|
22
|
+
*
|
|
23
|
+
* The key is a two-level map — language, then the code string itself — rather
|
|
24
|
+
* than one composite string, because the composite would COPY the whole file on
|
|
25
|
+
* every lookup, which is exactly the per-frame allocation this exists to remove.
|
|
26
|
+
* Insertion order is the LRU order.
|
|
27
|
+
*
|
|
28
|
+
* Nothing is cached while the language's grammar has not landed: `highlightCode`
|
|
29
|
+
* answers escaped text in that state, and caching it would pin the plain
|
|
30
|
+
* fallback for the life of the entry (the trap `highlight-cache.ts` documents
|
|
31
|
+
* for search rows). `useSyntaxReady` re-runs the caller when a grammar arrives,
|
|
32
|
+
* which is when the entry finally becomes cacheable.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { isLanguageReady } from '@/shared/lib/code/highlighter-lazy';
|
|
36
|
+
import { highlightCode, highlightLines, type HighlightLinesOptions } from '@/shared/lib/code/syntax-highlight';
|
|
37
|
+
|
|
38
|
+
/** Per language: a transcript page's worth of distinct blocks. */
|
|
39
|
+
const MAX_BLOCKS_PER_LANGUAGE = 24;
|
|
40
|
+
/** Across every language, so a long session cannot grow without bound. */
|
|
41
|
+
const MAX_CACHED_CHARS = 512 * 1024;
|
|
42
|
+
|
|
43
|
+
const blocks = new Map<string, Map<string, string>>();
|
|
44
|
+
let cachedChars = 0;
|
|
45
|
+
|
|
46
|
+
function bucket(language: string): Map<string, string> {
|
|
47
|
+
const existing = blocks.get(language);
|
|
48
|
+
if (existing) return existing;
|
|
49
|
+
const created = new Map<string, string>();
|
|
50
|
+
blocks.set(language, created);
|
|
51
|
+
return created;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Drop the oldest entry of the first language that has one. */
|
|
55
|
+
function evictOne(): boolean {
|
|
56
|
+
for (const entries of blocks.values()) {
|
|
57
|
+
const oldest = entries.keys().next();
|
|
58
|
+
if (oldest.done) continue;
|
|
59
|
+
const value = entries.get(oldest.value) ?? '';
|
|
60
|
+
entries.delete(oldest.value);
|
|
61
|
+
cachedChars -= value.length;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function remember(language: string, code: string, html: string): void {
|
|
68
|
+
const entries = bucket(language);
|
|
69
|
+
const previous = entries.get(code);
|
|
70
|
+
if (previous !== undefined) {
|
|
71
|
+
entries.delete(code);
|
|
72
|
+
cachedChars -= previous.length;
|
|
73
|
+
}
|
|
74
|
+
entries.set(code, html);
|
|
75
|
+
cachedChars += html.length;
|
|
76
|
+
while (entries.size > MAX_BLOCKS_PER_LANGUAGE) {
|
|
77
|
+
const oldest = entries.keys().next();
|
|
78
|
+
if (oldest.done) break;
|
|
79
|
+
const value = entries.get(oldest.value) ?? '';
|
|
80
|
+
entries.delete(oldest.value);
|
|
81
|
+
cachedChars -= value.length;
|
|
82
|
+
}
|
|
83
|
+
while (cachedChars > MAX_CACHED_CHARS && evictOne()) {
|
|
84
|
+
// Keep dropping until the budget holds or nothing is left to drop.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The cached markup for `(language, code)`, or `undefined`. */
|
|
89
|
+
export function cachedHighlightBlock(code: string, language: string): string | undefined {
|
|
90
|
+
const entries = blocks.get(language);
|
|
91
|
+
if (!entries) return undefined;
|
|
92
|
+
const hit = entries.get(code);
|
|
93
|
+
if (hit === undefined) return undefined;
|
|
94
|
+
// Reading is a use: move it to the most-recently-used end.
|
|
95
|
+
entries.delete(code);
|
|
96
|
+
entries.set(code, hit);
|
|
97
|
+
return hit;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The cached per-line markup, or `undefined`. */
|
|
101
|
+
export function cachedHighlightBlockLines(code: string, language: string): string[] | undefined {
|
|
102
|
+
const hit = cachedHighlightBlock(code, language);
|
|
103
|
+
return hit === undefined ? undefined : hit.split('\n');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function cacheable(language: string, options: HighlightLinesOptions): boolean {
|
|
107
|
+
// A find-in-progress needs the match markup, which only the line path emits —
|
|
108
|
+
// and which changes per query, so those results are never shared.
|
|
109
|
+
if (options.marks && options.marks.length > 0) return false;
|
|
110
|
+
return isLanguageReady(language);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `highlightCode` for one block, memoized per `(language, code)`. */
|
|
114
|
+
export function highlightBlock(code: string, language: string, options: HighlightLinesOptions = {}): string {
|
|
115
|
+
if (!code) return '';
|
|
116
|
+
const cached = options.marks?.length ? undefined : cachedHighlightBlock(code, language);
|
|
117
|
+
if (cached !== undefined) return cached;
|
|
118
|
+
const html = highlightCode(code, language, options);
|
|
119
|
+
if (cacheable(language, options)) remember(language, code, html);
|
|
120
|
+
return html;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `highlightLines` for one block, memoized per `(language, code)`. */
|
|
124
|
+
export function highlightBlockLines(
|
|
125
|
+
code: string,
|
|
126
|
+
language: string,
|
|
127
|
+
options: HighlightLinesOptions = {},
|
|
128
|
+
): string[] {
|
|
129
|
+
if (!code) return [''];
|
|
130
|
+
const cached = options.marks?.length ? undefined : cachedHighlightBlockLines(code, language);
|
|
131
|
+
if (cached !== undefined) return cached;
|
|
132
|
+
const lines = highlightLines(code, language, options);
|
|
133
|
+
if (cacheable(language, options)) remember(language, code, lines.join('\n'));
|
|
134
|
+
return lines;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Drop every entry. For tests and for a highlighter restart (grammar reload). */
|
|
138
|
+
export function clearHighlightMemo(): void {
|
|
139
|
+
blocks.clear();
|
|
140
|
+
cachedChars = 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** How many blocks are held — a hit and a recompute return equal strings, so
|
|
144
|
+
* the map is the only observable. */
|
|
145
|
+
export function highlightMemoSize(): number {
|
|
146
|
+
let total = 0;
|
|
147
|
+
for (const entries of blocks.values()) total += entries.size;
|
|
148
|
+
return total;
|
|
149
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pay the tokenizer's start-up cost before the reader taps anything.
|
|
8
|
+
*
|
|
9
|
+
* `EAGER_LANGS` are loaded in the background at boot, but loading a grammar is
|
|
10
|
+
* not compiling it: the grammar's regexes are built and JIT-ed on the first
|
|
11
|
+
* tokenize, which is why the FIRST expand of a card in a given language measured
|
|
12
|
+
* 220–300 ms at 4× CPU while the same card re-expanded in 33 ms. That first
|
|
13
|
+
* expand is the one a reader actually experiences — a transcript opens with
|
|
14
|
+
* `read` and `bash` cards.
|
|
15
|
+
*
|
|
16
|
+
* So each eager grammar is tokenized once, on a tiny sample, while the main
|
|
17
|
+
* thread is idle. The sample's markup is discarded; the work being paid for is
|
|
18
|
+
* the compile behind it. One language per idle slice, so a slow grammar cannot
|
|
19
|
+
* become the long task this exists to prevent — and a grammar that has not
|
|
20
|
+
* landed yet is retried rather than skipped, because tokenizing before it
|
|
21
|
+
* arrives compiles nothing.
|
|
22
|
+
*
|
|
23
|
+
* Booted from `main.tsx` rather than from `highlighter.ts`: the warm-up needs
|
|
24
|
+
* the memo, which needs the highlighter, and an import in the other direction
|
|
25
|
+
* would close that cycle.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { isLanguageReady } from '@/shared/lib/code/highlighter-lazy';
|
|
29
|
+
import { highlightBlock } from '@/shared/lib/code/highlight-memo';
|
|
30
|
+
import { scheduleIdle } from '@/shared/lib/code/idle';
|
|
31
|
+
import { EAGER_LANGS } from '@/shared/lib/code/shiki-langs';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A few lines that make the grammar walk its own rule set: a keyword, a string,
|
|
35
|
+
* a comment, a call and a block. The exact text does not matter — the compile
|
|
36
|
+
* does — so these are deliberately tiny.
|
|
37
|
+
*/
|
|
38
|
+
const SAMPLES: Record<string, string> = {
|
|
39
|
+
typescript: '// warm\nconst value: number = 1;\nfunction read(input: string) {\n return input;\n}\n',
|
|
40
|
+
tsx: '// warm\nexport const View = ({ title }: { title: string }) => (\n <div className="row">{title}</div>\n);\n',
|
|
41
|
+
javascript: '// warm\nconst value = 1;\nfunction read(input) {\n return input;\n}\n',
|
|
42
|
+
jsx: '// warm\nexport const View = ({ title }) => (\n <div className="row">{title}</div>\n);\n',
|
|
43
|
+
json: '{\n "name": "warm",\n "items": [1, 2, 3],\n "ok": true\n}\n',
|
|
44
|
+
bash: '#!/bin/sh\n# warm\nset -e\nfor file in *.ts; do\n echo "$file"\ndone\n',
|
|
45
|
+
python: '# warm\ndef read(value: str) -> str:\n return value.strip()\n',
|
|
46
|
+
markdown: '# warm\n\nA paragraph with **bold** text and a `code` span.\n\n- item\n',
|
|
47
|
+
yaml: 'name: warm\nitems:\n - one\n - two\nenabled: true\n',
|
|
48
|
+
diff: '--- a/file.ts\n+++ b/file.ts\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context\n',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const DEFAULT_SAMPLE = '// warm\nconst value = 1;\nfunction read(input) {\n return input;\n}\n';
|
|
52
|
+
|
|
53
|
+
/** How many idle slices a not-yet-loaded grammar is retried for. */
|
|
54
|
+
const MAX_WAIT_SLICES = 120;
|
|
55
|
+
|
|
56
|
+
export interface WarmOptions {
|
|
57
|
+
/** Languages to warm; defaults to the eager set. */
|
|
58
|
+
languages?: readonly string[];
|
|
59
|
+
/** The tokenizer; injected so a test can observe the schedule. */
|
|
60
|
+
highlight?: (code: string, language: string) => void;
|
|
61
|
+
/** Grammar readiness; injected for the same reason. */
|
|
62
|
+
ready?: (language: string) => boolean;
|
|
63
|
+
/** The idle scheduler; injected so a test can drive it synchronously. */
|
|
64
|
+
schedule?: (run: () => void) => () => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let started = false;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Warm every eager grammar once per page load, one per idle slice.
|
|
71
|
+
*
|
|
72
|
+
* Idempotent: a second call is a no-op, so a re-evaluated client entry cannot
|
|
73
|
+
* re-tokenize every language.
|
|
74
|
+
*/
|
|
75
|
+
export function warmSyntaxTokenizers(options: WarmOptions = {}): void {
|
|
76
|
+
if (started) return;
|
|
77
|
+
if (typeof window === 'undefined') return;
|
|
78
|
+
started = true;
|
|
79
|
+
|
|
80
|
+
const languages = options.languages ?? EAGER_LANGS;
|
|
81
|
+
const highlight = options.highlight ?? ((code: string, language: string) => { highlightBlock(code, language); });
|
|
82
|
+
const ready = options.ready ?? isLanguageReady;
|
|
83
|
+
const schedule = options.schedule ?? ((run: () => void) => scheduleIdle(run));
|
|
84
|
+
|
|
85
|
+
const queue = [...languages];
|
|
86
|
+
let waiting = 0;
|
|
87
|
+
const step = () => {
|
|
88
|
+
const language = queue.shift();
|
|
89
|
+
if (language === undefined) return;
|
|
90
|
+
if (!ready(language)) {
|
|
91
|
+
// The grammar is still being fetched. Warming it now would compile
|
|
92
|
+
// nothing and the sample would not even be cacheable.
|
|
93
|
+
if (++waiting <= MAX_WAIT_SLICES) queue.push(language);
|
|
94
|
+
schedule(step);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
waiting = 0;
|
|
98
|
+
try {
|
|
99
|
+
highlight(SAMPLES[language] ?? DEFAULT_SAMPLE, language);
|
|
100
|
+
} catch {
|
|
101
|
+
// A grammar that cannot warm is not an error: the first real highlight
|
|
102
|
+
// falls back to escaped text and retries when the grammar lands.
|
|
103
|
+
}
|
|
104
|
+
schedule(step);
|
|
105
|
+
};
|
|
106
|
+
schedule(step);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Test/restart seam: forget that the warm-up already ran. */
|
|
110
|
+
export function resetTokenizerWarmup(): void {
|
|
111
|
+
started = false;
|
|
112
|
+
}
|
|
@@ -69,7 +69,9 @@ async function boot(): Promise<HighlighterCore | null> {
|
|
|
69
69
|
notify();
|
|
70
70
|
// Grammars arrive on demand (`ensureLanguage`); the eager set is only a
|
|
71
71
|
// head start for the languages a chat session hits immediately, and boot
|
|
72
|
-
// does not wait on it.
|
|
72
|
+
// does not wait on it. `main.tsx` pays the tokenizer's compile cost once
|
|
73
|
+
// the eager set has landed — loading a grammar is not compiling it, and the
|
|
74
|
+
// first card expand used to pay that compile inside the tap.
|
|
73
75
|
void ensureLanguages(highlighter, EAGER_LANGS);
|
|
74
76
|
return highlighter;
|
|
75
77
|
} catch (error) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Run work when the main thread is next free.
|
|
8
|
+
*
|
|
9
|
+
* Tokenizing a tool card's code is the expensive half of expanding it — measured
|
|
10
|
+
* at 730–1484 ms on a throttled phone for a `read`/`grep` card — and running it
|
|
11
|
+
* inside the render that a tap scheduled put all of that in the tap's own task:
|
|
12
|
+
* the card appeared only after the grammar had walked every line. Deferring it
|
|
13
|
+
* costs one plain-text frame and moves the work out of the interaction.
|
|
14
|
+
*
|
|
15
|
+
* `requestIdleCallback` is the browser's own "nothing else is waiting" signal;
|
|
16
|
+
* the timeout is a FLOOR on latency rather than a budget, so a busy page still
|
|
17
|
+
* paints the colour within a quarter second. Runtimes without it (happy-dom,
|
|
18
|
+
* older Safari) fall back to the next macrotask.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const IDLE_TIMEOUT_MS = 250;
|
|
22
|
+
|
|
23
|
+
type IdleHost = {
|
|
24
|
+
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
|
|
25
|
+
cancelIdleCallback?: (handle: number) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Schedule `run`; the returned function cancels it. */
|
|
29
|
+
export function scheduleIdle(run: () => void, timeoutMs: number = IDLE_TIMEOUT_MS): () => void {
|
|
30
|
+
const host = globalThis as unknown as IdleHost;
|
|
31
|
+
if (typeof host.requestIdleCallback === 'function') {
|
|
32
|
+
const handle = host.requestIdleCallback(run, { timeout: timeoutMs });
|
|
33
|
+
return () => host.cancelIdleCallback?.(handle);
|
|
34
|
+
}
|
|
35
|
+
const handle = setTimeout(run, 0);
|
|
36
|
+
return () => clearTimeout(handle);
|
|
37
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The outline's two bounds.
|
|
8
|
+
*
|
|
9
|
+
* Both exist because a large file's outline is not a long list, it is a DOM:
|
|
10
|
+
* measured on a 10 MB source with 63 618 declarations, the panel mounted
|
|
11
|
+
* 446 308 nodes and a keystroke cost 953 ms (against the editor's own 34 ms).
|
|
12
|
+
* These cases pin the arithmetic that keeps that from happening, and the
|
|
13
|
+
* sentences that stop a truncated list from reading as a complete one.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, test } from 'bun:test';
|
|
17
|
+
import {
|
|
18
|
+
MAX_OUTLINE_ROWS,
|
|
19
|
+
MAX_OUTLINE_SCAN_CHARS,
|
|
20
|
+
outlineTooLargeNotice,
|
|
21
|
+
outlineTruncatedNotice,
|
|
22
|
+
outlineWindow,
|
|
23
|
+
} from '@/shared/lib/code/outline/rows';
|
|
24
|
+
import type { OutlineSymbol } from '@/shared/lib/code/outline/types';
|
|
25
|
+
|
|
26
|
+
function symbols(count: number): OutlineSymbol[] {
|
|
27
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
28
|
+
name: `fn${i}`,
|
|
29
|
+
kind: 'function' as const,
|
|
30
|
+
line: i + 1,
|
|
31
|
+
column: 0,
|
|
32
|
+
depth: 0,
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('outlineWindow', () => {
|
|
37
|
+
test('a list under the cap is returned whole, in source order', () => {
|
|
38
|
+
const all = symbols(5);
|
|
39
|
+
const window = outlineWindow(all);
|
|
40
|
+
|
|
41
|
+
expect(window.rows).toEqual(all);
|
|
42
|
+
expect(window.total).toBe(5);
|
|
43
|
+
expect(window.truncated).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('exactly the cap is not truncated', () => {
|
|
47
|
+
const window = outlineWindow(symbols(MAX_OUTLINE_ROWS));
|
|
48
|
+
|
|
49
|
+
expect(window.rows).toHaveLength(MAX_OUTLINE_ROWS);
|
|
50
|
+
expect(window.truncated).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('past the cap the list is the FIRST rows, and it says how many there are', () => {
|
|
54
|
+
const window = outlineWindow(symbols(MAX_OUTLINE_ROWS + 120));
|
|
55
|
+
|
|
56
|
+
expect(window.rows).toHaveLength(MAX_OUTLINE_ROWS);
|
|
57
|
+
expect(window.total).toBe(MAX_OUTLINE_ROWS + 120);
|
|
58
|
+
expect(window.truncated).toBe(true);
|
|
59
|
+
// Source order, not a sample: the outline is a map of the document, so the
|
|
60
|
+
// top of the file is what a window shows.
|
|
61
|
+
expect(window.rows[0].name).toBe('fn0');
|
|
62
|
+
expect(window.rows[MAX_OUTLINE_ROWS - 1].name).toBe(`fn${MAX_OUTLINE_ROWS - 1}`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('an empty list is not truncated', () => {
|
|
66
|
+
const window = outlineWindow([]);
|
|
67
|
+
|
|
68
|
+
expect(window.rows).toEqual([]);
|
|
69
|
+
expect(window.total).toBe(0);
|
|
70
|
+
expect(window.truncated).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('outlineTooLargeNotice', () => {
|
|
75
|
+
test('a buffer at the scan ceiling is still outlined', () => {
|
|
76
|
+
expect(outlineTooLargeNotice(MAX_OUTLINE_SCAN_CHARS)).toBeNull();
|
|
77
|
+
expect(outlineTooLargeNotice(1024)).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('past it the sentence names the size, so the empty panel is explained', () => {
|
|
81
|
+
const notice = outlineTooLargeNotice(MAX_OUTLINE_SCAN_CHARS + 1);
|
|
82
|
+
|
|
83
|
+
expect(notice).not.toBeNull();
|
|
84
|
+
expect(notice).toContain('8.0 MB');
|
|
85
|
+
expect(notice).toContain('too large to outline');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('outlineTruncatedNotice', () => {
|
|
90
|
+
test('it names both the shown count and the total, thousands separated', () => {
|
|
91
|
+
const notice = outlineTruncatedNotice(63618);
|
|
92
|
+
|
|
93
|
+
expect(notice).toContain('500');
|
|
94
|
+
expect(notice).toContain('63,618');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How much of an outline a panel is allowed to draw, and when it is not drawn
|
|
8
|
+
* at all.
|
|
9
|
+
*
|
|
10
|
+
* Both bounds exist because a large file's outline is not a long list — it is a
|
|
11
|
+
* DOM. Measured in the browser on a 10 MB source file with 63 618 declarations:
|
|
12
|
+
* **446 308 DOM nodes, 415 MB of JS heap, and 953 ms per keystroke** while the
|
|
13
|
+
* panel was on screen (the editor's own cost is 34 ms). Expanding the section
|
|
14
|
+
* took 2.7 s. The scan is not the problem — it is 366 ms in Bun, once per pause
|
|
15
|
+
* — the ROWS are.
|
|
16
|
+
*
|
|
17
|
+
* Two answers, and they are different questions:
|
|
18
|
+
*
|
|
19
|
+
* - **`MAX_OUTLINE_ROWS` caps the list.** An outline is a map you scan by eye or
|
|
20
|
+
* jump from; nobody reads 63 000 rows, and the panel says how many it is not
|
|
21
|
+
* showing rather than silently truncating. Same shape as the search panel's
|
|
22
|
+
* `MAX_SEARCH_RESULTS`.
|
|
23
|
+
* - **`MAX_OUTLINE_SCAN_CHARS` skips the scan above a size where it costs more
|
|
24
|
+
* than the list is worth.** At 10 MB a scan is ~366 ms of blocked main thread
|
|
25
|
+
* every time the buffer settles, which is a stutter per typing pause. The
|
|
26
|
+
* sentence says so instead of leaving the panel empty, which would read as
|
|
27
|
+
* "this file has no declarations".
|
|
28
|
+
*
|
|
29
|
+
* The size is counted in CODE UNITS (`content.length`), not bytes: measuring
|
|
30
|
+
* UTF-8 bytes would mean encoding the whole buffer to answer a guard, and for
|
|
31
|
+
* the ASCII that dominates source files the two numbers are the same. A file of
|
|
32
|
+
* multibyte characters therefore trips the guard slightly early — the safe
|
|
33
|
+
* direction.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type { OutlineSymbol } from '@/shared/lib/code/outline/types';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Rows a panel renders. Past this the list is truncated with a count, not
|
|
40
|
+
* silently shortened.
|
|
41
|
+
*/
|
|
42
|
+
export const MAX_OUTLINE_ROWS = 500;
|
|
43
|
+
|
|
44
|
+
/** Buffer size above which the outline is not scanned at all. */
|
|
45
|
+
/**
|
|
46
|
+
* Buffer size above which the outline is not scanned at all.
|
|
47
|
+
*
|
|
48
|
+
* The scan is one blocked stretch of main thread after each typing pause, and it
|
|
49
|
+
* measured 38 ms at 1 MB, 160 ms at 5 MB, 262 ms at 10 MB and 519 ms at 20 MB —
|
|
50
|
+
* linear in the buffer. 8 MiB keeps that pause under ~210 ms, which is less than
|
|
51
|
+
* the debounce that schedules it and therefore reads as "the list settled"
|
|
52
|
+
* rather than as a stutter.
|
|
53
|
+
*
|
|
54
|
+
* Past it the panel says the file is too large to outline instead of scanning
|
|
55
|
+
* it: a declaration list for a 30 MB file is not worth a half-second freeze on
|
|
56
|
+
* every pause, and an empty list would read as "no declarations here".
|
|
57
|
+
*/
|
|
58
|
+
export const MAX_OUTLINE_SCAN_CHARS = 8 * 1024 * 1024;
|
|
59
|
+
|
|
60
|
+
export interface OutlineWindow {
|
|
61
|
+
/** The rows to render — at most `MAX_OUTLINE_ROWS`. */
|
|
62
|
+
rows: OutlineSymbol[];
|
|
63
|
+
/** Symbols the scan actually found, including the ones not rendered. */
|
|
64
|
+
total: number;
|
|
65
|
+
/** True when `rows` is shorter than `total`. */
|
|
66
|
+
truncated: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The rows a panel should render, and whether it is showing all of them.
|
|
71
|
+
*
|
|
72
|
+
* Source order is preserved: an outline is a map of a document, so the first N
|
|
73
|
+
* declarations are the top of the file rather than an arbitrary sample.
|
|
74
|
+
*/
|
|
75
|
+
export function outlineWindow(symbols: readonly OutlineSymbol[]): OutlineWindow {
|
|
76
|
+
const total = symbols.length;
|
|
77
|
+
if (total <= MAX_OUTLINE_ROWS) return { rows: symbols as OutlineSymbol[], total, truncated: false };
|
|
78
|
+
return { rows: symbols.slice(0, MAX_OUTLINE_ROWS), total, truncated: true };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The sentence printed under a truncated list. */
|
|
82
|
+
export function outlineTruncatedNotice(total: number): string {
|
|
83
|
+
return `Showing the first ${MAX_OUTLINE_ROWS.toLocaleString()} of ${total.toLocaleString()} symbols.`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The sentence a panel prints instead of rows when the buffer is past the scan
|
|
88
|
+
* ceiling, or null when it is not.
|
|
89
|
+
*/
|
|
90
|
+
export function outlineTooLargeNotice(contentLength: number): string | null {
|
|
91
|
+
if (contentLength <= MAX_OUTLINE_SCAN_CHARS) return null;
|
|
92
|
+
const mb = contentLength / (1024 * 1024);
|
|
93
|
+
return `This file is ${mb.toFixed(1)} MB — too large to outline.`;
|
|
94
|
+
}
|
|
@@ -6,10 +6,9 @@
|
|
|
6
6
|
import { beforeAll, describe, expect, test } from 'bun:test';
|
|
7
7
|
|
|
8
8
|
import { bootSyntax, onLanguageReady, requestLanguage } from '@/shared/lib/code/highlighter';
|
|
9
|
-
import { findMatches } from '@/shared/lib/code/editor/find';
|
|
9
|
+
import { findMatches } from '@/shared/lib/code/editor/find-scan';
|
|
10
10
|
import { getLanguageFromPath } from '@/shared/lib/code/language';
|
|
11
11
|
import { highlightCode, highlightLines } from '@/shared/lib/code/syntax-highlight';
|
|
12
|
-
import { highlightCodeWindow } from '@/shared/lib/code/windowed-highlight';
|
|
13
12
|
|
|
14
13
|
/** Shiki emits `<span class="shiki">…` with dual-theme CSS variables per token. */
|
|
15
14
|
const SHIKI_RE = /<span class="shiki">/;
|
|
@@ -115,54 +114,6 @@ describe('highlightCode', () => {
|
|
|
115
114
|
});
|
|
116
115
|
});
|
|
117
116
|
|
|
118
|
-
describe('highlightCodeWindow', () => {
|
|
119
|
-
const source = Array.from({ length: 120 }, (_, index) =>
|
|
120
|
-
index === 0 ? 'export const first = 1;' : `export const value${index} = ${index}; // line ${index}`,
|
|
121
|
-
).join('\n');
|
|
122
|
-
|
|
123
|
-
/** The wrapper the app's `.shiki span` colour rule depends on. */
|
|
124
|
-
const unwrap = (html: string) => html.replace(/^<span class="shiki">/, '').replace(/<\/span>$/, '');
|
|
125
|
-
|
|
126
|
-
test('wraps the window in the class the token colours are scoped to', () => {
|
|
127
|
-
const window = highlightCodeWindow(source, 'typescript', { start: 0, end: 3 });
|
|
128
|
-
|
|
129
|
-
// Without this class no `.shiki span` rule matches, so the tokens would
|
|
130
|
-
// render in the plain ink colour.
|
|
131
|
-
expect(window).toMatch(SHIKI_RE);
|
|
132
|
-
expect(window.endsWith('</span>')).toBe(true);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test('returns only the requested lines, joined as the editor renders them', () => {
|
|
136
|
-
const window = highlightCodeWindow(source, 'typescript', { start: 10, end: 13 });
|
|
137
|
-
const rows = highlightLines(source, 'typescript');
|
|
138
|
-
|
|
139
|
-
expect(unwrap(window).split('\n')).toEqual(rows.slice(10, 13));
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test('keeps multi-line grammar state from the lines above the window', () => {
|
|
143
|
-
const commented = ['/*', ' * a block comment', ' * spanning several lines', ' */', 'const after = 1;'].join('\n');
|
|
144
|
-
const rows = highlightLines(commented, 'typescript');
|
|
145
|
-
// Line 2 sits inside the comment, whose opening line is outside the window.
|
|
146
|
-
const window = highlightCodeWindow(commented, 'typescript', { start: 2, end: 4 });
|
|
147
|
-
|
|
148
|
-
expect(unwrap(window).split('\n')).toEqual(rows.slice(2, 4));
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test('renders an over-long line as plain text instead of tokenizing it', () => {
|
|
152
|
-
const minified = `const bundled = "${'x'.repeat(200)}";\nconst next = 1;`;
|
|
153
|
-
const window = highlightCodeWindow(minified, 'javascript', { start: 0, end: 2 }, { maxLineLength: 50 });
|
|
154
|
-
|
|
155
|
-
expect(window).toContain('const bundled');
|
|
156
|
-
expect(unwrap(window).split('\n')[0]).not.toContain('--shiki-light:');
|
|
157
|
-
expect(unwrap(window).split('\n')[1]).toContain('--shiki-light:');
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test('returns nothing for a window outside the document', () => {
|
|
161
|
-
expect(highlightCodeWindow('const a = 1;', 'typescript', { start: 5, end: 9 })).toBe('');
|
|
162
|
-
expect(highlightCodeWindow('const a = 1;', 'typescript', { start: 0, end: 0 })).toBe('');
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
|
|
166
117
|
describe('find marks', () => {
|
|
167
118
|
const code = 'const alpha = 1;\nconst beta = alpha + 2;\n';
|
|
168
119
|
const PLAIN = { matchCase: false, wholeWord: false, isRegex: false };
|
|
@@ -206,35 +157,6 @@ describe('find marks', () => {
|
|
|
206
157
|
expect(plain).not.toContain('<mark');
|
|
207
158
|
});
|
|
208
159
|
|
|
209
|
-
test('keeps a windowed match on its own line, re-based to the slice', () => {
|
|
210
|
-
const marks = findMatches(code, 'alpha', PLAIN).matches;
|
|
211
|
-
// The window renders line 1 only. The match on line 0 must not reappear on
|
|
212
|
-
// line 1's text, and the current index (line 0's match) must not mark
|
|
213
|
-
// anything here.
|
|
214
|
-
const window = highlightCodeWindow(code, 'typescript', { start: 1, end: 2 }, { marks, currentMark: 0 });
|
|
215
|
-
|
|
216
|
-
expect(marked(window).join('')).toBe('alpha');
|
|
217
|
-
expect(window).not.toContain('data-find-current');
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
test('re-bases the current index onto the marks inside the window', () => {
|
|
221
|
-
const marks = findMatches(code, 'alpha', PLAIN).matches;
|
|
222
|
-
const window = highlightCodeWindow(code, 'typescript', { start: 1, end: 2 }, { marks, currentMark: 1 });
|
|
223
|
-
|
|
224
|
-
expect(window.match(/data-find-current/g)?.length).toBe(1);
|
|
225
|
-
expect(marked(window).join('')).toBe('alpha');
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
test('a mark reaching past the window is clipped to it', () => {
|
|
229
|
-
// The last line has no trailing newline, so a mark that runs to the end of
|
|
230
|
-
// the slice would be clamped by the rebase.
|
|
231
|
-
const window = highlightCodeWindow(code, 'typescript', { start: 1, end: 3 }, {
|
|
232
|
-
marks: [{ start: 30, end: 999 }],
|
|
233
|
-
currentMark: 0,
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
expect(marked(window).join('')).toBe('alpha + 2;');
|
|
237
|
-
});
|
|
238
160
|
});
|
|
239
161
|
|
|
240
162
|
describe('getLanguageFromPath', () => {
|
|
@@ -168,12 +168,11 @@ export function highlightLines(
|
|
|
168
168
|
const rawLines = code.split('\n');
|
|
169
169
|
const marks = options.marks;
|
|
170
170
|
const currentMark = options.currentMark ?? -1;
|
|
171
|
-
const occurrenceFrom = options.occurrenceFrom ?? -1;
|
|
172
171
|
// A grammar that has not arrived yet, or a highlighter that failed to boot,
|
|
173
172
|
// still gets the find marks: the widget's feedback must not depend on Shiki.
|
|
174
173
|
const plain = (lineIndex: number): string => {
|
|
175
174
|
if (!marks || marks.length === 0) return escapeCode(rawLines[lineIndex]);
|
|
176
|
-
return tokenMarkup(rawLines[lineIndex], lineStartOffset(code, lineIndex), marks, currentMark
|
|
175
|
+
return tokenMarkup(rawLines[lineIndex], lineStartOffset(code, lineIndex), marks, currentMark);
|
|
177
176
|
};
|
|
178
177
|
if (!code) return rawLines;
|
|
179
178
|
const hl = getHighlighterSync();
|
|
@@ -191,7 +190,7 @@ export function highlightLines(
|
|
|
191
190
|
let offset = lineStartOffset(code, lineIndex);
|
|
192
191
|
return lineTokens
|
|
193
192
|
.map((tok) => {
|
|
194
|
-
const content = tokenMarkup(tok.content, offset, marks, currentMark
|
|
193
|
+
const content = tokenMarkup(tok.content, offset, marks, currentMark);
|
|
195
194
|
offset += tok.content.length;
|
|
196
195
|
if (!tok.htmlStyle) return content;
|
|
197
196
|
const style = Object.entries(tok.htmlStyle)
|