@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
package/src/client/tailwind.css
CHANGED
|
@@ -245,6 +245,25 @@
|
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
/* The Files panel's tree, on a finger.
|
|
249
|
+
*
|
|
250
|
+
* Every row is `select-none`, but a long press on iOS/Android still raises the
|
|
251
|
+
* native callout and a selection of the CONTAINER's text — measured on a phone:
|
|
252
|
+
* holding a file row highlighted the whole panel instead of opening its menu.
|
|
253
|
+
* `user-select` alone does not stop it; `-webkit-touch-callout` is what removes
|
|
254
|
+
* the callout menu, and `user-select: none` on the scroller (not only the rows)
|
|
255
|
+
* is what keeps a press that drifts between two rows from selecting the text of
|
|
256
|
+
* both. Rows themselves stay `select-none` and no row carries `select-text`:
|
|
257
|
+
* a path is copied through the context menu's Copy Path, never by dragging over
|
|
258
|
+
* it, and a selectable row makes every press a candidate for a selection.
|
|
259
|
+
*/
|
|
260
|
+
.file-tree-touch,
|
|
261
|
+
.file-tree-touch * {
|
|
262
|
+
-webkit-touch-callout: none;
|
|
263
|
+
-webkit-user-select: none;
|
|
264
|
+
user-select: none;
|
|
265
|
+
}
|
|
266
|
+
|
|
248
267
|
/* Horizontally scrollable strips (mobile tab bars) that pan without a visible
|
|
249
268
|
* scrollbar track eating vertical space. */
|
|
250
269
|
.no-scrollbar {
|
|
@@ -365,6 +384,43 @@
|
|
|
365
384
|
.shiki span { color: var(--shiki-light); }
|
|
366
385
|
html[data-theme-variant="dark"] .shiki span { color: var(--shiki-dark); }
|
|
367
386
|
|
|
387
|
+
/* No per-token underline in a diff.
|
|
388
|
+
*
|
|
389
|
+
* `@codemirror/merge` wraps the characters that changed in `.cm-changedText`
|
|
390
|
+
* (and a deleted chunk's in `.cm-deletedText`) and its BASE THEME paints a 2px
|
|
391
|
+
* gradient along the bottom of each fragment. A word-level change is usually
|
|
392
|
+
* several fragments — `2` → `22` is one — so the reader got a run of short
|
|
393
|
+
* coloured bars under the code that reads as a second, broken baseline rather
|
|
394
|
+
* than as emphasis. The row's own band and the gutter marker already say which
|
|
395
|
+
* side a row belongs to.
|
|
396
|
+
*
|
|
397
|
+
* Three facts about how this has to be written, each measured:
|
|
398
|
+
*
|
|
399
|
+
* 1. **It cannot be suppressed from the panel's own `EditorView.theme`.** That
|
|
400
|
+
* theme rejects the `&light`/`&dark` selectors the package's rules are spelled
|
|
401
|
+
* with (`RangeError: Unsupported selector: &light`, which took the whole
|
|
402
|
+
* merge view down and left an empty host).
|
|
403
|
+
* 2. **A plain `.cm-changedText { background: none }` LOSES.** The package's rule
|
|
404
|
+
* is `.ͼ2.cm-merge-b .cm-changedText` — two classes on the root plus one on the
|
|
405
|
+
* span, i.e. specificity (0,3,0) — and CodeMirror mounts its style modules
|
|
406
|
+
* after this sheet, so document order is against us as well.
|
|
407
|
+
* 3. **So the selector carries three class-level parts of its own** (`.cm-editor`
|
|
408
|
+
* + `.cm-merge-b`/`.cm-merge-a` + the span class), which wins on specificity
|
|
409
|
+
* rather than by `!important` — an `!important` here would also beat a
|
|
410
|
+
* future theme that legitimately wants a different treatment, and this is a
|
|
411
|
+
* cosmetic suppression, not a hard override.
|
|
412
|
+
*
|
|
413
|
+
* `diff-panel.e2e.ts` asserts the computed `background-image` on a real changed
|
|
414
|
+
* token, so a package bump that reintroduces the bar fails there rather than in
|
|
415
|
+
* a screenshot review. */
|
|
416
|
+
.cm-editor.cm-merge-a .cm-changedText,
|
|
417
|
+
.cm-editor.cm-merge-b .cm-changedText,
|
|
418
|
+
.cm-editor.cm-merge-a .cm-deletedChunk .cm-deletedText,
|
|
419
|
+
.cm-editor.cm-merge-b .cm-deletedChunk .cm-deletedText,
|
|
420
|
+
.cm-editor .cm-deletedChunk .cm-deletedText {
|
|
421
|
+
background: none;
|
|
422
|
+
}
|
|
423
|
+
|
|
368
424
|
/* Find matches, painted inside the token spans the highlighter emits. An
|
|
369
425
|
* inline <mark> colours a background without moving a glyph, which is what
|
|
370
426
|
* keeps the transparent <textarea> above aligned with this layer — a block or
|
|
@@ -381,14 +437,6 @@ html[data-theme-variant="dark"] .shiki span { color: var(--shiki-dark); }
|
|
|
381
437
|
color: inherit;
|
|
382
438
|
background: color-mix(in srgb, var(--theme-warning) 32%, transparent);
|
|
383
439
|
}
|
|
384
|
-
/* An extra ⌘D selection: same geometry as a find match, different ink. The
|
|
385
|
-
* caret can only live in the primary range, so the two must not look alike —
|
|
386
|
-
* the user has to see where their keystrokes will land. */
|
|
387
|
-
.shiki .find-match[data-occurrence] {
|
|
388
|
-
background: color-mix(in srgb, var(--theme-info) 30%, transparent);
|
|
389
|
-
border-top: 1px solid color-mix(in srgb, var(--theme-info) 55%, transparent);
|
|
390
|
-
border-bottom: 1px solid color-mix(in srgb, var(--theme-info) 55%, transparent);
|
|
391
|
-
}
|
|
392
440
|
.shiki .find-match[data-find-current] {
|
|
393
441
|
background: color-mix(in srgb, var(--theme-warning) 55%, transparent);
|
|
394
442
|
border-top: 1px solid color-mix(in srgb, var(--theme-warning) 75%, transparent);
|
package/src/server/index.ts
CHANGED
|
@@ -268,7 +268,9 @@ startWaitingRuntime();
|
|
|
268
268
|
*
|
|
269
269
|
* A Kabmo update replaces the store and leaves the install — it is a
|
|
270
270
|
* copied directory of ordinary files — so this is the only thing that notices.
|
|
271
|
-
*
|
|
271
|
+
* An install Kabmo wrote is compared by CONTENT, so a shipped fix applies even
|
|
272
|
+
* when its version did not move; a directory of unknown provenance keeps the
|
|
273
|
+
* version gate, and git clones are skipped, so a plugin the user installed from
|
|
272
274
|
* a URL is never overwritten by a store entry that happens to share its id.
|
|
273
275
|
*
|
|
274
276
|
* BEFORE the server halves load, and that order is the whole point: a plugin's
|
|
@@ -283,7 +285,10 @@ startWaitingRuntime();
|
|
|
283
285
|
try {
|
|
284
286
|
const synced = await syncBundledPlugins();
|
|
285
287
|
for (const item of synced.updated) {
|
|
286
|
-
|
|
288
|
+
// A content-only sync has no version to show, and printing `1.0.0 → 1.0.0`
|
|
289
|
+
// would read as a no-op when a shipped fix actually landed.
|
|
290
|
+
const span = item.contentOnly ? `${item.to} (content changed, version unchanged)` : `${item.from} → ${item.to}`;
|
|
291
|
+
console.log(`[kabmo] plugins: synced ${item.pluginId} ${span}`);
|
|
287
292
|
if (item.warning) console.error(`[plugins] ${item.pluginId}: rebuild after sync failed — ${item.warning}`);
|
|
288
293
|
}
|
|
289
294
|
for (const failure of synced.failed) console.error(`[plugins] ${failure.pluginId}: bundled sync failed — ${failure.error}`);
|
|
@@ -30,6 +30,18 @@ describe('public paths', () => {
|
|
|
30
30
|
expect(isPublicPath('/login')).toBe(true);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The two shells ask their update check from native code, which has no
|
|
35
|
+
* session cookie — and the button is for a shell so stale it may not be able
|
|
36
|
+
* to load the console at all. Gating these answered `401` to every shell on a
|
|
37
|
+
* password-protected console, which the settings screen could only report as
|
|
38
|
+
* "Could not ask the server" (measured against a real install).
|
|
39
|
+
*/
|
|
40
|
+
test('the native shells can ask what is published without a session', () => {
|
|
41
|
+
expect(isPublicPath('/api/updates/android')).toBe(true);
|
|
42
|
+
expect(isPublicPath('/api/updates/ios')).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
33
45
|
test('the assets the login screen is built from are public', () => {
|
|
34
46
|
for (const path of [
|
|
35
47
|
'/_dev-assets/client/index-abc.js',
|
|
@@ -153,6 +165,22 @@ describe('authentication is per run, never inherited from disk', () => {
|
|
|
153
165
|
expect(response?.status).toBe(401);
|
|
154
166
|
});
|
|
155
167
|
|
|
168
|
+
/**
|
|
169
|
+
* The bug this pins: both shells ask their update check from NATIVE code,
|
|
170
|
+
* which carries no cookie, so a password-protected console answered `401` and
|
|
171
|
+
* the settings screen could only say "Could not ask the server" (measured on a
|
|
172
|
+
* real install). The desktop route stays gated because the CONSOLE asks it,
|
|
173
|
+
* and the console is behind the login.
|
|
174
|
+
*/
|
|
175
|
+
test('a password does not gate the native shells\u2019 update check', async () => {
|
|
176
|
+
useTempDataDir();
|
|
177
|
+
await initAuth('correct-horse');
|
|
178
|
+
|
|
179
|
+
expect(authGate(new Request('http://localhost/api/updates/android'))).toBeUndefined();
|
|
180
|
+
expect(authGate(new Request('http://localhost/api/updates/ios'))).toBeUndefined();
|
|
181
|
+
expect(authGate(new Request('http://localhost/api/updates/desktop?kind=macos'))?.status).toBe(401);
|
|
182
|
+
});
|
|
183
|
+
|
|
156
184
|
test('a document request is redirected to the login screen, not answered 401', async () => {
|
|
157
185
|
useTempDataDir();
|
|
158
186
|
await initAuth('correct-horse');
|
|
@@ -41,6 +41,16 @@ const LOGIN_PATH = '/login';
|
|
|
41
41
|
/** Paths that never require a session, because they ARE the way in. */
|
|
42
42
|
const PUBLIC_EXACT = new Set([
|
|
43
43
|
'/api/health',
|
|
44
|
+
// The native shells' own update check. Android and iOS ask these from
|
|
45
|
+
// NATIVE code (`AppUpdateCheck.kt`, `SettingsViewController.fetchRelease`),
|
|
46
|
+
// which carries no WebView cookie — and the button exists precisely for a
|
|
47
|
+
// shell too stale to load the console, so it cannot depend on a session
|
|
48
|
+
// either. Both answer GitHub's public release list (a version and a download
|
|
49
|
+
// URL), which is the same data an unauthenticated browser can read from
|
|
50
|
+
// github.com; `/api/updates/desktop` stays gated because only the CONSOLE
|
|
51
|
+
// asks it, and the console is behind the login.
|
|
52
|
+
'/api/updates/android',
|
|
53
|
+
'/api/updates/ios',
|
|
44
54
|
'/api/auth/state',
|
|
45
55
|
'/api/auth/login',
|
|
46
56
|
'/api/auth/logout',
|
|
@@ -50,11 +50,21 @@ describe('window-drag contract', () => {
|
|
|
50
50
|
/**
|
|
51
51
|
* The shell's hit test starts at the element under the pointer and walks up.
|
|
52
52
|
* A header without the attribute is a header that cannot move the window.
|
|
53
|
+
*
|
|
54
|
+
* EVERY header that can be the window's top strip is listed, not just the
|
|
55
|
+
* desktop pair. A desktop shell window may be narrower than `App`'s 768px
|
|
56
|
+
* layout breakpoint (all three shells allow 720px), so the phone layout's
|
|
57
|
+
* headers are title bars too — and the mobile ones were missing the attribute
|
|
58
|
+
* entirely, which on macOS left a narrowed window with no draggable strip in
|
|
59
|
+
* its main screen and no way to move it.
|
|
53
60
|
*/
|
|
54
|
-
test('
|
|
61
|
+
test('every title-bar header carries data-drag-region', () => {
|
|
55
62
|
for (const path of [
|
|
56
63
|
'src/client/components/layout/desktop-layout/TopNavbar.tsx',
|
|
57
64
|
'src/client/components/layout/session-sidebar/Header.tsx',
|
|
65
|
+
'src/client/components/mobile/mobile-main-view/Header.tsx',
|
|
66
|
+
'src/client/components/mobile/mobile-session-sidebar/Header.tsx',
|
|
67
|
+
'src/client/components/mobile/RightSidebar.tsx',
|
|
58
68
|
]) {
|
|
59
69
|
const file = source(path);
|
|
60
70
|
expect(file).toContain('dragRegionProps()');
|
|
@@ -63,6 +73,37 @@ describe('window-drag contract', () => {
|
|
|
63
73
|
expect(source('src/client/lib/desktop/titlebar.ts')).toContain("'data-drag-region'");
|
|
64
74
|
});
|
|
65
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The phone headers reserve the shell's own inset, not only the PWA's.
|
|
78
|
+
*
|
|
79
|
+
* `titlebarPaddingLeft` reads `env(titlebar-area-x)`, which a native WKWebView
|
|
80
|
+
* never defines — so the shell's 76px of traffic lights has to come from
|
|
81
|
+
* `titlebarInsetLeft`, the helper the desktop headers already use. Without it
|
|
82
|
+
* the phone header's hamburger and wordmark sat UNDER the traffic lights.
|
|
83
|
+
*/
|
|
84
|
+
test('the phone headers use the shell inset', () => {
|
|
85
|
+
for (const path of [
|
|
86
|
+
'src/client/components/mobile/mobile-main-view/Header.tsx',
|
|
87
|
+
'src/client/components/mobile/mobile-session-sidebar/Header.tsx',
|
|
88
|
+
'src/client/components/mobile/RightSidebar.tsx',
|
|
89
|
+
]) {
|
|
90
|
+
expect(source(path)).toContain('titlebarInsetLeft(');
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A header marked as a drag region makes its controls drag handles unless
|
|
96
|
+
* they say otherwise — `app-region` INHERITS (WebView2's spec, enforced by
|
|
97
|
+
* Chromium 152), and a drag handle swallows the click.
|
|
98
|
+
*/
|
|
99
|
+
test('a drag-region header marks its controls no-drag', () => {
|
|
100
|
+
const pairs: Array<[string, string]> = [
|
|
101
|
+
['src/client/components/mobile/RightSidebar.tsx', 'titlebar-no-drag'],
|
|
102
|
+
['src/client/components/common/header-controls/index.tsx', 'titlebar-no-drag'],
|
|
103
|
+
];
|
|
104
|
+
for (const [path, marker] of pairs) expect(source(path)).toContain(marker);
|
|
105
|
+
});
|
|
106
|
+
|
|
66
107
|
/** The PWA keeps its drag region: the property has to reach the browser. */
|
|
67
108
|
test('the webkit property survives the bundle', async () => {
|
|
68
109
|
const out = await build(`
|
|
@@ -18,6 +18,7 @@ import { ensureScheduleSchema } from '@/server/lib/schedule/schema.server';
|
|
|
18
18
|
import { ensureWaitingSchema } from '@/server/lib/waiting/schema.server';
|
|
19
19
|
import { ensurePluginSchema } from '@/server/lib/plugins/schema.server';
|
|
20
20
|
import { ensureModelRolePresetSchema } from '@/server/lib/model-roles/schema.server';
|
|
21
|
+
import { ensureWorktreeSchema } from '@/server/lib/db/worktree-registry.server';
|
|
21
22
|
import { migrateWorkspaceFolderColumns } from '@/shared/lib/workspace/schema-migrations';
|
|
22
23
|
import { migrateSessionStreamStateColumns } from '@/shared/lib/omp/session/schema-migrations';
|
|
23
24
|
|
|
@@ -112,6 +113,9 @@ export async function initSchema(db: DbClient): Promise<void> {
|
|
|
112
113
|
await ensurePluginSchema(db);
|
|
113
114
|
// Named model-role presets (the role editor's preset picker).
|
|
114
115
|
await ensureModelRolePresetSchema(db);
|
|
116
|
+
// Worktree registry: the ref each worktree branched from, and the identity
|
|
117
|
+
// cache that keys its folder.
|
|
118
|
+
await ensureWorktreeSchema(db);
|
|
115
119
|
|
|
116
120
|
await db.exec(`
|
|
117
121
|
CREATE TABLE IF NOT EXISTS files (
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `worktrees` table: one row per worktree Kabmo created.
|
|
8
|
+
*
|
|
9
|
+
* Everything a worktree row shows is read from git — except two things this
|
|
10
|
+
* table is the only home for:
|
|
11
|
+
*
|
|
12
|
+
* - **`start_ref`**, the ref the branch was created from. `aheadOfStart` gates
|
|
13
|
+
* the delete dialog's branch checkbox, and git cannot answer it later: the
|
|
14
|
+
* branch has moved on, and for an existing-branch worktree there was no start
|
|
15
|
+
* ref at all.
|
|
16
|
+
* - **`project_id`**, the repository's identity, which is what the default
|
|
17
|
+
* root is keyed by. It is a CACHE, not a derivation result: the derivation
|
|
18
|
+
* moves when a repository gains an unrelated root commit (see
|
|
19
|
+
* `worktree/project-id.ts`), so a stored value always wins.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { DbClient } from '@/server/lib/db/client';
|
|
23
|
+
|
|
24
|
+
const WORKTREE_SCHEMA_SQL = `
|
|
25
|
+
CREATE TABLE IF NOT EXISTS worktrees (
|
|
26
|
+
path TEXT PRIMARY KEY,
|
|
27
|
+
primary_root TEXT NOT NULL,
|
|
28
|
+
project_id TEXT NOT NULL,
|
|
29
|
+
branch TEXT NOT NULL,
|
|
30
|
+
start_ref TEXT NOT NULL DEFAULT '',
|
|
31
|
+
created_at TEXT NOT NULL
|
|
32
|
+
);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_worktrees_primary ON worktrees (primary_root);
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
export interface WorktreeRow {
|
|
37
|
+
path: string;
|
|
38
|
+
primary_root: string;
|
|
39
|
+
project_id: string;
|
|
40
|
+
branch: string;
|
|
41
|
+
start_ref: string;
|
|
42
|
+
created_at: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Idempotent: the worktree registry, created on every database open. */
|
|
46
|
+
export async function ensureWorktreeSchema(db: DbClient): Promise<void> {
|
|
47
|
+
await db.exec(WORKTREE_SCHEMA_SQL);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The identity cache: the project id already recorded for this repository.
|
|
52
|
+
* `LIMIT 1` because every row of one repository carries the same value.
|
|
53
|
+
*/
|
|
54
|
+
export async function worktreeIdentityFor(db: DbClient, primaryRoot: string): Promise<string | null> {
|
|
55
|
+
try {
|
|
56
|
+
const row = await db.get<{ project_id: string }>(
|
|
57
|
+
'SELECT project_id FROM worktrees WHERE primary_root = ? LIMIT 1',
|
|
58
|
+
[primaryRoot],
|
|
59
|
+
);
|
|
60
|
+
return typeof row?.project_id === 'string' && row.project_id ? row.project_id : null;
|
|
61
|
+
} catch {
|
|
62
|
+
// A database that cannot answer is not a reason to refuse the operation:
|
|
63
|
+
// the derivation still gives a usable key.
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function worktreeRowFor(db: DbClient, worktreePath: string): Promise<WorktreeRow | undefined> {
|
|
69
|
+
return db.get<WorktreeRow>('SELECT * FROM worktrees WHERE path = ?', [worktreePath]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function listWorktreeRows(db: DbClient, primaryRoot: string): Promise<WorktreeRow[]> {
|
|
73
|
+
return db.all<WorktreeRow>('SELECT * FROM worktrees WHERE primary_root = ?', [primaryRoot]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function putWorktreeRow(db: DbClient, row: WorktreeRow): Promise<void> {
|
|
77
|
+
await db.run(
|
|
78
|
+
`INSERT INTO worktrees (path, primary_root, project_id, branch, start_ref, created_at)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
80
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
81
|
+
primary_root = excluded.primary_root,
|
|
82
|
+
project_id = excluded.project_id,
|
|
83
|
+
branch = excluded.branch,
|
|
84
|
+
start_ref = excluded.start_ref`,
|
|
85
|
+
[row.path, row.primary_root, row.project_id, row.branch, row.start_ref, row.created_at],
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function removeWorktreeRow(db: DbClient, worktreePath: string): Promise<void> {
|
|
90
|
+
await db.run('DELETE FROM worktrees WHERE path = ?', [worktreePath]);
|
|
91
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Canonical paths and containment for filesystem work.
|
|
8
|
+
*
|
|
9
|
+
* Two traps this exists to close, both measured:
|
|
10
|
+
*
|
|
11
|
+
* - **A spelling difference is not a difference.** On macOS git prints
|
|
12
|
+
* `/private/tmp/...` for a directory the caller typed as `/tmp/...`, and
|
|
13
|
+
* `path.resolve` alone does not reconcile them. A worktree compared against
|
|
14
|
+
* its own entry answered "not inside", so the session badge silently never
|
|
15
|
+
* appeared.
|
|
16
|
+
* - **A missing path cannot be `realpath`'d at all** — and that is exactly the
|
|
17
|
+
* `missing` worktree, whose directory was removed outside Kabmo. Resolving
|
|
18
|
+
* the nearest existing ANCESTOR and re-appending the tail is what lets such a
|
|
19
|
+
* worktree still be matched against the entry git keeps listing.
|
|
20
|
+
*
|
|
21
|
+
* Everything that compares two paths goes through here; `isWithinRoot` in
|
|
22
|
+
* `lib/fs/root.ts` stays the raw prefix test it always was.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import path from 'path';
|
|
26
|
+
import { realpath } from 'fs/promises';
|
|
27
|
+
|
|
28
|
+
/** Absolute, symlink-resolved, no trailing separator. */
|
|
29
|
+
export async function canonicalPath(target: string): Promise<string> {
|
|
30
|
+
const absolute = path.resolve(target).replace(/\/+$/, '');
|
|
31
|
+
const pending: string[] = [];
|
|
32
|
+
let cursor = absolute;
|
|
33
|
+
while (true) {
|
|
34
|
+
try {
|
|
35
|
+
const resolved = (await realpath(cursor)).replace(/\/+$/, '');
|
|
36
|
+
return pending.length > 0 ? [resolved, ...pending].join(path.sep) : resolved;
|
|
37
|
+
} catch {
|
|
38
|
+
const parent = path.dirname(cursor);
|
|
39
|
+
// The whole ancestor chain is gone (or this is the filesystem root): the
|
|
40
|
+
// `..`-folded path is the best answer that exists.
|
|
41
|
+
if (parent === cursor) return absolute;
|
|
42
|
+
pending.unshift(path.basename(cursor));
|
|
43
|
+
cursor = parent;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** True when `target` is `root` itself or lies inside it. Both are canonicalized. */
|
|
49
|
+
export async function pathHolds(root: string, target: string): Promise<boolean> {
|
|
50
|
+
const [canonicalRoot, canonicalTarget] = await Promise.all([canonicalPath(root), canonicalPath(target)]);
|
|
51
|
+
return canonicalTarget === canonicalRoot || canonicalTarget.startsWith(`${canonicalRoot}${path.sep}`);
|
|
52
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One rule for every name the Files panel WRITES: a path relative to the folder
|
|
8
|
+
* that was named, nested segments allowed, and nothing that can leave it.
|
|
9
|
+
*
|
|
10
|
+
* The panel's create and rename both used to accept a bare name only, which made
|
|
11
|
+
* a nested path unreachable from the UI: creating `src/lib/parse.ts` meant
|
|
12
|
+
* creating `src`, expanding it, then `lib`, then the file. The restriction was
|
|
13
|
+
* never about safety — `resolveWithinRoot` already refuses an escape — it was
|
|
14
|
+
* the shape of the input field, so the grammar is now stated once and both
|
|
15
|
+
* callers read it.
|
|
16
|
+
*
|
|
17
|
+
* What is refused, and why each one is a distinct decision:
|
|
18
|
+
*
|
|
19
|
+
* - an ABSOLUTE path (`/etc/passwd`, `C:\x`): `path.join(dir, abs)` discards the
|
|
20
|
+
* folder the user named, so the write would land somewhere the dialog never
|
|
21
|
+
* showed. `resolveWithinRoot` catches most of it, but a drive-relative `C:x`
|
|
22
|
+
* normalizes under the root and would silently create a file named `C:x`.
|
|
23
|
+
* - any `..` SEGMENT: the escape itself. `resolveWithinRoot` would refuse a
|
|
24
|
+
* result outside the root, but `a/../b.ts` resolves INSIDE it, so the refusal
|
|
25
|
+
* would be about the wrong thing — the user would get "Invalid path" for a
|
|
26
|
+
* path that looks fine.
|
|
27
|
+
* - a NUL byte: it truncates the path at the syscall boundary.
|
|
28
|
+
*
|
|
29
|
+
* A backslash is treated as a separator rather than refused: a Windows user
|
|
30
|
+
* types `src\lib\x.ts` into the same field, and refusing it would be a rule the
|
|
31
|
+
* field cannot explain. Empty and `.` segments are dropped, so `src//x.ts` and
|
|
32
|
+
* `./src/x.ts` name the same file as `src/x.ts`.
|
|
33
|
+
*
|
|
34
|
+
* Returns the normalized POSIX-relative path, or null when it names nothing.
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeRelativePath(raw: string): string | null {
|
|
37
|
+
const trimmed = raw.trim().replace(/\\/g, '/');
|
|
38
|
+
if (!trimmed || trimmed.includes('\0')) return null;
|
|
39
|
+
if (trimmed.startsWith('/') || /^[a-zA-Z]:/.test(trimmed)) return null;
|
|
40
|
+
|
|
41
|
+
const segments: string[] = [];
|
|
42
|
+
for (const segment of trimmed.split('/')) {
|
|
43
|
+
if (segment === '' || segment === '.') continue;
|
|
44
|
+
if (segment === '..') return null;
|
|
45
|
+
segments.push(segment);
|
|
46
|
+
}
|
|
47
|
+
return segments.length > 0 ? segments.join('/') : null;
|
|
48
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The WORKTREE branch of the scoped-root allow-list.
|
|
8
|
+
*
|
|
9
|
+
* A chat started in a linked checkout runs THERE, outside its folder's
|
|
10
|
+
* `project_path` — and `resolveRoot` answered the fallback for it (measured), so
|
|
11
|
+
* Files/Search/Git/Terminal silently showed the app root while the agent wrote
|
|
12
|
+
* to the worktree.
|
|
13
|
+
*
|
|
14
|
+
* The rule is deliberately narrow: the candidate's own REPOSITORY ROOT must be a
|
|
15
|
+
* registered project, so a registered project never becomes a filesystem grant.
|
|
16
|
+
* A SUBDIRECTORY of a registered checkout stays refused — the allow-list is exact
|
|
17
|
+
* roots, and a panel is pointed at a worktree root, never at a folder inside it.
|
|
18
|
+
*
|
|
19
|
+
* Its own file, with its own database, because it needs a REAL git repository:
|
|
20
|
+
* `resolveProjectRoot` only answers for a directory holding a `.git` entry, and
|
|
21
|
+
* faking that would test the fake.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
25
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { tmpdir } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { resolveRoot } from '@/server/lib/fs/root';
|
|
29
|
+
import { getDb } from '@/server/db.server';
|
|
30
|
+
import { isolateDb, releaseDb } from '@/test-support/isolated-db';
|
|
31
|
+
import { removeDir } from '@/test-support/remove-dir';
|
|
32
|
+
|
|
33
|
+
const GIT_ENV = { ...Bun.env, GIT_EDITOR: 'true', GIT_TERMINAL_PROMPT: '0' };
|
|
34
|
+
|
|
35
|
+
function git(cwd: string, ...args: string[]): string {
|
|
36
|
+
return Bun.spawnSync({ cmd: ['git', ...args], cwd, env: GIT_ENV }).stdout.toString();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeRepo(dir: string): string {
|
|
40
|
+
mkdirSync(dir, { recursive: true });
|
|
41
|
+
git(dir, 'init', '-q', '-b', 'main', '.');
|
|
42
|
+
git(dir, 'config', 'user.email', 'test@example.invalid');
|
|
43
|
+
git(dir, 'config', 'user.name', 'Test');
|
|
44
|
+
writeFileSync(join(dir, 'f.txt'), 'a\n');
|
|
45
|
+
git(dir, 'add', '-A');
|
|
46
|
+
git(dir, 'commit', '-q', '-m', 'base');
|
|
47
|
+
return dir;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let workRoot: string;
|
|
51
|
+
const saved = { SYNC_WORKSPACE: Bun.env.SYNC_WORKSPACE, MOCK: Bun.env.MOCK };
|
|
52
|
+
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
await isolateDb();
|
|
55
|
+
Bun.env.SYNC_WORKSPACE = 'false';
|
|
56
|
+
Bun.env.MOCK = 'false';
|
|
57
|
+
workRoot = mkdtempSync(join(tmpdir(), 'kabmo-root-worktree-'));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(async () => {
|
|
61
|
+
await releaseDb();
|
|
62
|
+
removeDir(workRoot);
|
|
63
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
64
|
+
if (value === undefined) delete Bun.env[key];
|
|
65
|
+
else Bun.env[key] = value;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('resolveRoot and worktrees', () => {
|
|
70
|
+
test('a linked worktree of a registered project is allowed', async () => {
|
|
71
|
+
const repo = makeRepo(join(workRoot, 'repo'));
|
|
72
|
+
const linked = join(workRoot, 'linked-wt');
|
|
73
|
+
git(repo, 'worktree', 'add', '-q', '-b', 'feat', linked);
|
|
74
|
+
|
|
75
|
+
const db = await getDb();
|
|
76
|
+
await db.run('INSERT INTO workspace_folders (name, project_path) VALUES (?, ?)', ['proj', repo]);
|
|
77
|
+
|
|
78
|
+
expect(await resolveRoot(linked, '/fallback')).toBe(linked);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a SUBDIRECTORY of a registered checkout is still refused', async () => {
|
|
82
|
+
const repo = makeRepo(join(workRoot, 'repo-sub'));
|
|
83
|
+
const sub = join(repo, 'src');
|
|
84
|
+
mkdirSync(sub, { recursive: true });
|
|
85
|
+
|
|
86
|
+
const db = await getDb();
|
|
87
|
+
await db.run('INSERT INTO workspace_folders (name, project_path) VALUES (?, ?)', ['proj', repo]);
|
|
88
|
+
|
|
89
|
+
// The allow-list is EXACT roots. Panels point at a worktree root, which is
|
|
90
|
+
// what the new branch admits; widening it to subdirectories would turn a
|
|
91
|
+
// registered project into a filesystem grant for anything beneath it.
|
|
92
|
+
expect(await resolveRoot(sub, '/fallback')).toBe('/fallback');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a worktree of an UNREGISTERED repository is refused', async () => {
|
|
96
|
+
const registered = makeRepo(join(workRoot, 'registered'));
|
|
97
|
+
const stranger = makeRepo(join(workRoot, 'stranger'));
|
|
98
|
+
const strangerWt = join(workRoot, 'stranger-wt');
|
|
99
|
+
git(stranger, 'worktree', 'add', '-q', '-b', 's', strangerWt);
|
|
100
|
+
|
|
101
|
+
const db = await getDb();
|
|
102
|
+
await db.run('INSERT INTO workspace_folders (name, project_path) VALUES (?, ?)', ['proj', registered]);
|
|
103
|
+
|
|
104
|
+
expect(await resolveRoot(strangerWt, '/fallback')).toBe('/fallback');
|
|
105
|
+
expect(await resolveRoot(stranger, '/fallback')).toBe('/fallback');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('an exact registered project path is still honored, and a missing one falls back', async () => {
|
|
109
|
+
const repo = makeRepo(join(workRoot, 'exact'));
|
|
110
|
+
const db = await getDb();
|
|
111
|
+
await db.run('INSERT INTO workspace_folders (name, project_path) VALUES (?, ?)', ['proj', repo]);
|
|
112
|
+
|
|
113
|
+
expect(await resolveRoot(repo, '/fallback')).toBe(repo);
|
|
114
|
+
expect(await resolveRoot(join(workRoot, 'gone'), '/fallback')).toBe('/fallback');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import path from 'path';
|
|
15
15
|
import { getDb } from '@/server/db.server';
|
|
16
16
|
import { pathExists } from '@/server/lib/omp/core/paths';
|
|
17
|
+
import { canonicalPath } from '@/server/lib/fs/canonical';
|
|
18
|
+
import { resolveProjectRoot } from '@/server/lib/omp/core/worktree';
|
|
17
19
|
|
|
18
20
|
const APP_ROOT = process.cwd();
|
|
19
21
|
|
|
@@ -124,8 +126,18 @@ export async function getDefaultFsRoot(mock: boolean): Promise<string> {
|
|
|
124
126
|
* the server is allowed to operate on.
|
|
125
127
|
*
|
|
126
128
|
* Returns `fallback` when `rawRoot` is missing, does not exist, or is not on
|
|
127
|
-
* the allow-list (app root / subpath / registered project_path
|
|
128
|
-
* callers keep their previous behavior for
|
|
129
|
+
* the allow-list (app root / subpath / registered project_path / **worktree of
|
|
130
|
+
* a registered project**), so existing callers keep their previous behavior for
|
|
131
|
+
* unscoped requests.
|
|
132
|
+
*
|
|
133
|
+
* The worktree branch is why a worktree session's panels can point anywhere:
|
|
134
|
+
* a chat started in a linked checkout runs there, and `resolveRoot` answered the
|
|
135
|
+
* fallback for it — measured, so Files/Search/Git/Terminal silently showed the
|
|
136
|
+
* app root while the agent wrote to the worktree. The rule is deliberately
|
|
137
|
+
* narrow: the candidate's own REPOSITORY ROOT must be a registered project, so a
|
|
138
|
+
* registered project never becomes a filesystem grant. A SUBDIRECTORY is still
|
|
139
|
+
* refused — the allow-list stays exact roots, and a panel is pointed at the
|
|
140
|
+
* worktree root, never at a folder inside it.
|
|
129
141
|
*/
|
|
130
142
|
export async function resolveRoot(
|
|
131
143
|
rawRoot: string | null | undefined,
|
|
@@ -134,22 +146,39 @@ export async function resolveRoot(
|
|
|
134
146
|
if (!rawRoot || !rawRoot.trim()) return fallback;
|
|
135
147
|
|
|
136
148
|
const resolved = path.resolve(rawRoot.trim());
|
|
149
|
+
if (!(await pathExists(resolved))) return fallback;
|
|
137
150
|
|
|
138
151
|
// Fast path: the app root and anything beneath it are always allowed.
|
|
139
152
|
if (resolved === APP_ROOT || resolved.startsWith(APP_ROOT + path.sep)) {
|
|
140
|
-
return
|
|
153
|
+
return resolved;
|
|
141
154
|
}
|
|
142
155
|
|
|
143
|
-
// Registered workspaces are user-opted project roots — allow exact matches
|
|
156
|
+
// Registered workspaces are user-opted project roots — allow exact matches,
|
|
157
|
+
// compared by canonical path so a `/tmp` vs `/private/tmp` spelling of one
|
|
158
|
+
// directory is not read as two.
|
|
144
159
|
try {
|
|
145
160
|
const db = await getDb();
|
|
146
|
-
const
|
|
147
|
-
'SELECT project_path FROM workspace_folders WHERE project_path IS NOT NULL
|
|
148
|
-
|
|
149
|
-
);
|
|
150
|
-
|
|
161
|
+
const rows = (await db.all(
|
|
162
|
+
'SELECT project_path FROM workspace_folders WHERE project_path IS NOT NULL'
|
|
163
|
+
)) as { project_path: string }[];
|
|
164
|
+
const canonical = await canonicalPath(resolved);
|
|
165
|
+
for (const row of rows) {
|
|
166
|
+
if ((await canonicalPath(row.project_path)) === canonical) return resolved;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// A worktree ROOT: `resolveProjectRoot` only asks git when the candidate
|
|
170
|
+
// holds a `.git` entry, so a linked checkout resolves to the main repository
|
|
171
|
+
// while an ordinary subdirectory resolves to ITSELF and stays refused. That
|
|
172
|
+
// is what keeps this rule at "the roots of registered repositories" instead
|
|
173
|
+
// of "anything beneath one".
|
|
174
|
+
if (rows.length > 0) {
|
|
175
|
+
const projectRoot = await canonicalPath(await resolveProjectRoot(resolved));
|
|
176
|
+
for (const row of rows) {
|
|
177
|
+
if ((await canonicalPath(row.project_path)) === projectRoot) return resolved;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
151
180
|
} catch {
|
|
152
|
-
// Database unavailable — fall through to the safe fallback.
|
|
181
|
+
// Database or git unavailable — fall through to the safe fallback.
|
|
153
182
|
}
|
|
154
183
|
|
|
155
184
|
return fallback;
|