ccmanager 3.12.6 → 4.0.1
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/ConfigureCommand.js +1 -1
- 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/stateDetector/cursor.js +4 -1
- package/dist/services/stateDetector/cursor.test.js +35 -0
- 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
|
@@ -9,26 +9,6 @@ import { getClaudeProjectsDir, pathToClaudeProjectName, } from '../utils/claudeD
|
|
|
9
9
|
import { executeWorktreePostCreationHook, executeWorktreePreCreationHook, } from '../utils/hookExecutor.js';
|
|
10
10
|
import { configReader } from './config/configReader.js';
|
|
11
11
|
const CLAUDE_DIR = '.claude';
|
|
12
|
-
// Module-level state for worktree last opened tracking (runtime state, not persisted)
|
|
13
|
-
const worktreeLastOpened = new Map();
|
|
14
|
-
/**
|
|
15
|
-
* Get all worktree last opened timestamps
|
|
16
|
-
*/
|
|
17
|
-
export function getWorktreeLastOpened() {
|
|
18
|
-
return Object.fromEntries(worktreeLastOpened);
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* Set the last opened timestamp for a worktree
|
|
22
|
-
*/
|
|
23
|
-
export function setWorktreeLastOpened(worktreePath, timestamp) {
|
|
24
|
-
worktreeLastOpened.set(worktreePath, timestamp);
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Get the last opened timestamp for a specific worktree
|
|
28
|
-
*/
|
|
29
|
-
export function getWorktreeLastOpenedTime(worktreePath) {
|
|
30
|
-
return worktreeLastOpened.get(worktreePath);
|
|
31
|
-
}
|
|
32
12
|
/**
|
|
33
13
|
* WorktreeService - Git worktree management with Effect-based error handling
|
|
34
14
|
*
|
|
@@ -604,10 +584,9 @@ export class WorktreeService {
|
|
|
604
584
|
*
|
|
605
585
|
* @throws {GitError} When git worktree list command fails
|
|
606
586
|
*/
|
|
607
|
-
getWorktreesEffect(
|
|
587
|
+
getWorktreesEffect() {
|
|
608
588
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
609
589
|
const self = this;
|
|
610
|
-
const sortByLastSession = options?.sortByLastSession ?? false;
|
|
611
590
|
return Effect.catchAll(Effect.try({
|
|
612
591
|
try: () => {
|
|
613
592
|
const output = execSync('git worktree list --porcelain', {
|
|
@@ -662,23 +641,6 @@ export class WorktreeService {
|
|
|
662
641
|
if (mainWorktree && mainWorktree.path.includes('.git/modules')) {
|
|
663
642
|
mainWorktree.path = self.gitRootPath;
|
|
664
643
|
}
|
|
665
|
-
// Sort worktrees by last session if requested
|
|
666
|
-
if (sortByLastSession) {
|
|
667
|
-
worktrees.sort((a, b) => {
|
|
668
|
-
// Get last opened timestamps for both worktrees
|
|
669
|
-
const timeA = getWorktreeLastOpenedTime(a.path);
|
|
670
|
-
const timeB = getWorktreeLastOpenedTime(b.path);
|
|
671
|
-
// If both timestamps are undefined, preserve original order
|
|
672
|
-
if (timeA === undefined && timeB === undefined) {
|
|
673
|
-
return 0;
|
|
674
|
-
}
|
|
675
|
-
// If only one is undefined, treat it as older (0)
|
|
676
|
-
const compareTimeA = timeA || 0;
|
|
677
|
-
const compareTimeB = timeB || 0;
|
|
678
|
-
// Sort in descending order (most recent first)
|
|
679
|
-
return compareTimeB - compareTimeA;
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
644
|
return worktrees;
|
|
683
645
|
},
|
|
684
646
|
catch: (error) => error,
|
|
@@ -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: [],
|