@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
|
@@ -55,6 +55,8 @@ function makeCallbacks(overrides: Partial<OmpAgentCallbacksDeps> = {}): OmpAgent
|
|
|
55
55
|
appSettings: {},
|
|
56
56
|
enqueueExtensionDialog: () => {},
|
|
57
57
|
withdrawExtensionDialog: () => {},
|
|
58
|
+
reloadMessages: () => {},
|
|
59
|
+
needsReloadRef: { current: false },
|
|
58
60
|
...overrides,
|
|
59
61
|
};
|
|
60
62
|
return createOmpAgentCallbacks(deps);
|
|
@@ -133,6 +135,40 @@ describe('createOmpAgentCallbacks run boundaries', () => {
|
|
|
133
135
|
expect(verbs).toEqual(['Thinking']);
|
|
134
136
|
});
|
|
135
137
|
|
|
138
|
+
test('a run that lost frames re-reads its transcript when it settles', () => {
|
|
139
|
+
// A reconnect while the run was STILL streaming cannot reload: its rows are
|
|
140
|
+
// the stream's, and replacing them mid-turn is the lagging-snapshot bug.
|
|
141
|
+
// But the segments that finished during the outage have no other way back —
|
|
142
|
+
// their `message_end` went into the dead socket, and `agent_end` only
|
|
143
|
+
// materializes turns that stopped abnormally. So the loss is remembered and
|
|
144
|
+
// the read happens at the settle.
|
|
145
|
+
const reads: number[] = [];
|
|
146
|
+
const needsReloadRef = { current: true };
|
|
147
|
+
const callbacks = recordingCallbacks({
|
|
148
|
+
reloadMessages: () => { reads.push(1); },
|
|
149
|
+
needsReloadRef,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
callbacks.onAgentEnd?.({});
|
|
153
|
+
|
|
154
|
+
expect(reads.length).toBe(1);
|
|
155
|
+
// Consumed: a later run end must not re-read a transcript nothing lost.
|
|
156
|
+
expect(needsReloadRef.current).toBe(false);
|
|
157
|
+
callbacks.onAgentEnd?.({});
|
|
158
|
+
expect(reads.length).toBe(1);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('an ordinary run end does NOT re-read the transcript', () => {
|
|
162
|
+
// The read is a full parse of the session JSONL; a run that never lost a
|
|
163
|
+
// frame must not pay it.
|
|
164
|
+
const reads: number[] = [];
|
|
165
|
+
const callbacks = recordingCallbacks({ reloadMessages: () => { reads.push(1); } });
|
|
166
|
+
|
|
167
|
+
callbacks.onAgentEnd?.({});
|
|
168
|
+
|
|
169
|
+
expect(reads).toEqual([]);
|
|
170
|
+
});
|
|
171
|
+
|
|
136
172
|
test('a real omp frame sequence drives the boundary once per run', () => {
|
|
137
173
|
const callbacks = recordingCallbacks();
|
|
138
174
|
const deps = makeFoldDeps(callbacks);
|
|
@@ -51,6 +51,14 @@ export interface OmpAgentCallbacksDeps {
|
|
|
51
51
|
enqueueExtensionDialog: (request: ExtensionUiDialogRequest) => void;
|
|
52
52
|
/** Drop a queued request omp withdrew (`method: 'cancel'`). */
|
|
53
53
|
withdrawExtensionDialog: (targetId: string) => void;
|
|
54
|
+
/** Re-read the committed transcript (see `lost-run.ts`). Used at `agent_end`
|
|
55
|
+
* when frames were lost mid-run: the segments that finished during the
|
|
56
|
+
* outage have no other way back. */
|
|
57
|
+
reloadMessages: () => void;
|
|
58
|
+
/** Set when a run lost frames while STILL STREAMING. Its earlier segments can
|
|
59
|
+
* only be recovered by a full read, which is safe only once the run is over —
|
|
60
|
+
* so the read happens here, at the settle. */
|
|
61
|
+
needsReloadRef: { current: boolean };
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCallbacks {
|
|
@@ -70,6 +78,8 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
|
|
|
70
78
|
appSettings,
|
|
71
79
|
enqueueExtensionDialog,
|
|
72
80
|
withdrawExtensionDialog,
|
|
81
|
+
reloadMessages,
|
|
82
|
+
needsReloadRef,
|
|
73
83
|
} = deps;
|
|
74
84
|
|
|
75
85
|
bindStreamingCoalescer(setLocalMessages, () => scrollToBottom('smooth'));
|
|
@@ -245,6 +255,14 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
|
|
|
245
255
|
// placeholder until the realtime scan surfaces the row.
|
|
246
256
|
const sid = adoptedSessionIdRef.current ?? sessionIdRef.current;
|
|
247
257
|
if (sid) refreshSessionMeta(sid);
|
|
258
|
+
// A run that lost frames while still streaming: its earlier segments (a
|
|
259
|
+
// tool card, a thinking block, an earlier answer) have no other way back.
|
|
260
|
+
// Read now — the run is over, so a full transcript read cannot clobber a
|
|
261
|
+
// live row — and `reloadMessages` retries while omp has not flushed.
|
|
262
|
+
if (needsReloadRef.current) {
|
|
263
|
+
needsReloadRef.current = false;
|
|
264
|
+
reloadMessages();
|
|
265
|
+
}
|
|
248
266
|
},
|
|
249
267
|
onModelChanged: () => {
|
|
250
268
|
// omp's model_changed frame carries no payload — re-read the session
|
|
@@ -56,6 +56,8 @@ function deps(overrides: Partial<OmpAgentCallbacksDeps> = {}): OmpAgentCallbacks
|
|
|
56
56
|
appSettings: {},
|
|
57
57
|
enqueueExtensionDialog: () => {},
|
|
58
58
|
withdrawExtensionDialog: () => {},
|
|
59
|
+
reloadMessages: () => {},
|
|
60
|
+
needsReloadRef: { current: false },
|
|
59
61
|
...overrides,
|
|
60
62
|
};
|
|
61
63
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The comment tokens `toggleComment` reads out of `languageData`.
|
|
8
|
+
*
|
|
9
|
+
* A wrong token is not a cosmetic bug: `//` inserted into YAML is a syntax
|
|
10
|
+
* error, and a `//` inserted into CSS comments out nothing while breaking the
|
|
11
|
+
* rule it was added to. The three shapes are asserted, plus the default that
|
|
12
|
+
* every C-family grammar relies on.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
|
|
17
|
+
import { commentSyntaxFor } from '@/shared/lib/code/editor/comment-syntax';
|
|
18
|
+
|
|
19
|
+
describe('commentSyntaxFor', () => {
|
|
20
|
+
test('defaults to a line comment for the C-family', () => {
|
|
21
|
+
expect(commentSyntaxFor('typescript')).toEqual({ line: '//' });
|
|
22
|
+
expect(commentSyntaxFor('rust')).toEqual({ line: '//' });
|
|
23
|
+
expect(commentSyntaxFor('not-a-language')).toEqual({ line: '//' });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('names the languages whose line comment is not `//`', () => {
|
|
27
|
+
expect(commentSyntaxFor('python')).toEqual({ line: '#' });
|
|
28
|
+
expect(commentSyntaxFor('yaml')).toEqual({ line: '#' });
|
|
29
|
+
expect(commentSyntaxFor('sql')).toEqual({ line: '--' });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('wraps the languages that have only block comments', () => {
|
|
33
|
+
expect(commentSyntaxFor('css')).toEqual({ block: ['/*', '*/'] });
|
|
34
|
+
expect(commentSyntaxFor('html')).toEqual({ block: ['<!--', '-->'] });
|
|
35
|
+
expect(commentSyntaxFor('markdown')).toEqual({ block: ['<!--', '-->'] });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How each language writes a line comment.
|
|
8
|
+
*
|
|
9
|
+
* The only thing the editor still needs from the old text-command layer:
|
|
10
|
+
* CodeMirror's `toggleComment` reads its tokens out of the `languageData` facet
|
|
11
|
+
* and needs no syntax tree, so this table IS the whole of the comment feature.
|
|
12
|
+
* Everything else the old module did (line moves, duplicates, deletes, inserts,
|
|
13
|
+
* indent, occurrences, edit replication) is a CM6 command now.
|
|
14
|
+
*
|
|
15
|
+
* The default is `//`, which is right for every C-family grammar. The exceptions
|
|
16
|
+
* are the ones a wrong token would visibly break — `#` in YAML is a syntax
|
|
17
|
+
* error, not a comment — and a language that has ONLY block comments gets the
|
|
18
|
+
* wrapper, because a `//` inserted into CSS comments out nothing and breaks the
|
|
19
|
+
* rule it was added to.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A line-comment syntax: a prefix per line, or a wrapper for block-only languages. */
|
|
23
|
+
export type CommentSyntax = { line: string } | { block: readonly [string, string] };
|
|
24
|
+
|
|
25
|
+
const COMMENT_SYNTAX: Record<string, CommentSyntax> = {
|
|
26
|
+
python: { line: '#' },
|
|
27
|
+
bash: { line: '#' },
|
|
28
|
+
yaml: { line: '#' },
|
|
29
|
+
toml: { line: '#' },
|
|
30
|
+
ruby: { line: '#' },
|
|
31
|
+
perl: { line: '#' },
|
|
32
|
+
r: { line: '#' },
|
|
33
|
+
makefile: { line: '#' },
|
|
34
|
+
dockerfile: { line: '#' },
|
|
35
|
+
ini: { line: '#' },
|
|
36
|
+
properties: { line: '#' },
|
|
37
|
+
elixir: { line: '#' },
|
|
38
|
+
graphql: { line: '#' },
|
|
39
|
+
nix: { line: '#' },
|
|
40
|
+
sql: { line: '--' },
|
|
41
|
+
lua: { line: '--' },
|
|
42
|
+
haskell: { line: '--' },
|
|
43
|
+
css: { block: ['/*', '*/'] },
|
|
44
|
+
scss: { block: ['/*', '*/'] },
|
|
45
|
+
less: { block: ['/*', '*/'] },
|
|
46
|
+
sass: { block: ['/*', '*/'] },
|
|
47
|
+
stylus: { block: ['/*', '*/'] },
|
|
48
|
+
html: { block: ['<!--', '-->'] },
|
|
49
|
+
xml: { block: ['<!--', '-->'] },
|
|
50
|
+
markdown: { block: ['<!--', '-->'] },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function commentSyntaxFor(language: string): CommentSyntax {
|
|
54
|
+
return COMMENT_SYNTAX[language] ?? { line: '//' };
|
|
55
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Replacement expansion: what one match becomes, and how a template is read.
|
|
8
|
+
*
|
|
9
|
+
* Each rule is asserted against the engine's own `String.replace` where one
|
|
10
|
+
* exists, because the search panel's replace PREVIEW and the write that lands
|
|
11
|
+
* must agree — a preview that painted `$1` over a file that would receive the
|
|
12
|
+
* captured group is exactly the drift this covers.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
expandReplacement,
|
|
19
|
+
replaceAllMatches,
|
|
20
|
+
replaceMatch,
|
|
21
|
+
} from '@/shared/lib/code/editor/find-replace';
|
|
22
|
+
import type { FindOptions } from '@/shared/lib/code/editor/find-scan';
|
|
23
|
+
import { findMatches } from '@/shared/lib/code/editor/find-scan';
|
|
24
|
+
|
|
25
|
+
const PLAIN: FindOptions = { matchCase: false, wholeWord: false, isRegex: false };
|
|
26
|
+
|
|
27
|
+
describe('replaceMatch', () => {
|
|
28
|
+
test('literal replacement inserts the field verbatim, dollar signs included', () => {
|
|
29
|
+
const text = 'const a = 1;';
|
|
30
|
+
const result = replaceMatch(text, { start: 6, end: 7 }, 'a', '$&x', PLAIN);
|
|
31
|
+
expect(result?.text).toBe('const $&x = 1;');
|
|
32
|
+
expect(result?.caret).toBe(6 + 3);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('regex replacement expands capture groups like String.replace', () => {
|
|
36
|
+
const text = 'key = value';
|
|
37
|
+
const result = replaceMatch(text, { start: 6, end: 11 }, '(\\w+)', '[$1]', { ...PLAIN, isRegex: true });
|
|
38
|
+
expect(result?.text).toBe('key = [value]');
|
|
39
|
+
expect(result?.caret).toBe(13);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('refuses a match that is no longer at its offset', () => {
|
|
43
|
+
expect(replaceMatch('abc', { start: 5, end: 7 }, 'a', 'z', PLAIN)).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('replaceAllMatches', () => {
|
|
48
|
+
test('literal replace-all rewrites every occurrence', () => {
|
|
49
|
+
expect(replaceAllMatches('a a a', 'a', 'b', PLAIN)).toBe('b b b');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('regex replace-all agrees with the engine', () => {
|
|
53
|
+
const text = 'foo1 bar2';
|
|
54
|
+
const options: FindOptions = { ...PLAIN, isRegex: true };
|
|
55
|
+
expect(replaceAllMatches(text, '([a-z]+)(\\d)', '$2-$1', options)).toBe(text.replace(/([a-z]+)(\d)/g, '$2-$1'));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('whole-word replace-all leaves substrings alone', () => {
|
|
59
|
+
expect(replaceAllMatches('cat category', 'cat', 'dog', { ...PLAIN, wholeWord: true })).toBe('dog category');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('returns null for an unusable query', () => {
|
|
63
|
+
expect(replaceAllMatches('abc', '(', 'x', { ...PLAIN, isRegex: true })).toBeNull();
|
|
64
|
+
expect(replaceAllMatches('abc', '', 'x', PLAIN)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The write path and the match list must be the SAME answer.
|
|
70
|
+
*
|
|
71
|
+
* They were not, and every case below was silent: the scan runs through
|
|
72
|
+
* CodeMirror's `RegExpCursor` (`gm` + `u`) while the replacement compiled the
|
|
73
|
+
* query itself (`g`/`gi`), so the count in the widget and the bytes written
|
|
74
|
+
* disagreed about anchors, unicode property escapes and which escapes are even
|
|
75
|
+
* legal. `find-scan.ts` now states the flags once (`BASE_FLAGS`), and these
|
|
76
|
+
* assert the two ends together rather than one engine in isolation.
|
|
77
|
+
*/
|
|
78
|
+
describe('the write path agrees with the scan', () => {
|
|
79
|
+
const REGEX: FindOptions = { ...PLAIN, isRegex: true };
|
|
80
|
+
|
|
81
|
+
test('an anchored pattern rewrites every line the scan counted', () => {
|
|
82
|
+
const text = 'foo\nbar\nfoo';
|
|
83
|
+
expect(findMatches(text, '^foo', REGEX).matches).toHaveLength(2);
|
|
84
|
+
expect(replaceAllMatches(text, '^foo', 'X', REGEX)).toBe('X\nbar\nX');
|
|
85
|
+
|
|
86
|
+
expect(findMatches(text, 'foo$', REGEX).matches).toHaveLength(2);
|
|
87
|
+
expect(replaceAllMatches(text, 'foo$', 'X', REGEX)).toBe('X\nbar\nX');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a unicode property escape finds AND replaces', () => {
|
|
91
|
+
const text = 'a1 b2';
|
|
92
|
+
expect(findMatches(text, '\\p{L}\\d', REGEX).matches).toHaveLength(2);
|
|
93
|
+
expect(replaceAllMatches(text, '\\p{L}\\d', 'X', REGEX)).toBe('X X');
|
|
94
|
+
expect(replaceMatch(text, { start: 0, end: 2 }, '\\p{L}\\d', 'X', REGEX)?.text).toBe('X b2');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('an escape the scan calls invalid is refused by the write too', () => {
|
|
98
|
+
// `\-` is a legal identity escape without `u` and a syntax error with it.
|
|
99
|
+
expect(findMatches('a-b', '\\-', REGEX).invalid).toBe(true);
|
|
100
|
+
expect(replaceAllMatches('a-b', '\\-', 'X', REGEX)).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a zero-width match is replaced by nothing, exactly as it is uncounted', () => {
|
|
104
|
+
// The widget says "No results" for `^`; inserting at every line start under
|
|
105
|
+
// it was the write contradicting the count it was shown beside.
|
|
106
|
+
expect(findMatches('a\nb', '^', REGEX).matches).toEqual([]);
|
|
107
|
+
expect(replaceAllMatches('a\nb', '^', 'X', REGEX)).toBe('a\nb');
|
|
108
|
+
expect(replaceAllMatches('abc', 'x*', 'X', REGEX)).toBe('abc');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('expandReplacement', () => {
|
|
113
|
+
/** Indexed by group number, `[0]` unused — the shape `groupsOf` produces. */
|
|
114
|
+
const groups = { full: 'ab12', numbered: [undefined, 'a', 'b'] as const, named: { tail: '12' } };
|
|
115
|
+
|
|
116
|
+
test('expands $&, $1 and $<name>', () => {
|
|
117
|
+
expect(expandReplacement('$&|$1|$2|$<tail>', groups)).toBe('ab12|a|b|12');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('$$ is a literal dollar and an unknown group is empty', () => {
|
|
121
|
+
expect(expandReplacement('$$$9', groups)).toBe('$');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('a missing two-digit group falls back to the one-digit group plus the digit', () => {
|
|
125
|
+
// The engine's own reading of `$12` with two groups: `$1` then a literal `2`.
|
|
126
|
+
expect('x'.replace(/(x)/, '$12')).toBe('x2');
|
|
127
|
+
expect(expandReplacement('$12', groups)).toBe('a2');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('a template without a dollar is returned as-is', () => {
|
|
131
|
+
expect(expandReplacement('plain', groups)).toBe('plain');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
@@ -3,117 +3,23 @@
|
|
|
3
3
|
* SPDX-License-Identifier: Apache-2.0
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { lineIndexAt, lineStartOffset, type TextRange } from '@/shared/lib/code/editor/lines';
|
|
7
|
-
|
|
8
|
-
export { lineIndexAt, lineStartOffset };
|
|
9
|
-
|
|
10
6
|
/**
|
|
11
|
-
*
|
|
12
|
-
* find widget.
|
|
7
|
+
* Replacement expansion, split from the scan.
|
|
13
8
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
export interface FindOptions {
|
|
23
|
-
matchCase: boolean;
|
|
24
|
-
wholeWord: boolean;
|
|
25
|
-
isRegex: boolean;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Half-open `[start, end)` offset range in the document. */
|
|
29
|
-
export type FindMatch = TextRange;
|
|
30
|
-
|
|
31
|
-
export interface FindResult {
|
|
32
|
-
matches: FindMatch[];
|
|
33
|
-
/** The document holds more matches than the cap; the list stops at it. */
|
|
34
|
-
truncated: boolean;
|
|
35
|
-
/** A regex query that does not compile — a different answer from "no matches". */
|
|
36
|
-
invalid: boolean;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Ceiling on collected matches. A one-character query over a large file matches
|
|
41
|
-
* tens of thousands of times and every match costs a Range in the highlight
|
|
42
|
-
* registry, so past this the list stops and the widget reports `N+`.
|
|
43
|
-
*/
|
|
44
|
-
export const MAX_FIND_MATCHES = 5_000;
|
|
45
|
-
|
|
46
|
-
export const DEFAULT_FIND_OPTIONS: FindOptions = {
|
|
47
|
-
matchCase: false,
|
|
48
|
-
wholeWord: false,
|
|
49
|
-
isRegex: false,
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* The pattern a query compiles to, or null when the query is empty or does not
|
|
54
|
-
* compile. A literal query is escaped here rather than at the call site so the
|
|
55
|
-
* match scan and the replacement splice can never disagree about what the
|
|
56
|
-
* pattern was.
|
|
9
|
+
* The scan (`find-scan.ts`) answers "where does this query match"; this answers
|
|
10
|
+
* "what does one match become". They are separate because the search panel's
|
|
11
|
+
* replace PREVIEW and the write that actually lands must read ONE expansion
|
|
12
|
+
* rule — a row that painted `$1` literally over a file that would receive the
|
|
13
|
+
* captured group is the drift this module exists to prevent — while the scan has
|
|
14
|
+
* a different consumer (the find bar's match list and count).
|
|
57
15
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
16
|
+
* The template grammar is `String.prototype.replace`'s for a string replacement:
|
|
17
|
+
* `$$`, `$&`, `$1`–`$99` and `$<name>`. The prefix/suffix forms (`` $` `` /
|
|
18
|
+
* `$'`) are deliberately left literal — nothing in the UI offers them, and
|
|
19
|
+
* leaving the two characters alone is the honest reading.
|
|
60
20
|
*/
|
|
61
|
-
export function buildFindRegex(query: string, options: FindOptions): RegExp | null {
|
|
62
|
-
if (!query) return null;
|
|
63
|
-
const source = options.isRegex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
64
|
-
const wrapped = options.wholeWord ? `\\b(?:${source})\\b` : source;
|
|
65
|
-
try {
|
|
66
|
-
return new RegExp(wrapped, options.matchCase ? 'g' : 'gi');
|
|
67
|
-
} catch {
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
21
|
|
|
72
|
-
|
|
73
|
-
* Every match of `query` in `text`, in document order.
|
|
74
|
-
*
|
|
75
|
-
* Zero-width matches (`^`, `\b`, `a*`) are skipped: they have nothing to
|
|
76
|
-
* highlight and nothing to replace, and stepping past them is what keeps the
|
|
77
|
-
* scan terminating.
|
|
78
|
-
*/
|
|
79
|
-
export function findMatches(text: string, query: string, options: FindOptions): FindResult {
|
|
80
|
-
const regex = buildFindRegex(query, options);
|
|
81
|
-
if (!query) return { matches: [], truncated: false, invalid: false };
|
|
82
|
-
if (!regex) return { matches: [], truncated: false, invalid: true };
|
|
83
|
-
|
|
84
|
-
const matches: FindMatch[] = [];
|
|
85
|
-
let match: RegExpExecArray | null;
|
|
86
|
-
while ((match = regex.exec(text)) !== null) {
|
|
87
|
-
if (match[0].length === 0) {
|
|
88
|
-
regex.lastIndex += 1;
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
matches.push({ start: match.index, end: match.index + match[0].length });
|
|
92
|
-
if (matches.length >= MAX_FIND_MATCHES) return { matches, truncated: true, invalid: false };
|
|
93
|
-
}
|
|
94
|
-
return { matches, truncated: false, invalid: false };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* The match to call "current" for a caret/anchor at `offset`: the first one
|
|
99
|
-
* that ends after it, so a caret sitting inside a match still counts as being
|
|
100
|
-
* on that match. Wraps to the first match when the anchor is past the last one,
|
|
101
|
-
* and reports -1 for an empty list.
|
|
102
|
-
*/
|
|
103
|
-
export function matchIndexAtOrAfter(matches: readonly FindMatch[], offset: number): number {
|
|
104
|
-
if (matches.length === 0) return -1;
|
|
105
|
-
for (let index = 0; index < matches.length; index++) {
|
|
106
|
-
if (matches[index].end > offset) return index;
|
|
107
|
-
}
|
|
108
|
-
return 0;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Step one match forward (`1`) or back (`-1`), wrapping at either end. */
|
|
112
|
-
export function stepMatchIndex(index: number, count: number, direction: 1 | -1): number {
|
|
113
|
-
if (count <= 0) return -1;
|
|
114
|
-
if (index < 0) return direction === 1 ? 0 : count - 1;
|
|
115
|
-
return (index + direction + count) % count;
|
|
116
|
-
}
|
|
22
|
+
import { buildFindRegex, type FindMatch, type FindOptions } from '@/shared/lib/code/editor/find-scan';
|
|
117
23
|
|
|
118
24
|
/** What a replacement template is expanded against. */
|
|
119
25
|
export interface MatchGroups {
|
|
@@ -210,7 +116,16 @@ export function replaceMatch(
|
|
|
210
116
|
};
|
|
211
117
|
}
|
|
212
118
|
|
|
213
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* Replace every match. Null when the query is empty or does not compile.
|
|
121
|
+
*
|
|
122
|
+
* A ZERO-WIDTH match is left alone, exactly as the scan leaves it out of the
|
|
123
|
+
* list: `^`, `$`, `\b` and `a*` have nothing to highlight and nothing to
|
|
124
|
+
* replace, so a `^` query reads "No results" in the widget — and rewriting every
|
|
125
|
+
* line start under it was the write disagreeing with the count it was shown
|
|
126
|
+
* next to. (A non-empty literal query can never match zero characters, so this
|
|
127
|
+
* only ever applies to regex mode.)
|
|
128
|
+
*/
|
|
214
129
|
export function replaceAllMatches(
|
|
215
130
|
text: string,
|
|
216
131
|
query: string,
|
|
@@ -221,6 +136,7 @@ export function replaceAllMatches(
|
|
|
221
136
|
if (!regex) return null;
|
|
222
137
|
if (!options.isRegex) return text.replace(regex, () => replacement);
|
|
223
138
|
return text.replace(regex, (...args: unknown[]) => {
|
|
139
|
+
if (String(args[0]).length === 0) return '';
|
|
224
140
|
// A replacer's arguments are `(match, …groups, offset, string, groups?)`;
|
|
225
141
|
// rebuilding an exec result from them keeps one expansion rule for both
|
|
226
142
|
// the single and the bulk path.
|
|
@@ -4,28 +4,27 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* The find widget's
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* The find widget's scan: what a query matches and how the caret anchors to it.
|
|
8
|
+
*
|
|
9
|
+
* The scan runs on CodeMirror's `RegExpCursor`, so these cases are the CONTRACT
|
|
10
|
+
* that cursor has to keep — the same ordered list, the same case/whole-word/regex
|
|
11
|
+
* options, a broken pattern reported as `invalid` rather than as "no matches",
|
|
12
|
+
* and the cap. They are asserted here rather than through a live editor because
|
|
13
|
+
* the widget, the count and the painted marks all read this one function.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
import { describe, expect, test } from 'bun:test';
|
|
15
17
|
|
|
16
18
|
import {
|
|
17
19
|
buildFindRegex,
|
|
18
|
-
expandReplacement,
|
|
19
20
|
findMatches,
|
|
20
21
|
lineIndexAt,
|
|
21
22
|
lineStartOffset,
|
|
22
23
|
matchIndexAtOrAfter,
|
|
23
24
|
MAX_FIND_MATCHES,
|
|
24
|
-
replaceAllMatches,
|
|
25
|
-
replaceMatch,
|
|
26
25
|
stepMatchIndex,
|
|
27
26
|
type FindOptions,
|
|
28
|
-
} from '@/shared/lib/code/editor/find';
|
|
27
|
+
} from '@/shared/lib/code/editor/find-scan';
|
|
29
28
|
|
|
30
29
|
const PLAIN: FindOptions = { matchCase: false, wholeWord: false, isRegex: false };
|
|
31
30
|
|
|
@@ -83,6 +82,17 @@ describe('findMatches', () => {
|
|
|
83
82
|
expect(result.invalid).toBe(false);
|
|
84
83
|
});
|
|
85
84
|
|
|
85
|
+
test('finds a match that begins at a line start, and one that spans lines', () => {
|
|
86
|
+
// The cursor searches line by line, so both edges are where it can lose a
|
|
87
|
+
// match: a pattern anchored at a line start, and one whose `\n` forces the
|
|
88
|
+
// whole-document path.
|
|
89
|
+
expect(findMatches('one\ntwo\ntwo', 'two', PLAIN).matches).toEqual([
|
|
90
|
+
{ start: 4, end: 7 },
|
|
91
|
+
{ start: 8, end: 11 },
|
|
92
|
+
]);
|
|
93
|
+
expect(findMatches('one\ntwo', 'one\ntwo', { ...PLAIN, isRegex: true }).matches).toEqual([{ start: 0, end: 7 }]);
|
|
94
|
+
});
|
|
95
|
+
|
|
86
96
|
test('caps the collected matches and says so', () => {
|
|
87
97
|
const text = 'a'.repeat(MAX_FIND_MATCHES + 10);
|
|
88
98
|
const result = findMatches(text, 'a', PLAIN);
|
|
@@ -104,70 +114,6 @@ describe('buildFindRegex', () => {
|
|
|
104
114
|
});
|
|
105
115
|
});
|
|
106
116
|
|
|
107
|
-
describe('replaceMatch', () => {
|
|
108
|
-
test('literal replacement inserts the field verbatim, dollar signs included', () => {
|
|
109
|
-
const text = 'const a = 1;';
|
|
110
|
-
const result = replaceMatch(text, { start: 6, end: 7 }, 'a', '$&x', PLAIN);
|
|
111
|
-
expect(result?.text).toBe('const $&x = 1;');
|
|
112
|
-
expect(result?.caret).toBe(6 + 3);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test('regex replacement expands capture groups like String.replace', () => {
|
|
116
|
-
const text = 'key = value';
|
|
117
|
-
const result = replaceMatch(text, { start: 6, end: 11 }, '(\\w+)', '[$1]', { ...PLAIN, isRegex: true });
|
|
118
|
-
expect(result?.text).toBe('key = [value]');
|
|
119
|
-
expect(result?.caret).toBe(13);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test('refuses a match that is no longer at its offset', () => {
|
|
123
|
-
expect(replaceMatch('abc', { start: 5, end: 7 }, 'a', 'z', PLAIN)).toBeNull();
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
describe('replaceAllMatches', () => {
|
|
128
|
-
test('literal replace-all rewrites every occurrence', () => {
|
|
129
|
-
expect(replaceAllMatches('a a a', 'a', 'b', PLAIN)).toBe('b b b');
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test('regex replace-all agrees with the engine', () => {
|
|
133
|
-
const text = 'foo1 bar2';
|
|
134
|
-
const options: FindOptions = { ...PLAIN, isRegex: true };
|
|
135
|
-
expect(replaceAllMatches(text, '([a-z]+)(\\d)', '$2-$1', options)).toBe(text.replace(/([a-z]+)(\d)/g, '$2-$1'));
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test('whole-word replace-all leaves substrings alone', () => {
|
|
139
|
-
expect(replaceAllMatches('cat category', 'cat', 'dog', { ...PLAIN, wholeWord: true })).toBe('dog category');
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test('returns null for an unusable query', () => {
|
|
143
|
-
expect(replaceAllMatches('abc', '(', 'x', { ...PLAIN, isRegex: true })).toBeNull();
|
|
144
|
-
expect(replaceAllMatches('abc', '', 'x', PLAIN)).toBeNull();
|
|
145
|
-
});
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
describe('expandReplacement', () => {
|
|
149
|
-
/** Indexed by group number, `[0]` unused — the shape `groupsOf` produces. */
|
|
150
|
-
const groups = { full: 'ab12', numbered: [undefined, 'a', 'b'] as const, named: { tail: '12' } };
|
|
151
|
-
|
|
152
|
-
test('expands $&, $1 and $<name>', () => {
|
|
153
|
-
expect(expandReplacement('$&|$1|$2|$<tail>', groups)).toBe('ab12|a|b|12');
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test('$$ is a literal dollar and an unknown group is empty', () => {
|
|
157
|
-
expect(expandReplacement('$$$9', groups)).toBe('$');
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test('a missing two-digit group falls back to the one-digit group plus the digit', () => {
|
|
161
|
-
// The engine's own reading of `$12` with two groups: `$1` then a literal `2`.
|
|
162
|
-
expect('x'.replace(/(x)/, '$12')).toBe('x2');
|
|
163
|
-
expect(expandReplacement('$12', groups)).toBe('a2');
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
test('a template without a dollar is returned as-is', () => {
|
|
167
|
-
expect(expandReplacement('plain', groups)).toBe('plain');
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
117
|
describe('caret anchoring', () => {
|
|
172
118
|
test('lineIndexAt and lineStartOffset are inverses for every line start', () => {
|
|
173
119
|
const text = 'one\ntwo\n\nfour';
|