@kabmo-labs/kabmo 0.7.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 +403 -384
- package/dist/client/static/js/{index-0wvbca0b.js → index-bpptfkz2.js} +905 -875
- package/dist/client/static/js/index-k315p1e8.css +1 -0
- package/package.json +10 -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/header-controls/index.tsx +24 -1
- package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -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/common/workspace-options-menu/index.tsx +19 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
- package/src/client/components/layout/desktop-layout/index.tsx +55 -69
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
- package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +23 -16
- package/src/client/components/mobile/RightSidebar.tsx +21 -10
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
- 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/mobile/mobile-session-sidebar/Header.tsx +6 -2
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
- package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
- package/src/client/components/mobile/worktree-session.ts +51 -0
- 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/ProjectSettings.tsx +22 -8
- 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 +146 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +36 -55
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- 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/index.tsx +5 -0
- 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.test.tsx +146 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +32 -17
- 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/ContextMenu.tsx +2 -1
- package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
- 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/file-explorer/RootMenu.tsx +14 -5
- package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
- package/src/client/components/workspace/file-explorer/filter.ts +47 -0
- package/src/client/components/workspace/file-explorer/index.tsx +77 -64
- package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
- 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/components/workspace/worktree-modal/index.tsx +261 -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 +27 -28
- 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/send-execute.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.ts +11 -1
- 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/chat/timeline/spawn-target.ts +52 -0
- package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
- package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
- 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/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/settings-modal.ts +56 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +75 -0
- package/src/client/hooks/ui/updates/shell-download.ts +153 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +213 -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-create.ts +41 -11
- package/src/client/hooks/workspace/file-listing.ts +56 -1
- 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/file-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
- package/src/client/hooks/workspace/worktree/index.ts +165 -0
- 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 +56 -8
- package/src/server/index.ts +7 -2
- package/src/server/lib/auth/guard.test.ts +28 -0
- package/src/server/lib/auth/guard.ts +10 -0
- package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/worktree-registry.server.ts +91 -0
- package/src/server/lib/fs/canonical.ts +52 -0
- package/src/server/lib/fs/relative-path.ts +48 -0
- package/src/server/lib/fs/root-worktree.test.ts +116 -0
- package/src/server/lib/fs/root.ts +39 -10
- package/src/server/lib/fs/worktree/carry.ts +134 -0
- package/src/server/lib/fs/worktree/create.test.ts +249 -0
- package/src/server/lib/fs/worktree/create.ts +262 -0
- package/src/server/lib/fs/worktree/errors.ts +43 -0
- package/src/server/lib/fs/worktree/list.test.ts +207 -0
- package/src/server/lib/fs/worktree/list.ts +142 -0
- package/src/server/lib/fs/worktree/location.test.ts +206 -0
- package/src/server/lib/fs/worktree/location.ts +114 -0
- package/src/server/lib/fs/worktree/project-id.ts +53 -0
- package/src/server/lib/fs/worktree/remove.test.ts +242 -0
- package/src/server/lib/fs/worktree/remove.ts +152 -0
- package/src/server/lib/fs/worktree/settings.server.ts +56 -0
- package/src/server/lib/fs/worktree/topology.ts +142 -0
- 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/sidebar-data.canonical.test.ts +155 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
- 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/panels/local-edits.server.ts +33 -0
- package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
- package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
- package/src/server/lib/panels/sync.server.test.ts +125 -147
- package/src/server/lib/panels/sync.server.ts +153 -48
- 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.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +200 -0
- package/src/server/lib/updates/shell-cache.ts +105 -0
- package/src/server/lib/updates/shell-pages.test.ts +187 -0
- package/src/server/lib/updates/shell-pages.ts +157 -0
- package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
- package/src/server/lib/updates/shell-release.test.ts +59 -71
- package/src/server/lib/updates/shell-release.ts +178 -198
- package/src/server/routes/fs/action-create.test.ts +18 -13
- package/src/server/routes/fs/action-nested.test.ts +184 -0
- package/src/server/routes/fs/action.ts +47 -16
- 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/index.ts +2 -0
- package/src/server/routes/updates/android.ts +6 -1
- package/src/server/routes/updates/desktop.ts +59 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/updates/ios.ts +5 -1
- package/src/server/routes/worktrees/create.ts +138 -0
- package/src/server/routes/worktrees/index.ts +25 -0
- package/src/server/routes/worktrees/list.ts +134 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +326 -0
- package/src/server/routes/worktrees/shared.ts +90 -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-tree.test.ts +183 -0
- package/src/shared/lib/fs/file-tree.ts +159 -0
- 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/fs/paths.test.ts +24 -1
- package/src/shared/lib/fs/paths.ts +17 -0
- package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
- package/src/shared/lib/omp/session/prewarm.ts +7 -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 +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- 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-modules.test.ts +49 -1
- package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
- package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
- package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
- package/src/shared/lib/workspace/worktree/types.ts +104 -0
- package/src/shared/types/fs.ts +10 -0
- package/src/shared/types/realtime.ts +29 -0
- package/src/shared/types/settings/state.ts +10 -0
- package/src/shared/types/workspace.ts +14 -0
- package/src/test-support/file-explorer-disk.test-util.ts +76 -0
- package/src/test-support/file-explorer.test-util.ts +71 -40
- package/src/test-support/loopback.ts +183 -0
- package/dist/client/static/js/index-nzyjf92m.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/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,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';
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Find scanning over one document buffer.
|
|
8
|
+
*
|
|
9
|
+
* The SCAN is CodeMirror's: `RegExpCursor` walks the document and hands back the
|
|
10
|
+
* same ordered match list the surface paints, so the matcher and the editor
|
|
11
|
+
* cannot disagree about where a match is. That replaced a hand-written
|
|
12
|
+
* `RegExp.exec` loop, which had to re-implement the two things a cursor gets
|
|
13
|
+
* right for free — never returning a zero-width match (nothing to highlight,
|
|
14
|
+
* nothing to replace, and stepping past it is what keeps the scan terminating)
|
|
15
|
+
* and starting the next search after the previous match rather than at its own
|
|
16
|
+
* index.
|
|
17
|
+
*
|
|
18
|
+
* Everything around the scan is still Kabmo's, deliberately: the options shape,
|
|
19
|
+
* the cap and the `truncated`/`invalid` distinction are the find widget's
|
|
20
|
+
* contract. Replacement expansion lives in `find-replace.ts`, because a caller
|
|
21
|
+
* that only paints matches should not carry the template grammar.
|
|
22
|
+
*
|
|
23
|
+
* A regex that does not compile is a DIFFERENT answer from "no matches", which
|
|
24
|
+
* is why `invalid` exists: a user typing `(` must be told their pattern is
|
|
25
|
+
* broken, not that the file does not contain it.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { Text } from '@codemirror/state';
|
|
29
|
+
import { RegExpCursor } from '@codemirror/search';
|
|
30
|
+
|
|
31
|
+
import { lineIndexAt, lineStartOffset, type TextRange } from '@/shared/lib/code/editor/lines';
|
|
32
|
+
|
|
33
|
+
export { lineIndexAt, lineStartOffset };
|
|
34
|
+
|
|
35
|
+
export interface FindOptions {
|
|
36
|
+
matchCase: boolean;
|
|
37
|
+
wholeWord: boolean;
|
|
38
|
+
isRegex: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Half-open `[start, end)` offset range in the document. */
|
|
42
|
+
export type FindMatch = TextRange;
|
|
43
|
+
|
|
44
|
+
export interface FindResult {
|
|
45
|
+
matches: FindMatch[];
|
|
46
|
+
/** The document holds more matches than the cap; the list stops at it. */
|
|
47
|
+
truncated: boolean;
|
|
48
|
+
/** A regex query that does not compile — a different answer from "no matches". */
|
|
49
|
+
invalid: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Ceiling on collected matches. A one-character query over a large file matches
|
|
54
|
+
* tens of thousands of times and every match is a decoration, so past this the
|
|
55
|
+
* list stops and the widget reports `N+`.
|
|
56
|
+
*/
|
|
57
|
+
export const MAX_FIND_MATCHES = 5_000;
|
|
58
|
+
|
|
59
|
+
export const DEFAULT_FIND_OPTIONS: FindOptions = {
|
|
60
|
+
matchCase: false,
|
|
61
|
+
wholeWord: false,
|
|
62
|
+
isRegex: false,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The flags `RegExpCursor` compiles with, stated once.
|
|
67
|
+
*
|
|
68
|
+
* This is not a preference: the cursor builds its own `RegExp` from the pattern
|
|
69
|
+
* SOURCE with `gm` (plus `u` where the engine has unicode property escapes), and
|
|
70
|
+
* a write path that compiled the same source with different flags would answer
|
|
71
|
+
* differently from the list on screen. Measured before this was aligned:
|
|
72
|
+
*
|
|
73
|
+
* - `^foo` / `foo$` — the cursor's `m` makes the anchor per LINE, so the widget
|
|
74
|
+
* counted 2 matches on a three-line file while Replace All rewrote only the
|
|
75
|
+
* first (no `m` = start/end of the whole document).
|
|
76
|
+
* - `\p{L}\d` — matched three times in the scan and zero in the replacement,
|
|
77
|
+
* because a property escape is only a property escape with `u`; without it
|
|
78
|
+
* `\p` is a literal `p`, so Replace All and Replace One did nothing at all.
|
|
79
|
+
* - `\-`, `\A` — invalid escapes under `u`, so the scan called the pattern
|
|
80
|
+
* broken while Replace All still edited the file with the non-unicode reading.
|
|
81
|
+
*
|
|
82
|
+
* So the flags live here, `buildFindRegex` is the only compiler, and the scan,
|
|
83
|
+
* the replace preview and both write paths read it.
|
|
84
|
+
*/
|
|
85
|
+
const BASE_FLAGS = 'gm' + ('unicode' in RegExp.prototype ? 'u' : '');
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The pattern a query compiles to, or null when the query is empty or does not
|
|
89
|
+
* compile. A literal query is escaped here rather than at the call site so the
|
|
90
|
+
* match scan and the replacement splice can never disagree about what the
|
|
91
|
+
* pattern was.
|
|
92
|
+
*
|
|
93
|
+
* Whole-word is a `\b`-anchored non-capturing group rather than a bare wrap so
|
|
94
|
+
* an alternation (`foo|bar`) keeps its own precedence.
|
|
95
|
+
*/
|
|
96
|
+
export function buildFindRegex(query: string, options: FindOptions): RegExp | null {
|
|
97
|
+
if (!query) return null;
|
|
98
|
+
const source = options.isRegex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
99
|
+
const wrapped = options.wholeWord ? `\\b(?:${source})\\b` : source;
|
|
100
|
+
try {
|
|
101
|
+
return new RegExp(wrapped, options.matchCase ? BASE_FLAGS : `${BASE_FLAGS}i`);
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The last string converted to a `Text`, kept for one call.
|
|
109
|
+
*
|
|
110
|
+
* A cursor needs a `Text`, and `Text.of(text.split('\n'))` builds a rope over
|
|
111
|
+
* the whole file. The find widget asks for matches on every keystroke in its
|
|
112
|
+
* query and on every edit, always for the same document, so keeping the last
|
|
113
|
+
* conversion is the difference between one split per render and one per call.
|
|
114
|
+
*/
|
|
115
|
+
let lastTextSource: string | null = null;
|
|
116
|
+
let lastText: Text | null = null;
|
|
117
|
+
|
|
118
|
+
function asText(source: string): Text {
|
|
119
|
+
if (lastTextSource === source && lastText) return lastText;
|
|
120
|
+
lastText = Text.of(source.split('\n'));
|
|
121
|
+
lastTextSource = source;
|
|
122
|
+
return lastText;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Every match of `query` in `text`, in document order.
|
|
127
|
+
*
|
|
128
|
+
* `RegExpCursor` searches LINE by line for a pattern that cannot span one, and
|
|
129
|
+
* falls back to a whole-document cursor when the pattern mentions `\n`, `\r`,
|
|
130
|
+
* `\s`, `\W`, `\D`, `\b`-free character classes or `[^`. Both return document
|
|
131
|
+
* offsets, so the caller never learns which one ran.
|
|
132
|
+
*/
|
|
133
|
+
export function findMatches(text: string, query: string, options: FindOptions): FindResult {
|
|
134
|
+
if (!query) return { matches: [], truncated: false, invalid: false };
|
|
135
|
+
const pattern = buildFindRegex(query, options);
|
|
136
|
+
if (!pattern) return { matches: [], truncated: false, invalid: true };
|
|
137
|
+
|
|
138
|
+
const matches: FindMatch[] = [];
|
|
139
|
+
let cursor: RegExpCursor;
|
|
140
|
+
try {
|
|
141
|
+
cursor = new RegExpCursor(asText(text), pattern.source, { ignoreCase: !options.matchCase });
|
|
142
|
+
} catch {
|
|
143
|
+
// A pattern `new RegExp` accepts but the cursor's own construction refuses
|
|
144
|
+
// (an unsupported flag combination) is still a broken pattern to the user.
|
|
145
|
+
return { matches: [], truncated: false, invalid: true };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
while (matches.length < MAX_FIND_MATCHES && !cursor.next().done) {
|
|
149
|
+
const { from, to } = cursor.value;
|
|
150
|
+
if (from === to) continue;
|
|
151
|
+
matches.push({ start: from, end: to });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { matches, truncated: matches.length >= MAX_FIND_MATCHES, invalid: false };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The match to call "current" for a caret/anchor at `offset`: the first one
|
|
159
|
+
* that ends after it, so a caret sitting inside a match still counts as being
|
|
160
|
+
* on that match. Wraps to the first match when the anchor is past the last one,
|
|
161
|
+
* and reports -1 for an empty list.
|
|
162
|
+
*/
|
|
163
|
+
export function matchIndexAtOrAfter(matches: readonly FindMatch[], offset: number): number {
|
|
164
|
+
if (matches.length === 0) return -1;
|
|
165
|
+
for (let index = 0; index < matches.length; index++) {
|
|
166
|
+
if (matches[index].end > offset) return index;
|
|
167
|
+
}
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Step one match forward (`1`) or back (`-1`), wrapping at either end. */
|
|
172
|
+
export function stepMatchIndex(index: number, count: number, direction: 1 | -1): number {
|
|
173
|
+
if (count <= 0) return -1;
|
|
174
|
+
if (index < 0) return direction === 1 ? 0 : count - 1;
|
|
175
|
+
return (index + direction + count) % count;
|
|
176
|
+
}
|
|
177
|
+
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
*
|
|
9
9
|
* One table is the whole truth about an editor shortcut: what it does, which
|
|
10
10
|
* chords reach it on each platform, and what the palette calls it. The
|
|
11
|
-
*
|
|
12
|
-
* palette all read this table, so a shortcut cannot be listed in the
|
|
13
|
-
* and unbound in the editor.
|
|
11
|
+
* generated CodeMirror keymap, the find bar's buttons, the tooltips and the
|
|
12
|
+
* command palette all read this table, so a shortcut cannot be listed in the
|
|
13
|
+
* palette and unbound in the editor.
|
|
14
14
|
*
|
|
15
15
|
* VS Code's defaults are followed wherever this editor has an equivalent. Where
|
|
16
16
|
* it has none — folding, go-to-definition, the `⌘K` prefix chords — the command
|