@kabmo-labs/kabmo 0.7.0 → 0.8.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/dist/client/index.html +1 -1
- package/dist/client/index.js +403 -384
- package/dist/client/static/js/index-gtbpewr7.css +1 -0
- package/dist/client/static/js/{index-0wvbca0b.js → index-qwpvd9fv.js} +779 -779
- package/package.json +2 -1
- 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/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/index.tsx +8 -2
- 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/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 +22 -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-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/categories/ProjectSettings.tsx +22 -8
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -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 +29 -56
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
- 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 +24 -6
- 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/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/worktree-modal/index.tsx +261 -0
- package/src/client/hooks/chat/timeline/index.ts +9 -5
- 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/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/ui/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +73 -0
- package/src/client/hooks/ui/updates/shell-download.ts +112 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -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-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/worktree/index.ts +150 -0
- package/src/client/tailwind.css +19 -0
- package/src/server/index.ts +7 -2
- 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 +263 -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 +129 -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/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/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/updates/shell-assets.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +109 -0
- package/src/server/lib/updates/shell-pages.test.ts +156 -0
- package/src/server/lib/updates/shell-pages.ts +145 -0
- package/src/server/lib/updates/shell-release.test.ts +75 -70
- package/src/server/lib/updates/shell-release.ts +120 -136
- 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/index.ts +2 -0
- package/src/server/routes/updates/desktop.ts +37 -0
- package/src/server/routes/updates/index.ts +2 -0
- 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 +124 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +309 -0
- package/src/server/routes/worktrees/shared.ts +90 -0
- 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/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/updates/shell-ua.ts +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
- 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/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/dist/client/static/js/index-nzyjf92m.css +0 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Remove a worktree, in an order that is the REVERSE of OpenChamber's and
|
|
8
|
+
* deliberately so: the omp child holds the worktree as its cwd, so the sessions
|
|
9
|
+
* are stopped and deleted FIRST and only then does the directory go away.
|
|
10
|
+
*
|
|
11
|
+
* A child still holding a deleted cwd is the case `resolveSpawnCwd` papers over
|
|
12
|
+
* by silently running the session in the server's own directory — a session the
|
|
13
|
+
* user asked to delete, alive somewhere else.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here passes `--force`. A dirty worktree refuses (measured: git exits
|
|
16
|
+
* 128 with "contains modified or untracked files") and the caller reports the
|
|
17
|
+
* count instead of destroying work, so the refusal is recoverable.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, rmSync } from 'fs';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import { canonicalPath, pathHolds } from '@/server/lib/fs/canonical';
|
|
23
|
+
import { runGit } from '@/server/lib/fs/git-run';
|
|
24
|
+
import { resolveProjectRoot } from '@/server/lib/omp/core/worktree';
|
|
25
|
+
import { WorktreeError } from '@/server/lib/fs/worktree/errors';
|
|
26
|
+
import { listWorktrees } from '@/server/lib/fs/worktree/list';
|
|
27
|
+
|
|
28
|
+
/** Changed + untracked count in `directory`; 0 when git cannot answer. */
|
|
29
|
+
export async function dirtyCount(directory: string): Promise<number> {
|
|
30
|
+
const result = await runGit(['status', '--porcelain'], { cwd: directory, maxBuffer: 4 * 1024 * 1024 });
|
|
31
|
+
if (result.exitCode !== 0) return 0;
|
|
32
|
+
return result.stdout.split('\n').filter((line) => line.trim()).length;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Commits on `branch` that are not on `startRef`; null when it cannot be told. */
|
|
36
|
+
export async function commitsAheadOf(
|
|
37
|
+
repo: string,
|
|
38
|
+
startRef: string,
|
|
39
|
+
branch: string,
|
|
40
|
+
): Promise<number | null> {
|
|
41
|
+
if (!startRef || !branch) return null;
|
|
42
|
+
const result = await runGit(['rev-list', '--count', `${startRef}..${branch}`], { cwd: repo });
|
|
43
|
+
if (result.exitCode !== 0) return null;
|
|
44
|
+
const value = Number.parseInt(result.stdout.trim(), 10);
|
|
45
|
+
return Number.isFinite(value) ? value : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RemoveWorktreeInput {
|
|
49
|
+
/** Absolute worktree path as the caller holds it (any spelling). */
|
|
50
|
+
worktreePath: string;
|
|
51
|
+
/** Any directory inside the repository — used for the git commands. */
|
|
52
|
+
projectPath: string;
|
|
53
|
+
/** Delete the branch too, attempted with `git branch -d` only. */
|
|
54
|
+
deleteBranch?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* `<data dir>/worktrees` — a root Kabmo owns outright, where a leftover
|
|
57
|
+
* directory may be cleaned even after git stopped tracking it. A CONFIGURED
|
|
58
|
+
* root is not this: there, only a path git still lists is ever touched.
|
|
59
|
+
*/
|
|
60
|
+
managedRoot?: string | null;
|
|
61
|
+
/**
|
|
62
|
+
* Destroy the live children whose cwd is inside, and delete or archive their
|
|
63
|
+
* sessions. Runs BEFORE anything is removed; returns the ids it handled.
|
|
64
|
+
*/
|
|
65
|
+
stopSessions?: (worktreePath: string) => Promise<string[]>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RemoveWorktreeDeps {
|
|
69
|
+
rowFor: (worktreePath: string) => Promise<{ start_ref: string } | undefined>;
|
|
70
|
+
removeRow: (worktreePath: string) => Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RemovedWorktree {
|
|
74
|
+
removed: true;
|
|
75
|
+
archived: string[];
|
|
76
|
+
branchDeleted: boolean;
|
|
77
|
+
/** True when the user asked for the branch and git refused to lose it. */
|
|
78
|
+
branchKept: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function removeWorktree(
|
|
82
|
+
input: RemoveWorktreeInput,
|
|
83
|
+
deps: RemoveWorktreeDeps,
|
|
84
|
+
): Promise<RemovedWorktree> {
|
|
85
|
+
const requested = path.resolve(input.worktreePath);
|
|
86
|
+
const insideManagedRoot = input.managedRoot ? await pathHolds(input.managedRoot, requested) : false;
|
|
87
|
+
|
|
88
|
+
const insideWorkTree = await runGit(['rev-parse', '--is-inside-work-tree'], { cwd: input.projectPath });
|
|
89
|
+
if (insideWorkTree.exitCode !== 0 || insideWorkTree.stdout.trim() !== 'true') {
|
|
90
|
+
throw new WorktreeError('not_a_repo', 'This folder is not a Git repository.');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const primaryRoot = await resolveProjectRoot(input.projectPath);
|
|
94
|
+
const entries = await listWorktrees(primaryRoot);
|
|
95
|
+
const target = await canonicalPath(requested);
|
|
96
|
+
const entry = entries.find((candidate) => candidate.path === target);
|
|
97
|
+
|
|
98
|
+
if (!entry) {
|
|
99
|
+
// Not registered: only a directory inside a root we own may be cleaned, and
|
|
100
|
+
// only because git already let it go.
|
|
101
|
+
if (!insideManagedRoot) {
|
|
102
|
+
throw new WorktreeError('not_a_worktree', `${requested} is not a worktree of this repository.`);
|
|
103
|
+
}
|
|
104
|
+
await runGit(['worktree', 'prune'], { cwd: primaryRoot });
|
|
105
|
+
if (existsSync(requested)) rmSync(requested, { recursive: true, force: true });
|
|
106
|
+
await deps.removeRow(target);
|
|
107
|
+
return { removed: true, archived: [], branchDeleted: false, branchKept: false };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (entry.isPrimary) {
|
|
111
|
+
throw new WorktreeError('primary_checkout', 'The main checkout cannot be removed.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const archived = input.stopSessions ? await input.stopSessions(entry.path) : [];
|
|
115
|
+
|
|
116
|
+
const dirty = await dirtyCount(entry.path);
|
|
117
|
+
if (dirty > 0) {
|
|
118
|
+
throw new WorktreeError(
|
|
119
|
+
'dirty',
|
|
120
|
+
`${dirty} uncommitted file${dirty === 1 ? '' : 's'} here would be lost.`,
|
|
121
|
+
{ count: dirty },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const removed = await runGit(['worktree', 'remove', entry.path], { cwd: primaryRoot });
|
|
126
|
+
if (removed.exitCode !== 0) {
|
|
127
|
+
throw new WorktreeError(
|
|
128
|
+
'remove_failed',
|
|
129
|
+
removed.stderr.trim() || removed.stdout.trim() || `git exited ${removed.exitCode}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
await runGit(['worktree', 'prune'], { cwd: primaryRoot });
|
|
133
|
+
|
|
134
|
+
// The branch: `-d` only. Its refusal IS the safety net, so a branch with
|
|
135
|
+
// commits of its own survives and the answer says so.
|
|
136
|
+
let branchDeleted = false;
|
|
137
|
+
let branchKept = false;
|
|
138
|
+
if (input.deleteBranch && entry.branch) {
|
|
139
|
+
const row = await deps.rowFor(entry.path);
|
|
140
|
+
const ahead = await commitsAheadOf(primaryRoot, row?.start_ref ?? '', entry.branch);
|
|
141
|
+
if (ahead !== null && ahead > 0) {
|
|
142
|
+
branchKept = true;
|
|
143
|
+
} else {
|
|
144
|
+
const deleted = await runGit(['branch', '-d', entry.branch], { cwd: primaryRoot });
|
|
145
|
+
branchDeleted = deleted.exitCode === 0;
|
|
146
|
+
branchKept = !branchDeleted;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await deps.removeRow(entry.path);
|
|
151
|
+
return { removed: true, archived, branchDeleted, branchKept };
|
|
152
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The two worktree settings, read on the SERVER.
|
|
8
|
+
*
|
|
9
|
+
* They live in the `kabmo_settings` blob the settings UI writes, and a create
|
|
10
|
+
* has to honour them without a round trip through the page — a scheduled task or
|
|
11
|
+
* a plugin can create a worktree with no tab open.
|
|
12
|
+
*
|
|
13
|
+
* Absent means the default, never "off": a database written before this feature
|
|
14
|
+
* has no keys at all, and the feature must behave as documented on it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { getDb } from '@/server/db.server';
|
|
18
|
+
import { readSettingsBlob, SETTINGS_BLOB_KEY } from '@/shared/lib/settings/keys';
|
|
19
|
+
import {
|
|
20
|
+
WORKTREE_CARRY_SETTING,
|
|
21
|
+
WORKTREE_DIRECTORY_SETTING,
|
|
22
|
+
} from '@/shared/lib/fs/worktree/settings-keys';
|
|
23
|
+
|
|
24
|
+
export interface WorktreeSettings {
|
|
25
|
+
/** The configured parent directory, un-resolved; `null` when unset. */
|
|
26
|
+
directory: string | null;
|
|
27
|
+
carryIgnored: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function readWorktreeSettings(): Promise<WorktreeSettings> {
|
|
31
|
+
const fallback: WorktreeSettings = { directory: null, carryIgnored: true };
|
|
32
|
+
try {
|
|
33
|
+
const db = await getDb();
|
|
34
|
+
const row = await db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', [SETTINGS_BLOB_KEY]);
|
|
35
|
+
if (!row?.value) return fallback;
|
|
36
|
+
let parsed: unknown;
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(row.value);
|
|
39
|
+
} catch {
|
|
40
|
+
return fallback;
|
|
41
|
+
}
|
|
42
|
+
const blob = readSettingsBlob({ [SETTINGS_BLOB_KEY]: parsed });
|
|
43
|
+
const directory = typeof blob[WORKTREE_DIRECTORY_SETTING] === 'string'
|
|
44
|
+
? (blob[WORKTREE_DIRECTORY_SETTING] as string)
|
|
45
|
+
: null;
|
|
46
|
+
const carry = blob[WORKTREE_CARRY_SETTING];
|
|
47
|
+
return {
|
|
48
|
+
directory: directory?.trim() ? directory.trim() : null,
|
|
49
|
+
// Only an explicit `false` turns the carry off; anything else is the
|
|
50
|
+
// documented default of on.
|
|
51
|
+
carryIgnored: carry !== false,
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
return fallback;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Worktree topology of one repository, and the session → worktree lookup built
|
|
8
|
+
* on it.
|
|
9
|
+
*
|
|
10
|
+
* A session's badge is derived from its own cwd, never from a stored
|
|
11
|
+
* association: the cwd is the truth and it survives a branch rename.
|
|
12
|
+
*
|
|
13
|
+
* The topology is cached per primary root for a short TTL, because the sidebar
|
|
14
|
+
* scan runs on every publish while `git worktree list` costs a spawn per
|
|
15
|
+
* repository. `invalidateWorktreeTopology()` is called by the create and remove
|
|
16
|
+
* paths, and by the `workspace-dirty` signal, so a worktree made a second ago
|
|
17
|
+
* appears without waiting the TTL out.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { canonicalPath, pathHolds } from '@/server/lib/fs/canonical';
|
|
21
|
+
import { listWorktrees, type WorktreeEntry } from '@/server/lib/fs/worktree/list';
|
|
22
|
+
import type { SessionWorktreeInfo } from '@/shared/lib/workspace/worktree/types';
|
|
23
|
+
|
|
24
|
+
const TOPOLOGY_TTL_MS = 10_000;
|
|
25
|
+
|
|
26
|
+
declare global {
|
|
27
|
+
// eslint-disable-next-line no-var
|
|
28
|
+
var __kabmoWorktreeTopology:
|
|
29
|
+
| Map<string, { entries: WorktreeEntry[]; expiresAt: number; inFlight?: Promise<WorktreeEntry[]> }>
|
|
30
|
+
| undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function topologyCache(): Map<string, { entries: WorktreeEntry[]; expiresAt: number; inFlight?: Promise<WorktreeEntry[]> }> {
|
|
34
|
+
globalThis.__kabmoWorktreeTopology ??= new Map();
|
|
35
|
+
return globalThis.__kabmoWorktreeTopology;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Drop the cache for one repository, or all of them. */
|
|
39
|
+
export async function invalidateWorktreeTopology(primaryRoot?: string): Promise<void> {
|
|
40
|
+
const cache = topologyCache();
|
|
41
|
+
if (!primaryRoot) {
|
|
42
|
+
cache.clear();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
cache.delete(await canonicalPath(primaryRoot));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every worktree of `primaryRoot`, cached for a few seconds. A failure is NOT
|
|
50
|
+
* cached: an unreadable repository must not look like one with no worktrees for
|
|
51
|
+
* the next ten minutes.
|
|
52
|
+
*/
|
|
53
|
+
export async function readProjectWorktrees(primaryRoot: string): Promise<WorktreeEntry[]> {
|
|
54
|
+
const cache = topologyCache();
|
|
55
|
+
// Keyed by the CANONICAL root: a caller holding `/tmp/x` and one holding
|
|
56
|
+
// `/private/tmp/x` are asking about one repository, and two entries would let
|
|
57
|
+
// an invalidation miss the entry a later read created (measured: the
|
|
58
|
+
// invalidation dropped nothing and the new worktree stayed invisible).
|
|
59
|
+
const key = await canonicalPath(primaryRoot);
|
|
60
|
+
const hit = cache.get(key);
|
|
61
|
+
if (hit && hit.expiresAt > Date.now()) return hit.entries;
|
|
62
|
+
if (hit?.inFlight) return hit.inFlight;
|
|
63
|
+
|
|
64
|
+
const inFlight = listWorktrees(key)
|
|
65
|
+
.then((entries) => {
|
|
66
|
+
cache.set(key, { entries, expiresAt: Date.now() + TOPOLOGY_TTL_MS });
|
|
67
|
+
return entries;
|
|
68
|
+
})
|
|
69
|
+
.catch((error: unknown) => {
|
|
70
|
+
cache.delete(key);
|
|
71
|
+
throw error;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
cache.set(key, { entries: hit?.entries ?? [], expiresAt: 0, inFlight });
|
|
75
|
+
return inFlight;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The worktree holding `cwd`, or null when the session runs in the checkout
|
|
80
|
+
* itself — a chat in the main repository is not a "worktree session", and a
|
|
81
|
+
* badge on every row would be noise — or in none.
|
|
82
|
+
*
|
|
83
|
+
* Longest path wins, so a worktree nested inside another resolves to the inner
|
|
84
|
+
* checkout: the directory the agent actually writes to.
|
|
85
|
+
*/
|
|
86
|
+
export async function worktreeForCwd(
|
|
87
|
+
entries: WorktreeEntry[],
|
|
88
|
+
cwd: string,
|
|
89
|
+
primaryRoot: string,
|
|
90
|
+
): Promise<SessionWorktreeInfo | null> {
|
|
91
|
+
let best: WorktreeEntry | null = null;
|
|
92
|
+
let bestLength = -1;
|
|
93
|
+
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (!entry.path || entry.isPrimary) continue;
|
|
96
|
+
if (!(await pathHolds(entry.path, cwd))) continue;
|
|
97
|
+
const length = (await canonicalPath(entry.path)).length;
|
|
98
|
+
if (length <= bestLength) continue;
|
|
99
|
+
best = entry;
|
|
100
|
+
bestLength = length;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!best) return null;
|
|
104
|
+
return {
|
|
105
|
+
branch: best.branch,
|
|
106
|
+
path: await canonicalPath(best.path),
|
|
107
|
+
primaryRoot,
|
|
108
|
+
status: best.missing ? 'missing' : 'ready',
|
|
109
|
+
detached: best.detached,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One lookup per session of one repository, sharing a single topology read.
|
|
115
|
+
* Sessions whose cwd is outside every worktree are absent from the map — the
|
|
116
|
+
* caller treats "absent" as "an ordinary session", which is the common case.
|
|
117
|
+
*/
|
|
118
|
+
export async function sessionWorktreeMap(
|
|
119
|
+
primaryRoot: string,
|
|
120
|
+
sessions: readonly { id: string; cwd: string }[],
|
|
121
|
+
): Promise<Map<string, SessionWorktreeInfo>> {
|
|
122
|
+
const result = new Map<string, SessionWorktreeInfo>();
|
|
123
|
+
if (sessions.length === 0) return result;
|
|
124
|
+
|
|
125
|
+
let entries: WorktreeEntry[];
|
|
126
|
+
try {
|
|
127
|
+
entries = await readProjectWorktrees(primaryRoot);
|
|
128
|
+
} catch {
|
|
129
|
+
// A repository git cannot read has no badges to draw; it must not fail the
|
|
130
|
+
// sidebar, which lists sessions from files rather than from git.
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
if (entries.length <= 1) return result;
|
|
134
|
+
|
|
135
|
+
const resolved = await Promise.all(
|
|
136
|
+
sessions.map(async (session) => ({ id: session.id, info: await worktreeForCwd(entries, session.cwd, primaryRoot) })),
|
|
137
|
+
);
|
|
138
|
+
for (const { id, info } of resolved) {
|
|
139
|
+
if (info) result.set(id, info);
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A workspace folder finds its sessions through a CANONICAL path, not through
|
|
8
|
+
* the spelling the folder was registered under.
|
|
9
|
+
*
|
|
10
|
+
* `resolveRootsByCwd` runs every session's cwd through `resolveProjectRoot`,
|
|
11
|
+
* which realpaths it, so the bucket key is canonical. A folder row holds the
|
|
12
|
+
* spelling the user's path was registered with. On macOS those differ for
|
|
13
|
+
* anything under `/var` (a symlink to `/private/var`), which is where every
|
|
14
|
+
* temp directory lives — measured: a real-mode workspace in a temp directory
|
|
15
|
+
* listed ZERO sessions, and the worktree badge could never appear because the
|
|
16
|
+
* session was not even in the folder it belongs to.
|
|
17
|
+
*
|
|
18
|
+
* The symlink here is created by the test rather than borrowed from
|
|
19
|
+
* `os.tmpdir()`, because on Linux the two spellings are identical and a
|
|
20
|
+
* platform-shaped fixture would pass with the fix removed.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
|
|
28
|
+
import { getDb } from '@/server/db.server';
|
|
29
|
+
import { loadSidebarStructure } from '@/server/lib/omp/session/sidebar-data.server';
|
|
30
|
+
import { invalidateOmpSidebarData } from '@/server/lib/omp/session/reader';
|
|
31
|
+
import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
|
|
32
|
+
import { isolateDb, releaseDb } from '@/test-support/isolated-db';
|
|
33
|
+
import { removeDir } from '@/test-support/remove-dir';
|
|
34
|
+
|
|
35
|
+
const saved = {
|
|
36
|
+
agentDir: Bun.env.PI_CODING_AGENT_DIR,
|
|
37
|
+
xdg: Bun.env.XDG_DATA_HOME,
|
|
38
|
+
mock: Bun.env.MOCK,
|
|
39
|
+
sync: Bun.env.SYNC_WORKSPACE,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let root = '';
|
|
43
|
+
let sessionsRoot = '';
|
|
44
|
+
/** The directory a session actually ran in — what the scan resolves to. */
|
|
45
|
+
let realProject = '';
|
|
46
|
+
/** A symlink to it, which is how the folder is registered. */
|
|
47
|
+
let linkedProject = '';
|
|
48
|
+
|
|
49
|
+
beforeAll(async () => {
|
|
50
|
+
root = await fs.promises.mkdtemp(join(tmpdir(), 'omc-sidebar-canonical-'));
|
|
51
|
+
const agent = join(root, 'agent');
|
|
52
|
+
sessionsRoot = join(agent, 'sessions');
|
|
53
|
+
await fs.promises.mkdir(sessionsRoot, { recursive: true });
|
|
54
|
+
|
|
55
|
+
realProject = join(root, 'real-project');
|
|
56
|
+
await fs.promises.mkdir(realProject, { recursive: true });
|
|
57
|
+
linkedProject = join(root, 'linked-project');
|
|
58
|
+
await fs.promises.symlink(realProject, linkedProject, 'dir');
|
|
59
|
+
|
|
60
|
+
Bun.env.PI_CODING_AGENT_DIR = agent;
|
|
61
|
+
// XDG would redirect the sessions dir to $XDG_DATA_HOME/omp when it exists.
|
|
62
|
+
delete Bun.env.XDG_DATA_HOME;
|
|
63
|
+
Bun.env.MOCK = 'false';
|
|
64
|
+
Bun.env.SYNC_WORKSPACE = 'false';
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterAll(() => {
|
|
68
|
+
if (saved.agentDir === undefined) delete Bun.env.PI_CODING_AGENT_DIR;
|
|
69
|
+
else Bun.env.PI_CODING_AGENT_DIR = saved.agentDir;
|
|
70
|
+
if (saved.xdg === undefined) delete Bun.env.XDG_DATA_HOME;
|
|
71
|
+
else Bun.env.XDG_DATA_HOME = saved.xdg;
|
|
72
|
+
if (saved.mock === undefined) delete Bun.env.MOCK;
|
|
73
|
+
else Bun.env.MOCK = saved.mock;
|
|
74
|
+
if (saved.sync === undefined) delete Bun.env.SYNC_WORKSPACE;
|
|
75
|
+
else Bun.env.SYNC_WORKSPACE = saved.sync;
|
|
76
|
+
removeDir(root);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
beforeEach(async () => {
|
|
80
|
+
await isolateDb();
|
|
81
|
+
// The sessions root is shared by the file, so each case starts from an empty
|
|
82
|
+
// one: a file left by a previous case would be listed by the next, which is
|
|
83
|
+
// what made the first run of this suite fail.
|
|
84
|
+
await fs.promises.rm(join(sessionsRoot, 'project'), { recursive: true, force: true });
|
|
85
|
+
invalidateOmpSidebarData();
|
|
86
|
+
clearSessionFileCaches();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(async () => {
|
|
90
|
+
await releaseDb();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/** One session file whose cwd is the CANONICAL spelling, as omp would record it. */
|
|
94
|
+
async function writeSession(id: string, cwd: string): Promise<void> {
|
|
95
|
+
const dir = join(sessionsRoot, 'project');
|
|
96
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
97
|
+
const lines = [
|
|
98
|
+
JSON.stringify({ type: 'session', id, cwd, timestamp: '2026-10-23T00:00:00.000Z' }),
|
|
99
|
+
JSON.stringify({
|
|
100
|
+
type: 'message',
|
|
101
|
+
id: `${id}-m1`,
|
|
102
|
+
parentId: null,
|
|
103
|
+
timestamp: '2026-10-23T00:00:01.000Z',
|
|
104
|
+
message: { role: 'user', content: 'hello from the canonical bucket' },
|
|
105
|
+
}),
|
|
106
|
+
];
|
|
107
|
+
await Bun.write(join(dir, `${id}.jsonl`), `${lines.join('\n')}\n`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A workspace folder row bound to `projectPath`. */
|
|
111
|
+
async function registerFolder(projectPath: string): Promise<number> {
|
|
112
|
+
const db = await getDb();
|
|
113
|
+
await db.run('INSERT INTO workspace_folders (name, project_path) VALUES (?, ?)', ['ws', projectPath]);
|
|
114
|
+
const row = await db.get<{ id: number }>('SELECT id FROM workspace_folders ORDER BY id DESC LIMIT 1');
|
|
115
|
+
return Number(row?.id);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe('sidebar folders resolve sessions through a canonical path', () => {
|
|
119
|
+
test('a folder registered under a symlinked spelling lists the sessions that ran in its target', async () => {
|
|
120
|
+
// The session's cwd is the REAL directory; the folder names the symlink.
|
|
121
|
+
// Without the canonical bucket key this is an empty folder.
|
|
122
|
+
await writeSession('canonical-hit', realProject);
|
|
123
|
+
await registerFolder(linkedProject);
|
|
124
|
+
|
|
125
|
+
const payload = await loadSidebarStructure();
|
|
126
|
+
const folder = payload.folders.find((entry) => entry.project_path === linkedProject);
|
|
127
|
+
|
|
128
|
+
expect(folder).toBeDefined();
|
|
129
|
+
expect(folder?.sessions.map((session) => session.id)).toEqual(['canonical-hit']);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a folder registered under the real spelling keeps listing its sessions', async () => {
|
|
133
|
+
// The control: the fallback to the raw spelling must not be the only path
|
|
134
|
+
// that works, or the fix would trade one empty folder for another.
|
|
135
|
+
await writeSession('canonical-plain', realProject);
|
|
136
|
+
await registerFolder(realProject);
|
|
137
|
+
|
|
138
|
+
const payload = await loadSidebarStructure();
|
|
139
|
+
const folder = payload.folders.find((entry) => entry.project_path === realProject);
|
|
140
|
+
|
|
141
|
+
expect(folder?.sessions.map((session) => session.id)).toEqual(['canonical-plain']);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a session in an unrelated directory stays out of the folder', async () => {
|
|
145
|
+
const other = join(root, 'other-project');
|
|
146
|
+
await fs.promises.mkdir(other, { recursive: true });
|
|
147
|
+
await writeSession('canonical-elsewhere', other);
|
|
148
|
+
await registerFolder(linkedProject);
|
|
149
|
+
|
|
150
|
+
const payload = await loadSidebarStructure();
|
|
151
|
+
const folder = payload.folders.find((entry) => entry.project_path === linkedProject);
|
|
152
|
+
|
|
153
|
+
expect(folder?.sessions).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -26,6 +26,9 @@ import { loadStreamStates, type SessionRunModel } from '@/shared/lib/omp/session
|
|
|
26
26
|
import { healStaleStreamStatuses } from '@/shared/lib/omp/session/stream-heal.server';
|
|
27
27
|
import { getAwaitingInputSessionIds, getLiveRunSessionIds, listRpcSessions } from '@/server/lib/omp/rpc/session-registry';
|
|
28
28
|
import type { SessionItemData, SessionSortOption, WorkspaceFolderData } from '@/shared/types';
|
|
29
|
+
import { sessionWorktreeMap } from '@/server/lib/fs/worktree/topology';
|
|
30
|
+
import { canonicalPath } from '@/server/lib/fs/canonical';
|
|
31
|
+
import type { SessionWorktreeInfo } from '@/shared/lib/workspace/worktree/types';
|
|
29
32
|
import type { OmpSession } from '@/shared/types/omp/session';
|
|
30
33
|
|
|
31
34
|
/** What `GET /api/sessions/list` returns and sidebars consume. */
|
|
@@ -236,11 +239,30 @@ async function buildRealFolders(folderRows: FolderRow[], archivedIds: Set<string
|
|
|
236
239
|
|
|
237
240
|
return Promise.all(folderRows.map(async (folder) => {
|
|
238
241
|
const root = folder.project_path ?? '';
|
|
239
|
-
|
|
242
|
+
// The bucket key is CANONICAL: `resolveRootsByCwd` resolves each session's
|
|
243
|
+
// cwd through `resolveProjectRoot`, which realpaths it, while a folder row
|
|
244
|
+
// holds the spelling the user's path was registered under. On macOS those
|
|
245
|
+
// differ for anything under `/var` (a symlink to `/private/var`), so a
|
|
246
|
+
// project in a temp directory or under a symlinked home listed ZERO sessions
|
|
247
|
+
// — measured: every real-mode session was missing from the sidebar, and the
|
|
248
|
+
// worktree badge could never appear because the session was not even in the
|
|
249
|
+
// folder it belongs to.
|
|
250
|
+
const rootKey = root ? await canonicalPath(root) : '';
|
|
251
|
+
const rootSessions: OmpSession[] = root
|
|
252
|
+
? (sessionsByRoot.get(rootKey) ?? sessionsByRoot.get(root) ?? [])
|
|
253
|
+
: [];
|
|
254
|
+
// One topology read per repository, however many sessions it has. A chat in
|
|
255
|
+
// the checkout itself is absent from this map, which is exactly what the row
|
|
256
|
+
// draws (an ordinary row with no badge).
|
|
257
|
+
const worktrees = await sessionWorktreeMap(
|
|
258
|
+
root,
|
|
259
|
+
rootSessions.map((session) => ({ id: session.id, cwd: session.cwd })),
|
|
260
|
+
);
|
|
240
261
|
const folderSessions = await Promise.all(rootSessions.map(async (session: OmpSession) => {
|
|
241
262
|
const hasSub = session.path
|
|
242
263
|
? await sessionHasSubagents(session.path, session.modified)
|
|
243
264
|
: false;
|
|
265
|
+
const worktree: SessionWorktreeInfo | undefined = worktrees.get(session.id);
|
|
244
266
|
return {
|
|
245
267
|
id: session.id,
|
|
246
268
|
folder_id: folder.id,
|
|
@@ -250,6 +272,11 @@ async function buildRealFolders(folderRows: FolderRow[], archivedIds: Set<string
|
|
|
250
272
|
is_active: 0,
|
|
251
273
|
is_archived: archivedIds.has(String(session.id)) ? 1 : 0,
|
|
252
274
|
hasSubagents: hasSub,
|
|
275
|
+
// The session's own working directory, and the worktree it resolves to
|
|
276
|
+
// when that cwd is a linked checkout. Both travel so no client and no
|
|
277
|
+
// plugin has to ask git.
|
|
278
|
+
cwd: session.cwd,
|
|
279
|
+
...(worktree ? { worktree } : {}),
|
|
253
280
|
};
|
|
254
281
|
}));
|
|
255
282
|
return {
|
|
@@ -124,3 +124,36 @@ export function changedPaths(baseline: TrackedFileHashes, current: TrackedFileHa
|
|
|
124
124
|
}
|
|
125
125
|
return [...changed].sort();
|
|
126
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether a recorded baseline still describes this install.
|
|
130
|
+
*
|
|
131
|
+
* A baseline can only be evidence about the user while it agrees with what is on
|
|
132
|
+
* disk, and the test is exact and one-directional:
|
|
133
|
+
*
|
|
134
|
+
* > wherever the install and the store AGREE, the baseline must agree too.
|
|
135
|
+
*
|
|
136
|
+
* Kabmo is the only thing that writes store content into an install, and it
|
|
137
|
+
* re-takes the baseline when it does — so a path where the install already
|
|
138
|
+
* equals the store while the baseline says otherwise proves the baseline
|
|
139
|
+
* describes a copy this install never had. That is not hypothetical: the product
|
|
140
|
+
* rename rewrote every bundled file while an older baseline was still recorded,
|
|
141
|
+
* and reading that baseline as "the user edited 24 files" made the update refuse
|
|
142
|
+
* forever, with remove-and-reinstall as the only way out (measured).
|
|
143
|
+
*
|
|
144
|
+
* The converse is deliberately NOT required, so a genuine edit stays detectable:
|
|
145
|
+
* a user edit makes the install differ from the store, so those paths are
|
|
146
|
+
* skipped here and the remaining ones still vouch for the baseline. The edit is
|
|
147
|
+
* then reported by `changedPaths` rather than overwritten.
|
|
148
|
+
*/
|
|
149
|
+
export function baselineDescribesInstall(
|
|
150
|
+
baseline: TrackedFileHashes,
|
|
151
|
+
install: TrackedFileHashes,
|
|
152
|
+
store: TrackedFileHashes,
|
|
153
|
+
): boolean {
|
|
154
|
+
for (const [path, hash] of Object.entries(install)) {
|
|
155
|
+
if (hash !== store[path]) continue;
|
|
156
|
+
if (baseline[path] !== hash) return false;
|
|
157
|
+
}
|
|
158
|
+
return true;
|
|
159
|
+
}
|