ccmanager 1.4.5 → 2.1.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 +34 -1
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +30 -2
- package/dist/cli.test.d.ts +1 -0
- package/dist/cli.test.js +67 -0
- package/dist/components/App.d.ts +1 -0
- package/dist/components/App.js +107 -37
- package/dist/components/Menu.d.ts +6 -1
- package/dist/components/Menu.js +228 -50
- package/dist/components/Menu.recent-projects.test.d.ts +1 -0
- package/dist/components/Menu.recent-projects.test.js +159 -0
- package/dist/components/Menu.test.d.ts +1 -0
- package/dist/components/Menu.test.js +196 -0
- package/dist/components/NewWorktree.js +30 -2
- package/dist/components/ProjectList.d.ts +10 -0
- package/dist/components/ProjectList.js +231 -0
- package/dist/components/ProjectList.recent-projects.test.d.ts +1 -0
- package/dist/components/ProjectList.recent-projects.test.js +186 -0
- package/dist/components/ProjectList.test.d.ts +1 -0
- package/dist/components/ProjectList.test.js +501 -0
- package/dist/constants/env.d.ts +3 -0
- package/dist/constants/env.js +4 -0
- package/dist/constants/error.d.ts +6 -0
- package/dist/constants/error.js +7 -0
- package/dist/hooks/useSearchMode.d.ts +15 -0
- package/dist/hooks/useSearchMode.js +67 -0
- package/dist/services/configurationManager.d.ts +1 -0
- package/dist/services/configurationManager.js +14 -7
- package/dist/services/globalSessionOrchestrator.d.ts +16 -0
- package/dist/services/globalSessionOrchestrator.js +73 -0
- package/dist/services/globalSessionOrchestrator.test.d.ts +1 -0
- package/dist/services/globalSessionOrchestrator.test.js +180 -0
- package/dist/services/projectManager.d.ts +60 -0
- package/dist/services/projectManager.js +418 -0
- package/dist/services/projectManager.test.d.ts +1 -0
- package/dist/services/projectManager.test.js +342 -0
- package/dist/services/sessionManager.d.ts +8 -0
- package/dist/services/sessionManager.js +38 -0
- package/dist/services/sessionManager.test.js +79 -0
- package/dist/services/worktreeService.d.ts +1 -0
- package/dist/services/worktreeService.js +20 -5
- package/dist/services/worktreeService.test.js +72 -0
- package/dist/types/index.d.ts +55 -0
- package/package.json +1 -1
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import { ProjectManager } from './projectManager.js';
|
|
3
|
+
import { ENV_VARS } from '../constants/env.js';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import * as os from 'os';
|
|
7
|
+
// Mock fs module
|
|
8
|
+
vi.mock('fs');
|
|
9
|
+
vi.mock('os');
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
|
+
const mockFs = fs;
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
|
+
const mockOs = os;
|
|
14
|
+
describe('ProjectManager', () => {
|
|
15
|
+
let projectManager;
|
|
16
|
+
const mockProjectsDir = '/home/user/projects';
|
|
17
|
+
const mockConfigDir = '/home/user/.config/ccmanager';
|
|
18
|
+
const mockRecentProjectsPath = '/home/user/.config/ccmanager/recent-projects.json';
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
// Reset environment variables
|
|
22
|
+
delete process.env[ENV_VARS.MULTI_PROJECT_ROOT];
|
|
23
|
+
// Mock os.homedir
|
|
24
|
+
mockOs.homedir.mockReturnValue('/home/user');
|
|
25
|
+
// Mock fs methods for config directory
|
|
26
|
+
mockFs.existsSync.mockImplementation((path) => {
|
|
27
|
+
if (path === mockConfigDir)
|
|
28
|
+
return true;
|
|
29
|
+
if (path === mockRecentProjectsPath)
|
|
30
|
+
return false;
|
|
31
|
+
return false;
|
|
32
|
+
});
|
|
33
|
+
mockFs.mkdirSync.mockImplementation(() => { });
|
|
34
|
+
mockFs.readFileSync.mockImplementation(() => '[]');
|
|
35
|
+
mockFs.writeFileSync.mockImplementation(() => { });
|
|
36
|
+
});
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
vi.restoreAllMocks();
|
|
39
|
+
});
|
|
40
|
+
describe('initialization', () => {
|
|
41
|
+
it('should initialize in normal mode when no multi-project root is set', () => {
|
|
42
|
+
projectManager = new ProjectManager();
|
|
43
|
+
expect(projectManager.currentMode).toBe('normal');
|
|
44
|
+
expect(projectManager.currentProject).toBeUndefined();
|
|
45
|
+
expect(projectManager.projects).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
it('should initialize in multi-project mode when multi-project root is set', () => {
|
|
48
|
+
process.env[ENV_VARS.MULTI_PROJECT_ROOT] = mockProjectsDir;
|
|
49
|
+
projectManager = new ProjectManager();
|
|
50
|
+
expect(projectManager.currentMode).toBe('multi-project');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('project discovery', () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
process.env[ENV_VARS.MULTI_PROJECT_ROOT] = mockProjectsDir;
|
|
56
|
+
projectManager = new ProjectManager();
|
|
57
|
+
});
|
|
58
|
+
it('should discover git projects in the projects directory', async () => {
|
|
59
|
+
// Mock file system for project discovery
|
|
60
|
+
mockFs.promises = {
|
|
61
|
+
access: vi.fn().mockResolvedValue(undefined),
|
|
62
|
+
readdir: vi.fn().mockImplementation((dir) => {
|
|
63
|
+
if (dir === mockProjectsDir) {
|
|
64
|
+
return Promise.resolve([
|
|
65
|
+
{ name: 'project1', isDirectory: () => true },
|
|
66
|
+
{ name: 'project2', isDirectory: () => true },
|
|
67
|
+
{ name: 'not-a-project.txt', isDirectory: () => false },
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
70
|
+
return Promise.resolve([]);
|
|
71
|
+
}),
|
|
72
|
+
stat: vi.fn().mockImplementation((path) => {
|
|
73
|
+
if (path.endsWith('.git')) {
|
|
74
|
+
return Promise.resolve({
|
|
75
|
+
isDirectory: () => true,
|
|
76
|
+
isFile: () => false,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
throw new Error('Not found');
|
|
80
|
+
}),
|
|
81
|
+
};
|
|
82
|
+
await projectManager.refreshProjects();
|
|
83
|
+
expect(projectManager.projects).toHaveLength(2);
|
|
84
|
+
expect(projectManager.projects[0]).toMatchObject({
|
|
85
|
+
name: 'project1',
|
|
86
|
+
path: path.join(mockProjectsDir, 'project1'),
|
|
87
|
+
isValid: true,
|
|
88
|
+
});
|
|
89
|
+
expect(projectManager.projects[1]).toMatchObject({
|
|
90
|
+
name: 'project2',
|
|
91
|
+
path: path.join(mockProjectsDir, 'project2'),
|
|
92
|
+
isValid: true,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
it('should handle projects directory not existing', async () => {
|
|
96
|
+
mockFs.promises = {
|
|
97
|
+
access: vi.fn().mockRejectedValue({ code: 'ENOENT' }),
|
|
98
|
+
};
|
|
99
|
+
await expect(projectManager.refreshProjects()).rejects.toThrow(`Projects directory does not exist: ${mockProjectsDir}`);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe('recent projects', () => {
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
projectManager = new ProjectManager();
|
|
105
|
+
});
|
|
106
|
+
it('should get recent projects with default limit', () => {
|
|
107
|
+
const mockRecentProjects = [
|
|
108
|
+
{ path: '/path/to/project1', name: 'project1', lastAccessed: Date.now() },
|
|
109
|
+
{
|
|
110
|
+
path: '/path/to/project2',
|
|
111
|
+
name: 'project2',
|
|
112
|
+
lastAccessed: Date.now() - 1000,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
path: '/path/to/project3',
|
|
116
|
+
name: 'project3',
|
|
117
|
+
lastAccessed: Date.now() - 2000,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
path: '/path/to/project4',
|
|
121
|
+
name: 'project4',
|
|
122
|
+
lastAccessed: Date.now() - 3000,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
path: '/path/to/project5',
|
|
126
|
+
name: 'project5',
|
|
127
|
+
lastAccessed: Date.now() - 4000,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
path: '/path/to/project6',
|
|
131
|
+
name: 'project6',
|
|
132
|
+
lastAccessed: Date.now() - 5000,
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockRecentProjects));
|
|
136
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
137
|
+
// Re-create to load recent projects
|
|
138
|
+
projectManager = new ProjectManager();
|
|
139
|
+
const recent = projectManager.getRecentProjects();
|
|
140
|
+
expect(recent).toHaveLength(5); // Default limit
|
|
141
|
+
expect(recent[0]?.name).toBe('project1');
|
|
142
|
+
expect(recent[4]?.name).toBe('project5');
|
|
143
|
+
});
|
|
144
|
+
it('should get recent projects with custom limit', () => {
|
|
145
|
+
const mockRecentProjects = [
|
|
146
|
+
{ path: '/path/to/project1', name: 'project1', lastAccessed: Date.now() },
|
|
147
|
+
{
|
|
148
|
+
path: '/path/to/project2',
|
|
149
|
+
name: 'project2',
|
|
150
|
+
lastAccessed: Date.now() - 1000,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
path: '/path/to/project3',
|
|
154
|
+
name: 'project3',
|
|
155
|
+
lastAccessed: Date.now() - 2000,
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(mockRecentProjects));
|
|
159
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
160
|
+
projectManager = new ProjectManager();
|
|
161
|
+
const recent = projectManager.getRecentProjects(2);
|
|
162
|
+
expect(recent).toHaveLength(2);
|
|
163
|
+
});
|
|
164
|
+
it('should add a recent project', () => {
|
|
165
|
+
const project = {
|
|
166
|
+
name: 'test-project',
|
|
167
|
+
path: '/path/to/test-project',
|
|
168
|
+
relativePath: 'test-project',
|
|
169
|
+
isValid: true,
|
|
170
|
+
};
|
|
171
|
+
projectManager.addRecentProject(project);
|
|
172
|
+
expect(mockFs.writeFileSync).toHaveBeenCalled();
|
|
173
|
+
const writtenData = JSON.parse(mockFs.writeFileSync.mock.calls[0][1]);
|
|
174
|
+
expect(writtenData).toHaveLength(1);
|
|
175
|
+
expect(writtenData[0]).toMatchObject({
|
|
176
|
+
path: project.path,
|
|
177
|
+
name: project.name,
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
it('should update existing recent project', () => {
|
|
181
|
+
const existingProject = {
|
|
182
|
+
path: '/path/to/project1',
|
|
183
|
+
name: 'project1',
|
|
184
|
+
lastAccessed: Date.now() - 10000,
|
|
185
|
+
};
|
|
186
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify([existingProject]));
|
|
187
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
188
|
+
projectManager = new ProjectManager();
|
|
189
|
+
const updatedProject = {
|
|
190
|
+
name: 'project1',
|
|
191
|
+
path: '/path/to/project1',
|
|
192
|
+
relativePath: 'project1',
|
|
193
|
+
isValid: true,
|
|
194
|
+
};
|
|
195
|
+
projectManager.addRecentProject(updatedProject);
|
|
196
|
+
const writtenData = JSON.parse(mockFs.writeFileSync.mock.calls[0][1]);
|
|
197
|
+
expect(writtenData).toHaveLength(1);
|
|
198
|
+
expect(writtenData[0].lastAccessed).toBeGreaterThan(existingProject.lastAccessed);
|
|
199
|
+
});
|
|
200
|
+
it('should not add EXIT_APPLICATION to recent projects', () => {
|
|
201
|
+
const exitProject = {
|
|
202
|
+
name: 'Exit',
|
|
203
|
+
path: 'EXIT_APPLICATION',
|
|
204
|
+
relativePath: '',
|
|
205
|
+
isValid: true,
|
|
206
|
+
};
|
|
207
|
+
projectManager.addRecentProject(exitProject);
|
|
208
|
+
expect(mockFs.writeFileSync).not.toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
it('should clear recent projects', () => {
|
|
211
|
+
projectManager.clearRecentProjects();
|
|
212
|
+
expect(mockFs.writeFileSync).toHaveBeenCalledWith(mockRecentProjectsPath, JSON.stringify([], null, 2));
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
describe('mode management', () => {
|
|
216
|
+
beforeEach(() => {
|
|
217
|
+
process.env[ENV_VARS.MULTI_PROJECT_ROOT] = mockProjectsDir;
|
|
218
|
+
projectManager = new ProjectManager();
|
|
219
|
+
});
|
|
220
|
+
it('should set mode correctly', () => {
|
|
221
|
+
projectManager.setMode('normal');
|
|
222
|
+
expect(projectManager.currentMode).toBe('normal');
|
|
223
|
+
projectManager.setMode('multi-project');
|
|
224
|
+
expect(projectManager.currentMode).toBe('multi-project');
|
|
225
|
+
});
|
|
226
|
+
it('should clear current project when switching to normal mode', () => {
|
|
227
|
+
const project = {
|
|
228
|
+
name: 'test',
|
|
229
|
+
path: '/test',
|
|
230
|
+
relativePath: 'test',
|
|
231
|
+
isValid: true,
|
|
232
|
+
};
|
|
233
|
+
projectManager.selectProject(project);
|
|
234
|
+
expect(projectManager.currentProject).toBe(project);
|
|
235
|
+
projectManager.setMode('normal');
|
|
236
|
+
expect(projectManager.currentProject).toBeUndefined();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
describe('project selection', () => {
|
|
240
|
+
beforeEach(() => {
|
|
241
|
+
projectManager = new ProjectManager();
|
|
242
|
+
});
|
|
243
|
+
it('should select a project', () => {
|
|
244
|
+
const project = {
|
|
245
|
+
name: 'test',
|
|
246
|
+
path: '/test',
|
|
247
|
+
relativePath: 'test',
|
|
248
|
+
isValid: true,
|
|
249
|
+
};
|
|
250
|
+
projectManager.selectProject(project);
|
|
251
|
+
expect(projectManager.currentProject).toBe(project);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
describe('worktree service management', () => {
|
|
255
|
+
beforeEach(() => {
|
|
256
|
+
projectManager = new ProjectManager();
|
|
257
|
+
});
|
|
258
|
+
it('should get worktree service for current project', () => {
|
|
259
|
+
const project = {
|
|
260
|
+
name: 'test',
|
|
261
|
+
path: '/test/project',
|
|
262
|
+
relativePath: 'test',
|
|
263
|
+
isValid: true,
|
|
264
|
+
};
|
|
265
|
+
projectManager.selectProject(project);
|
|
266
|
+
const service = projectManager.getWorktreeService();
|
|
267
|
+
expect(service).toBeDefined();
|
|
268
|
+
expect(service.getGitRootPath()).toBe('/test/project');
|
|
269
|
+
});
|
|
270
|
+
it('should cache worktree services', () => {
|
|
271
|
+
const service1 = projectManager.getWorktreeService('/test/path1');
|
|
272
|
+
const service2 = projectManager.getWorktreeService('/test/path1');
|
|
273
|
+
expect(service1).toBe(service2);
|
|
274
|
+
});
|
|
275
|
+
it('should clear worktree service cache', () => {
|
|
276
|
+
projectManager.getWorktreeService('/test/path1');
|
|
277
|
+
projectManager.getWorktreeService('/test/path2');
|
|
278
|
+
projectManager.clearWorktreeServiceCache('/test/path1');
|
|
279
|
+
const cachedServices = projectManager.getCachedServices();
|
|
280
|
+
expect(cachedServices.size).toBe(1);
|
|
281
|
+
expect(cachedServices.has('/test/path2')).toBe(true);
|
|
282
|
+
});
|
|
283
|
+
it('should clear all worktree service cache', () => {
|
|
284
|
+
projectManager.getWorktreeService('/test/path1');
|
|
285
|
+
projectManager.getWorktreeService('/test/path2');
|
|
286
|
+
projectManager.clearWorktreeServiceCache();
|
|
287
|
+
const cachedServices = projectManager.getCachedServices();
|
|
288
|
+
expect(cachedServices.size).toBe(0);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
describe('helper methods', () => {
|
|
292
|
+
beforeEach(() => {
|
|
293
|
+
projectManager = new ProjectManager();
|
|
294
|
+
});
|
|
295
|
+
it('should check if multi-project is enabled', () => {
|
|
296
|
+
expect(projectManager.isMultiProjectEnabled()).toBe(false);
|
|
297
|
+
process.env[ENV_VARS.MULTI_PROJECT_ROOT] = mockProjectsDir;
|
|
298
|
+
projectManager = new ProjectManager();
|
|
299
|
+
expect(projectManager.isMultiProjectEnabled()).toBe(true);
|
|
300
|
+
});
|
|
301
|
+
it('should get projects directory', () => {
|
|
302
|
+
expect(projectManager.getProjectsDir()).toBeUndefined();
|
|
303
|
+
process.env[ENV_VARS.MULTI_PROJECT_ROOT] = mockProjectsDir;
|
|
304
|
+
projectManager = new ProjectManager();
|
|
305
|
+
expect(projectManager.getProjectsDir()).toBe(mockProjectsDir);
|
|
306
|
+
});
|
|
307
|
+
it('should get current project path', () => {
|
|
308
|
+
const cwd = process.cwd();
|
|
309
|
+
expect(projectManager.getCurrentProjectPath()).toBe(cwd);
|
|
310
|
+
const project = {
|
|
311
|
+
name: 'test',
|
|
312
|
+
path: '/test/project',
|
|
313
|
+
relativePath: 'test',
|
|
314
|
+
isValid: true,
|
|
315
|
+
};
|
|
316
|
+
projectManager.selectProject(project);
|
|
317
|
+
expect(projectManager.getCurrentProjectPath()).toBe('/test/project');
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
describe('project validation', () => {
|
|
321
|
+
beforeEach(() => {
|
|
322
|
+
projectManager = new ProjectManager();
|
|
323
|
+
});
|
|
324
|
+
it('should validate a git repository', async () => {
|
|
325
|
+
mockFs.promises = {
|
|
326
|
+
stat: vi.fn().mockResolvedValue({
|
|
327
|
+
isDirectory: () => true,
|
|
328
|
+
isFile: () => false,
|
|
329
|
+
}),
|
|
330
|
+
};
|
|
331
|
+
const isValid = await projectManager.validateGitRepository('/test/repo');
|
|
332
|
+
expect(isValid).toBe(true);
|
|
333
|
+
});
|
|
334
|
+
it('should invalidate non-git repository', async () => {
|
|
335
|
+
mockFs.promises = {
|
|
336
|
+
stat: vi.fn().mockRejectedValue(new Error('Not found')),
|
|
337
|
+
};
|
|
338
|
+
const isValid = await projectManager.validateGitRepository('/test/not-repo');
|
|
339
|
+
expect(isValid).toBe(false);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { Session, SessionManager as ISessionManager, SessionState, DevcontainerConfig } from '../types/index.js';
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
|
+
export interface SessionCounts {
|
|
4
|
+
idle: number;
|
|
5
|
+
busy: number;
|
|
6
|
+
waiting_input: number;
|
|
7
|
+
total: number;
|
|
8
|
+
}
|
|
3
9
|
export declare class SessionManager extends EventEmitter implements ISessionManager {
|
|
4
10
|
sessions: Map<string, Session>;
|
|
5
11
|
private waitingWithBottomBorder;
|
|
@@ -29,4 +35,6 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
29
35
|
private executeStatusHook;
|
|
30
36
|
createSessionWithDevcontainer(worktreePath: string, devcontainerConfig: DevcontainerConfig, presetId?: string): Promise<Session>;
|
|
31
37
|
destroy(): void;
|
|
38
|
+
static getSessionCounts(sessions: Session[]): SessionCounts;
|
|
39
|
+
static formatSessionCounts(counts: SessionCounts): string;
|
|
32
40
|
}
|
|
@@ -330,4 +330,42 @@ export class SessionManager extends EventEmitter {
|
|
|
330
330
|
this.destroySession(worktreePath);
|
|
331
331
|
}
|
|
332
332
|
}
|
|
333
|
+
static getSessionCounts(sessions) {
|
|
334
|
+
const counts = {
|
|
335
|
+
idle: 0,
|
|
336
|
+
busy: 0,
|
|
337
|
+
waiting_input: 0,
|
|
338
|
+
total: sessions.length,
|
|
339
|
+
};
|
|
340
|
+
sessions.forEach(session => {
|
|
341
|
+
switch (session.state) {
|
|
342
|
+
case 'idle':
|
|
343
|
+
counts.idle++;
|
|
344
|
+
break;
|
|
345
|
+
case 'busy':
|
|
346
|
+
counts.busy++;
|
|
347
|
+
break;
|
|
348
|
+
case 'waiting_input':
|
|
349
|
+
counts.waiting_input++;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
return counts;
|
|
354
|
+
}
|
|
355
|
+
static formatSessionCounts(counts) {
|
|
356
|
+
if (counts.total === 0) {
|
|
357
|
+
return '';
|
|
358
|
+
}
|
|
359
|
+
const parts = [];
|
|
360
|
+
if (counts.idle > 0) {
|
|
361
|
+
parts.push(`${counts.idle} Idle`);
|
|
362
|
+
}
|
|
363
|
+
if (counts.busy > 0) {
|
|
364
|
+
parts.push(`${counts.busy} Busy`);
|
|
365
|
+
}
|
|
366
|
+
if (counts.waiting_input > 0) {
|
|
367
|
+
parts.push(`${counts.waiting_input} Waiting`);
|
|
368
|
+
}
|
|
369
|
+
return parts.length > 0 ? ` (${parts.join(' / ')})` : '';
|
|
370
|
+
}
|
|
333
371
|
}
|
|
@@ -694,4 +694,83 @@ describe('SessionManager', () => {
|
|
|
694
694
|
expect(session.isPrimaryCommand).toBe(false);
|
|
695
695
|
});
|
|
696
696
|
});
|
|
697
|
+
describe('static methods', () => {
|
|
698
|
+
describe('getSessionCounts', () => {
|
|
699
|
+
it('should count sessions by state', () => {
|
|
700
|
+
const sessions = [
|
|
701
|
+
{ id: '1', state: 'idle' },
|
|
702
|
+
{ id: '2', state: 'busy' },
|
|
703
|
+
{ id: '3', state: 'busy' },
|
|
704
|
+
{ id: '4', state: 'waiting_input' },
|
|
705
|
+
{ id: '5', state: 'idle' },
|
|
706
|
+
];
|
|
707
|
+
const counts = SessionManager.getSessionCounts(sessions);
|
|
708
|
+
expect(counts.idle).toBe(2);
|
|
709
|
+
expect(counts.busy).toBe(2);
|
|
710
|
+
expect(counts.waiting_input).toBe(1);
|
|
711
|
+
expect(counts.total).toBe(5);
|
|
712
|
+
});
|
|
713
|
+
it('should handle empty sessions array', () => {
|
|
714
|
+
const counts = SessionManager.getSessionCounts([]);
|
|
715
|
+
expect(counts.idle).toBe(0);
|
|
716
|
+
expect(counts.busy).toBe(0);
|
|
717
|
+
expect(counts.waiting_input).toBe(0);
|
|
718
|
+
expect(counts.total).toBe(0);
|
|
719
|
+
});
|
|
720
|
+
it('should handle sessions with single state', () => {
|
|
721
|
+
const sessions = [
|
|
722
|
+
{ id: '1', state: 'busy' },
|
|
723
|
+
{ id: '2', state: 'busy' },
|
|
724
|
+
{ id: '3', state: 'busy' },
|
|
725
|
+
];
|
|
726
|
+
const counts = SessionManager.getSessionCounts(sessions);
|
|
727
|
+
expect(counts.idle).toBe(0);
|
|
728
|
+
expect(counts.busy).toBe(3);
|
|
729
|
+
expect(counts.waiting_input).toBe(0);
|
|
730
|
+
expect(counts.total).toBe(3);
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
describe('formatSessionCounts', () => {
|
|
734
|
+
it('should format counts with all states', () => {
|
|
735
|
+
const counts = {
|
|
736
|
+
idle: 1,
|
|
737
|
+
busy: 2,
|
|
738
|
+
waiting_input: 1,
|
|
739
|
+
total: 4,
|
|
740
|
+
};
|
|
741
|
+
const formatted = SessionManager.formatSessionCounts(counts);
|
|
742
|
+
expect(formatted).toBe(' (1 Idle / 2 Busy / 1 Waiting)');
|
|
743
|
+
});
|
|
744
|
+
it('should format counts with some states', () => {
|
|
745
|
+
const counts = {
|
|
746
|
+
idle: 2,
|
|
747
|
+
busy: 0,
|
|
748
|
+
waiting_input: 1,
|
|
749
|
+
total: 3,
|
|
750
|
+
};
|
|
751
|
+
const formatted = SessionManager.formatSessionCounts(counts);
|
|
752
|
+
expect(formatted).toBe(' (2 Idle / 1 Waiting)');
|
|
753
|
+
});
|
|
754
|
+
it('should format counts with single state', () => {
|
|
755
|
+
const counts = {
|
|
756
|
+
idle: 0,
|
|
757
|
+
busy: 3,
|
|
758
|
+
waiting_input: 0,
|
|
759
|
+
total: 3,
|
|
760
|
+
};
|
|
761
|
+
const formatted = SessionManager.formatSessionCounts(counts);
|
|
762
|
+
expect(formatted).toBe(' (3 Busy)');
|
|
763
|
+
});
|
|
764
|
+
it('should return empty string for zero sessions', () => {
|
|
765
|
+
const counts = {
|
|
766
|
+
idle: 0,
|
|
767
|
+
busy: 0,
|
|
768
|
+
waiting_input: 0,
|
|
769
|
+
total: 0,
|
|
770
|
+
};
|
|
771
|
+
const formatted = SessionManager.formatSessionCounts(counts);
|
|
772
|
+
expect(formatted).toBe('');
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
});
|
|
697
776
|
});
|
|
@@ -7,6 +7,7 @@ export declare class WorktreeService {
|
|
|
7
7
|
getWorktrees(): Worktree[];
|
|
8
8
|
private getCurrentBranch;
|
|
9
9
|
isGitRepository(): boolean;
|
|
10
|
+
getGitRootPath(): string;
|
|
10
11
|
getDefaultBranch(): string;
|
|
11
12
|
getAllBranches(): string[];
|
|
12
13
|
createWorktree(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): {
|
|
@@ -18,7 +18,7 @@ export class WorktreeService {
|
|
|
18
18
|
writable: true,
|
|
19
19
|
value: void 0
|
|
20
20
|
});
|
|
21
|
-
this.rootPath = rootPath || process.cwd();
|
|
21
|
+
this.rootPath = path.resolve(rootPath || process.cwd());
|
|
22
22
|
// Get the actual git repository root for worktree operations
|
|
23
23
|
this.gitRootPath = this.getGitRepositoryRoot();
|
|
24
24
|
}
|
|
@@ -29,12 +29,24 @@ export class WorktreeService {
|
|
|
29
29
|
cwd: this.rootPath,
|
|
30
30
|
encoding: 'utf8',
|
|
31
31
|
}).trim();
|
|
32
|
-
//
|
|
33
|
-
|
|
32
|
+
// Make sure we have an absolute path
|
|
33
|
+
const absoluteGitCommonDir = path.isAbsolute(gitCommonDir)
|
|
34
|
+
? gitCommonDir
|
|
35
|
+
: path.resolve(this.rootPath, gitCommonDir);
|
|
36
|
+
// Handle worktree paths: if path contains .git/worktrees, we need to find the real .git parent
|
|
37
|
+
if (absoluteGitCommonDir.includes('.git/worktrees')) {
|
|
38
|
+
// Extract the path up to and including .git
|
|
39
|
+
const gitIndex = absoluteGitCommonDir.indexOf('.git');
|
|
40
|
+
const gitPath = absoluteGitCommonDir.substring(0, gitIndex + 4);
|
|
41
|
+
// The parent of .git is the actual repository root
|
|
42
|
+
return path.dirname(gitPath);
|
|
43
|
+
}
|
|
44
|
+
// For regular .git directories, the parent is the repository root
|
|
45
|
+
return path.dirname(absoluteGitCommonDir);
|
|
34
46
|
}
|
|
35
47
|
catch {
|
|
36
|
-
// Fallback to current directory if command fails
|
|
37
|
-
return this.rootPath;
|
|
48
|
+
// Fallback to current directory if command fails - ensure it's absolute
|
|
49
|
+
return path.resolve(this.rootPath);
|
|
38
50
|
}
|
|
39
51
|
}
|
|
40
52
|
getWorktrees() {
|
|
@@ -114,6 +126,9 @@ export class WorktreeService {
|
|
|
114
126
|
isGitRepository() {
|
|
115
127
|
return existsSync(path.join(this.rootPath, '.git'));
|
|
116
128
|
}
|
|
129
|
+
getGitRootPath() {
|
|
130
|
+
return this.gitRootPath;
|
|
131
|
+
}
|
|
117
132
|
getDefaultBranch() {
|
|
118
133
|
try {
|
|
119
134
|
// Try to get the default branch from origin
|
|
@@ -31,6 +31,78 @@ describe('WorktreeService', () => {
|
|
|
31
31
|
});
|
|
32
32
|
service = new WorktreeService('/fake/path');
|
|
33
33
|
});
|
|
34
|
+
describe('getGitRootPath', () => {
|
|
35
|
+
it('should always return an absolute path when git command returns absolute path', () => {
|
|
36
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
37
|
+
if (typeof cmd === 'string' &&
|
|
38
|
+
cmd === 'git rev-parse --git-common-dir') {
|
|
39
|
+
return '/absolute/repo/.git\n';
|
|
40
|
+
}
|
|
41
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
42
|
+
});
|
|
43
|
+
const service = new WorktreeService('/some/path');
|
|
44
|
+
const result = service.getGitRootPath();
|
|
45
|
+
expect(result).toBe('/absolute/repo');
|
|
46
|
+
expect(result.startsWith('/')).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
it('should convert relative path to absolute path', () => {
|
|
49
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
50
|
+
if (typeof cmd === 'string' &&
|
|
51
|
+
cmd === 'git rev-parse --git-common-dir') {
|
|
52
|
+
return '.git\n';
|
|
53
|
+
}
|
|
54
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
55
|
+
});
|
|
56
|
+
const service = new WorktreeService('/work/project');
|
|
57
|
+
const result = service.getGitRootPath();
|
|
58
|
+
// Should resolve relative .git to absolute path
|
|
59
|
+
expect(result).toBe('/work/project');
|
|
60
|
+
expect(result.startsWith('/')).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
it('should handle relative paths with subdirectories', () => {
|
|
63
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
64
|
+
if (typeof cmd === 'string' &&
|
|
65
|
+
cmd === 'git rev-parse --git-common-dir') {
|
|
66
|
+
return '../.git\n';
|
|
67
|
+
}
|
|
68
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
69
|
+
});
|
|
70
|
+
const service = new WorktreeService('/work/project/subdir');
|
|
71
|
+
const result = service.getGitRootPath();
|
|
72
|
+
// Should resolve relative ../.git to absolute path
|
|
73
|
+
expect(result).toBe('/work/project');
|
|
74
|
+
expect(result.startsWith('/')).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
it('should return absolute path on git command failure', () => {
|
|
77
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
78
|
+
if (typeof cmd === 'string' &&
|
|
79
|
+
cmd === 'git rev-parse --git-common-dir') {
|
|
80
|
+
throw new Error('Not a git repository');
|
|
81
|
+
}
|
|
82
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
83
|
+
});
|
|
84
|
+
const service = new WorktreeService('relative/path');
|
|
85
|
+
const result = service.getGitRootPath();
|
|
86
|
+
// Should convert relative path to absolute path
|
|
87
|
+
expect(result.startsWith('/')).toBe(true);
|
|
88
|
+
expect(result.endsWith('relative/path')).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
it('should handle worktree paths correctly', () => {
|
|
91
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
92
|
+
if (typeof cmd === 'string' &&
|
|
93
|
+
cmd === 'git rev-parse --git-common-dir') {
|
|
94
|
+
// Worktrees often return paths like: /path/to/main/.git/worktrees/feature
|
|
95
|
+
return '/main/repo/.git/worktrees/feature\n';
|
|
96
|
+
}
|
|
97
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
98
|
+
});
|
|
99
|
+
const service = new WorktreeService('/main/repo/feature-worktree');
|
|
100
|
+
const result = service.getGitRootPath();
|
|
101
|
+
// Should get the parent of .git directory
|
|
102
|
+
expect(result).toBe('/main/repo');
|
|
103
|
+
expect(result.startsWith('/')).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
34
106
|
describe('getDefaultBranch', () => {
|
|
35
107
|
it('should return default branch from origin', () => {
|
|
36
108
|
mockedExecSync.mockImplementation((cmd, _options) => {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -88,3 +88,58 @@ export interface ConfigurationData {
|
|
|
88
88
|
command?: CommandConfig;
|
|
89
89
|
commandPresets?: CommandPresetsConfig;
|
|
90
90
|
}
|
|
91
|
+
export interface GitProject {
|
|
92
|
+
name: string;
|
|
93
|
+
path: string;
|
|
94
|
+
relativePath: string;
|
|
95
|
+
isValid: boolean;
|
|
96
|
+
error?: string;
|
|
97
|
+
}
|
|
98
|
+
export interface MultiProjectConfig {
|
|
99
|
+
enabled: boolean;
|
|
100
|
+
projectsDir: string;
|
|
101
|
+
rootMarker?: string;
|
|
102
|
+
}
|
|
103
|
+
export type MenuMode = 'normal' | 'multi-project';
|
|
104
|
+
export interface IMultiProjectService {
|
|
105
|
+
discoverProjects(projectsDir: string): Promise<GitProject[]>;
|
|
106
|
+
validateGitRepository(path: string): Promise<boolean>;
|
|
107
|
+
}
|
|
108
|
+
export interface RecentProject {
|
|
109
|
+
path: string;
|
|
110
|
+
name: string;
|
|
111
|
+
lastAccessed: number;
|
|
112
|
+
}
|
|
113
|
+
export interface IProjectManager {
|
|
114
|
+
currentMode: MenuMode;
|
|
115
|
+
currentProject?: GitProject;
|
|
116
|
+
projects: GitProject[];
|
|
117
|
+
setMode(mode: MenuMode): void;
|
|
118
|
+
selectProject(project: GitProject): void;
|
|
119
|
+
getWorktreeService(projectPath?: string): IWorktreeService;
|
|
120
|
+
refreshProjects(): Promise<void>;
|
|
121
|
+
getRecentProjects(limit?: number): RecentProject[];
|
|
122
|
+
addRecentProject(project: GitProject): void;
|
|
123
|
+
clearRecentProjects(): void;
|
|
124
|
+
validateGitRepository(path: string): Promise<boolean>;
|
|
125
|
+
}
|
|
126
|
+
export interface IWorktreeService {
|
|
127
|
+
getWorktrees(): Worktree[];
|
|
128
|
+
getGitRootPath(): string;
|
|
129
|
+
createWorktree(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): {
|
|
130
|
+
success: boolean;
|
|
131
|
+
error?: string;
|
|
132
|
+
};
|
|
133
|
+
deleteWorktree(worktreePath: string, options?: {
|
|
134
|
+
deleteBranch?: boolean;
|
|
135
|
+
}): {
|
|
136
|
+
success: boolean;
|
|
137
|
+
error?: string;
|
|
138
|
+
};
|
|
139
|
+
mergeWorktree(worktreePath: string, targetBranch?: string): {
|
|
140
|
+
success: boolean;
|
|
141
|
+
mergedBranch?: string;
|
|
142
|
+
error?: string;
|
|
143
|
+
deletedWorktree?: boolean;
|
|
144
|
+
};
|
|
145
|
+
}
|