ccmanager 3.8.1 → 3.10.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 +11 -2
- package/dist/components/App.test.js +4 -2
- package/dist/components/Configuration.js +14 -0
- package/dist/components/ConfigureMerge.d.ts +6 -0
- package/dist/components/ConfigureMerge.js +81 -0
- package/dist/components/Dashboard.d.ts +12 -0
- package/dist/components/Dashboard.js +443 -0
- package/dist/components/Dashboard.test.js +348 -0
- package/dist/components/MergeWorktree.js +20 -7
- package/dist/services/config/configEditor.d.ts +3 -1
- package/dist/services/config/configEditor.js +13 -0
- package/dist/services/config/configReader.d.ts +2 -1
- package/dist/services/config/configReader.js +12 -0
- package/dist/services/config/globalConfigManager.d.ts +3 -1
- package/dist/services/config/globalConfigManager.js +7 -0
- package/dist/services/config/projectConfigManager.d.ts +3 -1
- package/dist/services/config/projectConfigManager.js +8 -0
- package/dist/services/globalSessionOrchestrator.d.ts +1 -0
- package/dist/services/globalSessionOrchestrator.js +3 -0
- package/dist/services/projectManager.d.ts +7 -1
- package/dist/services/projectManager.js +26 -10
- package/dist/services/worktreeService.d.ts +4 -26
- package/dist/services/worktreeService.js +15 -32
- package/dist/services/worktreeService.merge.test.js +179 -0
- package/dist/services/worktreeService.test.js +149 -3
- package/dist/types/index.d.ts +9 -1
- package/dist/utils/worktreeUtils.d.ts +1 -2
- package/package.json +6 -6
- package/dist/components/ProjectList.d.ts +0 -10
- package/dist/components/ProjectList.js +0 -233
- package/dist/components/ProjectList.recent-projects.test.js +0 -193
- package/dist/components/ProjectList.test.js +0 -620
- /package/dist/components/{ProjectList.recent-projects.test.d.ts → Dashboard.test.d.ts} +0 -0
- /package/dist/{components/ProjectList.test.d.ts → services/worktreeService.merge.test.d.ts} +0 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
|
+
// Mock bunTerminal to avoid native module loading issues
|
|
5
|
+
vi.mock('../services/bunTerminal.js', () => ({
|
|
6
|
+
spawn: vi.fn(function () {
|
|
7
|
+
return null;
|
|
8
|
+
}),
|
|
9
|
+
}));
|
|
10
|
+
// Import the actual component code but skip the useInput hook
|
|
11
|
+
vi.mock('ink', async () => {
|
|
12
|
+
const actual = await vi.importActual('ink');
|
|
13
|
+
return {
|
|
14
|
+
...actual,
|
|
15
|
+
useInput: vi.fn(),
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
// Mock SelectInput to render items as simple text
|
|
19
|
+
vi.mock('ink-select-input', async () => {
|
|
20
|
+
const React = await vi.importActual('react');
|
|
21
|
+
const { Text, Box } = await vi.importActual('ink');
|
|
22
|
+
return {
|
|
23
|
+
default: ({ items }) => {
|
|
24
|
+
return React.createElement(Box, { flexDirection: 'column' }, items.map((item, index) => React.createElement(Text, { key: index }, item.label)));
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
// Mock TextInputWrapper to render as simple text
|
|
29
|
+
vi.mock('./TextInputWrapper.js', async () => {
|
|
30
|
+
const React = await vi.importActual('react');
|
|
31
|
+
const { Text } = await vi.importActual('ink');
|
|
32
|
+
return {
|
|
33
|
+
default: ({ value, placeholder }) => {
|
|
34
|
+
return React.createElement(Text, {}, value || placeholder || '');
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
// Mock Effect for testing
|
|
39
|
+
vi.mock('effect', async () => {
|
|
40
|
+
const actual = await vi.importActual('effect');
|
|
41
|
+
return actual;
|
|
42
|
+
});
|
|
43
|
+
// Mock the projectManager
|
|
44
|
+
vi.mock('../services/projectManager.js', () => ({
|
|
45
|
+
projectManager: {
|
|
46
|
+
instance: {
|
|
47
|
+
discoverProjectsEffect: vi.fn(),
|
|
48
|
+
},
|
|
49
|
+
getRecentProjects: vi.fn().mockReturnValue([]),
|
|
50
|
+
},
|
|
51
|
+
}));
|
|
52
|
+
// Mock globalSessionOrchestrator
|
|
53
|
+
vi.mock('../services/globalSessionOrchestrator.js', () => ({
|
|
54
|
+
globalSessionOrchestrator: {
|
|
55
|
+
getProjectPaths: vi.fn().mockReturnValue([]),
|
|
56
|
+
getProjectSessions: vi.fn().mockReturnValue([]),
|
|
57
|
+
getManagerForProject: vi.fn().mockReturnValue({
|
|
58
|
+
on: vi.fn(),
|
|
59
|
+
off: vi.fn(),
|
|
60
|
+
getAllSessions: vi.fn().mockReturnValue([]),
|
|
61
|
+
}),
|
|
62
|
+
},
|
|
63
|
+
}));
|
|
64
|
+
// Mock WorktreeService
|
|
65
|
+
vi.mock('../services/worktreeService.js', () => ({
|
|
66
|
+
WorktreeService: vi.fn().mockImplementation(() => ({
|
|
67
|
+
getWorktreesEffect: vi.fn(),
|
|
68
|
+
getGitRootPath: vi.fn().mockReturnValue('/test'),
|
|
69
|
+
})),
|
|
70
|
+
}));
|
|
71
|
+
// Mock useGitStatus to avoid async polling in tests
|
|
72
|
+
vi.mock('../hooks/useGitStatus.js', () => ({
|
|
73
|
+
useGitStatus: vi.fn((worktrees) => worktrees),
|
|
74
|
+
}));
|
|
75
|
+
// Mock SessionManager static methods
|
|
76
|
+
vi.mock('../services/sessionManager.js', () => ({
|
|
77
|
+
SessionManager: {
|
|
78
|
+
getSessionCounts: vi.fn().mockReturnValue({
|
|
79
|
+
idle: 0,
|
|
80
|
+
busy: 0,
|
|
81
|
+
waiting_input: 0,
|
|
82
|
+
pending_auto_approval: 0,
|
|
83
|
+
total: 0,
|
|
84
|
+
backgroundTasks: 0,
|
|
85
|
+
teamMembers: 0,
|
|
86
|
+
}),
|
|
87
|
+
formatSessionCounts: vi.fn().mockReturnValue(''),
|
|
88
|
+
},
|
|
89
|
+
}));
|
|
90
|
+
// Now import after mocking
|
|
91
|
+
const { default: Dashboard } = await import('./Dashboard.js');
|
|
92
|
+
const { projectManager } = await import('../services/projectManager.js');
|
|
93
|
+
const { globalSessionOrchestrator } = await import('../services/globalSessionOrchestrator.js');
|
|
94
|
+
const { SessionManager } = await import('../services/sessionManager.js');
|
|
95
|
+
const { WorktreeService } = await import('../services/worktreeService.js');
|
|
96
|
+
const { Effect } = await import('effect');
|
|
97
|
+
describe('Dashboard', () => {
|
|
98
|
+
const mockOnSelectSession = vi.fn();
|
|
99
|
+
const mockOnSelectProject = vi.fn();
|
|
100
|
+
const mockOnDismissError = vi.fn();
|
|
101
|
+
const mockProjects = [
|
|
102
|
+
{
|
|
103
|
+
name: 'my-app',
|
|
104
|
+
path: '/projects/my-app',
|
|
105
|
+
relativePath: 'my-app',
|
|
106
|
+
isValid: true,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'api-server',
|
|
110
|
+
path: '/projects/api-server',
|
|
111
|
+
relativePath: 'api-server',
|
|
112
|
+
isValid: true,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'shared-lib',
|
|
116
|
+
path: '/projects/shared-lib',
|
|
117
|
+
relativePath: 'shared-lib',
|
|
118
|
+
isValid: true,
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
vi.clearAllMocks();
|
|
123
|
+
vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(Effect.succeed(mockProjects));
|
|
124
|
+
vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([]);
|
|
125
|
+
vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue([]);
|
|
126
|
+
vi.mocked(globalSessionOrchestrator.getManagerForProject).mockReturnValue({
|
|
127
|
+
on: vi.fn(),
|
|
128
|
+
off: vi.fn(),
|
|
129
|
+
getAllSessions: vi.fn().mockReturnValue([]),
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
it('should render dashboard with correct title and version', () => {
|
|
133
|
+
const { lastFrame } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
134
|
+
expect(lastFrame()).toContain('CCManager - Dashboard v3.8.1');
|
|
135
|
+
});
|
|
136
|
+
it('should display loading state initially', () => {
|
|
137
|
+
vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(Effect.async(() => { }));
|
|
138
|
+
const { lastFrame } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
139
|
+
expect(lastFrame()).toContain('Discovering projects...');
|
|
140
|
+
});
|
|
141
|
+
it('should display projects after loading', async () => {
|
|
142
|
+
const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
143
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
144
|
+
rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
145
|
+
await vi.waitFor(() => {
|
|
146
|
+
const frame = lastFrame();
|
|
147
|
+
return frame && !frame.includes('Discovering projects...');
|
|
148
|
+
}, { timeout: 2000 });
|
|
149
|
+
const frame = lastFrame();
|
|
150
|
+
expect(frame).toContain('my-app');
|
|
151
|
+
expect(frame).toContain('api-server');
|
|
152
|
+
expect(frame).toContain('shared-lib');
|
|
153
|
+
});
|
|
154
|
+
it('should display Projects section separator', async () => {
|
|
155
|
+
const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
156
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
157
|
+
rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
158
|
+
await vi.waitFor(() => {
|
|
159
|
+
return lastFrame()?.includes('my-app') ?? false;
|
|
160
|
+
});
|
|
161
|
+
expect(lastFrame()).toContain('Projects');
|
|
162
|
+
});
|
|
163
|
+
it('should display Other section with Refresh and Exit', async () => {
|
|
164
|
+
const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
165
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
166
|
+
rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
167
|
+
await vi.waitFor(() => {
|
|
168
|
+
return lastFrame()?.includes('my-app') ?? false;
|
|
169
|
+
});
|
|
170
|
+
const frame = lastFrame();
|
|
171
|
+
expect(frame).toContain('Other');
|
|
172
|
+
expect(frame).toContain('Refresh');
|
|
173
|
+
expect(frame).toContain('Exit');
|
|
174
|
+
});
|
|
175
|
+
it('should display number shortcuts for projects', async () => {
|
|
176
|
+
const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
177
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
178
|
+
rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
179
|
+
await vi.waitFor(() => {
|
|
180
|
+
return lastFrame()?.includes('my-app') ?? false;
|
|
181
|
+
});
|
|
182
|
+
const frame = lastFrame();
|
|
183
|
+
expect(frame).toContain('0 ❯ my-app');
|
|
184
|
+
expect(frame).toContain('1 ❯ api-server');
|
|
185
|
+
expect(frame).toContain('2 ❯ shared-lib');
|
|
186
|
+
});
|
|
187
|
+
it('should display status legend', () => {
|
|
188
|
+
const { lastFrame } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
189
|
+
const frame = lastFrame();
|
|
190
|
+
expect(frame).toContain('Busy');
|
|
191
|
+
expect(frame).toContain('Waiting');
|
|
192
|
+
expect(frame).toContain('Idle');
|
|
193
|
+
});
|
|
194
|
+
it('should display error when provided', () => {
|
|
195
|
+
const { lastFrame } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: "Failed to load", onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
196
|
+
expect(lastFrame()).toContain('Error: Failed to load');
|
|
197
|
+
expect(lastFrame()).toContain('Press any key to dismiss');
|
|
198
|
+
});
|
|
199
|
+
it('should show empty state when no projects found', async () => {
|
|
200
|
+
vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(Effect.succeed([]));
|
|
201
|
+
const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
202
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
203
|
+
rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
204
|
+
await vi.waitFor(() => {
|
|
205
|
+
const frame = lastFrame();
|
|
206
|
+
return frame && !frame.includes('Discovering projects...');
|
|
207
|
+
}, { timeout: 2000 });
|
|
208
|
+
expect(lastFrame()).toContain('No git repositories found in /projects');
|
|
209
|
+
});
|
|
210
|
+
it('should not show Active Sessions section when there are no sessions', async () => {
|
|
211
|
+
const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
212
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
213
|
+
rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
214
|
+
await vi.waitFor(() => {
|
|
215
|
+
return lastFrame()?.includes('my-app') ?? false;
|
|
216
|
+
});
|
|
217
|
+
expect(lastFrame()).not.toContain('Active Sessions');
|
|
218
|
+
});
|
|
219
|
+
it('should show Active Sessions when sessions exist', async () => {
|
|
220
|
+
const mockSession = {
|
|
221
|
+
id: 'session-1',
|
|
222
|
+
worktreePath: '/projects/my-app/worktrees/feature-auth',
|
|
223
|
+
lastActivity: new Date(),
|
|
224
|
+
isActive: true,
|
|
225
|
+
stateMutex: {
|
|
226
|
+
getSnapshot: () => ({
|
|
227
|
+
state: 'busy',
|
|
228
|
+
backgroundTaskCount: 0,
|
|
229
|
+
teamMemberCount: 0,
|
|
230
|
+
}),
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
const mockWorktrees = [
|
|
234
|
+
{
|
|
235
|
+
path: '/projects/my-app/worktrees/feature-auth',
|
|
236
|
+
branch: 'feature/auth',
|
|
237
|
+
isMainWorktree: false,
|
|
238
|
+
hasSession: true,
|
|
239
|
+
},
|
|
240
|
+
];
|
|
241
|
+
vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([
|
|
242
|
+
'/projects/my-app',
|
|
243
|
+
]);
|
|
244
|
+
vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue([
|
|
245
|
+
mockSession,
|
|
246
|
+
]);
|
|
247
|
+
vi.mocked(WorktreeService).mockImplementation(function () {
|
|
248
|
+
return {
|
|
249
|
+
getWorktreesEffect: () => Effect.succeed(mockWorktrees),
|
|
250
|
+
getGitRootPath: () => '/projects/my-app',
|
|
251
|
+
};
|
|
252
|
+
});
|
|
253
|
+
const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
254
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
255
|
+
rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
256
|
+
await vi.waitFor(() => {
|
|
257
|
+
const frame = lastFrame();
|
|
258
|
+
return frame?.includes('Active Sessions') ?? false;
|
|
259
|
+
}, { timeout: 3000 });
|
|
260
|
+
const frame = lastFrame();
|
|
261
|
+
expect(frame).toContain('Active Sessions');
|
|
262
|
+
expect(frame).toContain('my-app :: feature/auth');
|
|
263
|
+
expect(frame).toContain('Busy');
|
|
264
|
+
});
|
|
265
|
+
it('should show session counts next to projects', async () => {
|
|
266
|
+
vi.mocked(SessionManager.formatSessionCounts).mockReturnValue(' (1 Busy)');
|
|
267
|
+
vi.mocked(globalSessionOrchestrator.getProjectSessions).mockImplementation((path) => {
|
|
268
|
+
if (path === '/projects/my-app') {
|
|
269
|
+
return [{ id: 'session-1' }];
|
|
270
|
+
}
|
|
271
|
+
return [];
|
|
272
|
+
});
|
|
273
|
+
const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
274
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
275
|
+
rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
276
|
+
await vi.waitFor(() => {
|
|
277
|
+
return lastFrame()?.includes('my-app') ?? false;
|
|
278
|
+
});
|
|
279
|
+
expect(lastFrame()).toContain('(1 Busy)');
|
|
280
|
+
});
|
|
281
|
+
it('should use relativePath for duplicate project names', async () => {
|
|
282
|
+
const duplicateProjects = [
|
|
283
|
+
{
|
|
284
|
+
name: 'utils',
|
|
285
|
+
path: '/projects/team-a/utils',
|
|
286
|
+
relativePath: 'team-a/utils',
|
|
287
|
+
isValid: true,
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
name: 'utils',
|
|
291
|
+
path: '/projects/team-b/utils',
|
|
292
|
+
relativePath: 'team-b/utils',
|
|
293
|
+
isValid: true,
|
|
294
|
+
},
|
|
295
|
+
];
|
|
296
|
+
vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(Effect.succeed(duplicateProjects));
|
|
297
|
+
const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
298
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
299
|
+
rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
300
|
+
await vi.waitFor(() => {
|
|
301
|
+
return lastFrame()?.includes('team-a/utils') ?? false;
|
|
302
|
+
});
|
|
303
|
+
const frame = lastFrame();
|
|
304
|
+
expect(frame).toContain('team-a/utils');
|
|
305
|
+
expect(frame).toContain('team-b/utils');
|
|
306
|
+
});
|
|
307
|
+
it('should display controls help text', () => {
|
|
308
|
+
const { lastFrame } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
309
|
+
expect(lastFrame()).toContain('Controls:');
|
|
310
|
+
expect(lastFrame()).toContain('0-9 Quick Select');
|
|
311
|
+
expect(lastFrame()).toContain('R-Refresh');
|
|
312
|
+
expect(lastFrame()).toContain('Q-Quit');
|
|
313
|
+
});
|
|
314
|
+
it('should handle filesystem error during project discovery', async () => {
|
|
315
|
+
const { FileSystemError } = await import('../types/errors.js');
|
|
316
|
+
vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(Effect.fail(new FileSystemError({
|
|
317
|
+
operation: 'read',
|
|
318
|
+
path: '/projects',
|
|
319
|
+
cause: 'Permission denied',
|
|
320
|
+
})));
|
|
321
|
+
const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
322
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
323
|
+
rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
324
|
+
await vi.waitFor(() => {
|
|
325
|
+
const frame = lastFrame();
|
|
326
|
+
return frame && !frame.includes('Discovering projects...');
|
|
327
|
+
}, { timeout: 2000 });
|
|
328
|
+
expect(lastFrame()).toContain('Error:');
|
|
329
|
+
});
|
|
330
|
+
it('should show recent projects first in the Projects section', async () => {
|
|
331
|
+
vi.mocked(projectManager.getRecentProjects).mockReturnValue([
|
|
332
|
+
{
|
|
333
|
+
path: '/projects/shared-lib',
|
|
334
|
+
name: 'shared-lib',
|
|
335
|
+
lastAccessed: Date.now(),
|
|
336
|
+
},
|
|
337
|
+
]);
|
|
338
|
+
const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
339
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
340
|
+
rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
|
|
341
|
+
await vi.waitFor(() => {
|
|
342
|
+
return lastFrame()?.includes('shared-lib') ?? false;
|
|
343
|
+
});
|
|
344
|
+
const frame = lastFrame();
|
|
345
|
+
// shared-lib should appear first (index 0) because it's recent
|
|
346
|
+
expect(frame).toContain('0 ❯ shared-lib');
|
|
347
|
+
});
|
|
348
|
+
});
|
|
@@ -4,6 +4,7 @@ import { Box, Text, useInput } from 'ink';
|
|
|
4
4
|
import SelectInput from 'ink-select-input';
|
|
5
5
|
import { Effect } from 'effect';
|
|
6
6
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
7
|
+
import { configReader } from '../services/config/configReader.js';
|
|
7
8
|
import Confirmation, { SimpleConfirmation } from './Confirmation.js';
|
|
8
9
|
import { shortcutManager } from '../services/shortcutManager.js';
|
|
9
10
|
import { GitError } from '../types/errors.js';
|
|
@@ -14,9 +15,10 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
14
15
|
const [targetBranch, setTargetBranch] = useState('');
|
|
15
16
|
const [branchItems, setBranchItems] = useState([]);
|
|
16
17
|
const [originalBranchItems, setOriginalBranchItems] = useState([]);
|
|
17
|
-
const [
|
|
18
|
+
const [operation, setOperation] = useState('merge');
|
|
18
19
|
const [mergeError, setMergeError] = useState(null);
|
|
19
20
|
const [worktreeService] = useState(() => new WorktreeService());
|
|
21
|
+
const [mergeConfig] = useState(() => configReader.getMergeConfig());
|
|
20
22
|
const [isLoading, setIsLoading] = useState(true);
|
|
21
23
|
const [loadError, setLoadError] = useState(null);
|
|
22
24
|
useEffect(() => {
|
|
@@ -83,7 +85,7 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
83
85
|
return;
|
|
84
86
|
const performMerge = async () => {
|
|
85
87
|
try {
|
|
86
|
-
await Effect.runPromise(worktreeService.mergeWorktreeEffect(sourceBranch, targetBranch,
|
|
88
|
+
await Effect.runPromise(worktreeService.mergeWorktreeEffect(sourceBranch, targetBranch, operation, mergeConfig));
|
|
87
89
|
// Merge successful, check for uncommitted changes before asking about deletion
|
|
88
90
|
setStep('check-uncommitted');
|
|
89
91
|
}
|
|
@@ -99,7 +101,14 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
99
101
|
}
|
|
100
102
|
};
|
|
101
103
|
performMerge();
|
|
102
|
-
}, [
|
|
104
|
+
}, [
|
|
105
|
+
step,
|
|
106
|
+
sourceBranch,
|
|
107
|
+
targetBranch,
|
|
108
|
+
operation,
|
|
109
|
+
mergeConfig,
|
|
110
|
+
worktreeService,
|
|
111
|
+
]);
|
|
103
112
|
// Check for uncommitted changes in source worktree when entering check-uncommitted step
|
|
104
113
|
useEffect(() => {
|
|
105
114
|
if (step !== 'check-uncommitted')
|
|
@@ -140,7 +149,7 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
140
149
|
const message = (_jsxs(Text, { children: ["Choose how to integrate ", _jsx(Text, { color: "yellow", children: sourceBranch }), " into", ' ', _jsx(Text, { color: "yellow", children: targetBranch }), ":"] }));
|
|
141
150
|
const hint = (_jsxs(Text, { dimColor: true, children: ["Use \u2191\u2193/j/k to navigate, Enter to select,", ' ', shortcutManager.getShortcutDisplay('cancel'), " to cancel"] }));
|
|
142
151
|
const handleOperationSelect = (value) => {
|
|
143
|
-
|
|
152
|
+
setOperation(value);
|
|
144
153
|
setStep('confirm-merge');
|
|
145
154
|
};
|
|
146
155
|
return (_jsx(Confirmation, { title: title, message: message, options: [
|
|
@@ -149,14 +158,18 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
149
158
|
], onSelect: handleOperationSelect, initialIndex: 0, hint: hint }));
|
|
150
159
|
}
|
|
151
160
|
if (step === 'confirm-merge') {
|
|
152
|
-
const
|
|
161
|
+
const operationLabel = operation === 'rebase' ? 'Rebase' : 'Merge';
|
|
162
|
+
const preposition = operation === 'rebase' ? 'onto' : 'into';
|
|
163
|
+
const confirmMessage = (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Confirm ", operationLabel] }) }), _jsxs(Text, { children: [operationLabel, " ", _jsx(Text, { color: "yellow", children: sourceBranch }), ' ', preposition, " ", _jsx(Text, { color: "yellow", children: targetBranch }), "?"] })] }));
|
|
153
164
|
return (_jsx(SimpleConfirmation, { message: confirmMessage, onConfirm: () => setStep('executing-merge'), onCancel: onCancel }));
|
|
154
165
|
}
|
|
155
166
|
if (step === 'executing-merge') {
|
|
156
|
-
|
|
167
|
+
const executingLabel = operation === 'rebase' ? 'Rebasing' : 'Merging';
|
|
168
|
+
return (_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { color: "green", children: [executingLabel, " branches..."] }) }));
|
|
157
169
|
}
|
|
158
170
|
if (step === 'merge-error') {
|
|
159
|
-
|
|
171
|
+
const errorLabel = operation === 'rebase' ? 'Rebase' : 'Merge';
|
|
172
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "red", children: [errorLabel, " Failed"] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "red", children: mergeError }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press any key to return to menu" }) })] }));
|
|
160
173
|
}
|
|
161
174
|
if (step === 'check-uncommitted') {
|
|
162
175
|
return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: "cyan", children: "Checking for uncommitted changes..." }) }));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigScope, ProjectConfigurationData, ShortcutConfig, StatusHookConfig, WorktreeHookConfig, WorktreeConfig, CommandPresetsConfig, IConfigEditor, AutoApprovalConfig } from '../../types/index.js';
|
|
1
|
+
import { ConfigScope, ProjectConfigurationData, ShortcutConfig, StatusHookConfig, WorktreeHookConfig, WorktreeConfig, CommandPresetsConfig, MergeConfig, IConfigEditor, AutoApprovalConfig } from '../../types/index.js';
|
|
2
2
|
/**
|
|
3
3
|
* ConfigEditor provides scope-aware configuration editing.
|
|
4
4
|
* The scope is determined at construction time.
|
|
@@ -24,6 +24,8 @@ export declare class ConfigEditor implements IConfigEditor {
|
|
|
24
24
|
setWorktreeConfig(value: WorktreeConfig): void;
|
|
25
25
|
getCommandPresets(): CommandPresetsConfig | undefined;
|
|
26
26
|
setCommandPresets(value: CommandPresetsConfig): void;
|
|
27
|
+
getMergeConfig(): MergeConfig | undefined;
|
|
28
|
+
setMergeConfig(value: MergeConfig): void;
|
|
27
29
|
getAutoApprovalConfig(): AutoApprovalConfig | undefined;
|
|
28
30
|
setAutoApprovalConfig(value: AutoApprovalConfig): void;
|
|
29
31
|
reload(): void;
|
|
@@ -77,6 +77,19 @@ export class ConfigEditor {
|
|
|
77
77
|
setCommandPresets(value) {
|
|
78
78
|
this.configEditor.setCommandPresets(value);
|
|
79
79
|
}
|
|
80
|
+
getMergeConfig() {
|
|
81
|
+
const globalConfig = globalConfigManager.getMergeConfig();
|
|
82
|
+
const scopedConfig = this.configEditor.getMergeConfig();
|
|
83
|
+
if (!globalConfig && !scopedConfig)
|
|
84
|
+
return undefined;
|
|
85
|
+
return {
|
|
86
|
+
...(globalConfig || {}),
|
|
87
|
+
...(scopedConfig || {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
setMergeConfig(value) {
|
|
91
|
+
this.configEditor.setMergeConfig(value);
|
|
92
|
+
}
|
|
80
93
|
getAutoApprovalConfig() {
|
|
81
94
|
const globalConfig = globalConfigManager.getAutoApprovalConfig();
|
|
82
95
|
const scopedConfig = this.configEditor.getAutoApprovalConfig();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Either } from 'effect';
|
|
2
|
-
import { ShortcutConfig, StatusHookConfig, WorktreeHookConfig, WorktreeConfig, CommandPresetsConfig, CommandPreset, ConfigurationData, IConfigReader } from '../../types/index.js';
|
|
2
|
+
import { ShortcutConfig, StatusHookConfig, WorktreeHookConfig, WorktreeConfig, CommandPresetsConfig, MergeConfig, CommandPreset, ConfigurationData, IConfigReader } from '../../types/index.js';
|
|
3
3
|
import { ValidationError } from '../../types/errors.js';
|
|
4
4
|
/**
|
|
5
5
|
* ConfigReader provides merged configuration reading for runtime components.
|
|
@@ -14,6 +14,7 @@ export declare class ConfigReader implements IConfigReader {
|
|
|
14
14
|
getWorktreeHooks(): WorktreeHookConfig;
|
|
15
15
|
getWorktreeConfig(): WorktreeConfig;
|
|
16
16
|
getCommandPresets(): CommandPresetsConfig;
|
|
17
|
+
getMergeConfig(): MergeConfig | undefined;
|
|
17
18
|
getConfiguration(): ConfigurationData;
|
|
18
19
|
getAutoApprovalConfig(): NonNullable<ConfigurationData['autoApproval']>;
|
|
19
20
|
isAutoApprovalEnabled(): boolean;
|
|
@@ -57,6 +57,17 @@ export class ConfigReader {
|
|
|
57
57
|
...(projectConfig || {}),
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
+
// Merge Config - returns merged value (project fields override global fields)
|
|
61
|
+
getMergeConfig() {
|
|
62
|
+
const globalConfig = globalConfigManager.getMergeConfig();
|
|
63
|
+
const projectConfig = projectConfigManager.getMergeConfig();
|
|
64
|
+
if (!globalConfig && !projectConfig)
|
|
65
|
+
return undefined;
|
|
66
|
+
return {
|
|
67
|
+
...(globalConfig || {}),
|
|
68
|
+
...(projectConfig || {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
60
71
|
// Get full merged configuration
|
|
61
72
|
getConfiguration() {
|
|
62
73
|
return {
|
|
@@ -65,6 +76,7 @@ export class ConfigReader {
|
|
|
65
76
|
worktreeHooks: this.getWorktreeHooks(),
|
|
66
77
|
worktree: this.getWorktreeConfig(),
|
|
67
78
|
commandPresets: this.getCommandPresets(),
|
|
79
|
+
mergeConfig: this.getMergeConfig(),
|
|
68
80
|
autoApproval: this.getAutoApprovalConfig(),
|
|
69
81
|
};
|
|
70
82
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigurationData, StatusHookConfig, WorktreeHookConfig, ShortcutConfig, WorktreeConfig, CommandPresetsConfig, IConfigEditor } from '../../types/index.js';
|
|
1
|
+
import { ConfigurationData, StatusHookConfig, WorktreeHookConfig, ShortcutConfig, WorktreeConfig, CommandPresetsConfig, MergeConfig, IConfigEditor } from '../../types/index.js';
|
|
2
2
|
declare class GlobalConfigManager implements IConfigEditor {
|
|
3
3
|
private configPath;
|
|
4
4
|
private legacyShortcutsPath;
|
|
@@ -21,6 +21,8 @@ declare class GlobalConfigManager implements IConfigEditor {
|
|
|
21
21
|
private ensureDefaultPresets;
|
|
22
22
|
getCommandPresets(): CommandPresetsConfig;
|
|
23
23
|
setCommandPresets(presets: CommandPresetsConfig): void;
|
|
24
|
+
getMergeConfig(): MergeConfig | undefined;
|
|
25
|
+
setMergeConfig(mergeConfig: MergeConfig): void;
|
|
24
26
|
/**
|
|
25
27
|
* Reload configuration from disk
|
|
26
28
|
*/
|
|
@@ -191,6 +191,13 @@ class GlobalConfigManager {
|
|
|
191
191
|
this.config.commandPresets = presets;
|
|
192
192
|
this.saveConfig();
|
|
193
193
|
}
|
|
194
|
+
getMergeConfig() {
|
|
195
|
+
return this.config.mergeConfig;
|
|
196
|
+
}
|
|
197
|
+
setMergeConfig(mergeConfig) {
|
|
198
|
+
this.config.mergeConfig = mergeConfig;
|
|
199
|
+
this.saveConfig();
|
|
200
|
+
}
|
|
194
201
|
/**
|
|
195
202
|
* Reload configuration from disk
|
|
196
203
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ProjectConfigurationData, ShortcutConfig, StatusHookConfig, WorktreeHookConfig, WorktreeConfig, CommandPresetsConfig, IConfigEditor, AutoApprovalConfig } from '../../types/index.js';
|
|
1
|
+
import { ProjectConfigurationData, ShortcutConfig, StatusHookConfig, WorktreeHookConfig, WorktreeConfig, CommandPresetsConfig, MergeConfig, IConfigEditor, AutoApprovalConfig } from '../../types/index.js';
|
|
2
2
|
/**
|
|
3
3
|
* ProjectConfigManager handles project-specific configuration.
|
|
4
4
|
* Reads/writes from `<git repository root>/.ccmanager.json`.
|
|
@@ -22,6 +22,8 @@ declare class ProjectConfigManager implements IConfigEditor {
|
|
|
22
22
|
setWorktreeConfig(value: WorktreeConfig): void;
|
|
23
23
|
getCommandPresets(): CommandPresetsConfig | undefined;
|
|
24
24
|
setCommandPresets(value: CommandPresetsConfig): void;
|
|
25
|
+
getMergeConfig(): MergeConfig | undefined;
|
|
26
|
+
setMergeConfig(value: MergeConfig): void;
|
|
25
27
|
getAutoApprovalConfig(): AutoApprovalConfig | undefined;
|
|
26
28
|
setAutoApprovalConfig(value: AutoApprovalConfig): void;
|
|
27
29
|
reload(): void;
|
|
@@ -110,6 +110,14 @@ class ProjectConfigManager {
|
|
|
110
110
|
config.commandPresets = value;
|
|
111
111
|
this.saveProjectConfig();
|
|
112
112
|
}
|
|
113
|
+
getMergeConfig() {
|
|
114
|
+
return this.projectConfig?.mergeConfig;
|
|
115
|
+
}
|
|
116
|
+
setMergeConfig(value) {
|
|
117
|
+
const config = this.ensureProjectConfig();
|
|
118
|
+
config.mergeConfig = value;
|
|
119
|
+
this.saveProjectConfig();
|
|
120
|
+
}
|
|
113
121
|
getAutoApprovalConfig() {
|
|
114
122
|
return this.projectConfig?.autoApproval;
|
|
115
123
|
}
|
|
@@ -10,6 +10,7 @@ declare class GlobalSessionOrchestrator {
|
|
|
10
10
|
getAllActiveSessions(): Session[];
|
|
11
11
|
destroyAllSessions(): void;
|
|
12
12
|
destroyProjectSessions(projectPath: string): void;
|
|
13
|
+
getProjectPaths(): string[];
|
|
13
14
|
getProjectSessions(projectPath: string): Session[];
|
|
14
15
|
}
|
|
15
16
|
export declare const globalSessionOrchestrator: GlobalSessionOrchestrator;
|
|
@@ -53,6 +53,9 @@ class GlobalSessionOrchestrator {
|
|
|
53
53
|
this.projectManagers.delete(projectPath);
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
+
getProjectPaths() {
|
|
57
|
+
return Array.from(this.projectManagers.keys());
|
|
58
|
+
}
|
|
56
59
|
getProjectSessions(projectPath) {
|
|
57
60
|
const manager = this.projectManagers.get(projectPath);
|
|
58
61
|
if (manager) {
|
|
@@ -32,9 +32,15 @@ export declare class ProjectManager implements IProjectManager {
|
|
|
32
32
|
*/
|
|
33
33
|
private discoverDirectories;
|
|
34
34
|
/**
|
|
35
|
-
* Quick check for .git directory without running git commands
|
|
35
|
+
* Quick check for .git presence (directory or file) without running git commands.
|
|
36
|
+
* Returns true for both main repositories and worktrees.
|
|
36
37
|
*/
|
|
37
38
|
private hasGitDirectory;
|
|
39
|
+
/**
|
|
40
|
+
* Check if a directory is a main git repository (not a worktree).
|
|
41
|
+
* Main repositories have .git as a directory; worktrees have .git as a file.
|
|
42
|
+
*/
|
|
43
|
+
private isMainGitRepository;
|
|
38
44
|
/**
|
|
39
45
|
* Process directories in parallel using worker pool pattern
|
|
40
46
|
*/
|