@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,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The tree edits that let a create and a rename skip a reload.
|
|
8
|
+
*
|
|
9
|
+
* Each rule here is a way the rendered tree would disagree with the disk:
|
|
10
|
+
*
|
|
11
|
+
* 1. **A nested entry brings the chain that leads to it.** `lib/parse.ts` names
|
|
12
|
+
* a folder the tree does not carry yet; without the chain the row is placed
|
|
13
|
+
* under a key no branch reaches and simply never renders.
|
|
14
|
+
* 2. **A collapsed folder is not guessed at.** Its children are not this
|
|
15
|
+
* tree's knowledge, so an entry created inside one is deliberately NOT
|
|
16
|
+
* inserted — expanding it reads the real directory, which by then holds the
|
|
17
|
+
* new entry. Inserting into a guess would show a listing the server never
|
|
18
|
+
* produced.
|
|
19
|
+
* 3. **A rename re-points every descendant.** A folder's children carry its
|
|
20
|
+
* path as a prefix; moving the folder without rewriting them leaves rows
|
|
21
|
+
* whose `path` no longer resolves and an expansion set keyed by names that
|
|
22
|
+
* are gone.
|
|
23
|
+
* 4. **A rename never loses the moved entry's own loaded children.** Its cache
|
|
24
|
+
* entry is rekeyed with it, so a folder renamed while open stays open with
|
|
25
|
+
* its contents.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, expect, test } from 'bun:test';
|
|
29
|
+
import type { FsNode } from '@/shared/types';
|
|
30
|
+
import {
|
|
31
|
+
baseNameOf,
|
|
32
|
+
parentPathOf,
|
|
33
|
+
placeEntry,
|
|
34
|
+
rekeyCache,
|
|
35
|
+
rekeyExpansion,
|
|
36
|
+
renameEntryAt,
|
|
37
|
+
sortEntries,
|
|
38
|
+
takeEntry,
|
|
39
|
+
} from '@/shared/lib/fs/file-tree';
|
|
40
|
+
|
|
41
|
+
const file = (path: string): FsNode => ({ name: baseNameOf(path), path, type: 'file' });
|
|
42
|
+
const folder = (path: string, children?: FsNode[]): FsNode =>
|
|
43
|
+
children ? { name: baseNameOf(path), path, type: 'folder', children } : { name: baseNameOf(path), path, type: 'folder' };
|
|
44
|
+
|
|
45
|
+
describe('placeEntry', () => {
|
|
46
|
+
test('a top-level entry lands in the root, folders first', () => {
|
|
47
|
+
const placed = placeEntry([folder('src'), file('z.ts')], {}, file('a.ts'));
|
|
48
|
+
|
|
49
|
+
expect(placed.nodes.map((node) => node.path)).toEqual(['src', 'a.ts', 'z.ts']);
|
|
50
|
+
expect(placed.cache).toEqual({});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a nested entry creates the chain that leads to it', () => {
|
|
54
|
+
const placed = placeEntry([], {}, file('lib/deep/parse.ts'));
|
|
55
|
+
|
|
56
|
+
expect(placed.nodes.map((node) => node.path)).toEqual(['lib']);
|
|
57
|
+
expect(placed.cache.lib?.map((node) => node.path)).toEqual(['lib/deep']);
|
|
58
|
+
expect(placed.cache['lib/deep']?.map((node) => node.path)).toEqual(['lib/deep/parse.ts']);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('an existing chain is extended, not duplicated', () => {
|
|
62
|
+
const placed = placeEntry([folder('lib')], { lib: [folder('lib/deep')], 'lib/deep': [] }, file('lib/deep/x.ts'));
|
|
63
|
+
|
|
64
|
+
expect(placed.nodes).toHaveLength(1);
|
|
65
|
+
expect(placed.cache['lib/deep']?.map((node) => node.path)).toEqual(['lib/deep/x.ts']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('an entry inside a COLLAPSED folder is not inserted', () => {
|
|
69
|
+
// `src` is listed with `children: null` — the tree has never read it, so
|
|
70
|
+
// there is nothing to place the entry into and no guess to make.
|
|
71
|
+
const tree = [folder('src')];
|
|
72
|
+
const placed = placeEntry(tree, {}, file('src/hidden.ts'));
|
|
73
|
+
|
|
74
|
+
expect(placed.nodes).toBe(tree);
|
|
75
|
+
expect(placed.cache).toEqual({});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('an empty folder is LOADED, and can receive an entry', () => {
|
|
79
|
+
const placed = placeEntry([folder('src')], { src: [] }, file('src/new.ts'));
|
|
80
|
+
|
|
81
|
+
expect(placed.cache.src?.map((node) => node.path)).toEqual(['src/new.ts']);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a created folder is a folder in the cache too, so it can be expanded', () => {
|
|
85
|
+
const placed = placeEntry([], {}, folder('packages'));
|
|
86
|
+
|
|
87
|
+
expect(placed.nodes[0].type).toBe('folder');
|
|
88
|
+
expect(placed.nodes[0].children).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('takeEntry', () => {
|
|
93
|
+
test('removes a root entry and hands it back', () => {
|
|
94
|
+
const taken = takeEntry([file('a.ts'), file('b.ts')], {}, 'a.ts');
|
|
95
|
+
|
|
96
|
+
expect(taken.entry?.path).toBe('a.ts');
|
|
97
|
+
expect(taken.nodes.map((node) => node.path)).toEqual(['b.ts']);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('removes a nested entry from its parent cache', () => {
|
|
101
|
+
const taken = takeEntry([folder('src')], { src: [file('src/a.ts'), file('src/b.ts')] }, 'src/a.ts');
|
|
102
|
+
|
|
103
|
+
expect(taken.entry?.path).toBe('src/a.ts');
|
|
104
|
+
expect(taken.cache.src?.map((node) => node.path)).toEqual(['src/b.ts']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a path the tree does not carry reports no entry and changes nothing', () => {
|
|
108
|
+
const cache = { src: [file('src/a.ts')] };
|
|
109
|
+
const taken = takeEntry([folder('src')], cache, 'src/hidden.ts');
|
|
110
|
+
|
|
111
|
+
expect(taken.entry).toBeNull();
|
|
112
|
+
expect(taken.cache).toBe(cache);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('renameEntryAt', () => {
|
|
117
|
+
test('re-points the node and every descendant', () => {
|
|
118
|
+
const tree = [folder('src', [folder('src/lib', [file('src/lib/a.ts')])])];
|
|
119
|
+
const moved = renameEntryAt(tree, 'src', 'lib');
|
|
120
|
+
|
|
121
|
+
expect(moved[0].path).toBe('lib');
|
|
122
|
+
expect(moved[0].name).toBe('lib');
|
|
123
|
+
expect(moved[0].children?.[0].path).toBe('lib/lib');
|
|
124
|
+
expect(moved[0].children?.[0].children?.[0].path).toBe('lib/lib/a.ts');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a sibling whose path merely shares a prefix is left alone', () => {
|
|
128
|
+
const tree = [file('src.ts'), file('src/a.ts')];
|
|
129
|
+
const moved = renameEntryAt(tree, 'src', 'lib');
|
|
130
|
+
|
|
131
|
+
expect(moved[0]).toBe(tree[0]);
|
|
132
|
+
expect(moved[1].path).toBe('lib/a.ts');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('a file rename updates its own name', () => {
|
|
136
|
+
const moved = renameEntryAt([file('a.ts')], 'a.ts', 'nested/b.ts');
|
|
137
|
+
|
|
138
|
+
expect(moved[0].path).toBe('nested/b.ts');
|
|
139
|
+
expect(moved[0].name).toBe('b.ts');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('rekeyCache', () => {
|
|
144
|
+
test('moves the folder key and every key beneath it', () => {
|
|
145
|
+
const cache = { src: [folder('src/lib')], 'src/lib': [file('src/lib/a.ts')], other: [file('other/x.ts')] };
|
|
146
|
+
const moved = rekeyCache(cache, 'src', 'app');
|
|
147
|
+
|
|
148
|
+
expect(Object.keys(moved).sort()).toEqual(['app', 'app/lib', 'other']);
|
|
149
|
+
expect(moved['app/lib']?.[0].path).toBe('app/lib/a.ts');
|
|
150
|
+
expect(moved.other).toBe(cache.other);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('rekeyExpansion', () => {
|
|
155
|
+
test('an open folder and its open children stay open under their new paths', () => {
|
|
156
|
+
const next = rekeyExpansion(new Set(['src', 'src/lib', 'docs']), 'src', 'app');
|
|
157
|
+
|
|
158
|
+
expect([...next].sort()).toEqual(['app', 'app/lib', 'docs']);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('a path that only shares a prefix is untouched', () => {
|
|
162
|
+
const next = rekeyExpansion(new Set(['src.ts']), 'src', 'app');
|
|
163
|
+
|
|
164
|
+
expect([...next]).toEqual(['src.ts']);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('sortEntries', () => {
|
|
169
|
+
test('folders first, then by name — the server’s own order', () => {
|
|
170
|
+
const sorted = sortEntries([file('b.ts'), folder('z'), file('a.ts'), folder('a')]);
|
|
171
|
+
|
|
172
|
+
expect(sorted.map((node) => node.path)).toEqual(['a', 'z', 'a.ts', 'b.ts']);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('path helpers', () => {
|
|
177
|
+
test('parentPathOf and baseNameOf split a nested path', () => {
|
|
178
|
+
expect(parentPathOf('lib/deep/a.ts')).toBe('lib/deep');
|
|
179
|
+
expect(parentPathOf('a.ts')).toBe('.');
|
|
180
|
+
expect(baseNameOf('lib/deep/a.ts')).toBe('a.ts');
|
|
181
|
+
expect(baseNameOf('a.ts')).toBe('a.ts');
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -29,3 +29,162 @@ export function rehydrateTree(nodes: FsNode[], cache: Record<string, FsNode[]>):
|
|
|
29
29
|
return { ...node, children: rehydrateTree(cached, cache) };
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
/** The folder a path lives in: `.` for a top-level entry. */
|
|
34
|
+
export function parentPathOf(path: string): string {
|
|
35
|
+
const cut = path.lastIndexOf('/');
|
|
36
|
+
return cut === -1 ? '.' : path.slice(0, cut);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The last segment of a path. */
|
|
40
|
+
export function baseNameOf(path: string): string {
|
|
41
|
+
const cut = path.lastIndexOf('/');
|
|
42
|
+
return cut === -1 ? path : path.slice(cut + 1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The server's own ordering (`lib/fs/listing.ts`): folders first, then by name.
|
|
47
|
+
*
|
|
48
|
+
* A locally inserted row has to land where the server would have put it, or the
|
|
49
|
+
* next real read would visibly reorder the tree under the user.
|
|
50
|
+
*/
|
|
51
|
+
export function sortEntries(nodes: FsNode[]): FsNode[] {
|
|
52
|
+
return [...nodes].sort((a, b) => {
|
|
53
|
+
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
|
|
54
|
+
return a.name.localeCompare(b.name);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The listing after a local edit: the root list plus the children cache. */
|
|
59
|
+
export interface TreeEdit {
|
|
60
|
+
nodes: FsNode[];
|
|
61
|
+
cache: Record<string, FsNode[]>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Insert `entry` where its own path says it belongs, creating the folder chain
|
|
66
|
+
* that leads to it when the tree does not carry it yet.
|
|
67
|
+
*
|
|
68
|
+
* This is what lets a create and a rename skip a reload: the panel already
|
|
69
|
+
* knows the whole answer, and re-reading the root plus every open folder to
|
|
70
|
+
* learn it back would collapse nothing but cost a request per open folder.
|
|
71
|
+
*
|
|
72
|
+
* A chain is only extended through folders whose children are KNOWN — the root
|
|
73
|
+
* list plus the cache. A collapsed folder's contents are not this tree's to
|
|
74
|
+
* guess at, so an entry created inside one is simply not inserted: expanding it
|
|
75
|
+
* reads the real directory, which by then holds the new entry. The chain nodes
|
|
76
|
+
* that ARE inserted carry no `children` array, so the row's own expand effect
|
|
77
|
+
* reads them from disk like any other unread folder.
|
|
78
|
+
*/
|
|
79
|
+
export function placeEntry(nodes: FsNode[], cache: Record<string, FsNode[]>, entry: FsNode): TreeEdit {
|
|
80
|
+
const segments = entry.path.split('/');
|
|
81
|
+
const nextNodes = [...nodes];
|
|
82
|
+
const nextCache = { ...cache };
|
|
83
|
+
let container = nextNodes;
|
|
84
|
+
let containerKey: string | null = null;
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
87
|
+
const folderPath = segments.slice(0, i + 1).join('/');
|
|
88
|
+
const existing = container.find((node) => node.path === folderPath);
|
|
89
|
+
const known = existing ? (nextCache[folderPath] ?? existing.children) : null;
|
|
90
|
+
if (existing && !Array.isArray(known)) return { nodes, cache };
|
|
91
|
+
const children = Array.isArray(known) ? [...known] : [];
|
|
92
|
+
if (!existing) container.push({ name: segments[i], path: folderPath, type: 'folder', ignored: false });
|
|
93
|
+
nextCache[folderPath] = children;
|
|
94
|
+
container = children;
|
|
95
|
+
containerKey = folderPath;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
container.push(entry);
|
|
99
|
+
if (containerKey !== null) nextCache[containerKey] = sortEntries(container);
|
|
100
|
+
return { nodes: sortEntries(nextNodes), cache: nextCache };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Remove the entry at `path` from wherever it lives, leaving the rest of the
|
|
105
|
+
* tree untouched. Returns `entry: null` when the path is not in this tree —
|
|
106
|
+
* a collapsed folder's contents are not, and that is not an error.
|
|
107
|
+
*/
|
|
108
|
+
export function takeEntry(
|
|
109
|
+
nodes: FsNode[],
|
|
110
|
+
cache: Record<string, FsNode[]>,
|
|
111
|
+
path: string,
|
|
112
|
+
): TreeEdit & { entry: FsNode | null } {
|
|
113
|
+
const parent = parentPathOf(path);
|
|
114
|
+
if (parent === '.') {
|
|
115
|
+
const entry = nodes.find((node) => node.path === path) ?? null;
|
|
116
|
+
if (!entry) return { entry: null, nodes, cache };
|
|
117
|
+
return { entry, nodes: nodes.filter((node) => node.path !== path), cache };
|
|
118
|
+
}
|
|
119
|
+
const siblings = cache[parent];
|
|
120
|
+
const entry = siblings?.find((node) => node.path === path) ?? null;
|
|
121
|
+
if (!entry || !siblings) return { entry: null, nodes, cache };
|
|
122
|
+
return {
|
|
123
|
+
entry,
|
|
124
|
+
nodes,
|
|
125
|
+
cache: { ...cache, [parent]: siblings.filter((node) => node.path !== path) },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Re-point one entry and every path beneath it at a new location.
|
|
131
|
+
*
|
|
132
|
+
* A folder's descendants carry their parent's path as a prefix, so moving the
|
|
133
|
+
* folder without rewriting them would leave every child pointing at the old
|
|
134
|
+
* tree — a row whose `path` no longer resolves, and an expansion set keyed by
|
|
135
|
+
* names that are gone.
|
|
136
|
+
*
|
|
137
|
+
* A branch that does not hold the moved path is returned AS IS: the arrays are
|
|
138
|
+
* what React diffs, and a rename that rebuilt every unrelated subtree would
|
|
139
|
+
* re-render the whole tree for a change it did not touch.
|
|
140
|
+
*/
|
|
141
|
+
export function renameEntryAt(nodes: FsNode[], fromPath: string, toPath: string): FsNode[] {
|
|
142
|
+
let changed = false;
|
|
143
|
+
const next = nodes.map((node) => {
|
|
144
|
+
const isMoved = node.path === fromPath || node.path.startsWith(`${fromPath}/`);
|
|
145
|
+
const nested = Array.isArray(node.children) ? renameEntryAt(node.children, fromPath, toPath) : undefined;
|
|
146
|
+
// `nested === node.children` holds for a file (`undefined`) and for a folder
|
|
147
|
+
// whose subtree did not move, so an untouched row keeps its identity.
|
|
148
|
+
if (!isMoved && nested === node.children) return node;
|
|
149
|
+
changed = true;
|
|
150
|
+
const path = isMoved ? (node.path === fromPath ? toPath : `${toPath}${node.path.slice(fromPath.length)}`) : node.path;
|
|
151
|
+
const renamed: FsNode = isMoved ? { ...node, path, name: baseNameOf(path) } : { ...node };
|
|
152
|
+
if (nested !== undefined) renamed.children = nested;
|
|
153
|
+
return renamed;
|
|
154
|
+
});
|
|
155
|
+
return changed ? next : nodes;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Move every cache entry under `fromPath` to `toPath`, paths included.
|
|
160
|
+
*
|
|
161
|
+
* An entry outside the move keeps its own array identity, for the reason
|
|
162
|
+
* `renameEntryAt` does: those arrays are what the tree diffs.
|
|
163
|
+
*/
|
|
164
|
+
export function rekeyCache(
|
|
165
|
+
cache: Record<string, FsNode[]>,
|
|
166
|
+
fromPath: string,
|
|
167
|
+
toPath: string,
|
|
168
|
+
): Record<string, FsNode[]> {
|
|
169
|
+
const next: Record<string, FsNode[]> = {};
|
|
170
|
+
for (const [key, children] of Object.entries(cache)) {
|
|
171
|
+
const moved = key === fromPath
|
|
172
|
+
? toPath
|
|
173
|
+
: key.startsWith(`${fromPath}/`)
|
|
174
|
+
? `${toPath}${key.slice(fromPath.length)}`
|
|
175
|
+
: key;
|
|
176
|
+
next[moved] = moved === key ? children : renameEntryAt(children, fromPath, toPath);
|
|
177
|
+
}
|
|
178
|
+
return next;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Rewrite the expansion set for a renamed folder, so open branches stay open. */
|
|
182
|
+
export function rekeyExpansion(expanded: Set<string>, fromPath: string, toPath: string): Set<string> {
|
|
183
|
+
const next = new Set<string>();
|
|
184
|
+
for (const path of expanded) {
|
|
185
|
+
if (path === fromPath) next.add(toPath);
|
|
186
|
+
else if (path.startsWith(`${fromPath}/`)) next.add(`${toPath}${path.slice(fromPath.length)}`);
|
|
187
|
+
else next.add(path);
|
|
188
|
+
}
|
|
189
|
+
return next;
|
|
190
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `pathFromFileUrl` — the platform-specific half of a dropped-file reference.
|
|
8
|
+
*
|
|
9
|
+
* Pinned here rather than through the scope route because the Windows case is
|
|
10
|
+
* invisible on the machine this repository is developed on: the route's own test
|
|
11
|
+
* is `testPosix`, so the drive-letter branch had no test at all until a Windows
|
|
12
|
+
* CI run refused every dropped file.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import { pathFromFileUrl } from '@/shared/lib/fs/file-url';
|
|
17
|
+
|
|
18
|
+
describe('pathFromFileUrl', () => {
|
|
19
|
+
test('a POSIX file URL decodes to the path it names', () => {
|
|
20
|
+
expect(pathFromFileUrl('file:///tmp/x/b.ts')).toBe('/tmp/x/b.ts');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('a percent-encoded space is decoded', () => {
|
|
24
|
+
expect(pathFromFileUrl('file:///tmp/x/My%20Notes.md')).toBe('/tmp/x/My Notes.md');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('a Windows drive letter loses the leading slash, in every spelling', () => {
|
|
28
|
+
// All three forms answer `/C:/…` from `new URL().pathname`, and a leading
|
|
29
|
+
// slash on win32 resolves against the CURRENT DRIVE's root — a path no
|
|
30
|
+
// workspace contains.
|
|
31
|
+
expect(pathFromFileUrl('file:///C:/Users/x/b.ts')).toBe('C:/Users/x/b.ts');
|
|
32
|
+
expect(pathFromFileUrl('file://C:/Users/x/b.ts')).toBe('C:/Users/x/b.ts');
|
|
33
|
+
expect(pathFromFileUrl('file://C:\\Users\\x\\b.ts')).toBe('C:/Users/x/b.ts');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a percent-encoded drive path is decoded before the slash is dropped', () => {
|
|
37
|
+
expect(pathFromFileUrl('file:///D:/a/My%20Notes.md')).toBe('D:/a/My Notes.md');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('a path that is not a file URL is refused rather than guessed at', () => {
|
|
41
|
+
expect(pathFromFileUrl('/tmp/x/b.ts')).toBeNull();
|
|
42
|
+
expect(pathFromFileUrl('https://example.com/x.ts')).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a malformed escape is refused, not thrown', () => {
|
|
46
|
+
expect(pathFromFileUrl('file:///tmp/%E0%A4%A')).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a lowercase drive letter is kept as written', () => {
|
|
50
|
+
expect(pathFromFileUrl('file:///c:/x/b.ts')).toBe('c:/x/b.ts');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A `file://` URL to the path it names.
|
|
8
|
+
*
|
|
9
|
+
* Shared rather than private to the scope route, because the transformation is
|
|
10
|
+
* platform-specific and a private copy can only be exercised on the platform it
|
|
11
|
+
* was written on: the Windows branch below was wrong for as long as it lived
|
|
12
|
+
* inside a route whose test skipped Windows.
|
|
13
|
+
*
|
|
14
|
+
* Two rules, both measured:
|
|
15
|
+
*
|
|
16
|
+
* - **The pathname is percent-encoded** — a space is `%20`, and a Finder drop of
|
|
17
|
+
* `My Notes.md` is exactly that — so decoding is not optional.
|
|
18
|
+
* `decodeURIComponent` throws on a malformed escape, which is answered as
|
|
19
|
+
* "not a path" rather than surfacing as a 500.
|
|
20
|
+
* - **A WINDOWS drive letter loses its leading slash.** `new URL('file://C:\a\b.ts')`
|
|
21
|
+
* and the canonical `file:///C:/a/b.ts` BOTH answer `/C:/a/b.ts`, and
|
|
22
|
+
* `path.resolve` on win32 turns that into `\C:\a\b.ts` — a path under the
|
|
23
|
+
* current DRIVE's root, which no workspace contains. On the Windows CI leg
|
|
24
|
+
* every dropped file was refused with "outside every workspace", so a file
|
|
25
|
+
* dropped on the editor opened nothing at all. `fileURLToPath` drops the same
|
|
26
|
+
* slash; this does it without a `node:url` import in a shared module.
|
|
27
|
+
*/
|
|
28
|
+
export function pathFromFileUrl(reference: string): string | null {
|
|
29
|
+
if (!/^file:\/\//i.test(reference)) return null;
|
|
30
|
+
try {
|
|
31
|
+
const pathname = decodeURIComponent(new URL(reference).pathname);
|
|
32
|
+
// `/C:/…` → `C:/…`; `/tmp/…` is left alone.
|
|
33
|
+
return /^\/[A-Za-z]:[\\/]/.test(pathname) ? pathname.slice(1) : pathname;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A unified patch → the two documents it compares.
|
|
8
|
+
*
|
|
9
|
+
* What this pins is the shape a chat tool card depends on: a card has a PATCH
|
|
10
|
+
* and no files, and it draws the same merge surface the diff panel does — so the
|
|
11
|
+
* sides it hands over must be exactly what the patch says they were. A context
|
|
12
|
+
* row belongs to both, a `-` row only to the original, a `+` row only to the new
|
|
13
|
+
* one; everything else (the `diff --git` header, `index`, `---`/`+++`, mode and
|
|
14
|
+
* rename lines, the "No newline" marker) is metadata and must not appear in
|
|
15
|
+
* either document.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, test } from 'bun:test';
|
|
19
|
+
|
|
20
|
+
import { patchSides } from '@/shared/lib/fs/patch-sides';
|
|
21
|
+
|
|
22
|
+
const GIT_DIFF = [
|
|
23
|
+
'diff --git a/src/a.ts b/src/a.ts',
|
|
24
|
+
'index 1111111..2222222 100644',
|
|
25
|
+
'--- a/src/a.ts',
|
|
26
|
+
'+++ b/src/a.ts',
|
|
27
|
+
'@@ -1,3 +1,3 @@',
|
|
28
|
+
' const one = 1;',
|
|
29
|
+
'-const two = 2;',
|
|
30
|
+
'+const two = 22;',
|
|
31
|
+
' const three = 3;',
|
|
32
|
+
'',
|
|
33
|
+
].join('\n');
|
|
34
|
+
|
|
35
|
+
describe('patchSides', () => {
|
|
36
|
+
test('splits a git diff into the documents it compares', () => {
|
|
37
|
+
const sides = patchSides(GIT_DIFF);
|
|
38
|
+
|
|
39
|
+
expect(sides.original).toBe('const one = 1;\nconst two = 2;\nconst three = 3;');
|
|
40
|
+
expect(sides.next).toBe('const one = 1;\nconst two = 22;\nconst three = 3;');
|
|
41
|
+
expect(sides.additions).toBe(1);
|
|
42
|
+
expect(sides.deletions).toBe(1);
|
|
43
|
+
expect(sides.hasHunk).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('keeps the header lines out of both documents', () => {
|
|
47
|
+
// A `--- a/x` header would otherwise land in the original as `-- a/x`, and
|
|
48
|
+
// the merge view would draw a chunk that is not a change.
|
|
49
|
+
const sides = patchSides(GIT_DIFF);
|
|
50
|
+
for (const line of ['diff --git', 'index ', '--- a/', '+++ b/']) {
|
|
51
|
+
expect(sides.original.includes(line)).toBe(false);
|
|
52
|
+
expect(sides.next.includes(line)).toBe(false);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a new file has an empty original and the whole new document', () => {
|
|
57
|
+
const sides = patchSides(['@@ -0,0 +1,2 @@', '+alpha', '+beta'].join('\n'));
|
|
58
|
+
|
|
59
|
+
expect(sides.original).toBe('');
|
|
60
|
+
expect(sides.next).toBe('alpha\nbeta');
|
|
61
|
+
expect(sides.additions).toBe(2);
|
|
62
|
+
expect(sides.deletions).toBe(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a deleted file is the mirror image', () => {
|
|
66
|
+
const sides = patchSides(['@@ -1,1 +0,0 @@', '-gone'].join('\n'));
|
|
67
|
+
|
|
68
|
+
expect(sides.original).toBe('gone');
|
|
69
|
+
expect(sides.next).toBe('');
|
|
70
|
+
expect(sides.deletions).toBe(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a header-only patch has no rows, so the caller can fall back to text', () => {
|
|
74
|
+
const sides = patchSides('--- a/x\n+++ b/x\n');
|
|
75
|
+
|
|
76
|
+
expect(sides.rows).toBe(0);
|
|
77
|
+
expect(sides.hasHunk).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('an empty patch is the empty answer, not a one-line document', () => {
|
|
81
|
+
expect(patchSides('')).toEqual({ original: '', next: '', additions: 0, deletions: 0, rows: 0, hasHunk: false });
|
|
82
|
+
expect(patchSides(' \n')).toEqual({ original: '', next: '', additions: 0, deletions: 0, rows: 0, hasHunk: false });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('counts rows without the blank line a trailing newline leaves behind', () => {
|
|
86
|
+
const sides = patchSides(['@@ -1 +1 @@', '-a', '+b'].join('\n'));
|
|
87
|
+
|
|
88
|
+
expect(sides.rows).toBe(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('a hunk starting mid-file keeps its real line numbers', () => {
|
|
92
|
+
// Without the padding the change would be line 1 of the rebuilt document,
|
|
93
|
+
// and the card's gutter would name a line the file does not have it on.
|
|
94
|
+
const sides = patchSides(['@@ -43,2 +43,2 @@', ' context', '-old', '+new'].join('\n'));
|
|
95
|
+
|
|
96
|
+
expect(sides.original.split('\n')).toHaveLength(44);
|
|
97
|
+
expect(sides.original.split('\n')[42]).toBe('context');
|
|
98
|
+
expect(sides.original.split('\n')[43]).toBe('old');
|
|
99
|
+
expect(sides.next.split('\n')[43]).toBe('new');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('the gap between two hunks is restored from their own headers', () => {
|
|
103
|
+
const sides = patchSides(
|
|
104
|
+
['@@ -2,1 +2,1 @@', '-a', '+b', '@@ -10,1 +10,1 @@', '-c', '+d'].join('\n'),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Line 2 is the first change, line 10 the second, and everything between is
|
|
108
|
+
// unchanged content the patch did not carry.
|
|
109
|
+
const original = sides.original.split('\n');
|
|
110
|
+
expect(original[1]).toBe('a');
|
|
111
|
+
expect(original[9]).toBe('c');
|
|
112
|
+
expect(original).toHaveLength(10);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A unified patch as its two DOCUMENTS.
|
|
8
|
+
*
|
|
9
|
+
* This replaced a table parser (`diff-parser.ts`) that produced per-row line
|
|
10
|
+
* numbers and paired split-view rows. Both of those are now the merge view's
|
|
11
|
+
* job — `@codemirror/merge` computes the alignment itself from two documents —
|
|
12
|
+
* so the only thing still needed from a patch is what the two sides were.
|
|
13
|
+
*
|
|
14
|
+
* That matters in one place: a chat tool card has a PATCH and no documents, and
|
|
15
|
+
* the card draws the same merge surface the diff panel does. Reconstructing the
|
|
16
|
+
* sides here keeps one renderer for both, instead of the card growing a second
|
|
17
|
+
* diff implementation that drifts.
|
|
18
|
+
*
|
|
19
|
+
* **The gap between hunks is restored as blank lines, and that is what keeps the
|
|
20
|
+
* gutter honest.** A hunk header names the line it starts on (`@@ -43,7 +43,8`),
|
|
21
|
+
* and a document rebuilt by concatenating hunks would number the change from 1.
|
|
22
|
+
* Padding each side up to the header's own counters puts the change back on the
|
|
23
|
+
* line the file has it on — and because the padding is unchanged content, the
|
|
24
|
+
* merge view collapses it into one marker (`contentMode: 'diff'`).
|
|
25
|
+
*
|
|
26
|
+
* The counters are the patch's own (`+`/`-` rows, hunk headers excluded), which
|
|
27
|
+
* is what the card's header prints. The diff panel deliberately does NOT use
|
|
28
|
+
* them: it has the real documents, and its counts must describe the rows the
|
|
29
|
+
* merge view actually draws (a collapsed or whitespace-folded change is not a
|
|
30
|
+
* changed row on screen).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** One side's text, and what the patch said about it. */
|
|
34
|
+
export interface PatchSides {
|
|
35
|
+
/** The document before the change, with the hunk gaps as blank lines. */
|
|
36
|
+
original: string;
|
|
37
|
+
/** The document after it, the same way. */
|
|
38
|
+
next: string;
|
|
39
|
+
additions: number;
|
|
40
|
+
deletions: number;
|
|
41
|
+
/** Content rows the patch produced — zero for a header-only or empty patch. */
|
|
42
|
+
rows: number;
|
|
43
|
+
/** The patch named at least one hunk (`@@`), so it is a real diff. */
|
|
44
|
+
hasHunk: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const EMPTY: PatchSides = { original: '', next: '', additions: 0, deletions: 0, rows: 0, hasHunk: false };
|
|
48
|
+
|
|
49
|
+
/** Header lines that describe the patch rather than its content. */
|
|
50
|
+
const HEADER_PREFIXES = [
|
|
51
|
+
'diff --git',
|
|
52
|
+
'index ',
|
|
53
|
+
'---',
|
|
54
|
+
'+++',
|
|
55
|
+
'new file mode',
|
|
56
|
+
'deleted file mode',
|
|
57
|
+
'similarity index',
|
|
58
|
+
'dissimilarity index',
|
|
59
|
+
'rename from',
|
|
60
|
+
'rename to',
|
|
61
|
+
'copy from',
|
|
62
|
+
'copy to',
|
|
63
|
+
'old mode',
|
|
64
|
+
'new mode',
|
|
65
|
+
'\\ No newline',
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/** `@@ -43,7 +43,8 @@` → the two starting lines; anything else → null. */
|
|
69
|
+
function hunkStarts(line: string): { old: number; next: number } | null {
|
|
70
|
+
const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
71
|
+
if (!match) return null;
|
|
72
|
+
return { old: Number(match[1]), next: Number(match[2]) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Pad `lines` with blanks until it holds `count` lines. */
|
|
76
|
+
function padTo(lines: string[], count: number): void {
|
|
77
|
+
while (lines.length < count) lines.push('');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function patchSides(patch: string): PatchSides {
|
|
81
|
+
if (!patch.trim()) return EMPTY;
|
|
82
|
+
|
|
83
|
+
const before: string[] = [];
|
|
84
|
+
const after: string[] = [];
|
|
85
|
+
let additions = 0;
|
|
86
|
+
let deletions = 0;
|
|
87
|
+
let rows = 0;
|
|
88
|
+
let hasHunk = false;
|
|
89
|
+
|
|
90
|
+
const lines = patch.split(/\r?\n/);
|
|
91
|
+
// A patch ends with a newline, and `split` turns that into a final empty
|
|
92
|
+
// string. It is the terminator, not a blank row: keeping it put an empty line
|
|
93
|
+
// at the end of BOTH documents, which the merge view drew as a chunk.
|
|
94
|
+
if (lines[lines.length - 1] === '') lines.pop();
|
|
95
|
+
|
|
96
|
+
for (const raw of lines) {
|
|
97
|
+
if (HEADER_PREFIXES.some((prefix) => raw.startsWith(prefix))) continue;
|
|
98
|
+
|
|
99
|
+
if (raw.startsWith('@@')) {
|
|
100
|
+
hasHunk = true;
|
|
101
|
+
const starts = hunkStarts(raw);
|
|
102
|
+
// `-0,0` (a new file) has no original line to reach, and `padTo` is a
|
|
103
|
+
// no-op for it because the count is below what is already there.
|
|
104
|
+
if (starts) {
|
|
105
|
+
padTo(before, starts.old - 1);
|
|
106
|
+
padTo(after, starts.next - 1);
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (raw.startsWith('+')) {
|
|
112
|
+
additions++;
|
|
113
|
+
rows++;
|
|
114
|
+
after.push(raw.slice(1));
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (raw.startsWith('-')) {
|
|
118
|
+
deletions++;
|
|
119
|
+
rows++;
|
|
120
|
+
before.push(raw.slice(1));
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// A context row (` ` prefix) is on BOTH sides; a bare line is what a
|
|
124
|
+
// header-less patch carries, and it is context too.
|
|
125
|
+
const text = raw.startsWith(' ') ? raw.slice(1) : raw;
|
|
126
|
+
rows++;
|
|
127
|
+
before.push(text);
|
|
128
|
+
after.push(text);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
original: before.join('\n'),
|
|
133
|
+
next: after.join('\n'),
|
|
134
|
+
additions,
|
|
135
|
+
deletions,
|
|
136
|
+
rows,
|
|
137
|
+
hasHunk,
|
|
138
|
+
};
|
|
139
|
+
}
|