@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
|
@@ -243,3 +243,104 @@ describe('useEditorDrop', () => {
|
|
|
243
243
|
expect(harness.results).toHaveLength(0);
|
|
244
244
|
});
|
|
245
245
|
});
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The capture-phase claim, which exists because CodeMirror handles `drop`
|
|
249
|
+
* itself: for a drag carrying files it reads each file's TEXT and inserts it
|
|
250
|
+
* into the document, then calls `preventDefault`, so the panel's own handler saw
|
|
251
|
+
* `defaultPrevented` and stood down. Measured in the browser: dropping `b.ts` on
|
|
252
|
+
* an open `a.ts` put `b.ts`'s text at the top of the buffer, and the autosave
|
|
253
|
+
* wrote it to disk.
|
|
254
|
+
*
|
|
255
|
+
* The claim is scoped to a code surface, so a drop on the composer is still the
|
|
256
|
+
* composer's.
|
|
257
|
+
*/
|
|
258
|
+
describe('useEditorDrop capture claim', () => {
|
|
259
|
+
/** A drop event whose target is `element`, recording the calls it received. */
|
|
260
|
+
function captureEvent(target: Node, transfer: DataTransfer) {
|
|
261
|
+
const calls: string[] = [];
|
|
262
|
+
const event = {
|
|
263
|
+
target,
|
|
264
|
+
dataTransfer: transfer,
|
|
265
|
+
preventDefault: () => calls.push('preventDefault'),
|
|
266
|
+
stopPropagation: () => calls.push('stopPropagation'),
|
|
267
|
+
};
|
|
268
|
+
return { event: event as never, calls };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function capture(api: UseEditorDropResult | null, event: unknown): Promise<void> {
|
|
272
|
+
await act(async () => {
|
|
273
|
+
api?.captureDropProps.onDropCapture(event as never);
|
|
274
|
+
});
|
|
275
|
+
for (let pass = 0; pass < 4; pass += 1) {
|
|
276
|
+
await act(async () => {
|
|
277
|
+
await Promise.resolve();
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Mount the hook and hand back the API plus a node for each target shape. */
|
|
283
|
+
async function mountWithTargets() {
|
|
284
|
+
const results: { ok: boolean; message: string }[] = [];
|
|
285
|
+
const container = document.createElement('div');
|
|
286
|
+
document.body.appendChild(container);
|
|
287
|
+
const surface = document.createElement('div');
|
|
288
|
+
surface.setAttribute('data-code-surface', '');
|
|
289
|
+
const inner = document.createElement('div');
|
|
290
|
+
surface.appendChild(inner);
|
|
291
|
+
const composer = document.createElement('div');
|
|
292
|
+
container.append(surface, composer);
|
|
293
|
+
|
|
294
|
+
let api: UseEditorDropResult | null = null;
|
|
295
|
+
const Probe = () => {
|
|
296
|
+
api = useEditorDrop({ enabled: true, onResult: (r) => results.push(r) });
|
|
297
|
+
return null;
|
|
298
|
+
};
|
|
299
|
+
await act(async () => {
|
|
300
|
+
render(h(Probe, {}), container);
|
|
301
|
+
});
|
|
302
|
+
return { api: () => api, inner, composer, results };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
beforeEach(() => {
|
|
306
|
+
scopeResponse = { ok: true, status: 200, body: {} };
|
|
307
|
+
fetchCalls = [];
|
|
308
|
+
opened = [];
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('claims a file drop aimed at a code surface, and opens the file', async () => {
|
|
312
|
+
scopeResponse = {
|
|
313
|
+
ok: true,
|
|
314
|
+
status: 200,
|
|
315
|
+
body: { root: '/repo', path: 'src/b.ts', name: 'b.ts', kind: 'text' },
|
|
316
|
+
};
|
|
317
|
+
const { api, inner } = await mountWithTargets();
|
|
318
|
+
const { event, calls } = captureEvent(inner, fileTransfer('file:///repo/src/b.ts'));
|
|
319
|
+
|
|
320
|
+
await capture(api(), event);
|
|
321
|
+
|
|
322
|
+
// Claimed BEFORE the surface's own handler could insert the file's text.
|
|
323
|
+
expect(calls).toEqual(['preventDefault', 'stopPropagation']);
|
|
324
|
+
expect(opened).toHaveLength(1);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('leaves a drop aimed anywhere else to the bubble handler', async () => {
|
|
328
|
+
const { api, composer } = await mountWithTargets();
|
|
329
|
+
const { event, calls } = captureEvent(composer, fileTransfer('file:///repo/src/b.ts'));
|
|
330
|
+
|
|
331
|
+
await capture(api(), event);
|
|
332
|
+
|
|
333
|
+
expect(calls).toEqual([]);
|
|
334
|
+
expect(opened).toHaveLength(0);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('ignores a text drag, which CodeMirror should still insert', async () => {
|
|
338
|
+
const { api, inner } = await mountWithTargets();
|
|
339
|
+
const textDrag = { types: ['text/plain'], files: [], getData: () => 'copied text' } as unknown as DataTransfer;
|
|
340
|
+
const { event, calls } = captureEvent(inner, textDrag);
|
|
341
|
+
|
|
342
|
+
await capture(api(), event);
|
|
343
|
+
|
|
344
|
+
expect(calls).toEqual([]);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
@@ -62,9 +62,34 @@ export interface EditorDropProps {
|
|
|
62
62
|
onDrop: (e: TargetedDragEvent<HTMLElement>) => void;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Handlers that must run BEFORE a code surface sees the drag.
|
|
67
|
+
*
|
|
68
|
+
* CodeMirror handles `drop` itself, on its own `contentDOM`: for a drag carrying
|
|
69
|
+
* FILES it reads each file's text and INSERTS it into the document, then calls
|
|
70
|
+
* `preventDefault` — so the panel's own drop handler saw `defaultPrevented` and
|
|
71
|
+
* stood down. Measured in the browser: dropping `b.ts` on an open `a.ts` put
|
|
72
|
+
* `DROPPED_CONTENT` at the top of the buffer (and the autosave then wrote it to
|
|
73
|
+
* disk), while the overlay had promised "Drop a file to open it".
|
|
74
|
+
*
|
|
75
|
+
* A capture-phase listener on the panel is what wins that race, because capture
|
|
76
|
+
* runs from the outermost element inward — the surface's own handler has not run
|
|
77
|
+
* yet. It claims ONLY a drag that is over a code surface (`[data-code-surface]`,
|
|
78
|
+
* set by `CodeSurface`), so a drop on the composer still belongs to the composer
|
|
79
|
+
* and a drop on the chat's own chrome still reaches `onDrop` as before.
|
|
80
|
+
*/
|
|
81
|
+
export interface EditorDropCaptureProps {
|
|
82
|
+
onDropCapture: (e: TargetedDragEvent<HTMLElement>) => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The attribute a code surface marks itself with, for the capture handler. */
|
|
86
|
+
export const CODE_SURFACE_ATTRIBUTE = 'data-code-surface';
|
|
87
|
+
|
|
65
88
|
export interface UseEditorDropResult {
|
|
66
89
|
isDragging: boolean;
|
|
67
90
|
dropProps: EditorDropProps;
|
|
91
|
+
/** Spread on the same element as `dropProps`. */
|
|
92
|
+
captureDropProps: EditorDropCaptureProps;
|
|
68
93
|
}
|
|
69
94
|
|
|
70
95
|
/**
|
|
@@ -241,18 +266,9 @@ export function useEditorDrop({ enabled = true, onResult }: UseEditorDropOptions
|
|
|
241
266
|
if (depthRef.current === 0) setIsDragging(false);
|
|
242
267
|
}, []);
|
|
243
268
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
// signal for "someone deeper already handled this".
|
|
248
|
-
if (e.defaultPrevented) return;
|
|
249
|
-
if (!isOpenableDrag(e.dataTransfer)) return;
|
|
250
|
-
e.preventDefault();
|
|
251
|
-
e.stopPropagation();
|
|
252
|
-
reset();
|
|
253
|
-
if (!enabledRef.current || !e.dataTransfer) return;
|
|
254
|
-
|
|
255
|
-
const dt = e.dataTransfer;
|
|
269
|
+
/** Open what a drop carries — shared by the bubble and the capture paths. */
|
|
270
|
+
const openDropped = useCallback((dt: DataTransfer | null) => {
|
|
271
|
+
if (!dt) return;
|
|
256
272
|
// Handles first: they are the only shape that carries write access, and a
|
|
257
273
|
// Finder drag has nothing else.
|
|
258
274
|
void openDroppedHandle(dt, onResultRef.current).then((opened) => {
|
|
@@ -269,7 +285,44 @@ export function useEditorDrop({ enabled = true, onResult }: UseEditorDropOptions
|
|
|
269
285
|
// several would leave the user on whichever resolved last.
|
|
270
286
|
void openDroppedReference(references[0], onResultRef.current);
|
|
271
287
|
});
|
|
272
|
-
}, [
|
|
288
|
+
}, []);
|
|
289
|
+
|
|
290
|
+
const onDrop = useCallback((e: TargetedDragEvent<HTMLElement>) => {
|
|
291
|
+
// The composer claimed it: one gesture must not both open the file and
|
|
292
|
+
// queue it as an attachment. `defaultPrevented` is the platform's own
|
|
293
|
+
// signal for "someone deeper already handled this".
|
|
294
|
+
if (e.defaultPrevented) return;
|
|
295
|
+
if (!isOpenableDrag(e.dataTransfer)) return;
|
|
296
|
+
e.preventDefault();
|
|
297
|
+
e.stopPropagation();
|
|
298
|
+
reset();
|
|
299
|
+
if (!enabledRef.current || !e.dataTransfer) return;
|
|
300
|
+
openDropped(e.dataTransfer);
|
|
301
|
+
}, [openDropped, reset]);
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Claim a file drop that is about to land on a code surface.
|
|
305
|
+
*
|
|
306
|
+
* Capture phase, so it runs before CodeMirror's own `drop` handler on the
|
|
307
|
+
* `contentDOM` — that one reads a dropped file's TEXT and inserts it into the
|
|
308
|
+
* document, which would silently rewrite the open file on the next autosave.
|
|
309
|
+
* Scoped to `[data-code-surface]` so a drop anywhere else keeps the existing
|
|
310
|
+
* behaviour, the composer's included.
|
|
311
|
+
*/
|
|
312
|
+
const onDropCapture = useCallback((e: TargetedDragEvent<HTMLElement>) => {
|
|
313
|
+
if (!isOpenableDrag(e.dataTransfer)) return;
|
|
314
|
+
const target = e.target;
|
|
315
|
+
if (!(target instanceof Element) || !target.closest(`[${CODE_SURFACE_ATTRIBUTE}]`)) return;
|
|
316
|
+
e.preventDefault();
|
|
317
|
+
e.stopPropagation();
|
|
318
|
+
reset();
|
|
319
|
+
if (!enabledRef.current || !e.dataTransfer) return;
|
|
320
|
+
openDropped(e.dataTransfer);
|
|
321
|
+
}, [openDropped, reset]);
|
|
273
322
|
|
|
274
|
-
return {
|
|
323
|
+
return {
|
|
324
|
+
isDragging,
|
|
325
|
+
dropProps: { onDragEnter, onDragOver, onDragLeave, onDrop },
|
|
326
|
+
captureDropProps: { onDropCapture },
|
|
327
|
+
};
|
|
275
328
|
}
|
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Owns the query, the option toggles, which match is "current", and the two
|
|
10
10
|
* write paths (replace one, replace all). Every rule it applies lives in
|
|
11
|
-
* `shared/lib/code/editor/find
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* that
|
|
11
|
+
* `shared/lib/code/editor/find-scan` (the match list) and `find-replace` (what
|
|
12
|
+
* one match becomes), so the count in the widget, the marks painted on the
|
|
13
|
+
* surface and the bytes written back are the same list — the failure this shape
|
|
14
|
+
* exists to prevent is a widget that says "3 of 7" over highlights that
|
|
15
|
+
* disagree.
|
|
15
16
|
*
|
|
16
17
|
* The query and the toggles persist per session; whether the bar is OPEN does
|
|
17
18
|
* not. A reload that reopened a find bar over the file would be noise the user
|
|
@@ -26,11 +27,10 @@ import {
|
|
|
26
27
|
findMatches,
|
|
27
28
|
lineStartOffset,
|
|
28
29
|
matchIndexAtOrAfter,
|
|
29
|
-
replaceAllMatches,
|
|
30
|
-
replaceMatch,
|
|
31
30
|
stepMatchIndex,
|
|
32
31
|
type FindOptions,
|
|
33
|
-
} from '@/shared/lib/code/editor/find';
|
|
32
|
+
} from '@/shared/lib/code/editor/find-scan';
|
|
33
|
+
import { replaceAllMatches, replaceMatch } from '@/shared/lib/code/editor/find-replace';
|
|
34
34
|
import type { EditorCommand } from '@/shared/lib/code/editor/keymap';
|
|
35
35
|
import type { EditorFindState, UseEditorFindOptions } from '@/client/hooks/editor/find-state';
|
|
36
36
|
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The outline hook's two bounds, which no component test can see:
|
|
8
|
+
*
|
|
9
|
+
* - **the scan is DEBOUNCED**, because it walks the whole buffer and the buffer
|
|
10
|
+
* changes on every keystroke (measured: 704 ms per keystroke at 10 MB with a
|
|
11
|
+
* live scan, 34 ms without). A declaration list does not change mid-word, so
|
|
12
|
+
* the rows are recomputed once typing stops;
|
|
13
|
+
* - **a different document scans IMMEDIATELY**, because rows from the previous
|
|
14
|
+
* file point at lines of a document that is gone;
|
|
15
|
+
* - **a buffer past the scan ceiling is never scanned**, and says why.
|
|
16
|
+
*
|
|
17
|
+
* Rendered with `h()` against happy-dom and driven with FAKE timers: the
|
|
18
|
+
* behaviour under test is a timer, so advancing the clock is the only way to
|
|
19
|
+
* assert it without paying the debounce in every run.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { afterAll, afterEach, beforeAll, describe, expect, jest, test } from 'bun:test';
|
|
23
|
+
import { Window } from 'happy-dom';
|
|
24
|
+
import { h, render } from 'preact';
|
|
25
|
+
import { act } from 'preact/test-utils';
|
|
26
|
+
import {
|
|
27
|
+
OUTLINE_SCAN_DELAY_MS,
|
|
28
|
+
useOutlineView,
|
|
29
|
+
type OutlineView,
|
|
30
|
+
} from '@/client/hooks/editor/use-outline-view';
|
|
31
|
+
import { MAX_OUTLINE_SCAN_CHARS } from '@/shared/lib/code/outline/rows';
|
|
32
|
+
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
33
|
+
|
|
34
|
+
let container: HTMLElement;
|
|
35
|
+
|
|
36
|
+
beforeAll(() => {
|
|
37
|
+
installDomGlobals(new Window({ url: 'http://localhost' }));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
if (container) {
|
|
42
|
+
render(null, container);
|
|
43
|
+
container.remove();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterAll(() => {
|
|
48
|
+
restoreDomGlobals();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** One declaration per `export function`, so a buffer's rows are countable. */
|
|
52
|
+
function source(count: number): string {
|
|
53
|
+
return Array.from({ length: count }, (_, i) => `export function fn${i}() {}`).join('\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ProbeProps {
|
|
57
|
+
content: string;
|
|
58
|
+
language?: string;
|
|
59
|
+
path?: string | null;
|
|
60
|
+
state?: 'ready' | 'loading';
|
|
61
|
+
note?: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let latest: OutlineView | null = null;
|
|
65
|
+
|
|
66
|
+
function Probe({ content, language = 'typescript', path = 'src/a.ts', state = 'ready', note = null }: ProbeProps) {
|
|
67
|
+
latest = useOutlineView({ content, language, path, state, note });
|
|
68
|
+
return h('div', {});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function mount(props: ProbeProps): Promise<void> {
|
|
72
|
+
latest = null;
|
|
73
|
+
container = document.createElement('div');
|
|
74
|
+
document.body.appendChild(container);
|
|
75
|
+
await act(async () => {
|
|
76
|
+
render(h(Probe, props), container);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Re-render with new props, then flush Preact's own scheduling. */
|
|
81
|
+
async function update(props: ProbeProps): Promise<void> {
|
|
82
|
+
await act(async () => {
|
|
83
|
+
render(h(Probe, props), container);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Let the debounce fire, plus a tick for the re-render it causes. */
|
|
88
|
+
async function settle(): Promise<void> {
|
|
89
|
+
jest.advanceTimersByTime(OUTLINE_SCAN_DELAY_MS);
|
|
90
|
+
await act(async () => {});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Run the body under fake timers, restoring real ones even on a throw. */
|
|
94
|
+
async function withFakeTimers(body: () => Promise<void>): Promise<void> {
|
|
95
|
+
jest.useFakeTimers();
|
|
96
|
+
try {
|
|
97
|
+
await body();
|
|
98
|
+
} finally {
|
|
99
|
+
jest.useRealTimers();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe('useOutlineView', () => {
|
|
104
|
+
test('the first document is outlined on the FIRST render, not one timer later', async () => {
|
|
105
|
+
await mount({ content: source(3) });
|
|
106
|
+
|
|
107
|
+
// No `settle()`: a freshly opened file's rows must not arrive late.
|
|
108
|
+
expect(latest?.symbols.map((symbol) => symbol.name)).toEqual(['fn0', 'fn1', 'fn2']);
|
|
109
|
+
expect(latest?.total).toBe(3);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('a keystroke does NOT rescan until the buffer goes quiet', async () => {
|
|
113
|
+
await withFakeTimers(async () => {
|
|
114
|
+
await mount({ content: source(2) });
|
|
115
|
+
expect(latest?.total).toBe(2);
|
|
116
|
+
|
|
117
|
+
// A new declaration typed in, but the debounce has not elapsed: the rows
|
|
118
|
+
// are still the previous scan's. This is the property that makes typing
|
|
119
|
+
// cheap on a large file.
|
|
120
|
+
await update({ content: source(3) });
|
|
121
|
+
expect(latest?.total).toBe(2);
|
|
122
|
+
|
|
123
|
+
await settle();
|
|
124
|
+
expect(latest?.total).toBe(3);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('a burst of edits coalesces into ONE scan of the final text', async () => {
|
|
129
|
+
await withFakeTimers(async () => {
|
|
130
|
+
await mount({ content: source(1) });
|
|
131
|
+
|
|
132
|
+
for (const count of [2, 3, 4, 5]) {
|
|
133
|
+
await update({ content: source(count) });
|
|
134
|
+
// Shorter than the debounce, so each edit re-arms it.
|
|
135
|
+
jest.advanceTimersByTime(20);
|
|
136
|
+
await act(async () => {});
|
|
137
|
+
}
|
|
138
|
+
// Nothing has landed yet: the last edit re-armed the timer.
|
|
139
|
+
expect(latest?.total).toBe(1);
|
|
140
|
+
|
|
141
|
+
await settle();
|
|
142
|
+
expect(latest?.total).toBe(5);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('a DIFFERENT document is scanned at once, never on the timer', async () => {
|
|
147
|
+
await mount({ content: source(2), path: 'src/a.ts' });
|
|
148
|
+
expect(latest?.total).toBe(2);
|
|
149
|
+
|
|
150
|
+
await update({ content: source(7), path: 'src/b.ts' });
|
|
151
|
+
// No settle: rows from the previous file must not survive a tab switch.
|
|
152
|
+
expect(latest?.total).toBe(7);
|
|
153
|
+
expect(latest?.symbols[0].name).toBe('fn0');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('a buffer past the scan ceiling is never scanned, and the notice says why', async () => {
|
|
157
|
+
await withFakeTimers(async () => {
|
|
158
|
+
// One character over the ceiling, without a megabyte of real text.
|
|
159
|
+
const padded = `${source(2)}\n${'x'.repeat(MAX_OUTLINE_SCAN_CHARS)}`;
|
|
160
|
+
await mount({ content: padded });
|
|
161
|
+
await settle();
|
|
162
|
+
|
|
163
|
+
expect(latest?.symbols).toEqual([]);
|
|
164
|
+
expect(latest?.total).toBe(0);
|
|
165
|
+
expect(latest?.notice).toContain('too large to outline');
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('a document that is not ready has no rows, and prints the state sentence', async () => {
|
|
170
|
+
await mount({ content: source(3), state: 'loading' });
|
|
171
|
+
|
|
172
|
+
expect(latest?.symbols).toEqual([]);
|
|
173
|
+
expect(latest?.notice).toBe('Reading the file…');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('the caller note wins over the state sentence', async () => {
|
|
177
|
+
await mount({ content: '', state: 'ready', note: 'This tab has no outline.' });
|
|
178
|
+
|
|
179
|
+
expect(latest?.symbols).toEqual([]);
|
|
180
|
+
expect(latest?.notice).toBe('This tab has no outline.');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -15,15 +15,44 @@
|
|
|
15
15
|
*
|
|
16
16
|
* The scan runs only in the `ready` state, so a half-read buffer never produces
|
|
17
17
|
* a list of rows that are about to move.
|
|
18
|
+
*
|
|
19
|
+
* Two bounds keep a large file from making the panel the most expensive thing in
|
|
20
|
+
* the editor, and each was measured rather than guessed (see `outline/rows.ts`):
|
|
21
|
+
*
|
|
22
|
+
* - **The scan is DEBOUNCED.** It walks the whole document — 366 ms on 10 MB in
|
|
23
|
+
* Bun — and the published buffer changes on every keystroke, so a live scan
|
|
24
|
+
* charged that to typing. Measured in the browser at 10 MB with the panel
|
|
25
|
+
* open: 704 ms per keystroke live, 34 ms without. A declaration list does not
|
|
26
|
+
* change mid-word, so the rows are recomputed once the buffer goes quiet.
|
|
27
|
+
* - **The scan is SKIPPED past `MAX_OUTLINE_SCAN_CHARS`**, and the panel says
|
|
28
|
+
* why instead of showing an empty list that reads as "no declarations here".
|
|
29
|
+
*
|
|
30
|
+
* A DIFFERENT DOCUMENT is scanned at once, never on the timer: rows from the
|
|
31
|
+
* previous file point at lines of a document that is gone.
|
|
18
32
|
*/
|
|
19
33
|
|
|
20
|
-
import { useMemo } from 'preact/hooks';
|
|
34
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
21
35
|
import { extractOutline } from '@/shared/lib/code/outline/extract';
|
|
22
36
|
import type { OutlineSymbol } from '@/shared/lib/code/outline/types';
|
|
23
37
|
import { outlineEmptyNotice, type EditorDocumentState } from '@/shared/lib/code/outline/document';
|
|
38
|
+
import { MAX_OUTLINE_SCAN_CHARS, outlineTooLargeNotice, outlineWindow } from '@/shared/lib/code/outline/rows';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How long the buffer must be quiet before the outline is re-scanned.
|
|
42
|
+
*
|
|
43
|
+
* Long enough that typing never pays for it, short enough that the rows are
|
|
44
|
+
* back before the reader looks for them — and sooner than the 800 ms autosave,
|
|
45
|
+
* so the outline still describes the buffer before the file on disk does.
|
|
46
|
+
*/
|
|
47
|
+
export const OUTLINE_SCAN_DELAY_MS = 300;
|
|
24
48
|
|
|
25
49
|
export interface OutlineView {
|
|
50
|
+
/** The rows to render — bounded by `MAX_OUTLINE_ROWS`. */
|
|
26
51
|
symbols: OutlineSymbol[];
|
|
52
|
+
/** Every symbol the scan found, including the ones not rendered. */
|
|
53
|
+
total: number;
|
|
54
|
+
/** True when the panel is showing only the first `MAX_OUTLINE_ROWS`. */
|
|
55
|
+
truncated: boolean;
|
|
27
56
|
/** The sentence to print when `symbols` is empty. */
|
|
28
57
|
notice: string;
|
|
29
58
|
}
|
|
@@ -36,12 +65,70 @@ export interface OutlineSource {
|
|
|
36
65
|
/** The document's own state, so "still reading" is not "no symbols". */
|
|
37
66
|
state: EditorDocumentState;
|
|
38
67
|
note: string | null;
|
|
68
|
+
/**
|
|
69
|
+
* Identity of the document being drawn. A change here scans IMMEDIATELY
|
|
70
|
+
* instead of on the timer — see the module note.
|
|
71
|
+
*/
|
|
72
|
+
path: string | null;
|
|
39
73
|
}
|
|
40
74
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
75
|
+
interface ScanInput {
|
|
76
|
+
content: string;
|
|
77
|
+
language: string;
|
|
78
|
+
path: string | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Too large to outline: no scan, and a sentence that names the reason. */
|
|
82
|
+
function tooLarge(content: string): boolean {
|
|
83
|
+
return content.length > MAX_OUTLINE_SCAN_CHARS;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function useOutlineView({ content, language, state, note, path }: OutlineSource): OutlineView {
|
|
87
|
+
// Seeded from the mount values, so the first document's outline is ready on
|
|
88
|
+
// the first render rather than one timer later.
|
|
89
|
+
const [scan, setScan] = useState<ScanInput>({ content, language, path });
|
|
90
|
+
const pathRef = useRef(path);
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const documentChanged = path !== pathRef.current;
|
|
94
|
+
pathRef.current = path;
|
|
95
|
+
// A document past the ceiling is never scanned, so there is nothing to
|
|
96
|
+
// schedule — and re-arming a timer per keystroke on a file the panel will
|
|
97
|
+
// not describe is work for a sentence that never changes.
|
|
98
|
+
if (documentChanged || tooLarge(content)) {
|
|
99
|
+
setScan((prev) =>
|
|
100
|
+
prev.content === content && prev.language === language ? prev : { content, language, path },
|
|
101
|
+
);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Trailing debounce: every keystroke re-arms it, so the scan runs once the
|
|
105
|
+
// buffer goes quiet. The updater returns `prev` when nothing moved, which
|
|
106
|
+
// makes the mount's own timer a no-op instead of a second scan of the same
|
|
107
|
+
// text.
|
|
108
|
+
const timer = window.setTimeout(() => {
|
|
109
|
+
setScan((prev) =>
|
|
110
|
+
prev.content === content && prev.language === language ? prev : { content, language, path },
|
|
111
|
+
);
|
|
112
|
+
}, OUTLINE_SCAN_DELAY_MS);
|
|
113
|
+
return () => window.clearTimeout(timer);
|
|
114
|
+
}, [path, content, language]);
|
|
115
|
+
|
|
116
|
+
const symbols = useMemo(() => {
|
|
117
|
+
if (state !== 'ready' || tooLarge(scan.content)) return [];
|
|
118
|
+
return extractOutline(scan.content, scan.language);
|
|
119
|
+
}, [state, scan]);
|
|
120
|
+
|
|
121
|
+
const visible = useMemo(() => outlineWindow(symbols), [symbols]);
|
|
122
|
+
// The LIVE length, not the scanned one: the sentence names the file's size as
|
|
123
|
+
// it is now, and reading it off `scan` would make it lag a debounce behind.
|
|
124
|
+
const tooLargeNotice = state === 'ready' ? outlineTooLargeNotice(content.length) : null;
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
symbols: visible.rows,
|
|
128
|
+
total: visible.total,
|
|
129
|
+
truncated: visible.truncated,
|
|
130
|
+
// The size refusal wins over the empty-state sentence: "too large to
|
|
131
|
+
// outline" is the reason, and the generic one would hide it.
|
|
132
|
+
notice: tooLargeNotice ?? outlineEmptyNotice(state, note),
|
|
133
|
+
};
|
|
47
134
|
}
|
|
@@ -161,6 +161,19 @@ function storedOffsets(): ScrollPositions | undefined {
|
|
|
161
161
|
return getSessionValue<ScrollPositions>(SESSION, SCROLL_POSITIONS_KEY);
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* A READER's scroll: the input that causes it, then the movement.
|
|
166
|
+
*
|
|
167
|
+
* The hook distinguishes the two, and it has to: a `scroll` with no input behind
|
|
168
|
+
* it is the browser re-anchoring the document as its real height lands, which
|
|
169
|
+
* must NOT be read as the reader moving. `wheel` is the simplest input a code
|
|
170
|
+
* surface sees.
|
|
171
|
+
*/
|
|
172
|
+
function readerScroll(probe: ProbeHandle, top: number): void {
|
|
173
|
+
probe.el.dispatchEvent(new Event('wheel', { bubbles: true }));
|
|
174
|
+
probe.el.scrollTop = top;
|
|
175
|
+
}
|
|
176
|
+
|
|
164
177
|
describe('useScrollRestore', () => {
|
|
165
178
|
test('restores the stored offset for the tab and surface', async () => {
|
|
166
179
|
const probe = await mount('7', 'code', { '7': { code: 900 } });
|
|
@@ -181,7 +194,47 @@ describe('useScrollRestore', () => {
|
|
|
181
194
|
|
|
182
195
|
test("a reader's own scroll wins over a pending restore", async () => {
|
|
183
196
|
const probe = await mount('7', 'code', { '7': { code: 900 } });
|
|
184
|
-
probe
|
|
197
|
+
readerScroll(probe, 120);
|
|
198
|
+
await act(async () => {
|
|
199
|
+
StubResizeObserver.latest()?.fire();
|
|
200
|
+
});
|
|
201
|
+
expect(probe.el.scrollTop).toBe(120);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('the document re-anchoring is not the reader, and the restore re-applies', async () => {
|
|
205
|
+
// The bug the e2e spec found: the restore writes 900, the document's real
|
|
206
|
+
// height lands a frame later, and CodeMirror re-anchors to keep the visible
|
|
207
|
+
// content stable — `scrollTop` moves to 420 with nobody assigning. Reading
|
|
208
|
+
// that as the reader's own scroll dropped the pending restore, so the
|
|
209
|
+
// anchored value became where the reader stayed (measured: opening a tab
|
|
210
|
+
// moved them 29 lines) and the wrong offset was persisted.
|
|
211
|
+
const probe = await mount('7', 'code', { '7': { code: 900 } }, 600);
|
|
212
|
+
expect(probe.el.scrollTop).toBe(100);
|
|
213
|
+
|
|
214
|
+
// The browser's own correction: no input event behind it.
|
|
215
|
+
probe.el.scrollTop = 420;
|
|
216
|
+
probe.setHeight(5000);
|
|
217
|
+
await act(async () => {
|
|
218
|
+
StubResizeObserver.latest()?.fire();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// The restore is still in charge, so the reader lands where the session
|
|
222
|
+
// recorded — not where the anchoring put them.
|
|
223
|
+
expect(probe.el.scrollTop).toBe(900);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('a reader’s scroll is still theirs after an anchoring correction', async () => {
|
|
227
|
+
// The discriminator must not swallow the NEXT real scroll: the input that
|
|
228
|
+
// explains a movement is consumed by it, so a reader who scrolls after the
|
|
229
|
+
// document settles keeps their own position.
|
|
230
|
+
const probe = await mount('7', 'code', { '7': { code: 900 } });
|
|
231
|
+
probe.el.scrollTop = 420; // anchoring, no input
|
|
232
|
+
await act(async () => {
|
|
233
|
+
StubResizeObserver.latest()?.fire();
|
|
234
|
+
});
|
|
235
|
+
expect(probe.el.scrollTop).toBe(900);
|
|
236
|
+
|
|
237
|
+
readerScroll(probe, 120);
|
|
185
238
|
await act(async () => {
|
|
186
239
|
StubResizeObserver.latest()?.fire();
|
|
187
240
|
});
|
|
@@ -192,7 +245,7 @@ describe('useScrollRestore', () => {
|
|
|
192
245
|
const probe = await mount('7', 'code', {});
|
|
193
246
|
jest.useFakeTimers();
|
|
194
247
|
try {
|
|
195
|
-
probe
|
|
248
|
+
readerScroll(probe, 350);
|
|
196
249
|
jest.advanceTimersByTime(200);
|
|
197
250
|
await act(async () => {});
|
|
198
251
|
} finally {
|