@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,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One `EditorState` per open document, applied with `view.setState`.
|
|
8
|
+
*
|
|
9
|
+
* This is what the tab model actually needs and what the textarea surface could
|
|
10
|
+
* only fake: a tab's caret, its extra cursors and its UNDO HISTORY belong to the
|
|
11
|
+
* tab, not to the panel. Measured in CM6: switching states and coming back
|
|
12
|
+
* restores all three, and an undo in one tab cannot touch another — with the
|
|
13
|
+
* single buffer the panel used to hold, `⌘Z` after a tab switch undid an edit in
|
|
14
|
+
* the file the user was no longer looking at.
|
|
15
|
+
*
|
|
16
|
+
* The cache is bounded. A session can open a dozen files and each history holds
|
|
17
|
+
* whole-document snapshots, so the least recently used state is dropped past
|
|
18
|
+
* `MAX_CACHED`. Losing a closed tab's history is the intended cost; losing an
|
|
19
|
+
* OPEN tab's is not, which is why the drop is LRU rather than oldest-first.
|
|
20
|
+
*
|
|
21
|
+
* `wordWrap` and `readOnly` are per-VIEW settings, so they live in compartments
|
|
22
|
+
* this module owns. A cached state is NOT reconfigured in place: `setState`
|
|
23
|
+
* installs that state's own configuration, so the caller re-applies the current
|
|
24
|
+
* options after every swap (`optionsEffects`). Reconfiguring in the cache too
|
|
25
|
+
* would be a second place the same answer is stored.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { Compartment, EditorState, type Extension, type StateEffect } from '@codemirror/state';
|
|
29
|
+
|
|
30
|
+
import type { EditorCommand } from '@/shared/lib/code/editor/keymap';
|
|
31
|
+
import { readOnlyExtension, surfaceExtensions, wrapExtension } from '@/client/components/common/code-surface/extensions';
|
|
32
|
+
import { shikiHighlight, type ShikiThemeNames } from '@/client/components/common/code-surface/shiki';
|
|
33
|
+
|
|
34
|
+
/** States kept alive; past this the least recently used one is dropped. */
|
|
35
|
+
const MAX_CACHED = 12;
|
|
36
|
+
|
|
37
|
+
export interface SurfaceOptions {
|
|
38
|
+
wordWrap: boolean;
|
|
39
|
+
readOnly: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* The tokenizer's two theme names.
|
|
42
|
+
*
|
|
43
|
+
* Part of the live options rather than of the document key: two tabs in
|
|
44
|
+
* different languages are two states (their decorations are built from
|
|
45
|
+
* different grammars), but a THEME is one setting for every tab — a new key
|
|
46
|
+
* per theme would throw away every open tab's undo history on a colour change.
|
|
47
|
+
*/
|
|
48
|
+
themes: ShikiThemeNames;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DocumentCacheDeps extends SurfaceOptions {
|
|
52
|
+
/** Panel-owned commands the editor delegates instead of running. */
|
|
53
|
+
onCommand?: (command: EditorCommand) => void;
|
|
54
|
+
/** Every user edit, with the whole new document. */
|
|
55
|
+
onDocChange: (value: string) => void;
|
|
56
|
+
/** Every state worth remembering (an edit or a selection move). */
|
|
57
|
+
onState: (state: EditorState) => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DocumentCache {
|
|
61
|
+
/** The state for `key`, created (and cached) from `value` when absent. */
|
|
62
|
+
stateFor(key: string, language: string, value: string): EditorState;
|
|
63
|
+
/** Store the live state so a switch back finds it. */
|
|
64
|
+
remember(key: string, state: EditorState): void;
|
|
65
|
+
/** Drop a closed tab's history. */
|
|
66
|
+
drop(key: string): void;
|
|
67
|
+
/**
|
|
68
|
+
* Effects that bring a view to the current options; empty when nothing moved.
|
|
69
|
+
*
|
|
70
|
+
* `key` names the document the effects will be dispatched to, because the
|
|
71
|
+
* tokenizer is per-LANGUAGE: a theme reconfigure must rebuild it from that
|
|
72
|
+
* document's own grammar, not from whichever one the cache saw last.
|
|
73
|
+
*/
|
|
74
|
+
optionsEffects(next: SurfaceOptions, key?: string): StateEffect<unknown>[];
|
|
75
|
+
/**
|
|
76
|
+
* Effects that install the CURRENT options unconditionally.
|
|
77
|
+
*
|
|
78
|
+
* A cached state carries the configuration it was created with, and
|
|
79
|
+
* `view.setState` installs that configuration wholesale — so a tab opened
|
|
80
|
+
* while wrap was off came back unwrapped after a toggle. This is the re-apply
|
|
81
|
+
* a document switch owes.
|
|
82
|
+
*/
|
|
83
|
+
currentEffects(key?: string): StateEffect<unknown>[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whether two theme pairs name the same Shiki themes. */
|
|
87
|
+
function sameThemes(a: ShikiThemeNames | undefined, b: ShikiThemeNames): boolean {
|
|
88
|
+
return a !== undefined && a.light === b.light && a.dark === b.dark;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createDocumentCache(deps: DocumentCacheDeps): DocumentCache {
|
|
92
|
+
const states = new Map<string, EditorState>();
|
|
93
|
+
const options: SurfaceOptions = {
|
|
94
|
+
wordWrap: deps.wordWrap,
|
|
95
|
+
readOnly: deps.readOnly,
|
|
96
|
+
themes: deps.themes,
|
|
97
|
+
};
|
|
98
|
+
const wordWrapCompartment = new Compartment();
|
|
99
|
+
const readOnlyCompartment = new Compartment();
|
|
100
|
+
const themeCompartment = new Compartment();
|
|
101
|
+
/** The language each cached state was built for, so a theme reconfigure can
|
|
102
|
+
* rebuild its tokenizer with the SAME grammar. */
|
|
103
|
+
const languages = new Map<string, string>();
|
|
104
|
+
/**
|
|
105
|
+
* The theme pair each cached state's tokenizer currently holds.
|
|
106
|
+
*
|
|
107
|
+
* Tracked because a theme reconfigure REBUILDS the decoration plugin, which
|
|
108
|
+
* re-tokenizes the viewport. `currentEffects` runs on every tab switch, and
|
|
109
|
+
* paying that for a state that already has the right theme is work for
|
|
110
|
+
* nothing — 2.1 ms on a 20 000-line file, every switch.
|
|
111
|
+
*/
|
|
112
|
+
const themesByKey = new Map<string, ShikiThemeNames>();
|
|
113
|
+
|
|
114
|
+
const extensionsFor = (language: string): Extension[] =>
|
|
115
|
+
surfaceExtensions({
|
|
116
|
+
language,
|
|
117
|
+
wordWrapCompartment,
|
|
118
|
+
readOnlyCompartment,
|
|
119
|
+
themeCompartment,
|
|
120
|
+
onCommand: deps.onCommand,
|
|
121
|
+
onDocChange: deps.onDocChange,
|
|
122
|
+
onState: deps.onState,
|
|
123
|
+
...options,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/** Move `key` to the end of the map so the LRU drop takes the right one. */
|
|
127
|
+
const touch = (key: string, state: EditorState): void => {
|
|
128
|
+
states.delete(key);
|
|
129
|
+
states.set(key, state);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const evict = (): void => {
|
|
133
|
+
while (states.size > MAX_CACHED) {
|
|
134
|
+
const oldest = states.keys().next();
|
|
135
|
+
if (oldest.done) return;
|
|
136
|
+
states.delete(oldest.value);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
stateFor(key, language, value) {
|
|
142
|
+
const cached = states.get(key);
|
|
143
|
+
if (cached) {
|
|
144
|
+
touch(key, cached);
|
|
145
|
+
return cached;
|
|
146
|
+
}
|
|
147
|
+
const state = EditorState.create({ doc: value, extensions: extensionsFor(language) });
|
|
148
|
+
states.set(key, state);
|
|
149
|
+
languages.set(key, language);
|
|
150
|
+
themesByKey.set(key, options.themes);
|
|
151
|
+
evict();
|
|
152
|
+
return state;
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
remember(key, state) {
|
|
156
|
+
touch(key, state);
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
drop(key) {
|
|
160
|
+
states.delete(key);
|
|
161
|
+
languages.delete(key);
|
|
162
|
+
themesByKey.delete(key);
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
optionsEffects(next, key) {
|
|
166
|
+
const wrapChanged = next.wordWrap !== options.wordWrap;
|
|
167
|
+
const readOnlyChanged = next.readOnly !== options.readOnly;
|
|
168
|
+
const themeChanged = next.themes.light !== options.themes.light || next.themes.dark !== options.themes.dark;
|
|
169
|
+
options.wordWrap = next.wordWrap;
|
|
170
|
+
options.readOnly = next.readOnly;
|
|
171
|
+
options.themes = next.themes;
|
|
172
|
+
const effects: StateEffect<unknown>[] = [];
|
|
173
|
+
if (wrapChanged) effects.push(wordWrapCompartment.reconfigure(wrapExtension(options.wordWrap)));
|
|
174
|
+
if (readOnlyChanged) effects.push(readOnlyCompartment.reconfigure(readOnlyExtension(options.readOnly)));
|
|
175
|
+
// The plugin carries the language, so the tokenizer is rebuilt from THIS
|
|
176
|
+
// document's grammar. Other cached states are handled by `currentEffects`
|
|
177
|
+
// when they are applied, which is the moment they need it.
|
|
178
|
+
const language = key ? languages.get(key) : undefined;
|
|
179
|
+
if (themeChanged && language) {
|
|
180
|
+
effects.push(themeCompartment.reconfigure(shikiHighlight(language, options.themes)));
|
|
181
|
+
if (key) themesByKey.set(key, options.themes);
|
|
182
|
+
}
|
|
183
|
+
return effects;
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
currentEffects(key) {
|
|
187
|
+
const effects: StateEffect<unknown>[] = [
|
|
188
|
+
wordWrapCompartment.reconfigure(wrapExtension(options.wordWrap)),
|
|
189
|
+
readOnlyCompartment.reconfigure(readOnlyExtension(options.readOnly)),
|
|
190
|
+
];
|
|
191
|
+
const language = key ? languages.get(key) : undefined;
|
|
192
|
+
// Only when this state's tokenizer still holds another theme: a switch
|
|
193
|
+
// back to a tab that already has it must not re-tokenize.
|
|
194
|
+
if (key && language && !sameThemes(themesByKey.get(key), options.themes)) {
|
|
195
|
+
effects.push(themeCompartment.reconfigure(shikiHighlight(language, options.themes)));
|
|
196
|
+
themesByKey.set(key, options.themes);
|
|
197
|
+
}
|
|
198
|
+
return effects;
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The editor's extension stack, and the one thing in it that fails SILENTLY.
|
|
8
|
+
*
|
|
9
|
+
* `toggleComment` reads its tokens out of the `languageData` facet, and
|
|
10
|
+
* `languageDataAt` looks the name up in each provider's RESULT — so a provider
|
|
11
|
+
* that returns the tokens bare (`() => [{ line: '//' }]`) yields an empty list,
|
|
12
|
+
* the command returns `false`, and ⌘/ does nothing at all. Measured: the line
|
|
13
|
+
* stayed uncommented in TypeScript and in YAML, with no error anywhere. These
|
|
14
|
+
* cases assert the command's own effect on a state built from the same stack the
|
|
15
|
+
* component mounts.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, test } from 'bun:test';
|
|
19
|
+
import { Compartment, EditorSelection, EditorState } from '@codemirror/state';
|
|
20
|
+
import { toggleComment } from '@codemirror/commands';
|
|
21
|
+
|
|
22
|
+
import { readOnlyExtension, surfaceExtensions, wrapExtension } from '@/client/components/common/code-surface/extensions';
|
|
23
|
+
import { SHIKI_THEMES } from '@/shared/lib/code/shiki-themes';
|
|
24
|
+
|
|
25
|
+
/** A state from the component's own stack, with a bare caret on line 1. */
|
|
26
|
+
function stateFor(language: string, doc = 'const alpha = 1;\n') {
|
|
27
|
+
return EditorState.create({
|
|
28
|
+
doc,
|
|
29
|
+
extensions: surfaceExtensions({
|
|
30
|
+
language,
|
|
31
|
+
wordWrapCompartment: new Compartment(),
|
|
32
|
+
readOnlyCompartment: new Compartment(),
|
|
33
|
+
themeCompartment: new Compartment(),
|
|
34
|
+
wordWrap: false,
|
|
35
|
+
readOnly: false,
|
|
36
|
+
themes: SHIKI_THEMES,
|
|
37
|
+
onDocChange: () => {},
|
|
38
|
+
onState: () => {},
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Apply `toggleComment` the way a `Command` would, returning the new text. */
|
|
44
|
+
function toggled(state: EditorState): string {
|
|
45
|
+
let next = state;
|
|
46
|
+
toggleComment({ state, dispatch: (transaction) => { next = transaction.state; } });
|
|
47
|
+
return next.doc.toString();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('comment tokens', () => {
|
|
51
|
+
test('toggling comments a TypeScript line with //', () => {
|
|
52
|
+
expect(toggled(stateFor('typescript'))).toBe('// const alpha = 1;\n');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('toggling uses the language’s own token, not a hard-coded //', () => {
|
|
56
|
+
// A `//` in YAML is a syntax error, which is what makes this the case worth
|
|
57
|
+
// pinning: the table has to reach the command.
|
|
58
|
+
expect(toggled(stateFor('yaml', 'key: value\n'))).toBe('# key: value\n');
|
|
59
|
+
expect(toggled(stateFor('sql', 'select 1;\n'))).toBe('-- select 1;\n');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a block-only language is wrapped instead of prefixed', () => {
|
|
63
|
+
expect(toggled(stateFor('css', 'a { color: red; }\n'))).toContain('/*');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('a second toggle restores the line', () => {
|
|
67
|
+
const once = toggled(stateFor('typescript'));
|
|
68
|
+
const twice = toggled(stateFor('typescript', once));
|
|
69
|
+
expect(twice).toBe('const alpha = 1;\n');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('read-only', () => {
|
|
74
|
+
test('both halves are installed, so neither typing nor a dispatch can edit', () => {
|
|
75
|
+
const state = EditorState.create({
|
|
76
|
+
doc: 'x',
|
|
77
|
+
extensions: [readOnlyExtension(true)],
|
|
78
|
+
});
|
|
79
|
+
// `readOnly` is what stops the commands; `editable` is what stops the DOM.
|
|
80
|
+
expect(state.readOnly).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('an editable surface installs neither', () => {
|
|
84
|
+
const state = EditorState.create({ doc: 'x', extensions: [readOnlyExtension(false)] });
|
|
85
|
+
expect(state.readOnly).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('wrap', () => {
|
|
90
|
+
test('wrapping is a compartment payload, so it can be swapped without a re-mount', () => {
|
|
91
|
+
const compartment = new Compartment();
|
|
92
|
+
const state = EditorState.create({ doc: 'x', extensions: [compartment.of(wrapExtension(true))] });
|
|
93
|
+
const unwrapped = state.update({ effects: compartment.reconfigure(wrapExtension(false)) }).state;
|
|
94
|
+
// Same document and selection: the reconfigure must not disturb either.
|
|
95
|
+
expect(unwrapped.doc.toString()).toBe('x');
|
|
96
|
+
expect(unwrapped.selection.main.head).toBe(state.selection.main.head);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('the selection survives a wrap toggle', () => {
|
|
100
|
+
const compartment = new Compartment();
|
|
101
|
+
const state = EditorState.create({
|
|
102
|
+
doc: 'alpha beta',
|
|
103
|
+
selection: EditorSelection.range(0, 5),
|
|
104
|
+
extensions: [compartment.of(wrapExtension(false))],
|
|
105
|
+
});
|
|
106
|
+
const wrapped = state.update({ effects: compartment.reconfigure(wrapExtension(true)) }).state;
|
|
107
|
+
expect(wrapped.selection.main.from).toBe(0);
|
|
108
|
+
expect(wrapped.selection.main.to).toBe(5);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The code surface's extension stack, in one place.
|
|
8
|
+
*
|
|
9
|
+
* Split from the component because the mount and the configuration are two
|
|
10
|
+
* different concerns, and because this is the list a reader checks when asking
|
|
11
|
+
* "what does this editor do". Each entry is here for a reason worth stating:
|
|
12
|
+
*
|
|
13
|
+
* - `history()` replaces the hand-rolled undo stack. It is per `EditorState`,
|
|
14
|
+
* and every document gets its own (see `documents.ts`), so history is already
|
|
15
|
+
* isolated per tab.
|
|
16
|
+
* - `drawSelection()` paints the selection and the multi-cursor bands. Without
|
|
17
|
+
* it a second cursor from `⌘D` is a state change with nothing on screen.
|
|
18
|
+
* - `rectangularSelection` / `crosshairCursor` are CM6's own answer to a column
|
|
19
|
+
* selection; the textarea surface could not do one at all.
|
|
20
|
+
* - `languageData` carries the comment tokens, which is the whole of what
|
|
21
|
+
* `toggleComment` needs — no lezer grammar, no syntax tree. It is read from
|
|
22
|
+
* the same table the old text commands used, so `#` stays right in YAML and
|
|
23
|
+
* `/* *\/` stays right in CSS.
|
|
24
|
+
* - Read-only is BOTH facets: `readOnly` stops the commands, `editable(false)`
|
|
25
|
+
* sets `contenteditable=false`, and only the pair makes a viewer a viewer
|
|
26
|
+
* (measured: `readOnly` alone let a programmatic `dispatch({changes})` through,
|
|
27
|
+
* which a viewer has no business accepting).
|
|
28
|
+
*
|
|
29
|
+
* `wordWrap` and `readOnly` arrive as compartments because they are per-view
|
|
30
|
+
* settings that must reconfigure a live editor without re-creating it — a
|
|
31
|
+
* reconfigure keeps the document, the selection and the history, which is
|
|
32
|
+
* exactly what a wrap toggle must not disturb.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { type Compartment, EditorState, type Extension } from '@codemirror/state';
|
|
36
|
+
import {
|
|
37
|
+
crosshairCursor,
|
|
38
|
+
drawSelection,
|
|
39
|
+
dropCursor,
|
|
40
|
+
EditorView,
|
|
41
|
+
lineNumbers,
|
|
42
|
+
rectangularSelection,
|
|
43
|
+
} from '@codemirror/view';
|
|
44
|
+
import { history } from '@codemirror/commands';
|
|
45
|
+
import { indentUnit } from '@codemirror/language';
|
|
46
|
+
|
|
47
|
+
import { commentSyntaxFor } from '@/shared/lib/code/editor/comment-syntax';
|
|
48
|
+
import type { EditorCommand } from '@/shared/lib/code/editor/keymap';
|
|
49
|
+
import { findMarksField } from '@/client/components/common/code-surface/marks';
|
|
50
|
+
import { surfaceKeymap } from '@/client/components/common/code-surface/keymap';
|
|
51
|
+
import { shikiHighlight, type ShikiThemeNames } from '@/client/components/common/code-surface/shiki';
|
|
52
|
+
import { surfaceTheme } from '@/client/components/common/code-surface/theme';
|
|
53
|
+
|
|
54
|
+
/** Indentation the surface inserts, in spaces. */
|
|
55
|
+
const INDENT = ' ';
|
|
56
|
+
|
|
57
|
+
/** Wrap on: CM6 wraps the content box; the panel no longer measures anything. */
|
|
58
|
+
export function wrapExtension(wordWrap: boolean): Extension {
|
|
59
|
+
return wordWrap ? EditorView.lineWrapping : [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Both halves of read-only, or nothing for an editable surface. */
|
|
63
|
+
export function readOnlyExtension(readOnly: boolean): Extension {
|
|
64
|
+
return readOnly ? [EditorState.readOnly.of(true), EditorView.editable.of(false)] : [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The comment tokens `toggleComment` reads out of `languageData`. */
|
|
68
|
+
function commentTokens(language: string): { line?: string; block?: { open: string; close: string } } {
|
|
69
|
+
const syntax = commentSyntaxFor(language);
|
|
70
|
+
if ('line' in syntax) return { line: syntax.line };
|
|
71
|
+
return { block: { open: syntax.block[0], close: syntax.block[1] } };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface SurfaceExtensionsOptions {
|
|
75
|
+
language: string;
|
|
76
|
+
/** Compartment holding `wrapExtension`. */
|
|
77
|
+
wordWrapCompartment: Compartment;
|
|
78
|
+
/** Compartment holding `readOnlyExtension`. */
|
|
79
|
+
readOnlyCompartment: Compartment;
|
|
80
|
+
/**
|
|
81
|
+
* Compartment holding the tokenizer.
|
|
82
|
+
*
|
|
83
|
+
* The editor theme is baked into the decoration plugin (Shiki is handed the
|
|
84
|
+
* theme NAMES at tokenize time), so a theme change reconfigures this — which
|
|
85
|
+
* rebuilds the plugin while the document, the selection and the history stay
|
|
86
|
+
* exactly where they were.
|
|
87
|
+
*/
|
|
88
|
+
themeCompartment: Compartment;
|
|
89
|
+
wordWrap: boolean;
|
|
90
|
+
readOnly: boolean;
|
|
91
|
+
/** The two Shiki theme names this surface tokenizes with. */
|
|
92
|
+
themes: ShikiThemeNames;
|
|
93
|
+
/** Panel-owned commands the editor delegates instead of running. */
|
|
94
|
+
onCommand?: (command: EditorCommand) => void;
|
|
95
|
+
/** Fired for every user edit, with the whole new document. */
|
|
96
|
+
onDocChange: (value: string) => void;
|
|
97
|
+
/** Fired on every state that must be remembered (an edit or a selection move). */
|
|
98
|
+
onState: (state: EditorState) => void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Everything the editor needs, built once per document state. */
|
|
102
|
+
export function surfaceExtensions({
|
|
103
|
+
language,
|
|
104
|
+
wordWrapCompartment,
|
|
105
|
+
readOnlyCompartment,
|
|
106
|
+
themeCompartment,
|
|
107
|
+
wordWrap,
|
|
108
|
+
readOnly,
|
|
109
|
+
themes,
|
|
110
|
+
onCommand,
|
|
111
|
+
onDocChange,
|
|
112
|
+
onState,
|
|
113
|
+
}: SurfaceExtensionsOptions): Extension[] {
|
|
114
|
+
return [
|
|
115
|
+
lineNumbers(),
|
|
116
|
+
history(),
|
|
117
|
+
drawSelection(),
|
|
118
|
+
dropCursor(),
|
|
119
|
+
rectangularSelection(),
|
|
120
|
+
crosshairCursor(),
|
|
121
|
+
EditorState.allowMultipleSelections.of(true),
|
|
122
|
+
indentUnit.of(INDENT),
|
|
123
|
+
// `toggleComment` with no grammar: the tokens are the entire input it needs.
|
|
124
|
+
// The provider must return an object KEYED by the data name —
|
|
125
|
+
// `languageDataAt` looks the name up in each result, so returning the tokens
|
|
126
|
+
// bare yields an empty list and the command silently does nothing.
|
|
127
|
+
EditorState.languageData.of(() => [{ commentTokens: commentTokens(language) }]),
|
|
128
|
+
findMarksField,
|
|
129
|
+
themeCompartment.of(shikiHighlight(language, themes)),
|
|
130
|
+
surfaceTheme(),
|
|
131
|
+
wordWrapCompartment.of(wrapExtension(wordWrap)),
|
|
132
|
+
readOnlyCompartment.of(readOnlyExtension(readOnly)),
|
|
133
|
+
// The browser's own spellchecker underlines every identifier and offers to
|
|
134
|
+
// "correct" them; autocapitalise/autocorrect mangle code on a phone. CM6
|
|
135
|
+
// sets these itself, and stating them keeps a future default from silently
|
|
136
|
+
// regressing a mobile keyboard.
|
|
137
|
+
EditorView.contentAttributes.of({
|
|
138
|
+
spellcheck: 'false',
|
|
139
|
+
autocorrect: 'off',
|
|
140
|
+
autocapitalize: 'off',
|
|
141
|
+
translate: 'no',
|
|
142
|
+
}),
|
|
143
|
+
EditorView.updateListener.of((update) => {
|
|
144
|
+
// The per-tab cache stores the LIVE state so a switch back finds its
|
|
145
|
+
// caret: a selection move is as much of that state as an edit is.
|
|
146
|
+
if (update.docChanged || update.selectionSet) onState(update.state);
|
|
147
|
+
if (update.docChanged) onDocChange(update.state.doc.toString());
|
|
148
|
+
// The selection as a DOM fact, because a `contenteditable` exposes none.
|
|
149
|
+
// The old surface was a `<textarea>`, whose `selectionStart`/`selectionEnd`
|
|
150
|
+
// and line arithmetic were readable from the element; these two attributes
|
|
151
|
+
// are the same facts for the same readers (the e2e layer's outline and
|
|
152
|
+
// caret specs), and nothing in the app reads them.
|
|
153
|
+
if (update.docChanged || update.selectionSet) {
|
|
154
|
+
const range = update.state.selection.main;
|
|
155
|
+
const attrs = update.view.contentDOM.dataset;
|
|
156
|
+
attrs.selection = `${range.from},${range.to}`;
|
|
157
|
+
attrs.line = String(update.state.doc.lineAt(range.from).number);
|
|
158
|
+
}
|
|
159
|
+
}),
|
|
160
|
+
surfaceKeymap(onCommand, readOnly),
|
|
161
|
+
];
|
|
162
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The imperative surface handle, built from the mount's refs.
|
|
8
|
+
*
|
|
9
|
+
* Split out of `index.tsx` because the component is at the repository's line
|
|
10
|
+
* ceiling and this half is dispatch, not rendering: every method here is "look
|
|
11
|
+
* up the live view, then hand CodeMirror a transaction". Keeping it a factory
|
|
12
|
+
* over the refs (rather than a hook) is what makes the identity stable — the
|
|
13
|
+
* handle is built once for the component's life, so a consumer holding it never
|
|
14
|
+
* re-subscribes.
|
|
15
|
+
*
|
|
16
|
+
* Two rules live here and nowhere else, both measured:
|
|
17
|
+
*
|
|
18
|
+
* - **`applyDocument` is a USER edit.** It is deliberately not silenced as an
|
|
19
|
+
* external write: the find bar's replace has to reach `onValueChange` exactly
|
|
20
|
+
* like typing does, or the parent keeps the pre-replace buffer and the next
|
|
21
|
+
* render writes the OLD text back over it.
|
|
22
|
+
* - **A reveal cancels a pending scroll restore.** The restore's
|
|
23
|
+
* `ResizeObserver` can run BEFORE the scroll event a reveal's dispatch causes,
|
|
24
|
+
* so it would write the remembered offset back and the view would never move.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { EditorView } from '@codemirror/view';
|
|
28
|
+
import type { MutableRef } from '@/client/components/common/code-surface/sync';
|
|
29
|
+
import { cancelScrollRestore } from '@/client/hooks/editor/use-scroll-restore';
|
|
30
|
+
import { runSurfaceCommand } from '@/client/components/common/code-surface/keymap';
|
|
31
|
+
import type { EditorCommand } from '@/shared/lib/code/editor/keymap';
|
|
32
|
+
|
|
33
|
+
/** The imperative surface every consumer of the editor holds. */
|
|
34
|
+
export interface CodeSurfaceHandle {
|
|
35
|
+
/** Current selection offsets in the buffer, or null before the view mounts. */
|
|
36
|
+
getSelection(): { start: number; end: number } | null;
|
|
37
|
+
/** Select `[start, end)`; `focus` moves keyboard focus into the editor. */
|
|
38
|
+
select(start: number, end: number, focus?: boolean): void;
|
|
39
|
+
/** Replace the buffer as one undoable edit, caret at `[caretStart, caretEnd)`, focus unchanged. */
|
|
40
|
+
applyDocument(value: string, caretStart: number, caretEnd: number): void;
|
|
41
|
+
/** Run an editor command by name (the palette's path into the editor). */
|
|
42
|
+
runCommand(command: EditorCommand): void;
|
|
43
|
+
/**
|
|
44
|
+
* Bring the line holding `offset` into view WITHOUT moving the caret — what
|
|
45
|
+
* "step to the next match" needs: the user's focus stays in the find field
|
|
46
|
+
* while the match scrolls under it.
|
|
47
|
+
*/
|
|
48
|
+
revealOffset(offset: number): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SurfaceHandleRefs {
|
|
52
|
+
view: MutableRef<EditorView | null>;
|
|
53
|
+
/** The live `onCommand`, mirrored so the handle never captures a stale one. */
|
|
54
|
+
onCommand: MutableRef<((command: EditorCommand) => void) | undefined>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The handle a `forwardRef` caller receives. Built once per mount. */
|
|
58
|
+
export function createSurfaceHandle({ view: viewRef, onCommand: onCommandRef }: SurfaceHandleRefs): CodeSurfaceHandle {
|
|
59
|
+
return {
|
|
60
|
+
getSelection: () => {
|
|
61
|
+
const view = viewRef.current;
|
|
62
|
+
if (!view) return null;
|
|
63
|
+
const range = view.state.selection.main;
|
|
64
|
+
return { start: range.from, end: range.to };
|
|
65
|
+
},
|
|
66
|
+
select: (start, end, focus = true) => {
|
|
67
|
+
const view = viewRef.current;
|
|
68
|
+
if (!view) return;
|
|
69
|
+
view.dispatch({ selection: { anchor: start, head: end }, scrollIntoView: false });
|
|
70
|
+
// `focus()` is CM6's own `focusPreventScroll`, which is what stops the
|
|
71
|
+
// browser scrolling the caret into view a second time — the caller's
|
|
72
|
+
// `revealOffset` is the only thing that scrolls.
|
|
73
|
+
if (focus) view.focus();
|
|
74
|
+
},
|
|
75
|
+
applyDocument: (next, caretStart, caretEnd) => {
|
|
76
|
+
const view = viewRef.current;
|
|
77
|
+
if (!view) return;
|
|
78
|
+
// One transaction, so ⌘Z undoes the whole replacement: a replace-all that
|
|
79
|
+
// took one undo step per match is what the old surface had to hand-roll a
|
|
80
|
+
// history for.
|
|
81
|
+
view.dispatch({
|
|
82
|
+
changes: { from: 0, to: view.state.doc.length, insert: next },
|
|
83
|
+
selection: { anchor: caretStart, head: caretEnd },
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
runCommand: (command) => {
|
|
87
|
+
const view = viewRef.current;
|
|
88
|
+
if (!view) return;
|
|
89
|
+
runSurfaceCommand(command, view, onCommandRef.current);
|
|
90
|
+
},
|
|
91
|
+
revealOffset: (offset) => {
|
|
92
|
+
const view = viewRef.current;
|
|
93
|
+
if (!view) return;
|
|
94
|
+
const at = Math.max(0, Math.min(offset, view.state.doc.length));
|
|
95
|
+
cancelScrollRestore(view.scrollDOM);
|
|
96
|
+
// `y: 'nearest'` is the rule the old surface hand-rolled: a reveal only
|
|
97
|
+
// scrolls when the target is outside the visible band, so stepping
|
|
98
|
+
// through matches does not jump the whole view on every step.
|
|
99
|
+
view.dispatch({ effects: EditorView.scrollIntoView(at, { y: 'nearest', yMargin: 24 }) });
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|