@kabmo-labs/kabmo 0.8.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 +314 -314
- package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
- package/dist/client/static/js/index-k315p1e8.css +1 -0
- package/package.json +9 -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/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/layout/desktop-layout/WorkspacePanels.tsx +41 -78
- package/src/client/components/layout/desktop-layout/index.tsx +47 -67
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
- package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
- package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
- 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/settings/Modal.tsx +8 -0
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- 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 +8 -2
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
- 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/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.tsx +8 -11
- 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/OutlineSection.test.tsx +18 -0
- package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
- 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/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 +19 -24
- 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/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/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/settings-modal.ts +56 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
- package/src/client/hooks/ui/updates/shell-download.ts +55 -14
- package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -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-tabs.test.ts +62 -5
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
- package/src/client/hooks/workspace/worktree/index.ts +20 -5
- 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 +37 -8
- package/src/server/lib/auth/guard.test.ts +28 -0
- package/src/server/lib/auth/guard.ts +10 -0
- package/src/server/lib/fs/worktree/create.ts +2 -3
- package/src/server/lib/fs/worktree/list.ts +13 -0
- package/src/server/lib/fs/worktree/remove.ts +3 -3
- 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/telemetry/compute.ts +13 -1
- package/src/server/lib/omp/session/telemetry.test.ts +33 -0
- 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.ts +94 -3
- package/src/server/lib/updates/shell-cache.ts +105 -0
- package/src/server/lib/updates/shell-pages.test.ts +33 -2
- package/src/server/lib/updates/shell-pages.ts +17 -5
- package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
- package/src/server/lib/updates/shell-release.test.ts +10 -27
- package/src/server/lib/updates/shell-release.ts +107 -111
- 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/updates/android.ts +6 -1
- package/src/server/routes/updates/desktop.ts +29 -7
- package/src/server/routes/updates/ios.ts +5 -1
- package/src/server/routes/worktrees/list.ts +11 -1
- package/src/server/routes/worktrees/routes.test.ts +17 -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-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/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 +4 -4
- 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-panel-widths.ts +124 -0
- package/src/shared/types/realtime.ts +29 -0
- package/src/shared/types/settings/state.ts +10 -0
- package/src/test-support/loopback.ts +183 -0
- package/dist/client/static/js/index-gtbpewr7.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/chat-timeline/target-picker/index.test.tsx +0 -227
- package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
- 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
|
@@ -52,24 +52,37 @@ export function useFileTabs(
|
|
|
52
52
|
[sessionId],
|
|
53
53
|
);
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
/** The last root a wipe was performed FOR; `null` until one is known. */
|
|
56
|
+
const wipedRootRef = useRef<string | null>(null);
|
|
56
57
|
const { ready: layoutReady } = useSessionStateContext();
|
|
57
|
-
const wipedRootRef = useRef<string | null | undefined>(undefined);
|
|
58
58
|
|
|
59
59
|
useEffect(() => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
// Only a CHANGE from one known root to another closes the previous
|
|
61
|
+
// workspace's tabs.
|
|
62
|
+
//
|
|
63
|
+
// Two rules, and each was a bug first. A root that is not known yet is not a
|
|
64
|
+
// root that changed: the path is derived from the sidebar's folder list,
|
|
65
|
+
// which is empty for a beat on every load (and again whenever the structure
|
|
66
|
+
// topic re-subscribes), so treating `null` as a root wiped the tabs a user
|
|
67
|
+
// had just opened. And the FIRST known root is not a change either — it is
|
|
68
|
+
// the workspace the page was already showing, so wiping on it discarded the
|
|
69
|
+
// tabs the session blob had just restored (measured: the e2e specs had to
|
|
70
|
+
// gate every tree click on the `/state` response to survive it, and a click
|
|
71
|
+
// that lost that race left the editor reading "Select a file to open" over
|
|
72
|
+
// the file the user had opened).
|
|
73
|
+
if (!activeProjectPath || !layoutReady) return;
|
|
74
|
+
const previous = wipedRootRef.current;
|
|
63
75
|
wipedRootRef.current = activeProjectPath;
|
|
76
|
+
if (previous === null || previous === activeProjectPath) return;
|
|
64
77
|
// The tabs are dropped, but their scroll offsets deliberately are NOT: this
|
|
65
|
-
// wipe is not a user closing a tab.
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
78
|
+
// wipe is not a user closing a tab. Dropping here erased the offset the
|
|
79
|
+
// session blob had just restored — measured: the offset persisted as `900`,
|
|
80
|
+
// and came back as `{}` after a reload. The entry is keyed by the file's
|
|
81
|
+
// path hash, so it is reused when that file is opened again, which is the
|
|
82
|
+
// point.
|
|
70
83
|
setOpenedFiles([]);
|
|
71
84
|
setActiveFileId(null);
|
|
72
|
-
}, [activeProjectPath,
|
|
85
|
+
}, [activeProjectPath, layoutReady, setOpenedFiles, setActiveFileId]);
|
|
73
86
|
|
|
74
87
|
const handleOpenFile = useCallback((file: OpenFileInput) => {
|
|
75
88
|
const fileEntry: OpenedFile = {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The navbar's session menu, resolved from the session ON SCREEN.
|
|
8
|
+
*
|
|
9
|
+
* A hook rather than three statements in the layout because the lookup has a
|
|
10
|
+
* rule of its own: the session's row may live in a folder that is not expanded
|
|
11
|
+
* in the sidebar (or not visible at all), so it is found by id across every
|
|
12
|
+
* folder instead of by walking the visible list. No row means no menu — a
|
|
13
|
+
* session the sidebar does not know (a chat running outside every registered
|
|
14
|
+
* workspace) gets nothing rather than dead items.
|
|
15
|
+
*
|
|
16
|
+
* The pending `new-…` refusal lives here too: such a chat has no transcript
|
|
17
|
+
* anywhere yet, so the server refuses rename and delete, and the menu hides
|
|
18
|
+
* them the way the sidebar does.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useMemo } from 'preact/hooks';
|
|
22
|
+
import { useSidebarData } from '@/client/hooks/chat/omp/session-list';
|
|
23
|
+
import { useSessionActions } from '@/client/hooks/workspace/session-actions';
|
|
24
|
+
import { useSessionDelete, type SessionDeleteActions } from '@/client/hooks/workspace/session-delete';
|
|
25
|
+
import type { WorkspaceFolderData } from '@/shared/types';
|
|
26
|
+
|
|
27
|
+
/** What `TopNavbar` needs to draw its title menu. */
|
|
28
|
+
export interface NavbarSessionMenu {
|
|
29
|
+
isArchived: boolean;
|
|
30
|
+
onRename?: (name: string) => void;
|
|
31
|
+
onRenameWithAi?: () => void;
|
|
32
|
+
onArchive: () => void;
|
|
33
|
+
onDelete?: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface NavbarSessionActions {
|
|
37
|
+
menu: NavbarSessionMenu | undefined;
|
|
38
|
+
/** The delete confirmation's own state machine, which the layout renders. */
|
|
39
|
+
sessionDelete: SessionDeleteActions;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useNavbarSessionActions(
|
|
43
|
+
folders: WorkspaceFolderData[],
|
|
44
|
+
sessionId: string | null,
|
|
45
|
+
report: (message: string, tone?: 'success' | 'error') => void,
|
|
46
|
+
): NavbarSessionActions {
|
|
47
|
+
const sidebar = useSidebarData();
|
|
48
|
+
const actions = useSessionActions(sidebar.refresh, report);
|
|
49
|
+
const sessionDelete = useSessionDelete();
|
|
50
|
+
|
|
51
|
+
const activeSession = useMemo(() => {
|
|
52
|
+
if (!sessionId) return null;
|
|
53
|
+
for (const folder of folders) {
|
|
54
|
+
const found = (folder.sessions || []).find((s) => String(s.id) === String(sessionId));
|
|
55
|
+
if (found) return found;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}, [folders, sessionId]);
|
|
59
|
+
|
|
60
|
+
const pending = Boolean(sessionId && sessionId.startsWith('new-'));
|
|
61
|
+
const menu: NavbarSessionMenu | undefined = activeSession
|
|
62
|
+
? {
|
|
63
|
+
isArchived: activeSession.is_archived === 1,
|
|
64
|
+
onRename: pending ? undefined : (name: string) => void actions.handleRename(activeSession, name),
|
|
65
|
+
onRenameWithAi: pending ? undefined : () => void actions.handleRenameWithAi(activeSession),
|
|
66
|
+
onArchive: () => actions.handleArchive(activeSession),
|
|
67
|
+
onDelete: pending ? undefined : () => sessionDelete.requestDelete(activeSession),
|
|
68
|
+
}
|
|
69
|
+
: undefined;
|
|
70
|
+
|
|
71
|
+
return { menu, sessionDelete };
|
|
72
|
+
}
|
|
@@ -58,7 +58,7 @@ async function readFailure(response: Response): Promise<never> {
|
|
|
58
58
|
export function useWorktrees(scope: WorktreeRequestScope, enabled: boolean) {
|
|
59
59
|
const [payload, setPayload] = useState<WorktreeListPayload | null>(null);
|
|
60
60
|
const [loading, setLoading] = useState(false);
|
|
61
|
-
const [
|
|
61
|
+
const [failure, setFailure] = useState<WorktreeFailure | null>(null);
|
|
62
62
|
|
|
63
63
|
const refresh = useCallback(async () => {
|
|
64
64
|
if (!enabled) return;
|
|
@@ -67,11 +67,18 @@ export function useWorktrees(scope: WorktreeRequestScope, enabled: boolean) {
|
|
|
67
67
|
const response = await fetch(`/api/worktrees${queryFor(scope)}`);
|
|
68
68
|
if (!response.ok) await readFailure(response);
|
|
69
69
|
setPayload((await response.json()) as WorktreeListPayload);
|
|
70
|
-
|
|
70
|
+
setFailure(null);
|
|
71
71
|
} catch (cause) {
|
|
72
72
|
// Kept apart from the payload: an empty list and a failed read are
|
|
73
|
-
// different answers, and only one of them means nothing is there.
|
|
74
|
-
|
|
73
|
+
// different answers, and only one of them means nothing is there. The
|
|
74
|
+
// CODE travels too, because "this folder is not a repository" and "git
|
|
75
|
+
// could not answer" are different states to draw — and the first one is
|
|
76
|
+
// how a caller knows to offer no worktree control at all.
|
|
77
|
+
setFailure(
|
|
78
|
+
cause instanceof WorktreeRequestError
|
|
79
|
+
? cause.failure
|
|
80
|
+
: { code: 'create_failed', message: cause instanceof Error ? cause.message : String(cause) },
|
|
81
|
+
);
|
|
75
82
|
} finally {
|
|
76
83
|
setLoading(false);
|
|
77
84
|
}
|
|
@@ -84,10 +91,18 @@ export function useWorktrees(scope: WorktreeRequestScope, enabled: boolean) {
|
|
|
84
91
|
return {
|
|
85
92
|
payload,
|
|
86
93
|
worktrees: payload?.worktrees ?? ([] as WorktreeInfo[]),
|
|
94
|
+
/**
|
|
95
|
+
* Whether the project is a git repository at all. `null` while it is still
|
|
96
|
+
* unknown: a caller that draws a worktree control must wait for the answer
|
|
97
|
+
* rather than offer one a plain folder can never satisfy.
|
|
98
|
+
*/
|
|
99
|
+
isRepo: payload ? payload.isRepo : null,
|
|
87
100
|
/** The resolved parent directory a create would use. */
|
|
88
101
|
base: payload?.base ?? '',
|
|
89
102
|
loading,
|
|
90
|
-
|
|
103
|
+
failure,
|
|
104
|
+
/** The failure's message, for the surfaces that only print one. */
|
|
105
|
+
error: failure?.message ?? null,
|
|
91
106
|
refresh,
|
|
92
107
|
};
|
|
93
108
|
}
|
|
@@ -30,6 +30,14 @@
|
|
|
30
30
|
export interface DesktopInfo {
|
|
31
31
|
platform: string;
|
|
32
32
|
shellVersion: string;
|
|
33
|
+
/**
|
|
34
|
+
* The machine's architecture, in the canonical vocabulary (`arm64` / `x64`).
|
|
35
|
+
*
|
|
36
|
+
* Every desktop artifact is per-architecture, so without this the console can
|
|
37
|
+
* only offer the release page. Absent on a shell too old to report it, which
|
|
38
|
+
* degrades the offer to that page rather than breaking it.
|
|
39
|
+
*/
|
|
40
|
+
arch?: string;
|
|
33
41
|
/** True when the shell hides the native title bar and the page draws it. */
|
|
34
42
|
overlayTitlebar: boolean;
|
|
35
43
|
/**
|
|
@@ -156,3 +164,40 @@ export function windowToggleMaximize(): Promise<void> {
|
|
|
156
164
|
export function windowClose(): Promise<void> {
|
|
157
165
|
return call<void>('window_close');
|
|
158
166
|
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* What the shell did with an update it was handed.
|
|
170
|
+
*
|
|
171
|
+
* The shell owns the whole run — download, verify, replace, relaunch — because
|
|
172
|
+
* only it can do any of those things: a page cannot replace the executable it is
|
|
173
|
+
* running inside, and a page's "download" leaves a file in a folder the user has
|
|
174
|
+
* to find. This is the report it sends back.
|
|
175
|
+
*
|
|
176
|
+
* `relaunching` is the honest terminal state on success: the shell is about to
|
|
177
|
+
* quit, so a caller that keeps a spinner going is describing a page that is
|
|
178
|
+
* already gone.
|
|
179
|
+
*/
|
|
180
|
+
export interface ShellUpdateOutcome {
|
|
181
|
+
success: boolean;
|
|
182
|
+
/** The version the shell installed, when it could read one. */
|
|
183
|
+
version?: string | null;
|
|
184
|
+
/** A sentence for the user — the shell's own, never a generic failure. */
|
|
185
|
+
message: string;
|
|
186
|
+
relaunching?: boolean;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Ask the shell to install the build it just offered.
|
|
191
|
+
*
|
|
192
|
+
* `url` and `version` are the console's answer, passed through rather than
|
|
193
|
+
* re-derived: the shell has no release list of its own, and asking it to fetch
|
|
194
|
+
* one would be a second reader of the same GitHub endpoint.
|
|
195
|
+
*
|
|
196
|
+
* The shell VERIFIES the bytes before installing them (the platform's own check —
|
|
197
|
+
* a code signature, an archive shape, a version floor). A console that is
|
|
198
|
+
* compromised can therefore make the shell download, but not make it run
|
|
199
|
+
* something the platform will not vouch for.
|
|
200
|
+
*/
|
|
201
|
+
export function applyShellUpdate(url: string, version: string): Promise<ShellUpdateOutcome> {
|
|
202
|
+
return call<ShellUpdateOutcome>('update_apply', { url, version });
|
|
203
|
+
}
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* editor can decide by equality whether the request is for the file it holds.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import type { FindOptions } from '@/shared/lib/code/editor/find';
|
|
26
|
+
import type { FindOptions } from '@/shared/lib/code/editor/find-scan';
|
|
27
27
|
|
|
28
28
|
export interface SearchRevealRequest {
|
|
29
29
|
/** Workspace-relative path of the file the match lives in. */
|
package/src/client/main.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { hydrate } from 'preact';
|
|
2
2
|
import { App } from '@/client/App';
|
|
3
3
|
import { bootSyntax } from '@/shared/lib/code/highlighter';
|
|
4
|
+
import { warmSyntaxTokenizers } from '@/shared/lib/code/highlighter-warmup';
|
|
4
5
|
import { primeKabmoSettings } from '@/shared/lib/settings/client';
|
|
5
6
|
import { initDocumentTheme } from '@/client/hooks/ui/theme';
|
|
6
7
|
import { primeWindowChrome } from '@/client/hooks/ui/window-chrome';
|
|
@@ -70,7 +71,10 @@ installFileLaunchHandler((result) => {
|
|
|
70
71
|
});
|
|
71
72
|
|
|
72
73
|
// Fire-and-forget: warms the Shiki highlighter in parallel with hydration.
|
|
73
|
-
|
|
74
|
+
// Once the eager grammars land, each is tokenized once on a tiny sample during
|
|
75
|
+
// idle time — loading a grammar is not compiling it, and the compile used to be
|
|
76
|
+
// paid inside the first tool card the reader expanded.
|
|
77
|
+
void bootSyntax().then(() => warmSyntaxTokenizers());
|
|
74
78
|
|
|
75
79
|
if (root) {
|
|
76
80
|
hydrate(
|
package/src/client/tailwind.css
CHANGED
|
@@ -384,6 +384,43 @@
|
|
|
384
384
|
.shiki span { color: var(--shiki-light); }
|
|
385
385
|
html[data-theme-variant="dark"] .shiki span { color: var(--shiki-dark); }
|
|
386
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
|
+
|
|
387
424
|
/* Find matches, painted inside the token spans the highlighter emits. An
|
|
388
425
|
* inline <mark> colours a background without moving a glyph, which is what
|
|
389
426
|
* keeps the transparent <textarea> above aligned with this layer — a block or
|
|
@@ -400,14 +437,6 @@ html[data-theme-variant="dark"] .shiki span { color: var(--shiki-dark); }
|
|
|
400
437
|
color: inherit;
|
|
401
438
|
background: color-mix(in srgb, var(--theme-warning) 32%, transparent);
|
|
402
439
|
}
|
|
403
|
-
/* An extra ⌘D selection: same geometry as a find match, different ink. The
|
|
404
|
-
* caret can only live in the primary range, so the two must not look alike —
|
|
405
|
-
* the user has to see where their keystrokes will land. */
|
|
406
|
-
.shiki .find-match[data-occurrence] {
|
|
407
|
-
background: color-mix(in srgb, var(--theme-info) 30%, transparent);
|
|
408
|
-
border-top: 1px solid color-mix(in srgb, var(--theme-info) 55%, transparent);
|
|
409
|
-
border-bottom: 1px solid color-mix(in srgb, var(--theme-info) 55%, transparent);
|
|
410
|
-
}
|
|
411
440
|
.shiki .find-match[data-find-current] {
|
|
412
441
|
background: color-mix(in srgb, var(--theme-warning) 55%, transparent);
|
|
413
442
|
border-top: 1px solid color-mix(in srgb, var(--theme-warning) 75%, transparent);
|
|
@@ -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',
|
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
resolveConfiguredWorktreeRoot,
|
|
46
46
|
resolveTargetDirectory,
|
|
47
47
|
} from '@/server/lib/fs/worktree/location';
|
|
48
|
-
import { findBranchInUse, refExists } from '@/server/lib/fs/worktree/list';
|
|
48
|
+
import { findBranchInUse, isWorkTree, refExists } from '@/server/lib/fs/worktree/list';
|
|
49
49
|
import { resolveProjectId } from '@/server/lib/fs/worktree/project-id';
|
|
50
50
|
|
|
51
51
|
/**
|
|
@@ -113,8 +113,7 @@ export async function planWorktree(
|
|
|
113
113
|
deps: Pick<CreateWorktreeDeps, 'identityFor' | 'dataDir' | 'configuredDirectory'>,
|
|
114
114
|
): Promise<{ primaryRoot: string; projectId: string; root: string; name: string; directory: string; branch: string; startRef: string }> {
|
|
115
115
|
const projectPath = path.resolve(input.projectPath);
|
|
116
|
-
|
|
117
|
-
if (insideWorkTree.exitCode !== 0 || insideWorkTree.stdout.trim() !== 'true') {
|
|
116
|
+
if (!(await isWorkTree(projectPath))) {
|
|
118
117
|
throw new WorktreeError('not_a_repo', 'This folder is not a Git repository.');
|
|
119
118
|
}
|
|
120
119
|
|
|
@@ -116,6 +116,19 @@ export async function listWorktrees(cwd: string): Promise<WorktreeEntry[]> {
|
|
|
116
116
|
return entries;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Whether `cwd` is inside a work tree at all.
|
|
121
|
+
*
|
|
122
|
+
* The check a plain directory fails, and the one every caller here needs before
|
|
123
|
+
* it may talk about worktrees: `git worktree list` in a non-repository exits
|
|
124
|
+
* non-zero, so without this a notes folder bound as a workspace reads as a
|
|
125
|
+
* BROKEN repository rather than as one that has none.
|
|
126
|
+
*/
|
|
127
|
+
export async function isWorkTree(cwd: string): Promise<boolean> {
|
|
128
|
+
const probe = await runGit(['rev-parse', '--is-inside-work-tree'], { cwd });
|
|
129
|
+
return probe.exitCode === 0 && probe.stdout.trim() === 'true';
|
|
130
|
+
}
|
|
131
|
+
|
|
119
132
|
/** Whether `branch` is checked out in any worktree of this repository, and where. */
|
|
120
133
|
export async function findBranchInUse(cwd: string, branch: string): Promise<{ path: string } | null> {
|
|
121
134
|
const entries = await listWorktrees(cwd);
|
|
@@ -23,7 +23,7 @@ import { canonicalPath, pathHolds } from '@/server/lib/fs/canonical';
|
|
|
23
23
|
import { runGit } from '@/server/lib/fs/git-run';
|
|
24
24
|
import { resolveProjectRoot } from '@/server/lib/omp/core/worktree';
|
|
25
25
|
import { WorktreeError } from '@/server/lib/fs/worktree/errors';
|
|
26
|
-
import { listWorktrees } from '@/server/lib/fs/worktree/list';
|
|
26
|
+
import { isWorkTree, listWorktrees } from '@/server/lib/fs/worktree/list';
|
|
27
27
|
|
|
28
28
|
/** Changed + untracked count in `directory`; 0 when git cannot answer. */
|
|
29
29
|
export async function dirtyCount(directory: string): Promise<number> {
|
|
@@ -85,8 +85,8 @@ export async function removeWorktree(
|
|
|
85
85
|
const requested = path.resolve(input.worktreePath);
|
|
86
86
|
const insideManagedRoot = input.managedRoot ? await pathHolds(input.managedRoot, requested) : false;
|
|
87
87
|
|
|
88
|
-
const insideWorkTree = await
|
|
89
|
-
if (insideWorkTree
|
|
88
|
+
const insideWorkTree = await isWorkTree(input.projectPath);
|
|
89
|
+
if (!insideWorkTree) {
|
|
90
90
|
throw new WorktreeError('not_a_repo', 'This folder is not a Git repository.');
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `claimPort` — the bind decision, and its whole value is in the failure path:
|
|
8
|
+
* a starter that loses must fail with a message that names the real situation
|
|
9
|
+
* instead of a bare `EADDRINUSE`.
|
|
10
|
+
*
|
|
11
|
+
* The cases drive real listeners on OS-assigned ports, because the question is
|
|
12
|
+
* about sockets. One of them is the reported bug: `localhost` stands for two
|
|
13
|
+
* addresses, the guard asked about only the one Bun would bind, and a stale
|
|
14
|
+
* instance kept the other — two servers on one port, and a browser landing on
|
|
15
|
+
* whichever family it resolved.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { testPosix } from '@/test-support/platform';
|
|
23
|
+
|
|
24
|
+
import { claimPort, PortInUseError } from '@/server/lib/lifecycle/port-guard';
|
|
25
|
+
import { createLoopbackHarness, ipv6Loopback, realFetch } from '@/test-support/loopback';
|
|
26
|
+
|
|
27
|
+
const harness = createLoopbackHarness();
|
|
28
|
+
const { occupy, occupyAt, freePort, freePortBothFamilies, livePid, spawnChamberHolder } = harness;
|
|
29
|
+
|
|
30
|
+
let tempDir: string;
|
|
31
|
+
let originalDataDir: string | undefined;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
// The instance record and the port lock both live under the data directory;
|
|
35
|
+
// pointing it at a temp dir keeps a test from reading (or writing) the real
|
|
36
|
+
// `~/.kabmo/run`.
|
|
37
|
+
originalDataDir = Bun.env.KABMO_DATA_DIR;
|
|
38
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-test-claim-port-'));
|
|
39
|
+
Bun.env.KABMO_DATA_DIR = tempDir;
|
|
40
|
+
globalThis.fetch = realFetch;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
await harness.dispose();
|
|
45
|
+
if (originalDataDir === undefined) delete Bun.env.KABMO_DATA_DIR;
|
|
46
|
+
else Bun.env.KABMO_DATA_DIR = originalDataDir;
|
|
47
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('claimPort', () => {
|
|
51
|
+
test('binds a free port through the injected listen', async () => {
|
|
52
|
+
const port = await freePort();
|
|
53
|
+
let binds = 0;
|
|
54
|
+
|
|
55
|
+
await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } });
|
|
56
|
+
|
|
57
|
+
expect(binds).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('re-binds when the occupant is this very process', async () => {
|
|
61
|
+
// `bun run --hot` re-evaluates the entry inside the running process, so the
|
|
62
|
+
// port is held by us and Bun swaps the handler on the next listen. Killing
|
|
63
|
+
// the dev loop on every save is the failure this prevents.
|
|
64
|
+
const { port } = occupy(() => Response.json({ service: 'kabmo', pid: process.pid }));
|
|
65
|
+
let binds = 0;
|
|
66
|
+
|
|
67
|
+
await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } });
|
|
68
|
+
|
|
69
|
+
expect(binds).toBe(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
testPosix('names a Kabmo occupant held by another process', async () => {
|
|
73
|
+
const holder = await spawnChamberHolder();
|
|
74
|
+
const { port } = occupy(() => Response.json({ service: 'kabmo', pid: holder.pid, version: '9.9.9' }));
|
|
75
|
+
let binds = 0;
|
|
76
|
+
|
|
77
|
+
const error = await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } })
|
|
78
|
+
.then(() => null, (thrown: unknown) => thrown);
|
|
79
|
+
|
|
80
|
+
expect(error).toBeInstanceOf(PortInUseError);
|
|
81
|
+
const message = (error as Error).message;
|
|
82
|
+
expect(message).toContain(`Port ${port} is already served by Kabmo`);
|
|
83
|
+
expect(message).toContain(`pid ${holder.pid}`);
|
|
84
|
+
expect(message).toContain('v9.9.9');
|
|
85
|
+
expect(message).toContain('Nothing was stopped');
|
|
86
|
+
// A starting instance never binds over an identified one.
|
|
87
|
+
expect(binds).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('names a foreign listener as not Kabmo, without a bind error', async () => {
|
|
91
|
+
// Nothing answers `/api/health` and nothing identifies itself, so the probe
|
|
92
|
+
// fails first and `listen` is never attempted.
|
|
93
|
+
const { port } = occupy(() => new Response('not kabmo'));
|
|
94
|
+
let binds = 0;
|
|
95
|
+
|
|
96
|
+
const error = await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } })
|
|
97
|
+
.then(() => null, (thrown: unknown) => thrown);
|
|
98
|
+
|
|
99
|
+
expect(error).toBeInstanceOf(PortInUseError);
|
|
100
|
+
const message = (error as Error).message;
|
|
101
|
+
expect(message).toContain(`Port ${port} is in use by another process`);
|
|
102
|
+
expect(message).toContain(`lsof -nP -iTCP:${port} -sTCP:LISTEN`);
|
|
103
|
+
expect(message).not.toContain('Bind error');
|
|
104
|
+
expect(binds).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test.skipIf(!ipv6Loopback)('refuses a name whose OTHER address is held', async () => {
|
|
108
|
+
// The reported bug: a stale instance held `127.0.0.1:3000`, `bun run dev`
|
|
109
|
+
// probed only `localhost`'s other family (`::1`), found it free, and bound
|
|
110
|
+
// it. Two servers, one port, and a browser landing on whichever family it
|
|
111
|
+
// resolved — the stale one, serving a different data mode.
|
|
112
|
+
const port = await freePortBothFamilies();
|
|
113
|
+
// A listener reporting OUR pid is read as this process re-binding, so the
|
|
114
|
+
// occupant has to name another live process to be refused.
|
|
115
|
+
const holder = await livePid();
|
|
116
|
+
const held = occupyAt(port, '127.0.0.1', () => Response.json({ service: 'kabmo', pid: holder }));
|
|
117
|
+
let binds = 0;
|
|
118
|
+
|
|
119
|
+
const error = await claimPort({ port, host: 'localhost', listen: async () => { binds += 1; } })
|
|
120
|
+
.then(() => null, (thrown: unknown) => thrown);
|
|
121
|
+
|
|
122
|
+
expect(error).toBeInstanceOf(PortInUseError);
|
|
123
|
+
const message = (error as Error).message;
|
|
124
|
+
expect(message).toContain(`Port ${port} is already served by Kabmo`);
|
|
125
|
+
// The address clause is the actionable half: `lsof` lists both sockets, so
|
|
126
|
+
// a bare "port busy" does not say which one has to be freed.
|
|
127
|
+
expect(message).toContain('on 127.0.0.1');
|
|
128
|
+
expect(binds).toBe(0);
|
|
129
|
+
|
|
130
|
+
held.stop(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test.skipIf(!ipv6Loopback)('binds a name whose addresses are both free', async () => {
|
|
134
|
+
const port = await freePortBothFamilies();
|
|
135
|
+
let binds = 0;
|
|
136
|
+
|
|
137
|
+
await claimPort({ port, host: 'localhost', listen: async () => { binds += 1; } });
|
|
138
|
+
|
|
139
|
+
expect(binds).toBe(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('carries the bind error when the port looked free', async () => {
|
|
143
|
+
// The probe is only a short-circuit; when `listen` fails anyway, the real
|
|
144
|
+
// reason must survive into the error rather than being replaced by a guess.
|
|
145
|
+
const port = await freePort();
|
|
146
|
+
|
|
147
|
+
const error = await claimPort({
|
|
148
|
+
port,
|
|
149
|
+
host: '127.0.0.1',
|
|
150
|
+
listen: async () => { throw new Error('EADDRINUSE: address already in use'); },
|
|
151
|
+
}).then(() => null, (thrown: unknown) => thrown);
|
|
152
|
+
|
|
153
|
+
expect(error).toBeInstanceOf(PortInUseError);
|
|
154
|
+
expect((error as Error).message).toContain('Bind error: EADDRINUSE: address already in use');
|
|
155
|
+
});
|
|
156
|
+
});
|