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,501 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
|
+
// Import the actual component code but skip the useInput hook
|
|
5
|
+
vi.mock('ink', async () => {
|
|
6
|
+
const actual = await vi.importActual('ink');
|
|
7
|
+
return {
|
|
8
|
+
...actual,
|
|
9
|
+
useInput: vi.fn(),
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
// Mock SelectInput to render items as simple text
|
|
13
|
+
vi.mock('ink-select-input', async () => {
|
|
14
|
+
const React = await vi.importActual('react');
|
|
15
|
+
const { Text, Box } = await vi.importActual('ink');
|
|
16
|
+
return {
|
|
17
|
+
default: ({ items }) => {
|
|
18
|
+
return React.createElement(Box, { flexDirection: 'column' }, items.map((item, index) => React.createElement(Text, { key: index }, item.label)));
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
// Mock TextInputWrapper to render as simple text
|
|
23
|
+
vi.mock('./TextInputWrapper.js', async () => {
|
|
24
|
+
const React = await vi.importActual('react');
|
|
25
|
+
const { Text } = await vi.importActual('ink');
|
|
26
|
+
return {
|
|
27
|
+
default: ({ value, placeholder }) => {
|
|
28
|
+
return React.createElement(Text, {}, value || placeholder || '');
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
// Mock the projectManager
|
|
33
|
+
vi.mock('../services/projectManager.js', () => ({
|
|
34
|
+
projectManager: {
|
|
35
|
+
instance: {
|
|
36
|
+
discoverProjects: vi.fn(),
|
|
37
|
+
},
|
|
38
|
+
getRecentProjects: vi.fn().mockReturnValue([]),
|
|
39
|
+
},
|
|
40
|
+
}));
|
|
41
|
+
// Now import after mocking
|
|
42
|
+
const { default: ProjectList } = await import('./ProjectList.js');
|
|
43
|
+
const { projectManager } = await import('../services/projectManager.js');
|
|
44
|
+
describe('ProjectList', () => {
|
|
45
|
+
const mockOnSelectProject = vi.fn();
|
|
46
|
+
const mockOnDismissError = vi.fn();
|
|
47
|
+
const mockProjects = [
|
|
48
|
+
{
|
|
49
|
+
name: 'project1',
|
|
50
|
+
path: '/projects/project1',
|
|
51
|
+
relativePath: 'project1',
|
|
52
|
+
isValid: true,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'project2',
|
|
56
|
+
path: '/projects/project2',
|
|
57
|
+
relativePath: 'project2',
|
|
58
|
+
isValid: true,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'project3',
|
|
62
|
+
path: '/projects/project3',
|
|
63
|
+
relativePath: 'project3',
|
|
64
|
+
isValid: true,
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
vi.clearAllMocks();
|
|
69
|
+
vi.mocked(projectManager.instance.discoverProjects).mockResolvedValue(mockProjects);
|
|
70
|
+
});
|
|
71
|
+
it('should render project list with correct title', () => {
|
|
72
|
+
const { lastFrame } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
73
|
+
expect(lastFrame()).toContain('CCManager - Multi-Project Mode');
|
|
74
|
+
expect(lastFrame()).toContain('Select a project:');
|
|
75
|
+
});
|
|
76
|
+
it('should display loading state initially', () => {
|
|
77
|
+
// Create a promise that never resolves to keep loading state
|
|
78
|
+
vi.mocked(projectManager.instance.discoverProjects).mockReturnValue(new Promise(() => { }));
|
|
79
|
+
const { lastFrame } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
80
|
+
expect(lastFrame()).toContain('Loading projects...');
|
|
81
|
+
});
|
|
82
|
+
it('should display projects after loading', async () => {
|
|
83
|
+
const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
84
|
+
// Wait a bit for async operations
|
|
85
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
86
|
+
// Force rerender
|
|
87
|
+
rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
88
|
+
// Wait for SelectInput to render with our mock
|
|
89
|
+
await vi.waitFor(() => {
|
|
90
|
+
const frame = lastFrame();
|
|
91
|
+
return frame && !frame.includes('Loading projects...');
|
|
92
|
+
}, { timeout: 2000 });
|
|
93
|
+
const frame = lastFrame();
|
|
94
|
+
expect(frame).toContain('0 ❯ project1');
|
|
95
|
+
expect(frame).toContain('1 ❯ project2');
|
|
96
|
+
expect(frame).toContain('2 ❯ project3');
|
|
97
|
+
});
|
|
98
|
+
it('should display error when provided', () => {
|
|
99
|
+
const { lastFrame } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: "Failed to load projects", onDismissError: mockOnDismissError }));
|
|
100
|
+
expect(lastFrame()).toContain('Error: Failed to load projects');
|
|
101
|
+
expect(lastFrame()).toContain('Press any key to dismiss');
|
|
102
|
+
});
|
|
103
|
+
it('should handle project selection via menu', async () => {
|
|
104
|
+
const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
105
|
+
// Wait a bit for async operations
|
|
106
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
107
|
+
// Force rerender
|
|
108
|
+
rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
109
|
+
// Wait for component to update after async loading
|
|
110
|
+
await vi.waitFor(() => {
|
|
111
|
+
const frame = lastFrame();
|
|
112
|
+
return frame && !frame.includes('Loading projects...');
|
|
113
|
+
});
|
|
114
|
+
// Verify menu structure
|
|
115
|
+
const frame = lastFrame();
|
|
116
|
+
expect(frame).toContain('0 ❯ project1');
|
|
117
|
+
expect(frame).toContain('R');
|
|
118
|
+
expect(frame).toContain('Refresh');
|
|
119
|
+
expect(frame).toContain('Q');
|
|
120
|
+
expect(frame).toContain('Exit');
|
|
121
|
+
});
|
|
122
|
+
it('should display number shortcuts for first 10 projects', async () => {
|
|
123
|
+
const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
124
|
+
// Wait a bit for async operations
|
|
125
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
126
|
+
// Force rerender
|
|
127
|
+
rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
128
|
+
// Wait for projects to load
|
|
129
|
+
await vi.waitFor(() => {
|
|
130
|
+
const frame = lastFrame();
|
|
131
|
+
return frame && !frame.includes('Loading projects...');
|
|
132
|
+
});
|
|
133
|
+
// Verify number prefixes are shown
|
|
134
|
+
const frame = lastFrame();
|
|
135
|
+
expect(frame).toContain('0 ❯ project1');
|
|
136
|
+
expect(frame).toContain('1 ❯ project2');
|
|
137
|
+
expect(frame).toContain('2 ❯ project3');
|
|
138
|
+
});
|
|
139
|
+
it('should display exit option in menu', async () => {
|
|
140
|
+
const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
141
|
+
// Wait a bit for async operations
|
|
142
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
143
|
+
// Force rerender
|
|
144
|
+
rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
145
|
+
// Wait for projects to load
|
|
146
|
+
await vi.waitFor(() => {
|
|
147
|
+
const frame = lastFrame();
|
|
148
|
+
return frame && !frame.includes('Loading projects...');
|
|
149
|
+
});
|
|
150
|
+
// Verify exit option is shown
|
|
151
|
+
const frame = lastFrame();
|
|
152
|
+
expect(frame).toContain('Q');
|
|
153
|
+
expect(frame).toContain('Exit');
|
|
154
|
+
});
|
|
155
|
+
it('should display refresh option in menu', async () => {
|
|
156
|
+
const { lastFrame } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
157
|
+
// Wait for projects to load
|
|
158
|
+
await vi.waitFor(() => {
|
|
159
|
+
return lastFrame()?.includes('project1') ?? false;
|
|
160
|
+
});
|
|
161
|
+
// Verify refresh option is shown
|
|
162
|
+
const frame = lastFrame();
|
|
163
|
+
expect(frame).toContain('R');
|
|
164
|
+
expect(frame).toContain('Refresh');
|
|
165
|
+
});
|
|
166
|
+
it('should show empty state when no projects found', async () => {
|
|
167
|
+
vi.mocked(projectManager.instance.discoverProjects).mockResolvedValue([]);
|
|
168
|
+
const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
169
|
+
// Wait for projects to load
|
|
170
|
+
await vi.waitFor(() => {
|
|
171
|
+
rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
172
|
+
return lastFrame()?.includes('No git repositories found') ?? false;
|
|
173
|
+
});
|
|
174
|
+
expect(lastFrame()).toContain('No git repositories found in /projects');
|
|
175
|
+
});
|
|
176
|
+
it('should display error message when error prop is provided', () => {
|
|
177
|
+
const { lastFrame } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: "Test error", onDismissError: mockOnDismissError }));
|
|
178
|
+
expect(lastFrame()).toContain('Error: Test error');
|
|
179
|
+
expect(lastFrame()).toContain('Press any key to dismiss');
|
|
180
|
+
});
|
|
181
|
+
describe('search functionality', () => {
|
|
182
|
+
it.skip('should enter search mode when "/" key is pressed', async () => {
|
|
183
|
+
const mockUseInput = vi.mocked(await import('ink')).useInput;
|
|
184
|
+
const inputHandlers = [];
|
|
185
|
+
mockUseInput.mockImplementation(handler => {
|
|
186
|
+
inputHandlers.push(handler);
|
|
187
|
+
});
|
|
188
|
+
// Need to set up stdin.setRawMode for the test
|
|
189
|
+
const originalSetRawMode = process.stdin.setRawMode;
|
|
190
|
+
process.stdin.setRawMode = vi.fn();
|
|
191
|
+
const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
192
|
+
// Wait for projects to load
|
|
193
|
+
await vi.waitFor(() => {
|
|
194
|
+
return lastFrame()?.includes('project1') ?? false;
|
|
195
|
+
});
|
|
196
|
+
// Simulate pressing "/" key on all handlers (both from useSearchMode and ProjectList)
|
|
197
|
+
inputHandlers.forEach(handler => {
|
|
198
|
+
handler('/', {
|
|
199
|
+
escape: false,
|
|
200
|
+
return: false,
|
|
201
|
+
leftArrow: false,
|
|
202
|
+
rightArrow: false,
|
|
203
|
+
upArrow: false,
|
|
204
|
+
downArrow: false,
|
|
205
|
+
pageDown: false,
|
|
206
|
+
pageUp: false,
|
|
207
|
+
ctrl: false,
|
|
208
|
+
shift: false,
|
|
209
|
+
tab: false,
|
|
210
|
+
backspace: false,
|
|
211
|
+
delete: false,
|
|
212
|
+
meta: false,
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
// Wait a bit for state update
|
|
216
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
217
|
+
// Force rerender to see updated state
|
|
218
|
+
rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
219
|
+
// Should show search input
|
|
220
|
+
expect(lastFrame()).toContain('Search:');
|
|
221
|
+
// Restore original
|
|
222
|
+
process.stdin.setRawMode = originalSetRawMode;
|
|
223
|
+
});
|
|
224
|
+
it('should filter projects based on search query', async () => {
|
|
225
|
+
const mockUseInput = vi.mocked(await import('ink')).useInput;
|
|
226
|
+
let inputHandler = () => { };
|
|
227
|
+
mockUseInput.mockImplementation(handler => {
|
|
228
|
+
inputHandler = handler;
|
|
229
|
+
});
|
|
230
|
+
const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
231
|
+
// Wait for projects to load
|
|
232
|
+
await vi.waitFor(() => {
|
|
233
|
+
return lastFrame()?.includes('project1') ?? false;
|
|
234
|
+
});
|
|
235
|
+
// Enter search mode
|
|
236
|
+
inputHandler('/', {
|
|
237
|
+
escape: false,
|
|
238
|
+
return: false,
|
|
239
|
+
leftArrow: false,
|
|
240
|
+
rightArrow: false,
|
|
241
|
+
upArrow: false,
|
|
242
|
+
downArrow: false,
|
|
243
|
+
pageDown: false,
|
|
244
|
+
pageUp: false,
|
|
245
|
+
ctrl: false,
|
|
246
|
+
shift: false,
|
|
247
|
+
tab: false,
|
|
248
|
+
backspace: false,
|
|
249
|
+
delete: false,
|
|
250
|
+
meta: false,
|
|
251
|
+
});
|
|
252
|
+
// Force rerender with search active and query
|
|
253
|
+
rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
254
|
+
// Simulate typing "project2" in search
|
|
255
|
+
// This would be handled by the TextInput component
|
|
256
|
+
// We'll test the filtering logic separately
|
|
257
|
+
});
|
|
258
|
+
it.skip('should exit search mode but keep filter when ESC is pressed in search mode', async () => {
|
|
259
|
+
const mockUseInput = vi.mocked(await import('ink')).useInput;
|
|
260
|
+
let inputHandler = () => { };
|
|
261
|
+
mockUseInput.mockImplementation(handler => {
|
|
262
|
+
inputHandler = handler;
|
|
263
|
+
});
|
|
264
|
+
// Need to set up stdin.setRawMode for the test
|
|
265
|
+
const originalSetRawMode = process.stdin.setRawMode;
|
|
266
|
+
process.stdin.setRawMode = vi.fn();
|
|
267
|
+
const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
268
|
+
// Wait for projects to load
|
|
269
|
+
await vi.waitFor(() => {
|
|
270
|
+
return lastFrame()?.includes('project1') ?? false;
|
|
271
|
+
});
|
|
272
|
+
// Enter search mode
|
|
273
|
+
inputHandler('/', {
|
|
274
|
+
escape: false,
|
|
275
|
+
return: false,
|
|
276
|
+
leftArrow: false,
|
|
277
|
+
rightArrow: false,
|
|
278
|
+
upArrow: false,
|
|
279
|
+
downArrow: false,
|
|
280
|
+
pageDown: false,
|
|
281
|
+
pageUp: false,
|
|
282
|
+
ctrl: false,
|
|
283
|
+
shift: false,
|
|
284
|
+
tab: false,
|
|
285
|
+
backspace: false,
|
|
286
|
+
delete: false,
|
|
287
|
+
meta: false,
|
|
288
|
+
});
|
|
289
|
+
// Wait a bit for state update
|
|
290
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
291
|
+
// Force rerender
|
|
292
|
+
rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
293
|
+
// Should be in search mode
|
|
294
|
+
expect(lastFrame()).toContain('Search:');
|
|
295
|
+
// Press ESC
|
|
296
|
+
inputHandler('', {
|
|
297
|
+
escape: true,
|
|
298
|
+
return: false,
|
|
299
|
+
leftArrow: false,
|
|
300
|
+
rightArrow: false,
|
|
301
|
+
upArrow: false,
|
|
302
|
+
downArrow: false,
|
|
303
|
+
pageDown: false,
|
|
304
|
+
pageUp: false,
|
|
305
|
+
ctrl: false,
|
|
306
|
+
shift: false,
|
|
307
|
+
tab: false,
|
|
308
|
+
backspace: false,
|
|
309
|
+
delete: false,
|
|
310
|
+
meta: false,
|
|
311
|
+
});
|
|
312
|
+
// Wait a bit for state update
|
|
313
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
314
|
+
// Force rerender
|
|
315
|
+
rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
316
|
+
// Should exit search mode
|
|
317
|
+
expect(lastFrame()).not.toContain('Search:');
|
|
318
|
+
// Restore original
|
|
319
|
+
process.stdin.setRawMode = originalSetRawMode;
|
|
320
|
+
});
|
|
321
|
+
it('should not enter search mode when "/" is pressed during error display', async () => {
|
|
322
|
+
const mockUseInput = vi.mocked(await import('ink')).useInput;
|
|
323
|
+
let inputHandler = () => { };
|
|
324
|
+
mockUseInput.mockImplementation(handler => {
|
|
325
|
+
inputHandler = handler;
|
|
326
|
+
});
|
|
327
|
+
// Need to set up stdin.setRawMode for the test
|
|
328
|
+
const originalSetRawMode = process.stdin.setRawMode;
|
|
329
|
+
process.stdin.setRawMode = vi.fn();
|
|
330
|
+
const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: "Test error", onDismissError: mockOnDismissError }));
|
|
331
|
+
// Press "/" key
|
|
332
|
+
inputHandler('/', {
|
|
333
|
+
escape: false,
|
|
334
|
+
return: false,
|
|
335
|
+
leftArrow: false,
|
|
336
|
+
rightArrow: false,
|
|
337
|
+
upArrow: false,
|
|
338
|
+
downArrow: false,
|
|
339
|
+
pageDown: false,
|
|
340
|
+
pageUp: false,
|
|
341
|
+
ctrl: false,
|
|
342
|
+
shift: false,
|
|
343
|
+
tab: false,
|
|
344
|
+
backspace: false,
|
|
345
|
+
delete: false,
|
|
346
|
+
meta: false,
|
|
347
|
+
});
|
|
348
|
+
// Wait a bit for state update
|
|
349
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
350
|
+
// Force rerender
|
|
351
|
+
rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: "Test error", onDismissError: mockOnDismissError }));
|
|
352
|
+
// Should not show search input, should dismiss error instead
|
|
353
|
+
expect(lastFrame()).not.toContain('Search:');
|
|
354
|
+
expect(mockOnDismissError).toHaveBeenCalled();
|
|
355
|
+
// Restore original
|
|
356
|
+
process.stdin.setRawMode = originalSetRawMode;
|
|
357
|
+
});
|
|
358
|
+
it.skip('should exit search mode when Enter is pressed but keep filter', async () => {
|
|
359
|
+
const mockUseInput = vi.mocked(await import('ink')).useInput;
|
|
360
|
+
let inputHandler = () => { };
|
|
361
|
+
mockUseInput.mockImplementation(handler => {
|
|
362
|
+
inputHandler = handler;
|
|
363
|
+
});
|
|
364
|
+
// Need to set up stdin.setRawMode for the test
|
|
365
|
+
const originalSetRawMode = process.stdin.setRawMode;
|
|
366
|
+
process.stdin.setRawMode = vi.fn();
|
|
367
|
+
const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
368
|
+
// Wait for projects to load
|
|
369
|
+
await vi.waitFor(() => {
|
|
370
|
+
return lastFrame()?.includes('project1') ?? false;
|
|
371
|
+
});
|
|
372
|
+
// Enter search mode
|
|
373
|
+
inputHandler('/', {
|
|
374
|
+
escape: false,
|
|
375
|
+
return: false,
|
|
376
|
+
leftArrow: false,
|
|
377
|
+
rightArrow: false,
|
|
378
|
+
upArrow: false,
|
|
379
|
+
downArrow: false,
|
|
380
|
+
pageDown: false,
|
|
381
|
+
pageUp: false,
|
|
382
|
+
ctrl: false,
|
|
383
|
+
shift: false,
|
|
384
|
+
tab: false,
|
|
385
|
+
backspace: false,
|
|
386
|
+
delete: false,
|
|
387
|
+
meta: false,
|
|
388
|
+
});
|
|
389
|
+
// Wait a bit for state update
|
|
390
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
391
|
+
// Force rerender
|
|
392
|
+
rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
393
|
+
// Should be in search mode
|
|
394
|
+
expect(lastFrame()).toContain('Search:');
|
|
395
|
+
// Press Enter
|
|
396
|
+
inputHandler('', {
|
|
397
|
+
escape: false,
|
|
398
|
+
return: true,
|
|
399
|
+
leftArrow: false,
|
|
400
|
+
rightArrow: false,
|
|
401
|
+
upArrow: false,
|
|
402
|
+
downArrow: false,
|
|
403
|
+
pageDown: false,
|
|
404
|
+
pageUp: false,
|
|
405
|
+
ctrl: false,
|
|
406
|
+
shift: false,
|
|
407
|
+
tab: false,
|
|
408
|
+
backspace: false,
|
|
409
|
+
delete: false,
|
|
410
|
+
meta: false,
|
|
411
|
+
});
|
|
412
|
+
// Wait a bit for state update
|
|
413
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
414
|
+
// Force rerender
|
|
415
|
+
rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
416
|
+
// Should exit search mode
|
|
417
|
+
expect(lastFrame()).not.toContain('Search:');
|
|
418
|
+
// Should not have called onSelectProject
|
|
419
|
+
expect(mockOnSelectProject).not.toHaveBeenCalled();
|
|
420
|
+
// Restore original
|
|
421
|
+
process.stdin.setRawMode = originalSetRawMode;
|
|
422
|
+
});
|
|
423
|
+
it('should clear filter when ESC is pressed outside search mode', async () => {
|
|
424
|
+
const mockUseInput = vi.mocked(await import('ink')).useInput;
|
|
425
|
+
let inputHandler = () => { };
|
|
426
|
+
mockUseInput.mockImplementation(handler => {
|
|
427
|
+
inputHandler = handler;
|
|
428
|
+
});
|
|
429
|
+
// Need to set up stdin.setRawMode for the test
|
|
430
|
+
const originalSetRawMode = process.stdin.setRawMode;
|
|
431
|
+
process.stdin.setRawMode = vi.fn();
|
|
432
|
+
const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
433
|
+
// Wait for projects to load
|
|
434
|
+
await vi.waitFor(() => {
|
|
435
|
+
return lastFrame()?.includes('project1') ?? false;
|
|
436
|
+
});
|
|
437
|
+
// Enter search mode
|
|
438
|
+
inputHandler('/', {
|
|
439
|
+
escape: false,
|
|
440
|
+
return: false,
|
|
441
|
+
leftArrow: false,
|
|
442
|
+
rightArrow: false,
|
|
443
|
+
upArrow: false,
|
|
444
|
+
downArrow: false,
|
|
445
|
+
pageDown: false,
|
|
446
|
+
pageUp: false,
|
|
447
|
+
ctrl: false,
|
|
448
|
+
shift: false,
|
|
449
|
+
tab: false,
|
|
450
|
+
backspace: false,
|
|
451
|
+
delete: false,
|
|
452
|
+
meta: false,
|
|
453
|
+
});
|
|
454
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
455
|
+
// Exit search mode with Enter (keeping filter)
|
|
456
|
+
inputHandler('', {
|
|
457
|
+
escape: false,
|
|
458
|
+
return: true,
|
|
459
|
+
leftArrow: false,
|
|
460
|
+
rightArrow: false,
|
|
461
|
+
upArrow: false,
|
|
462
|
+
downArrow: false,
|
|
463
|
+
pageDown: false,
|
|
464
|
+
pageUp: false,
|
|
465
|
+
ctrl: false,
|
|
466
|
+
shift: false,
|
|
467
|
+
tab: false,
|
|
468
|
+
backspace: false,
|
|
469
|
+
delete: false,
|
|
470
|
+
meta: false,
|
|
471
|
+
});
|
|
472
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
473
|
+
// Now press ESC outside search mode to clear filter
|
|
474
|
+
inputHandler('', {
|
|
475
|
+
escape: true,
|
|
476
|
+
return: false,
|
|
477
|
+
leftArrow: false,
|
|
478
|
+
rightArrow: false,
|
|
479
|
+
upArrow: false,
|
|
480
|
+
downArrow: false,
|
|
481
|
+
pageDown: false,
|
|
482
|
+
pageUp: false,
|
|
483
|
+
ctrl: false,
|
|
484
|
+
shift: false,
|
|
485
|
+
tab: false,
|
|
486
|
+
backspace: false,
|
|
487
|
+
delete: false,
|
|
488
|
+
meta: false,
|
|
489
|
+
});
|
|
490
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
491
|
+
// Force rerender
|
|
492
|
+
rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
493
|
+
// Should display all projects (filter cleared)
|
|
494
|
+
expect(lastFrame()).toContain('project1');
|
|
495
|
+
expect(lastFrame()).toContain('project2');
|
|
496
|
+
expect(lastFrame()).toContain('project3');
|
|
497
|
+
// Restore original
|
|
498
|
+
process.stdin.setRawMode = originalSetRawMode;
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const MULTI_PROJECT_ERRORS: {
|
|
2
|
+
readonly NO_PROJECTS_DIR: "CCMANAGER_MULTI_PROJECT_ROOT environment variable is required in multi-project mode";
|
|
3
|
+
readonly INVALID_PROJECTS_DIR: "CCMANAGER_MULTI_PROJECT_ROOT points to a non-existent directory";
|
|
4
|
+
readonly NO_PROJECTS_FOUND: "No git repositories found in the projects directory";
|
|
5
|
+
readonly CORRUPTED_REPO: "Git repository is corrupted or inaccessible";
|
|
6
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Error messages for multi-project mode
|
|
2
|
+
export const MULTI_PROJECT_ERRORS = {
|
|
3
|
+
NO_PROJECTS_DIR: 'CCMANAGER_MULTI_PROJECT_ROOT environment variable is required in multi-project mode',
|
|
4
|
+
INVALID_PROJECTS_DIR: 'CCMANAGER_MULTI_PROJECT_ROOT points to a non-existent directory',
|
|
5
|
+
NO_PROJECTS_FOUND: 'No git repositories found in the projects directory',
|
|
6
|
+
CORRUPTED_REPO: 'Git repository is corrupted or inaccessible',
|
|
7
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
interface UseSearchModeOptions {
|
|
2
|
+
onEscape?: () => void;
|
|
3
|
+
onEnter?: () => void;
|
|
4
|
+
skipInTest?: boolean;
|
|
5
|
+
isDisabled?: boolean;
|
|
6
|
+
}
|
|
7
|
+
interface UseSearchModeReturn {
|
|
8
|
+
isSearchMode: boolean;
|
|
9
|
+
searchQuery: string;
|
|
10
|
+
selectedIndex: number;
|
|
11
|
+
setSearchQuery: (query: string) => void;
|
|
12
|
+
setSelectedIndex: (index: number) => void;
|
|
13
|
+
}
|
|
14
|
+
export declare function useSearchMode(itemsLength: number, options?: UseSearchModeOptions): UseSearchModeReturn;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useInput } from 'ink';
|
|
3
|
+
export function useSearchMode(itemsLength, options = {}) {
|
|
4
|
+
const [isSearchMode, setIsSearchMode] = useState(false);
|
|
5
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
6
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
7
|
+
const { onEscape, onEnter, skipInTest = true, isDisabled = false } = options;
|
|
8
|
+
// Reset selected index when items change in search mode
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (isSearchMode && selectedIndex >= itemsLength) {
|
|
11
|
+
setSelectedIndex(Math.max(0, itemsLength - 1));
|
|
12
|
+
}
|
|
13
|
+
}, [itemsLength, isSearchMode, selectedIndex]);
|
|
14
|
+
// Handle keyboard input
|
|
15
|
+
useInput((input, key) => {
|
|
16
|
+
// Skip in test environment to avoid stdin.ref error
|
|
17
|
+
if (skipInTest && !process.stdin.setRawMode) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
// Skip if disabled
|
|
21
|
+
if (isDisabled) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
// Handle ESC key
|
|
25
|
+
if (key.escape) {
|
|
26
|
+
if (isSearchMode) {
|
|
27
|
+
// Exit search mode but keep filter
|
|
28
|
+
setIsSearchMode(false);
|
|
29
|
+
onEscape?.();
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
// Clear filter when not in search mode
|
|
33
|
+
setSearchQuery('');
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// Handle Enter key in search mode to exit search mode but keep filter
|
|
38
|
+
if (key.return && isSearchMode) {
|
|
39
|
+
setIsSearchMode(false);
|
|
40
|
+
onEnter?.();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Handle arrow keys in search mode for navigation
|
|
44
|
+
if (isSearchMode) {
|
|
45
|
+
if (key.upArrow && selectedIndex > 0) {
|
|
46
|
+
setSelectedIndex(selectedIndex - 1);
|
|
47
|
+
}
|
|
48
|
+
else if (key.downArrow && selectedIndex < itemsLength - 1) {
|
|
49
|
+
setSelectedIndex(selectedIndex + 1);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// Handle "/" key to enter search mode
|
|
54
|
+
if (input === '/') {
|
|
55
|
+
setIsSearchMode(true);
|
|
56
|
+
setSelectedIndex(0);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}, { isActive: !isDisabled });
|
|
60
|
+
return {
|
|
61
|
+
isSearchMode,
|
|
62
|
+
searchQuery,
|
|
63
|
+
selectedIndex,
|
|
64
|
+
setSearchQuery,
|
|
65
|
+
setSelectedIndex,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -16,6 +16,12 @@ export class ConfigurationManager {
|
|
|
16
16
|
writable: true,
|
|
17
17
|
value: void 0
|
|
18
18
|
});
|
|
19
|
+
Object.defineProperty(this, "configDir", {
|
|
20
|
+
enumerable: true,
|
|
21
|
+
configurable: true,
|
|
22
|
+
writable: true,
|
|
23
|
+
value: void 0
|
|
24
|
+
});
|
|
19
25
|
Object.defineProperty(this, "config", {
|
|
20
26
|
enumerable: true,
|
|
21
27
|
configurable: true,
|
|
@@ -24,15 +30,16 @@ export class ConfigurationManager {
|
|
|
24
30
|
});
|
|
25
31
|
// Determine config directory based on platform
|
|
26
32
|
const homeDir = homedir();
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
this.configDir =
|
|
34
|
+
process.platform === 'win32'
|
|
35
|
+
? join(process.env['APPDATA'] || join(homeDir, 'AppData', 'Roaming'), 'ccmanager')
|
|
36
|
+
: join(homeDir, '.config', 'ccmanager');
|
|
30
37
|
// Ensure config directory exists
|
|
31
|
-
if (!existsSync(configDir)) {
|
|
32
|
-
mkdirSync(configDir, { recursive: true });
|
|
38
|
+
if (!existsSync(this.configDir)) {
|
|
39
|
+
mkdirSync(this.configDir, { recursive: true });
|
|
33
40
|
}
|
|
34
|
-
this.configPath = join(configDir, 'config.json');
|
|
35
|
-
this.legacyShortcutsPath = join(configDir, 'shortcuts.json');
|
|
41
|
+
this.configPath = join(this.configDir, 'config.json');
|
|
42
|
+
this.legacyShortcutsPath = join(this.configDir, 'shortcuts.json');
|
|
36
43
|
this.loadConfig();
|
|
37
44
|
}
|
|
38
45
|
loadConfig() {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { SessionManager } from './sessionManager.js';
|
|
2
|
+
import { Session } from '../types/index.js';
|
|
3
|
+
declare class GlobalSessionOrchestrator {
|
|
4
|
+
private static instance;
|
|
5
|
+
private projectManagers;
|
|
6
|
+
private globalManager;
|
|
7
|
+
private constructor();
|
|
8
|
+
static getInstance(): GlobalSessionOrchestrator;
|
|
9
|
+
getManagerForProject(projectPath?: string): SessionManager;
|
|
10
|
+
getAllActiveSessions(): Session[];
|
|
11
|
+
destroyAllSessions(): void;
|
|
12
|
+
destroyProjectSessions(projectPath: string): void;
|
|
13
|
+
getProjectSessions(projectPath: string): Session[];
|
|
14
|
+
}
|
|
15
|
+
export declare const globalSessionOrchestrator: GlobalSessionOrchestrator;
|
|
16
|
+
export {};
|