ccmanager 3.12.6 → 4.0.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/components/App.js +137 -63
- package/dist/components/App.test.js +16 -30
- package/dist/components/Dashboard.js +3 -3
- package/dist/components/Menu.d.ts +2 -2
- package/dist/components/Menu.js +66 -140
- package/dist/components/Menu.recent-projects.test.js +8 -8
- package/dist/components/Menu.test.js +17 -17
- package/dist/components/Session.js +3 -3
- package/dist/components/SessionActions.d.ts +9 -0
- package/dist/components/SessionActions.js +29 -0
- package/dist/components/SessionRename.d.ts +8 -0
- package/dist/components/SessionRename.js +18 -0
- package/dist/constants/statusIcons.d.ts +3 -0
- package/dist/constants/statusIcons.js +3 -0
- package/dist/services/globalSessionOrchestrator.test.js +11 -5
- package/dist/services/sessionManager.autoApproval.test.js +1 -4
- package/dist/services/sessionManager.d.ts +7 -7
- package/dist/services/sessionManager.effect.test.js +17 -16
- package/dist/services/sessionManager.js +43 -48
- package/dist/services/sessionManager.statePersistence.test.js +3 -6
- package/dist/services/sessionManager.test.js +21 -24
- package/dist/services/worktreeService.d.ts +1 -15
- package/dist/services/worktreeService.js +1 -39
- package/dist/services/worktreeService.sort.test.js +141 -303
- package/dist/types/index.d.ts +37 -6
- package/dist/utils/hookExecutor.test.js +8 -0
- package/dist/utils/worktreeUtils.d.ts +12 -6
- package/dist/utils/worktreeUtils.js +116 -50
- package/dist/utils/worktreeUtils.test.js +9 -7
- package/package.json +6 -6
|
@@ -1,317 +1,155 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { prepareSessionItems } from '../utils/worktreeUtils.js';
|
|
3
|
+
const makeWorktree = (path, branch) => ({
|
|
4
|
+
path,
|
|
5
|
+
branch,
|
|
6
|
+
isMainWorktree: path.endsWith('/main'),
|
|
7
|
+
hasSession: false,
|
|
8
|
+
});
|
|
9
|
+
const makeSession = (id, worktreePath, number, lastAccessedAt, name) => ({
|
|
10
|
+
id,
|
|
11
|
+
worktreePath,
|
|
12
|
+
sessionNumber: number,
|
|
13
|
+
sessionName: name,
|
|
14
|
+
lastAccessedAt,
|
|
15
|
+
stateMutex: {
|
|
16
|
+
getSnapshot: () => ({
|
|
17
|
+
state: 'idle',
|
|
18
|
+
backgroundTaskCount: 0,
|
|
19
|
+
teamMemberCount: 0,
|
|
20
|
+
}),
|
|
17
21
|
},
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
});
|
|
23
|
+
describe('prepareSessionItems - sortByLastSession', () => {
|
|
24
|
+
it('should not sort worktrees when sortByLastSession is false', () => {
|
|
25
|
+
const worktrees = [
|
|
26
|
+
makeWorktree('/repo', 'main'),
|
|
27
|
+
makeWorktree('/repo/feature-a', 'feature-a'),
|
|
28
|
+
makeWorktree('/repo/feature-b', 'feature-b'),
|
|
29
|
+
];
|
|
30
|
+
const sessions = [
|
|
31
|
+
makeSession('s1', '/repo', 1, 1000),
|
|
32
|
+
makeSession('s2', '/repo/feature-a', 1, 3000),
|
|
33
|
+
];
|
|
34
|
+
const items = prepareSessionItems(worktrees, sessions, {
|
|
25
35
|
sortByLastSession: false,
|
|
26
|
-
})),
|
|
27
|
-
getWorktreeHooks: vi.fn(() => ({})),
|
|
28
|
-
},
|
|
29
|
-
}));
|
|
30
|
-
// Mock HookExecutor
|
|
31
|
-
vi.mock('../utils/hookExecutor.js', () => ({
|
|
32
|
-
executeWorktreePostCreationHook: vi.fn(),
|
|
33
|
-
}));
|
|
34
|
-
// Get the mocked functions with proper typing
|
|
35
|
-
const mockedExecSync = vi.mocked(execSync);
|
|
36
|
-
// Helper to clear worktree last opened state by setting all known paths to undefined time
|
|
37
|
-
// Since we can't clear the Map directly, we'll set timestamps to 0 for cleanup
|
|
38
|
-
const clearWorktreeTimestamps = () => {
|
|
39
|
-
// This is a workaround since we can't access the internal Map
|
|
40
|
-
// Tests should use unique paths or set their own timestamps
|
|
41
|
-
};
|
|
42
|
-
describe('WorktreeService - Sorting', () => {
|
|
43
|
-
let service;
|
|
44
|
-
beforeEach(() => {
|
|
45
|
-
vi.clearAllMocks();
|
|
46
|
-
// Mock git rev-parse --git-common-dir to return a predictable path
|
|
47
|
-
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
48
|
-
if (typeof cmd === 'string' && cmd === 'git rev-parse --git-common-dir') {
|
|
49
|
-
return '/test/repo/.git\n';
|
|
50
|
-
}
|
|
51
|
-
throw new Error('Command not mocked: ' + cmd);
|
|
52
36
|
});
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
afterEach(() => {
|
|
57
|
-
clearWorktreeTimestamps();
|
|
37
|
+
expect(items[0]?.worktree.path).toBe('/repo');
|
|
38
|
+
expect(items[1]?.worktree.path).toBe('/repo/feature-a');
|
|
39
|
+
expect(items[2]?.worktree.path).toBe('/repo/feature-b');
|
|
58
40
|
});
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// Execute
|
|
73
|
-
const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: false }));
|
|
74
|
-
// Verify order is unchanged (as returned by git)
|
|
75
|
-
expect(result).toHaveLength(3);
|
|
76
|
-
expect(result[0]?.path).toBe('/test/repo');
|
|
77
|
-
expect(result[1]?.path).toBe('/test/repo/feature-a');
|
|
78
|
-
expect(result[2]?.path).toBe('/test/repo/feature-b');
|
|
79
|
-
});
|
|
80
|
-
it('should not sort worktrees when sortByLastSession is undefined', async () => {
|
|
81
|
-
// Setup mock git output
|
|
82
|
-
const gitOutput = `worktree /test/repo
|
|
83
|
-
branch refs/heads/main
|
|
84
|
-
|
|
85
|
-
worktree /test/repo/feature-a
|
|
86
|
-
branch refs/heads/feature-a
|
|
87
|
-
|
|
88
|
-
worktree /test/repo/feature-b
|
|
89
|
-
branch refs/heads/feature-b
|
|
90
|
-
`;
|
|
91
|
-
mockedExecSync.mockReturnValue(gitOutput);
|
|
92
|
-
// Execute without options
|
|
93
|
-
const result = await Effect.runPromise(service.getWorktreesEffect());
|
|
94
|
-
// Verify order is unchanged (as returned by git)
|
|
95
|
-
expect(result).toHaveLength(3);
|
|
96
|
-
expect(result[0]?.path).toBe('/test/repo');
|
|
97
|
-
expect(result[1]?.path).toBe('/test/repo/feature-a');
|
|
98
|
-
expect(result[2]?.path).toBe('/test/repo/feature-b');
|
|
99
|
-
});
|
|
100
|
-
it('should sort worktrees by last opened timestamp in descending order', async () => {
|
|
101
|
-
// Setup mock git output
|
|
102
|
-
const gitOutput = `worktree /test/repo
|
|
103
|
-
branch refs/heads/main
|
|
104
|
-
|
|
105
|
-
worktree /test/repo/feature-a
|
|
106
|
-
branch refs/heads/feature-a
|
|
107
|
-
|
|
108
|
-
worktree /test/repo/feature-b
|
|
109
|
-
branch refs/heads/feature-b
|
|
110
|
-
`;
|
|
111
|
-
mockedExecSync.mockReturnValue(gitOutput);
|
|
112
|
-
// Setup timestamps - feature-b was opened most recently, then main, then feature-a
|
|
113
|
-
setWorktreeLastOpened('/test/repo', 2000);
|
|
114
|
-
setWorktreeLastOpened('/test/repo/feature-a', 1000);
|
|
115
|
-
setWorktreeLastOpened('/test/repo/feature-b', 3000);
|
|
116
|
-
// Execute
|
|
117
|
-
const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
|
|
118
|
-
// Verify sorted order (most recent first)
|
|
119
|
-
expect(result).toHaveLength(3);
|
|
120
|
-
expect(result[0]?.path).toBe('/test/repo/feature-b'); // 3000
|
|
121
|
-
expect(result[1]?.path).toBe('/test/repo'); // 2000
|
|
122
|
-
expect(result[2]?.path).toBe('/test/repo/feature-a'); // 1000
|
|
123
|
-
});
|
|
124
|
-
it('should place worktrees without timestamps at the end', async () => {
|
|
125
|
-
// Setup mock git output - use unique paths to avoid state pollution
|
|
126
|
-
const gitOutput = `worktree /test/repo-no-ts/main
|
|
127
|
-
branch refs/heads/main
|
|
128
|
-
|
|
129
|
-
worktree /test/repo-no-ts/feature-a
|
|
130
|
-
branch refs/heads/feature-a
|
|
131
|
-
|
|
132
|
-
worktree /test/repo-no-ts/feature-b
|
|
133
|
-
branch refs/heads/feature-b
|
|
134
|
-
|
|
135
|
-
worktree /test/repo-no-ts/feature-c
|
|
136
|
-
branch refs/heads/feature-c
|
|
137
|
-
`;
|
|
138
|
-
mockedExecSync.mockReturnValue(gitOutput);
|
|
139
|
-
// Setup timestamps - only feature-a and feature-b have timestamps
|
|
140
|
-
// main and feature-c have no timestamps set
|
|
141
|
-
setWorktreeLastOpened('/test/repo-no-ts/feature-a', 1000);
|
|
142
|
-
setWorktreeLastOpened('/test/repo-no-ts/feature-b', 2000);
|
|
143
|
-
// Execute
|
|
144
|
-
const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
|
|
145
|
-
// Verify sorted order
|
|
146
|
-
expect(result).toHaveLength(4);
|
|
147
|
-
expect(result[0]?.path).toBe('/test/repo-no-ts/feature-b'); // 2000
|
|
148
|
-
expect(result[1]?.path).toBe('/test/repo-no-ts/feature-a'); // 1000
|
|
149
|
-
// main and feature-c at the end with timestamp 0 (original order preserved)
|
|
150
|
-
expect(result[2]?.path).toBe('/test/repo-no-ts/main'); // undefined -> 0
|
|
151
|
-
expect(result[3]?.path).toBe('/test/repo-no-ts/feature-c'); // undefined -> 0
|
|
152
|
-
});
|
|
153
|
-
it('should handle empty worktree list', async () => {
|
|
154
|
-
// Setup empty git output
|
|
155
|
-
mockedExecSync.mockReturnValue('');
|
|
156
|
-
// Execute
|
|
157
|
-
const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
|
|
158
|
-
// Verify empty result
|
|
159
|
-
expect(result).toHaveLength(0);
|
|
41
|
+
it('should sort worktrees by most recent session lastAccessedAt', () => {
|
|
42
|
+
const worktrees = [
|
|
43
|
+
makeWorktree('/repo', 'main'),
|
|
44
|
+
makeWorktree('/repo/feature-a', 'feature-a'),
|
|
45
|
+
makeWorktree('/repo/feature-b', 'feature-b'),
|
|
46
|
+
];
|
|
47
|
+
const sessions = [
|
|
48
|
+
makeSession('s1', '/repo', 1, 2000),
|
|
49
|
+
makeSession('s2', '/repo/feature-a', 1, 1000),
|
|
50
|
+
makeSession('s3', '/repo/feature-b', 1, 3000),
|
|
51
|
+
];
|
|
52
|
+
const items = prepareSessionItems(worktrees, sessions, {
|
|
53
|
+
sortByLastSession: true,
|
|
160
54
|
});
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
branch refs/heads/feature-a
|
|
178
|
-
|
|
179
|
-
worktree /test/repo-stable/feature-b
|
|
180
|
-
branch refs/heads/feature-b
|
|
181
|
-
|
|
182
|
-
worktree /test/repo-stable/feature-c
|
|
183
|
-
branch refs/heads/feature-c
|
|
184
|
-
`;
|
|
185
|
-
mockedExecSync.mockReturnValue(gitOutput);
|
|
186
|
-
// All have the same timestamp
|
|
187
|
-
setWorktreeLastOpened('/test/repo-stable/feature-a', 1000);
|
|
188
|
-
setWorktreeLastOpened('/test/repo-stable/feature-b', 1000);
|
|
189
|
-
setWorktreeLastOpened('/test/repo-stable/feature-c', 1000);
|
|
190
|
-
// Execute
|
|
191
|
-
const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
|
|
192
|
-
// Verify original order is maintained (stable sort)
|
|
193
|
-
expect(result).toHaveLength(3);
|
|
194
|
-
expect(result[0]?.path).toBe('/test/repo-stable/feature-a');
|
|
195
|
-
expect(result[1]?.path).toBe('/test/repo-stable/feature-b');
|
|
196
|
-
expect(result[2]?.path).toBe('/test/repo-stable/feature-c');
|
|
55
|
+
expect(items[0]?.worktree.path).toBe('/repo/feature-b'); // 3000
|
|
56
|
+
expect(items[1]?.worktree.path).toBe('/repo'); // 2000
|
|
57
|
+
expect(items[2]?.worktree.path).toBe('/repo/feature-a'); // 1000
|
|
58
|
+
});
|
|
59
|
+
it('should use the max lastAccessedAt across multiple sessions in one worktree', () => {
|
|
60
|
+
const worktrees = [
|
|
61
|
+
makeWorktree('/repo/wt-a', 'a'),
|
|
62
|
+
makeWorktree('/repo/wt-b', 'b'),
|
|
63
|
+
];
|
|
64
|
+
const sessions = [
|
|
65
|
+
makeSession('s1', '/repo/wt-a', 1, 1000),
|
|
66
|
+
makeSession('s2', '/repo/wt-a', 2, 5000),
|
|
67
|
+
makeSession('s3', '/repo/wt-b', 1, 3000),
|
|
68
|
+
];
|
|
69
|
+
const items = prepareSessionItems(worktrees, sessions, {
|
|
70
|
+
sortByLastSession: true,
|
|
197
71
|
});
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
setWorktreeLastOpened('/test/repo-zero/zero-timestamp', 0);
|
|
212
|
-
setWorktreeLastOpened('/test/repo-zero/recent', 3000);
|
|
213
|
-
setWorktreeLastOpened('/test/repo-zero/older', 1000);
|
|
214
|
-
// Execute
|
|
215
|
-
const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
|
|
216
|
-
// Verify sorted order
|
|
217
|
-
expect(result).toHaveLength(3);
|
|
218
|
-
expect(result[0]?.path).toBe('/test/repo-zero/recent'); // 3000
|
|
219
|
-
expect(result[1]?.path).toBe('/test/repo-zero/older'); // 1000
|
|
220
|
-
expect(result[2]?.path).toBe('/test/repo-zero/zero-timestamp'); // 0
|
|
72
|
+
// wt-a has max 5000, wt-b has 3000
|
|
73
|
+
expect(items[0]?.worktree.path).toBe('/repo/wt-a');
|
|
74
|
+
expect(items[1]?.worktree.path).toBe('/repo/wt-a');
|
|
75
|
+
expect(items[2]?.worktree.path).toBe('/repo/wt-b');
|
|
76
|
+
});
|
|
77
|
+
it('should place worktrees without sessions at the end', () => {
|
|
78
|
+
const worktrees = [
|
|
79
|
+
makeWorktree('/repo/no-session', 'no-session'),
|
|
80
|
+
makeWorktree('/repo/has-session', 'has-session'),
|
|
81
|
+
];
|
|
82
|
+
const sessions = [makeSession('s1', '/repo/has-session', 1, 1000)];
|
|
83
|
+
const items = prepareSessionItems(worktrees, sessions, {
|
|
84
|
+
sortByLastSession: true,
|
|
221
85
|
});
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
// Execute
|
|
235
|
-
const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
|
|
236
|
-
// Verify properties are preserved
|
|
237
|
-
expect(result).toHaveLength(2);
|
|
238
|
-
expect(result[0]?.path).toBe('/test/repo-props/feature-a');
|
|
239
|
-
expect(result[0]?.branch).toBe('feature-a');
|
|
240
|
-
expect(result[0]?.isMainWorktree).toBe(false);
|
|
241
|
-
expect(result[1]?.path).toBe('/test/repo-props');
|
|
242
|
-
expect(result[1]?.branch).toBe('main');
|
|
243
|
-
expect(result[1]?.isMainWorktree).toBe(true);
|
|
86
|
+
expect(items[0]?.worktree.path).toBe('/repo/has-session');
|
|
87
|
+
expect(items[1]?.worktree.path).toBe('/repo/no-session');
|
|
88
|
+
});
|
|
89
|
+
it('should sort sessions within a worktree by lastAccessedAt', () => {
|
|
90
|
+
const worktrees = [makeWorktree('/repo/wt', 'wt')];
|
|
91
|
+
const sessions = [
|
|
92
|
+
makeSession('s1', '/repo/wt', 1, 1000),
|
|
93
|
+
makeSession('s2', '/repo/wt', 2, 3000),
|
|
94
|
+
makeSession('s3', '/repo/wt', 3, 2000),
|
|
95
|
+
];
|
|
96
|
+
const items = prepareSessionItems(worktrees, sessions, {
|
|
97
|
+
sortByLastSession: true,
|
|
244
98
|
});
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
worktree
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
99
|
+
expect(items).toHaveLength(3);
|
|
100
|
+
expect(items[0]?.session?.id).toBe('s2'); // 3000
|
|
101
|
+
expect(items[1]?.session?.id).toBe('s3'); // 2000
|
|
102
|
+
expect(items[2]?.session?.id).toBe('s1'); // 1000
|
|
103
|
+
});
|
|
104
|
+
it('should handle empty worktree list', () => {
|
|
105
|
+
const items = prepareSessionItems([], [], { sortByLastSession: true });
|
|
106
|
+
expect(items).toHaveLength(0);
|
|
107
|
+
});
|
|
108
|
+
it('should preserve worktree properties after sorting', () => {
|
|
109
|
+
const worktrees = [
|
|
110
|
+
makeWorktree('/repo', 'main'),
|
|
111
|
+
makeWorktree('/repo/feature', 'feature'),
|
|
112
|
+
];
|
|
113
|
+
const sessions = [
|
|
114
|
+
makeSession('s1', '/repo/feature', 1, 2000),
|
|
115
|
+
makeSession('s2', '/repo', 1, 1000),
|
|
116
|
+
];
|
|
117
|
+
const items = prepareSessionItems(worktrees, sessions, {
|
|
118
|
+
sortByLastSession: true,
|
|
265
119
|
});
|
|
120
|
+
expect(items[0]?.worktree.path).toBe('/repo/feature');
|
|
121
|
+
expect(items[0]?.worktree.branch).toBe('feature');
|
|
122
|
+
expect(items[0]?.worktree.isMainWorktree).toBe(false);
|
|
123
|
+
expect(items[1]?.worktree.path).toBe('/repo');
|
|
124
|
+
expect(items[1]?.worktree.branch).toBe('main');
|
|
266
125
|
});
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
// Set timestamps to verify sorting works
|
|
281
|
-
setWorktreeLastOpened('/test/repo-sort', 1000);
|
|
282
|
-
setWorktreeLastOpened('/test/repo-sort/feature-a', 3000);
|
|
283
|
-
setWorktreeLastOpened('/test/repo-sort/feature-b', 2000);
|
|
284
|
-
// Execute with sorting
|
|
285
|
-
const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
|
|
286
|
-
// Verify sorting happened correctly (implicitly means getWorktreeLastOpenedTime was called)
|
|
287
|
-
expect(result).toHaveLength(3);
|
|
288
|
-
expect(result[0]?.path).toBe('/test/repo-sort/feature-a'); // 3000
|
|
289
|
-
expect(result[1]?.path).toBe('/test/repo-sort/feature-b'); // 2000
|
|
290
|
-
expect(result[2]?.path).toBe('/test/repo-sort'); // 1000
|
|
126
|
+
it('should maintain stable order for worktrees with same timestamp', () => {
|
|
127
|
+
const worktrees = [
|
|
128
|
+
makeWorktree('/repo/a', 'a'),
|
|
129
|
+
makeWorktree('/repo/b', 'b'),
|
|
130
|
+
makeWorktree('/repo/c', 'c'),
|
|
131
|
+
];
|
|
132
|
+
const sessions = [
|
|
133
|
+
makeSession('s1', '/repo/a', 1, 1000),
|
|
134
|
+
makeSession('s2', '/repo/b', 1, 1000),
|
|
135
|
+
makeSession('s3', '/repo/c', 1, 1000),
|
|
136
|
+
];
|
|
137
|
+
const items = prepareSessionItems(worktrees, sessions, {
|
|
138
|
+
sortByLastSession: true,
|
|
291
139
|
});
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
mockedExecSync.mockReturnValue(gitOutput);
|
|
304
|
-
// Set timestamps that would cause reordering if sorting was applied
|
|
305
|
-
setWorktreeLastOpened('/test/repo-nosort', 1000);
|
|
306
|
-
setWorktreeLastOpened('/test/repo-nosort/feature-a', 3000);
|
|
307
|
-
setWorktreeLastOpened('/test/repo-nosort/feature-b', 2000);
|
|
308
|
-
// Execute without sorting
|
|
309
|
-
const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: false }));
|
|
310
|
-
// Verify original order is preserved
|
|
311
|
-
expect(result).toHaveLength(3);
|
|
312
|
-
expect(result[0]?.path).toBe('/test/repo-nosort');
|
|
313
|
-
expect(result[1]?.path).toBe('/test/repo-nosort/feature-a');
|
|
314
|
-
expect(result[2]?.path).toBe('/test/repo-nosort/feature-b');
|
|
140
|
+
expect(items[0]?.worktree.path).toBe('/repo/a');
|
|
141
|
+
expect(items[1]?.worktree.path).toBe('/repo/b');
|
|
142
|
+
expect(items[2]?.worktree.path).toBe('/repo/c');
|
|
143
|
+
});
|
|
144
|
+
it('should not sort when no sessions exist', () => {
|
|
145
|
+
const worktrees = [
|
|
146
|
+
makeWorktree('/repo', 'main'),
|
|
147
|
+
makeWorktree('/repo/feature', 'feature'),
|
|
148
|
+
];
|
|
149
|
+
const items = prepareSessionItems(worktrees, [], {
|
|
150
|
+
sortByLastSession: true,
|
|
315
151
|
});
|
|
152
|
+
expect(items[0]?.worktree.path).toBe('/repo');
|
|
153
|
+
expect(items[1]?.worktree.path).toBe('/repo/feature');
|
|
316
154
|
});
|
|
317
155
|
});
|
package/dist/types/index.d.ts
CHANGED
|
@@ -18,6 +18,9 @@ export interface Worktree {
|
|
|
18
18
|
export interface Session {
|
|
19
19
|
id: string;
|
|
20
20
|
worktreePath: string;
|
|
21
|
+
sessionNumber: number;
|
|
22
|
+
sessionName?: string;
|
|
23
|
+
lastAccessedAt: number;
|
|
21
24
|
process: IPty;
|
|
22
25
|
output: string[];
|
|
23
26
|
outputHistory: Buffer[];
|
|
@@ -44,12 +47,42 @@ export interface AutoApprovalResponse {
|
|
|
44
47
|
needsPermission: boolean;
|
|
45
48
|
reason?: string;
|
|
46
49
|
}
|
|
50
|
+
export type MenuAction = {
|
|
51
|
+
type: 'selectWorktree';
|
|
52
|
+
worktree: Worktree;
|
|
53
|
+
session?: Session;
|
|
54
|
+
} | {
|
|
55
|
+
type: 'newWorktree';
|
|
56
|
+
} | {
|
|
57
|
+
type: 'newSession';
|
|
58
|
+
worktreePath: string;
|
|
59
|
+
} | {
|
|
60
|
+
type: 'renameSession';
|
|
61
|
+
session: Session;
|
|
62
|
+
} | {
|
|
63
|
+
type: 'killSession';
|
|
64
|
+
sessionId: string;
|
|
65
|
+
} | {
|
|
66
|
+
type: 'sessionActions';
|
|
67
|
+
session: Session;
|
|
68
|
+
worktreePath: string;
|
|
69
|
+
} | {
|
|
70
|
+
type: 'deleteWorktree';
|
|
71
|
+
} | {
|
|
72
|
+
type: 'mergeWorktree';
|
|
73
|
+
} | {
|
|
74
|
+
type: 'configuration';
|
|
75
|
+
scope: ConfigScope;
|
|
76
|
+
} | {
|
|
77
|
+
type: 'exit';
|
|
78
|
+
};
|
|
47
79
|
export interface SessionManager {
|
|
48
80
|
sessions: Map<string, Session>;
|
|
49
|
-
|
|
50
|
-
|
|
81
|
+
getSessionById(id: string): Session | undefined;
|
|
82
|
+
getSessionsForWorktree(worktreePath: string): Session[];
|
|
83
|
+
destroySession(sessionId: string): void;
|
|
51
84
|
getAllSessions(): Session[];
|
|
52
|
-
cancelAutoApproval(
|
|
85
|
+
cancelAutoApproval(sessionId: string, reason?: string): void;
|
|
53
86
|
}
|
|
54
87
|
export interface ShortcutKey {
|
|
55
88
|
ctrl?: boolean;
|
|
@@ -210,9 +243,7 @@ export declare class AmbiguousBranchError extends Error {
|
|
|
210
243
|
constructor(branchName: string, matches: RemoteBranchMatch[]);
|
|
211
244
|
}
|
|
212
245
|
export interface IWorktreeService {
|
|
213
|
-
getWorktreesEffect(
|
|
214
|
-
sortByLastSession?: boolean;
|
|
215
|
-
}): import('effect').Effect.Effect<Worktree[], import('../types/errors.js').GitError, never>;
|
|
246
|
+
getWorktreesEffect(): import('effect').Effect.Effect<Worktree[], import('../types/errors.js').GitError, never>;
|
|
216
247
|
getGitRootPath(): string;
|
|
217
248
|
createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): import('effect').Effect.Effect<Worktree, import('../types/errors.js').GitError | import('../types/errors.js').FileSystemError | import('../types/errors.js').ProcessError, never>;
|
|
218
249
|
deleteWorktreeEffect(worktreePath: string, options?: {
|
|
@@ -376,6 +376,8 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
376
376
|
const mockSession = {
|
|
377
377
|
id: 'test-session-123',
|
|
378
378
|
worktreePath: tmpDir, // Use tmpDir as the worktree path
|
|
379
|
+
sessionNumber: 1,
|
|
380
|
+
lastAccessedAt: Date.now(),
|
|
379
381
|
process: {},
|
|
380
382
|
terminal: {},
|
|
381
383
|
output: [],
|
|
@@ -430,6 +432,8 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
430
432
|
const mockSession = {
|
|
431
433
|
id: 'test-session-456',
|
|
432
434
|
worktreePath: tmpDir, // Use tmpDir as the worktree path
|
|
435
|
+
sessionNumber: 1,
|
|
436
|
+
lastAccessedAt: Date.now(),
|
|
433
437
|
process: {},
|
|
434
438
|
terminal: {},
|
|
435
439
|
output: [],
|
|
@@ -482,6 +486,8 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
482
486
|
const mockSession = {
|
|
483
487
|
id: 'test-session-789',
|
|
484
488
|
worktreePath: tmpDir, // Use tmpDir as the worktree path
|
|
489
|
+
sessionNumber: 1,
|
|
490
|
+
lastAccessedAt: Date.now(),
|
|
485
491
|
process: {},
|
|
486
492
|
terminal: {},
|
|
487
493
|
output: [],
|
|
@@ -536,6 +542,8 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
536
542
|
const mockSession = {
|
|
537
543
|
id: 'test-session-failure',
|
|
538
544
|
worktreePath: tmpDir,
|
|
545
|
+
sessionNumber: 1,
|
|
546
|
+
lastAccessedAt: Date.now(),
|
|
539
547
|
process: {},
|
|
540
548
|
terminal: {},
|
|
541
549
|
output: [],
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Worktree, Session } from '../types/index.js';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* One menu row: worktree metadata plus optional session (for multi-session worktrees).
|
|
4
4
|
*/
|
|
5
|
-
export interface
|
|
5
|
+
export interface SessionItem {
|
|
6
6
|
worktree: Worktree;
|
|
7
7
|
session?: Session;
|
|
8
8
|
baseLabel: string;
|
|
@@ -30,13 +30,19 @@ export declare function extractBranchParts(branchName: string): {
|
|
|
30
30
|
name: string;
|
|
31
31
|
};
|
|
32
32
|
/**
|
|
33
|
-
* Prepares
|
|
33
|
+
* Prepares session items for display.
|
|
34
|
+
* Supports multiple sessions per worktree.
|
|
35
|
+
* When sortByLastSession is true, worktrees are sorted by the most recent
|
|
36
|
+
* session lastAccessedAt timestamp (descending), and sessions within each
|
|
37
|
+
* worktree are also sorted by lastAccessedAt.
|
|
34
38
|
*/
|
|
35
|
-
export declare function
|
|
39
|
+
export declare function prepareSessionItems(worktrees: Worktree[], sessions: Session[], options?: {
|
|
40
|
+
sortByLastSession?: boolean;
|
|
41
|
+
}): SessionItem[];
|
|
36
42
|
/**
|
|
37
43
|
* Calculates column positions based on content widths.
|
|
38
44
|
*/
|
|
39
|
-
export declare function calculateColumnPositions(items:
|
|
45
|
+
export declare function calculateColumnPositions(items: SessionItem[]): {
|
|
40
46
|
fileChanges: number;
|
|
41
47
|
aheadBehind: number;
|
|
42
48
|
parentBranch: number;
|
|
@@ -45,4 +51,4 @@ export declare function calculateColumnPositions(items: WorktreeItem[]): {
|
|
|
45
51
|
/**
|
|
46
52
|
* Assembles the final worktree label with proper column alignment
|
|
47
53
|
*/
|
|
48
|
-
export declare function
|
|
54
|
+
export declare function assembleSessionLabel(item: SessionItem, columns: ReturnType<typeof calculateColumnPositions>): string;
|