@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,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Repository identity — the key a repository's worktrees live under.
|
|
8
|
+
*
|
|
9
|
+
* The derivation is OpenChamber's (`git rev-list --max-parents=0 --all`, sorted,
|
|
10
|
+
* first line: the root commit), and two clones of one repository share it.
|
|
11
|
+
*
|
|
12
|
+
* It is NOT stable on its own, which is why they cache it in
|
|
13
|
+
* `<primary>/.git/opencode`. Measured: adding a branch with unrelated history
|
|
14
|
+
* (`git checkout --orphan`) inserts a second root and MOVES the sorted-first
|
|
15
|
+
* value, so a repository that gained one would look for its worktrees in a
|
|
16
|
+
* different folder. Kabmo does not write into a user's `.git`, so the stored row
|
|
17
|
+
* is the cache and it always wins.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { runGit } from '@/server/lib/fs/git-run';
|
|
21
|
+
|
|
22
|
+
/** `rev-list --max-parents=0 --all`, sorted, first. `null` for an unborn branch. */
|
|
23
|
+
export async function deriveProjectId(primaryRoot: string): Promise<string | null> {
|
|
24
|
+
const listed = await runGit(['rev-list', '--max-parents=0', '--all'], {
|
|
25
|
+
cwd: primaryRoot,
|
|
26
|
+
maxBuffer: 1024 * 1024,
|
|
27
|
+
});
|
|
28
|
+
if (listed.exitCode !== 0) return null;
|
|
29
|
+
const roots = listed.stdout
|
|
30
|
+
.split('\n')
|
|
31
|
+
.map((line) => line.trim())
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.sort((a, b) => a.localeCompare(b));
|
|
34
|
+
return roots[0] ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The identity to key this repository's worktrees under.
|
|
39
|
+
*
|
|
40
|
+
* A stored value beats the derivation — that is the whole point of keeping one.
|
|
41
|
+
* Only a repository with no record derives, which is the moment it has no
|
|
42
|
+
* worktree folder to protect.
|
|
43
|
+
*/
|
|
44
|
+
export async function resolveProjectId(
|
|
45
|
+
primaryRoot: string,
|
|
46
|
+
identityFor: (primaryRoot: string) => Promise<string | null>,
|
|
47
|
+
): Promise<string> {
|
|
48
|
+
const stored = await identityFor(primaryRoot);
|
|
49
|
+
if (stored) return stored;
|
|
50
|
+
const derived = await deriveProjectId(primaryRoot);
|
|
51
|
+
if (!derived) throw new Error('This repository has no commits yet.');
|
|
52
|
+
return derived;
|
|
53
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Removing a worktree, against real repositories.
|
|
8
|
+
*
|
|
9
|
+
* Three rules, each measured, each a way work is lost if it is wrong:
|
|
10
|
+
*
|
|
11
|
+
* - **Sessions are stopped BEFORE the directory goes.** The omp child holds the
|
|
12
|
+
* worktree as its cwd; a child in a deleted directory makes
|
|
13
|
+
* `resolveSpawnCwd` quietly run that session in the server's own folder.
|
|
14
|
+
* - **A dirty worktree refuses.** git exits 128 with "contains modified or
|
|
15
|
+
* untracked files" and `--force` is never passed, so the refusal is
|
|
16
|
+
* recoverable and the count is reported.
|
|
17
|
+
* - **The branch is deleted with `-d` only.** Its refusal (an unmerged branch)
|
|
18
|
+
* IS the safety net, so a branch with commits of its own survives and the
|
|
19
|
+
* answer says so rather than pretending the delete worked.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { canonicalPath } from '@/server/lib/fs/canonical';
|
|
27
|
+
import { createWorktree } from '@/server/lib/fs/worktree/create';
|
|
28
|
+
import type { CreateWorktreeDeps } from '@/server/lib/fs/worktree/create';
|
|
29
|
+
import { listWorktrees } from '@/server/lib/fs/worktree/list';
|
|
30
|
+
import { commitsAheadOf, dirtyCount, removeWorktree, type RemoveWorktreeDeps } from '@/server/lib/fs/worktree/remove';
|
|
31
|
+
import { invalidateWorktreeTopology } from '@/server/lib/fs/worktree/topology';
|
|
32
|
+
import { removeDir } from '@/test-support/remove-dir';
|
|
33
|
+
|
|
34
|
+
const FIXTURE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-worktree-remove-'));
|
|
35
|
+
const DATA_DIR = path.join(FIXTURE_ROOT, 'data');
|
|
36
|
+
const MANAGED_ROOT = path.join(DATA_DIR, 'worktrees');
|
|
37
|
+
|
|
38
|
+
const GIT_ENV_FOR_TESTS = {
|
|
39
|
+
...Bun.env,
|
|
40
|
+
GIT_EDITOR: 'true',
|
|
41
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
42
|
+
GIT_AUTHOR_NAME: 'Test',
|
|
43
|
+
GIT_AUTHOR_EMAIL: 'test@example.invalid',
|
|
44
|
+
GIT_COMMITTER_NAME: 'Test',
|
|
45
|
+
GIT_COMMITTER_EMAIL: 'test@example.invalid',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function git(cwd: string, ...args: string[]): string {
|
|
49
|
+
return Bun.spawnSync({ cmd: ['git', ...args], cwd, env: GIT_ENV_FOR_TESTS }).stdout.toString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeRepo(name: string): string {
|
|
53
|
+
const dir = path.join(FIXTURE_ROOT, name);
|
|
54
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
55
|
+
git(dir, 'init', '-q', '-b', 'main', '.');
|
|
56
|
+
fs.writeFileSync(path.join(dir, 'readme.md'), 'hello\n');
|
|
57
|
+
git(dir, 'add', '-A');
|
|
58
|
+
git(dir, 'commit', '-q', '-m', 'base');
|
|
59
|
+
return dir;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The registry stub: the production one is two SQLite statements. */
|
|
63
|
+
function registry(rows = new Map<string, { start_ref: string }>()): RemoveWorktreeDeps & {
|
|
64
|
+
rows: Map<string, { start_ref: string }>;
|
|
65
|
+
} {
|
|
66
|
+
return {
|
|
67
|
+
rows,
|
|
68
|
+
rowFor: async (candidate) => rows.get(candidate),
|
|
69
|
+
removeRow: async (candidate) => {
|
|
70
|
+
rows.delete(candidate);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const createDeps = (rows = new Map<string, { startRef: string; projectId: string }>()): CreateWorktreeDeps => ({
|
|
76
|
+
identityFor: async () => null,
|
|
77
|
+
carryIgnored: false,
|
|
78
|
+
dataDir: DATA_DIR,
|
|
79
|
+
configuredDirectory: null,
|
|
80
|
+
record: async (row) => {
|
|
81
|
+
rows.set(await canonicalPath(row.path), { startRef: row.startRef, projectId: row.projectId });
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterAll(async () => {
|
|
86
|
+
await invalidateWorktreeTopology();
|
|
87
|
+
removeDir(FIXTURE_ROOT);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('removeWorktree', () => {
|
|
91
|
+
test('a dirty worktree refuses with the count and keeps everything', async () => {
|
|
92
|
+
const repo = makeRepo('remove-dirty');
|
|
93
|
+
const created = await createWorktree({ projectPath: repo, mode: 'new', branch: 'wt/dirty' }, createDeps());
|
|
94
|
+
fs.writeFileSync(path.join(created.path, 'readme.md'), 'changed\n');
|
|
95
|
+
fs.writeFileSync(path.join(created.path, 'untracked.txt'), 'new\n');
|
|
96
|
+
|
|
97
|
+
const failure = removeWorktree(
|
|
98
|
+
{ projectPath: repo, worktreePath: created.path, managedRoot: MANAGED_ROOT },
|
|
99
|
+
registry(),
|
|
100
|
+
);
|
|
101
|
+
await failure.catch((error) => expect(error).toMatchObject({ code: 'dirty', count: 2 }));
|
|
102
|
+
|
|
103
|
+
expect(fs.existsSync(created.path)).toBe(true);
|
|
104
|
+
expect((await listWorktrees(repo)).some((entry) => entry.path === created.path)).toBe(true);
|
|
105
|
+
// The refusal names what it found, so the dialog can show it.
|
|
106
|
+
expect(await dirtyCount(created.path)).toBe(2);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a clean worktree goes, and a branch with commits of its own is kept', async () => {
|
|
110
|
+
const repo = makeRepo('remove-keep-branch');
|
|
111
|
+
const records = new Map<string, { startRef: string; projectId: string }>();
|
|
112
|
+
const created = await createWorktree(
|
|
113
|
+
{ projectPath: repo, mode: 'new', branch: 'wt/keepme' },
|
|
114
|
+
createDeps(records),
|
|
115
|
+
);
|
|
116
|
+
fs.writeFileSync(path.join(created.path, 'work.txt'), 'done\n');
|
|
117
|
+
git(created.path, 'add', '-A');
|
|
118
|
+
git(created.path, 'commit', '-q', '-m', 'work');
|
|
119
|
+
|
|
120
|
+
const rows = new Map([[await canonicalPath(created.path), { start_ref: 'HEAD' }]]);
|
|
121
|
+
const deps = registry(rows);
|
|
122
|
+
// The dialog gates on this number: anything above zero must disable the
|
|
123
|
+
// branch checkbox, because `git branch -d` would refuse anyway.
|
|
124
|
+
expect(await commitsAheadOf(await canonicalPath(repo), 'HEAD', 'wt/keepme')).toBe(1);
|
|
125
|
+
|
|
126
|
+
const result = await removeWorktree(
|
|
127
|
+
{ projectPath: repo, worktreePath: created.path, deleteBranch: true, managedRoot: MANAGED_ROOT },
|
|
128
|
+
deps,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(result.removed).toBe(true);
|
|
132
|
+
expect(result.branchDeleted).toBe(false);
|
|
133
|
+
expect(result.branchKept).toBe(true);
|
|
134
|
+
expect(fs.existsSync(created.path)).toBe(false);
|
|
135
|
+
expect(git(repo, 'branch', '--list', 'wt/keepme').trim()).toContain('wt/keepme');
|
|
136
|
+
expect(rows.size).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('a branch with nothing of its own is deleted with -d', async () => {
|
|
140
|
+
const repo = makeRepo('remove-delete-branch');
|
|
141
|
+
const created = await createWorktree({ projectPath: repo, mode: 'new', branch: 'wt/disposable' }, createDeps());
|
|
142
|
+
const rows = new Map([[await canonicalPath(created.path), { start_ref: 'HEAD' }]]);
|
|
143
|
+
|
|
144
|
+
const result = await removeWorktree(
|
|
145
|
+
{ projectPath: repo, worktreePath: created.path, deleteBranch: true, managedRoot: MANAGED_ROOT },
|
|
146
|
+
registry(rows),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect(result.branchDeleted).toBe(true);
|
|
150
|
+
expect(git(repo, 'branch', '--list', 'wt/disposable').trim()).toBe('');
|
|
151
|
+
expect(rows.size).toBe(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('the main checkout cannot be removed', async () => {
|
|
155
|
+
const repo = makeRepo('remove-primary');
|
|
156
|
+
const primary = (await listWorktrees(repo)).find((entry) => entry.isPrimary)?.path as string;
|
|
157
|
+
const failure = removeWorktree({ projectPath: repo, worktreePath: primary }, registry());
|
|
158
|
+
await failure.catch((error) => expect(error).toMatchObject({ code: 'primary_checkout' }));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('a path that is not a worktree of this repository is refused', async () => {
|
|
162
|
+
const repo = makeRepo('remove-stranger');
|
|
163
|
+
const stranger = path.join(FIXTURE_ROOT, 'stranger');
|
|
164
|
+
fs.mkdirSync(stranger, { recursive: true });
|
|
165
|
+
const failure = removeWorktree({ projectPath: repo, worktreePath: stranger }, registry());
|
|
166
|
+
await failure.catch((error) => expect(error).toMatchObject({ code: 'not_a_worktree' }));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('a worktree whose folder was deleted outside Kabmo still removes, and prunes', async () => {
|
|
170
|
+
const repo = makeRepo('remove-vanished');
|
|
171
|
+
const created = await createWorktree({ projectPath: repo, mode: 'new', branch: 'wt/vanish' }, createDeps());
|
|
172
|
+
fs.rmSync(created.path, { recursive: true, force: true });
|
|
173
|
+
|
|
174
|
+
// It is still listed — that is what keeps its sessions on screen.
|
|
175
|
+
expect((await listWorktrees(repo)).some((entry) => entry.path === created.path)).toBe(true);
|
|
176
|
+
|
|
177
|
+
const result = await removeWorktree(
|
|
178
|
+
{ projectPath: repo, worktreePath: created.path, deleteBranch: true, managedRoot: MANAGED_ROOT },
|
|
179
|
+
registry(),
|
|
180
|
+
);
|
|
181
|
+
expect(result.removed).toBe(true);
|
|
182
|
+
expect((await listWorktrees(repo)).some((entry) => entry.path === created.path)).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('children are stopped BEFORE the directory disappears', async () => {
|
|
186
|
+
const repo = makeRepo('remove-order');
|
|
187
|
+
const created = await createWorktree({ projectPath: repo, mode: 'new', branch: 'wt/order' }, createDeps());
|
|
188
|
+
|
|
189
|
+
// A child that simply waits: `await new Promise(() => {})` never settles, so
|
|
190
|
+
// the process stays alive without a timer a test would have to wait on.
|
|
191
|
+
const child = Bun.spawn({
|
|
192
|
+
cmd: ['bun', '-e', 'await new Promise(() => {});'],
|
|
193
|
+
cwd: created.path,
|
|
194
|
+
stdin: 'ignore',
|
|
195
|
+
stdout: 'ignore',
|
|
196
|
+
stderr: 'ignore',
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const order: string[] = [];
|
|
200
|
+
const result = await removeWorktree(
|
|
201
|
+
{
|
|
202
|
+
projectPath: repo,
|
|
203
|
+
worktreePath: created.path,
|
|
204
|
+
managedRoot: MANAGED_ROOT,
|
|
205
|
+
stopSessions: async (worktreePath) => {
|
|
206
|
+
order.push(`stop:${path.basename(worktreePath)}`);
|
|
207
|
+
child.kill();
|
|
208
|
+
await child.exited;
|
|
209
|
+
return ['session-1'];
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
registry(),
|
|
213
|
+
);
|
|
214
|
+
order.push('removed');
|
|
215
|
+
|
|
216
|
+
expect(order).toEqual(['stop:order', 'removed']);
|
|
217
|
+
expect(result.archived).toEqual(['session-1']);
|
|
218
|
+
expect(fs.existsSync(created.path)).toBe(false);
|
|
219
|
+
// A SIGTERM'd process reports `signalCode`, not an exit code.
|
|
220
|
+
expect(child.signalCode).toBe('SIGTERM');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('a refusal after the sessions are stopped leaves a worktree that can be retried', async () => {
|
|
224
|
+
const repo = makeRepo('remove-retry');
|
|
225
|
+
const created = await createWorktree({ projectPath: repo, mode: 'new', branch: 'wt/retry' }, createDeps());
|
|
226
|
+
fs.writeFileSync(path.join(created.path, 'dirty.txt'), 'x');
|
|
227
|
+
|
|
228
|
+
const failure = removeWorktree(
|
|
229
|
+
{
|
|
230
|
+
projectPath: repo,
|
|
231
|
+
worktreePath: created.path,
|
|
232
|
+
managedRoot: MANAGED_ROOT,
|
|
233
|
+
stopSessions: async () => ['session-1'],
|
|
234
|
+
},
|
|
235
|
+
registry(),
|
|
236
|
+
);
|
|
237
|
+
await failure.catch((error) => expect(error).toMatchObject({ code: 'dirty' }));
|
|
238
|
+
|
|
239
|
+
expect(fs.existsSync(created.path)).toBe(true);
|
|
240
|
+
expect((await listWorktrees(repo)).some((entry) => entry.path === created.path)).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Remove a worktree, in an order that is the REVERSE of OpenChamber's and
|
|
8
|
+
* deliberately so: the omp child holds the worktree as its cwd, so the sessions
|
|
9
|
+
* are stopped and deleted FIRST and only then does the directory go away.
|
|
10
|
+
*
|
|
11
|
+
* A child still holding a deleted cwd is the case `resolveSpawnCwd` papers over
|
|
12
|
+
* by silently running the session in the server's own directory — a session the
|
|
13
|
+
* user asked to delete, alive somewhere else.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here passes `--force`. A dirty worktree refuses (measured: git exits
|
|
16
|
+
* 128 with "contains modified or untracked files") and the caller reports the
|
|
17
|
+
* count instead of destroying work, so the refusal is recoverable.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, rmSync } from 'fs';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import { canonicalPath, pathHolds } from '@/server/lib/fs/canonical';
|
|
23
|
+
import { runGit } from '@/server/lib/fs/git-run';
|
|
24
|
+
import { resolveProjectRoot } from '@/server/lib/omp/core/worktree';
|
|
25
|
+
import { WorktreeError } from '@/server/lib/fs/worktree/errors';
|
|
26
|
+
import { isWorkTree, listWorktrees } from '@/server/lib/fs/worktree/list';
|
|
27
|
+
|
|
28
|
+
/** Changed + untracked count in `directory`; 0 when git cannot answer. */
|
|
29
|
+
export async function dirtyCount(directory: string): Promise<number> {
|
|
30
|
+
const result = await runGit(['status', '--porcelain'], { cwd: directory, maxBuffer: 4 * 1024 * 1024 });
|
|
31
|
+
if (result.exitCode !== 0) return 0;
|
|
32
|
+
return result.stdout.split('\n').filter((line) => line.trim()).length;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Commits on `branch` that are not on `startRef`; null when it cannot be told. */
|
|
36
|
+
export async function commitsAheadOf(
|
|
37
|
+
repo: string,
|
|
38
|
+
startRef: string,
|
|
39
|
+
branch: string,
|
|
40
|
+
): Promise<number | null> {
|
|
41
|
+
if (!startRef || !branch) return null;
|
|
42
|
+
const result = await runGit(['rev-list', '--count', `${startRef}..${branch}`], { cwd: repo });
|
|
43
|
+
if (result.exitCode !== 0) return null;
|
|
44
|
+
const value = Number.parseInt(result.stdout.trim(), 10);
|
|
45
|
+
return Number.isFinite(value) ? value : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RemoveWorktreeInput {
|
|
49
|
+
/** Absolute worktree path as the caller holds it (any spelling). */
|
|
50
|
+
worktreePath: string;
|
|
51
|
+
/** Any directory inside the repository — used for the git commands. */
|
|
52
|
+
projectPath: string;
|
|
53
|
+
/** Delete the branch too, attempted with `git branch -d` only. */
|
|
54
|
+
deleteBranch?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* `<data dir>/worktrees` — a root Kabmo owns outright, where a leftover
|
|
57
|
+
* directory may be cleaned even after git stopped tracking it. A CONFIGURED
|
|
58
|
+
* root is not this: there, only a path git still lists is ever touched.
|
|
59
|
+
*/
|
|
60
|
+
managedRoot?: string | null;
|
|
61
|
+
/**
|
|
62
|
+
* Destroy the live children whose cwd is inside, and delete or archive their
|
|
63
|
+
* sessions. Runs BEFORE anything is removed; returns the ids it handled.
|
|
64
|
+
*/
|
|
65
|
+
stopSessions?: (worktreePath: string) => Promise<string[]>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RemoveWorktreeDeps {
|
|
69
|
+
rowFor: (worktreePath: string) => Promise<{ start_ref: string } | undefined>;
|
|
70
|
+
removeRow: (worktreePath: string) => Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RemovedWorktree {
|
|
74
|
+
removed: true;
|
|
75
|
+
archived: string[];
|
|
76
|
+
branchDeleted: boolean;
|
|
77
|
+
/** True when the user asked for the branch and git refused to lose it. */
|
|
78
|
+
branchKept: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function removeWorktree(
|
|
82
|
+
input: RemoveWorktreeInput,
|
|
83
|
+
deps: RemoveWorktreeDeps,
|
|
84
|
+
): Promise<RemovedWorktree> {
|
|
85
|
+
const requested = path.resolve(input.worktreePath);
|
|
86
|
+
const insideManagedRoot = input.managedRoot ? await pathHolds(input.managedRoot, requested) : false;
|
|
87
|
+
|
|
88
|
+
const insideWorkTree = await isWorkTree(input.projectPath);
|
|
89
|
+
if (!insideWorkTree) {
|
|
90
|
+
throw new WorktreeError('not_a_repo', 'This folder is not a Git repository.');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const primaryRoot = await resolveProjectRoot(input.projectPath);
|
|
94
|
+
const entries = await listWorktrees(primaryRoot);
|
|
95
|
+
const target = await canonicalPath(requested);
|
|
96
|
+
const entry = entries.find((candidate) => candidate.path === target);
|
|
97
|
+
|
|
98
|
+
if (!entry) {
|
|
99
|
+
// Not registered: only a directory inside a root we own may be cleaned, and
|
|
100
|
+
// only because git already let it go.
|
|
101
|
+
if (!insideManagedRoot) {
|
|
102
|
+
throw new WorktreeError('not_a_worktree', `${requested} is not a worktree of this repository.`);
|
|
103
|
+
}
|
|
104
|
+
await runGit(['worktree', 'prune'], { cwd: primaryRoot });
|
|
105
|
+
if (existsSync(requested)) rmSync(requested, { recursive: true, force: true });
|
|
106
|
+
await deps.removeRow(target);
|
|
107
|
+
return { removed: true, archived: [], branchDeleted: false, branchKept: false };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (entry.isPrimary) {
|
|
111
|
+
throw new WorktreeError('primary_checkout', 'The main checkout cannot be removed.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const archived = input.stopSessions ? await input.stopSessions(entry.path) : [];
|
|
115
|
+
|
|
116
|
+
const dirty = await dirtyCount(entry.path);
|
|
117
|
+
if (dirty > 0) {
|
|
118
|
+
throw new WorktreeError(
|
|
119
|
+
'dirty',
|
|
120
|
+
`${dirty} uncommitted file${dirty === 1 ? '' : 's'} here would be lost.`,
|
|
121
|
+
{ count: dirty },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const removed = await runGit(['worktree', 'remove', entry.path], { cwd: primaryRoot });
|
|
126
|
+
if (removed.exitCode !== 0) {
|
|
127
|
+
throw new WorktreeError(
|
|
128
|
+
'remove_failed',
|
|
129
|
+
removed.stderr.trim() || removed.stdout.trim() || `git exited ${removed.exitCode}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
await runGit(['worktree', 'prune'], { cwd: primaryRoot });
|
|
133
|
+
|
|
134
|
+
// The branch: `-d` only. Its refusal IS the safety net, so a branch with
|
|
135
|
+
// commits of its own survives and the answer says so.
|
|
136
|
+
let branchDeleted = false;
|
|
137
|
+
let branchKept = false;
|
|
138
|
+
if (input.deleteBranch && entry.branch) {
|
|
139
|
+
const row = await deps.rowFor(entry.path);
|
|
140
|
+
const ahead = await commitsAheadOf(primaryRoot, row?.start_ref ?? '', entry.branch);
|
|
141
|
+
if (ahead !== null && ahead > 0) {
|
|
142
|
+
branchKept = true;
|
|
143
|
+
} else {
|
|
144
|
+
const deleted = await runGit(['branch', '-d', entry.branch], { cwd: primaryRoot });
|
|
145
|
+
branchDeleted = deleted.exitCode === 0;
|
|
146
|
+
branchKept = !branchDeleted;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await deps.removeRow(entry.path);
|
|
151
|
+
return { removed: true, archived, branchDeleted, branchKept };
|
|
152
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The two worktree settings, read on the SERVER.
|
|
8
|
+
*
|
|
9
|
+
* They live in the `kabmo_settings` blob the settings UI writes, and a create
|
|
10
|
+
* has to honour them without a round trip through the page — a scheduled task or
|
|
11
|
+
* a plugin can create a worktree with no tab open.
|
|
12
|
+
*
|
|
13
|
+
* Absent means the default, never "off": a database written before this feature
|
|
14
|
+
* has no keys at all, and the feature must behave as documented on it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { getDb } from '@/server/db.server';
|
|
18
|
+
import { readSettingsBlob, SETTINGS_BLOB_KEY } from '@/shared/lib/settings/keys';
|
|
19
|
+
import {
|
|
20
|
+
WORKTREE_CARRY_SETTING,
|
|
21
|
+
WORKTREE_DIRECTORY_SETTING,
|
|
22
|
+
} from '@/shared/lib/fs/worktree/settings-keys';
|
|
23
|
+
|
|
24
|
+
export interface WorktreeSettings {
|
|
25
|
+
/** The configured parent directory, un-resolved; `null` when unset. */
|
|
26
|
+
directory: string | null;
|
|
27
|
+
carryIgnored: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function readWorktreeSettings(): Promise<WorktreeSettings> {
|
|
31
|
+
const fallback: WorktreeSettings = { directory: null, carryIgnored: true };
|
|
32
|
+
try {
|
|
33
|
+
const db = await getDb();
|
|
34
|
+
const row = await db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', [SETTINGS_BLOB_KEY]);
|
|
35
|
+
if (!row?.value) return fallback;
|
|
36
|
+
let parsed: unknown;
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(row.value);
|
|
39
|
+
} catch {
|
|
40
|
+
return fallback;
|
|
41
|
+
}
|
|
42
|
+
const blob = readSettingsBlob({ [SETTINGS_BLOB_KEY]: parsed });
|
|
43
|
+
const directory = typeof blob[WORKTREE_DIRECTORY_SETTING] === 'string'
|
|
44
|
+
? (blob[WORKTREE_DIRECTORY_SETTING] as string)
|
|
45
|
+
: null;
|
|
46
|
+
const carry = blob[WORKTREE_CARRY_SETTING];
|
|
47
|
+
return {
|
|
48
|
+
directory: directory?.trim() ? directory.trim() : null,
|
|
49
|
+
// Only an explicit `false` turns the carry off; anything else is the
|
|
50
|
+
// documented default of on.
|
|
51
|
+
carryIgnored: carry !== false,
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
return fallback;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Worktree topology of one repository, and the session → worktree lookup built
|
|
8
|
+
* on it.
|
|
9
|
+
*
|
|
10
|
+
* A session's badge is derived from its own cwd, never from a stored
|
|
11
|
+
* association: the cwd is the truth and it survives a branch rename.
|
|
12
|
+
*
|
|
13
|
+
* The topology is cached per primary root for a short TTL, because the sidebar
|
|
14
|
+
* scan runs on every publish while `git worktree list` costs a spawn per
|
|
15
|
+
* repository. `invalidateWorktreeTopology()` is called by the create and remove
|
|
16
|
+
* paths, and by the `workspace-dirty` signal, so a worktree made a second ago
|
|
17
|
+
* appears without waiting the TTL out.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { canonicalPath, pathHolds } from '@/server/lib/fs/canonical';
|
|
21
|
+
import { listWorktrees, type WorktreeEntry } from '@/server/lib/fs/worktree/list';
|
|
22
|
+
import type { SessionWorktreeInfo } from '@/shared/lib/workspace/worktree/types';
|
|
23
|
+
|
|
24
|
+
const TOPOLOGY_TTL_MS = 10_000;
|
|
25
|
+
|
|
26
|
+
declare global {
|
|
27
|
+
// eslint-disable-next-line no-var
|
|
28
|
+
var __kabmoWorktreeTopology:
|
|
29
|
+
| Map<string, { entries: WorktreeEntry[]; expiresAt: number; inFlight?: Promise<WorktreeEntry[]> }>
|
|
30
|
+
| undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function topologyCache(): Map<string, { entries: WorktreeEntry[]; expiresAt: number; inFlight?: Promise<WorktreeEntry[]> }> {
|
|
34
|
+
globalThis.__kabmoWorktreeTopology ??= new Map();
|
|
35
|
+
return globalThis.__kabmoWorktreeTopology;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Drop the cache for one repository, or all of them. */
|
|
39
|
+
export async function invalidateWorktreeTopology(primaryRoot?: string): Promise<void> {
|
|
40
|
+
const cache = topologyCache();
|
|
41
|
+
if (!primaryRoot) {
|
|
42
|
+
cache.clear();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
cache.delete(await canonicalPath(primaryRoot));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every worktree of `primaryRoot`, cached for a few seconds. A failure is NOT
|
|
50
|
+
* cached: an unreadable repository must not look like one with no worktrees for
|
|
51
|
+
* the next ten minutes.
|
|
52
|
+
*/
|
|
53
|
+
export async function readProjectWorktrees(primaryRoot: string): Promise<WorktreeEntry[]> {
|
|
54
|
+
const cache = topologyCache();
|
|
55
|
+
// Keyed by the CANONICAL root: a caller holding `/tmp/x` and one holding
|
|
56
|
+
// `/private/tmp/x` are asking about one repository, and two entries would let
|
|
57
|
+
// an invalidation miss the entry a later read created (measured: the
|
|
58
|
+
// invalidation dropped nothing and the new worktree stayed invisible).
|
|
59
|
+
const key = await canonicalPath(primaryRoot);
|
|
60
|
+
const hit = cache.get(key);
|
|
61
|
+
if (hit && hit.expiresAt > Date.now()) return hit.entries;
|
|
62
|
+
if (hit?.inFlight) return hit.inFlight;
|
|
63
|
+
|
|
64
|
+
const inFlight = listWorktrees(key)
|
|
65
|
+
.then((entries) => {
|
|
66
|
+
cache.set(key, { entries, expiresAt: Date.now() + TOPOLOGY_TTL_MS });
|
|
67
|
+
return entries;
|
|
68
|
+
})
|
|
69
|
+
.catch((error: unknown) => {
|
|
70
|
+
cache.delete(key);
|
|
71
|
+
throw error;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
cache.set(key, { entries: hit?.entries ?? [], expiresAt: 0, inFlight });
|
|
75
|
+
return inFlight;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The worktree holding `cwd`, or null when the session runs in the checkout
|
|
80
|
+
* itself — a chat in the main repository is not a "worktree session", and a
|
|
81
|
+
* badge on every row would be noise — or in none.
|
|
82
|
+
*
|
|
83
|
+
* Longest path wins, so a worktree nested inside another resolves to the inner
|
|
84
|
+
* checkout: the directory the agent actually writes to.
|
|
85
|
+
*/
|
|
86
|
+
export async function worktreeForCwd(
|
|
87
|
+
entries: WorktreeEntry[],
|
|
88
|
+
cwd: string,
|
|
89
|
+
primaryRoot: string,
|
|
90
|
+
): Promise<SessionWorktreeInfo | null> {
|
|
91
|
+
let best: WorktreeEntry | null = null;
|
|
92
|
+
let bestLength = -1;
|
|
93
|
+
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (!entry.path || entry.isPrimary) continue;
|
|
96
|
+
if (!(await pathHolds(entry.path, cwd))) continue;
|
|
97
|
+
const length = (await canonicalPath(entry.path)).length;
|
|
98
|
+
if (length <= bestLength) continue;
|
|
99
|
+
best = entry;
|
|
100
|
+
bestLength = length;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!best) return null;
|
|
104
|
+
return {
|
|
105
|
+
branch: best.branch,
|
|
106
|
+
path: await canonicalPath(best.path),
|
|
107
|
+
primaryRoot,
|
|
108
|
+
status: best.missing ? 'missing' : 'ready',
|
|
109
|
+
detached: best.detached,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One lookup per session of one repository, sharing a single topology read.
|
|
115
|
+
* Sessions whose cwd is outside every worktree are absent from the map — the
|
|
116
|
+
* caller treats "absent" as "an ordinary session", which is the common case.
|
|
117
|
+
*/
|
|
118
|
+
export async function sessionWorktreeMap(
|
|
119
|
+
primaryRoot: string,
|
|
120
|
+
sessions: readonly { id: string; cwd: string }[],
|
|
121
|
+
): Promise<Map<string, SessionWorktreeInfo>> {
|
|
122
|
+
const result = new Map<string, SessionWorktreeInfo>();
|
|
123
|
+
if (sessions.length === 0) return result;
|
|
124
|
+
|
|
125
|
+
let entries: WorktreeEntry[];
|
|
126
|
+
try {
|
|
127
|
+
entries = await readProjectWorktrees(primaryRoot);
|
|
128
|
+
} catch {
|
|
129
|
+
// A repository git cannot read has no badges to draw; it must not fail the
|
|
130
|
+
// sidebar, which lists sessions from files rather than from git.
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
if (entries.length <= 1) return result;
|
|
134
|
+
|
|
135
|
+
const resolved = await Promise.all(
|
|
136
|
+
sessions.map(async (session) => ({ id: session.id, info: await worktreeForCwd(entries, session.cwd, primaryRoot) })),
|
|
137
|
+
);
|
|
138
|
+
for (const { id, info } of resolved) {
|
|
139
|
+
if (info) result.set(id, info);
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|