ccmanager 2.7.0 → 2.9.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/cli.test.js +13 -2
- package/dist/components/App.js +125 -50
- package/dist/components/App.test.js +270 -0
- package/dist/components/ConfigureShortcuts.js +82 -8
- package/dist/components/DeleteWorktree.js +39 -5
- package/dist/components/DeleteWorktree.test.d.ts +1 -0
- package/dist/components/DeleteWorktree.test.js +128 -0
- package/dist/components/LoadingSpinner.d.ts +8 -0
- package/dist/components/LoadingSpinner.js +37 -0
- package/dist/components/LoadingSpinner.test.d.ts +1 -0
- package/dist/components/LoadingSpinner.test.js +187 -0
- package/dist/components/Menu.js +64 -16
- package/dist/components/Menu.recent-projects.test.js +32 -11
- package/dist/components/Menu.test.js +136 -4
- package/dist/components/MergeWorktree.js +79 -18
- package/dist/components/MergeWorktree.test.d.ts +1 -0
- package/dist/components/MergeWorktree.test.js +227 -0
- package/dist/components/NewWorktree.js +88 -9
- package/dist/components/NewWorktree.test.d.ts +1 -0
- package/dist/components/NewWorktree.test.js +244 -0
- package/dist/components/ProjectList.js +44 -13
- package/dist/components/ProjectList.recent-projects.test.js +8 -3
- package/dist/components/ProjectList.test.js +105 -8
- package/dist/components/RemoteBranchSelector.test.js +3 -1
- package/dist/hooks/useGitStatus.d.ts +11 -0
- package/dist/hooks/useGitStatus.js +70 -12
- package/dist/hooks/useGitStatus.test.js +30 -23
- package/dist/services/configurationManager.d.ts +75 -0
- package/dist/services/configurationManager.effect.test.d.ts +1 -0
- package/dist/services/configurationManager.effect.test.js +407 -0
- package/dist/services/configurationManager.js +246 -0
- package/dist/services/globalSessionOrchestrator.test.js +0 -8
- package/dist/services/projectManager.d.ts +98 -2
- package/dist/services/projectManager.js +228 -59
- package/dist/services/projectManager.test.js +242 -2
- package/dist/services/sessionManager.d.ts +44 -2
- package/dist/services/sessionManager.effect.test.d.ts +1 -0
- package/dist/services/sessionManager.effect.test.js +321 -0
- package/dist/services/sessionManager.js +216 -65
- package/dist/services/sessionManager.statePersistence.test.js +18 -9
- package/dist/services/sessionManager.test.js +40 -36
- package/dist/services/worktreeService.d.ts +356 -26
- package/dist/services/worktreeService.js +793 -353
- package/dist/services/worktreeService.test.js +294 -313
- package/dist/types/errors.d.ts +74 -0
- package/dist/types/errors.js +31 -0
- package/dist/types/errors.test.d.ts +1 -0
- package/dist/types/errors.test.js +201 -0
- package/dist/types/index.d.ts +5 -17
- package/dist/utils/claudeDir.d.ts +58 -6
- package/dist/utils/claudeDir.js +103 -8
- package/dist/utils/claudeDir.test.d.ts +1 -0
- package/dist/utils/claudeDir.test.js +108 -0
- package/dist/utils/concurrencyLimit.d.ts +5 -0
- package/dist/utils/concurrencyLimit.js +11 -0
- package/dist/utils/concurrencyLimit.test.js +40 -1
- package/dist/utils/gitStatus.d.ts +36 -8
- package/dist/utils/gitStatus.js +170 -88
- package/dist/utils/gitStatus.test.js +12 -9
- package/dist/utils/hookExecutor.d.ts +41 -6
- package/dist/utils/hookExecutor.js +75 -32
- package/dist/utils/hookExecutor.test.js +73 -20
- package/dist/utils/terminalCapabilities.d.ts +18 -0
- package/dist/utils/terminalCapabilities.js +81 -0
- package/dist/utils/terminalCapabilities.test.d.ts +1 -0
- package/dist/utils/terminalCapabilities.test.js +104 -0
- package/dist/utils/testHelpers.d.ts +106 -0
- package/dist/utils/testHelpers.js +153 -0
- package/dist/utils/testHelpers.test.d.ts +1 -0
- package/dist/utils/testHelpers.test.js +114 -0
- package/dist/utils/worktreeConfig.d.ts +77 -2
- package/dist/utils/worktreeConfig.js +156 -16
- package/dist/utils/worktreeConfig.test.d.ts +1 -0
- package/dist/utils/worktreeConfig.test.js +39 -0
- package/package.json +4 -4
- package/dist/integration-tests/devcontainer.integration.test.js +0 -101
- /package/dist/{integration-tests/devcontainer.integration.test.d.ts → components/App.test.d.ts} +0 -0
package/dist/cli.test.js
CHANGED
|
@@ -5,6 +5,17 @@ import { fileURLToPath } from 'url';
|
|
|
5
5
|
import { dirname } from 'path';
|
|
6
6
|
const __filename = fileURLToPath(import.meta.url);
|
|
7
7
|
const __dirname = dirname(__filename);
|
|
8
|
+
// Check if node-pty native module is available
|
|
9
|
+
function isNodePtyAvailable() {
|
|
10
|
+
try {
|
|
11
|
+
// Use eval to bypass linter's require() check
|
|
12
|
+
new Function('return require("node-pty")')();
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
8
19
|
describe('CLI', () => {
|
|
9
20
|
let originalEnv;
|
|
10
21
|
beforeEach(() => {
|
|
@@ -14,7 +25,7 @@ describe('CLI', () => {
|
|
|
14
25
|
process.env = originalEnv;
|
|
15
26
|
});
|
|
16
27
|
describe('--multi-project flag', () => {
|
|
17
|
-
it('should exit with error when CCMANAGER_MULTI_PROJECT_ROOT is not set', async () => {
|
|
28
|
+
it.skipIf(!isNodePtyAvailable())('should exit with error when CCMANAGER_MULTI_PROJECT_ROOT is not set', async () => {
|
|
18
29
|
// Ensure the env var is not set
|
|
19
30
|
delete process.env['CCMANAGER_MULTI_PROJECT_ROOT'];
|
|
20
31
|
// Create a wrapper script that mocks TTY
|
|
@@ -43,7 +54,7 @@ describe('CLI', () => {
|
|
|
43
54
|
expect(result.stderr).toContain('CCMANAGER_MULTI_PROJECT_ROOT environment variable must be set');
|
|
44
55
|
expect(result.stderr).toContain('export CCMANAGER_MULTI_PROJECT_ROOT=/path/to/projects');
|
|
45
56
|
});
|
|
46
|
-
it('should not check for env var when --multi-project is not used', async () => {
|
|
57
|
+
it.skipIf(!isNodePtyAvailable())('should not check for env var when --multi-project is not used', async () => {
|
|
47
58
|
// Ensure the env var is not set
|
|
48
59
|
delete process.env['CCMANAGER_MULTI_PROJECT_ROOT'];
|
|
49
60
|
const result = await new Promise(resolve => {
|
package/dist/components/App.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
2
2
|
import { useApp, Box, Text } from 'ink';
|
|
3
|
+
import { Effect } from 'effect';
|
|
3
4
|
import Menu from './Menu.js';
|
|
4
5
|
import ProjectList from './ProjectList.js';
|
|
5
6
|
import Session from './Session.js';
|
|
@@ -9,6 +10,7 @@ import MergeWorktree from './MergeWorktree.js';
|
|
|
9
10
|
import Configuration from './Configuration.js';
|
|
10
11
|
import PresetSelector from './PresetSelector.js';
|
|
11
12
|
import RemoteBranchSelector from './RemoteBranchSelector.js';
|
|
13
|
+
import LoadingSpinner from './LoadingSpinner.js';
|
|
12
14
|
import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
|
|
13
15
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
14
16
|
import { AmbiguousBranchError, } from '../types/index.js';
|
|
@@ -28,6 +30,44 @@ const App = ({ devcontainerConfig, multiProject }) => {
|
|
|
28
30
|
const [selectedProject, setSelectedProject] = useState(null); // Store selected project in multi-project mode
|
|
29
31
|
// State for remote branch disambiguation
|
|
30
32
|
const [pendingWorktreeCreation, setPendingWorktreeCreation] = useState(null);
|
|
33
|
+
// State for loading context - track flags for message composition
|
|
34
|
+
const [loadingContext, setLoadingContext] = useState({});
|
|
35
|
+
// Helper function to format error messages based on error type using _tag discrimination
|
|
36
|
+
const formatErrorMessage = (error) => {
|
|
37
|
+
switch (error._tag) {
|
|
38
|
+
case 'ProcessError':
|
|
39
|
+
return `Process error: ${error.message}`;
|
|
40
|
+
case 'ConfigError':
|
|
41
|
+
return `Configuration error (${error.reason}): ${error.details}`;
|
|
42
|
+
case 'GitError':
|
|
43
|
+
return `Git command failed: ${error.command} (exit ${error.exitCode})\n${error.stderr}`;
|
|
44
|
+
case 'FileSystemError':
|
|
45
|
+
return `File ${error.operation} failed for ${error.path}: ${error.cause}`;
|
|
46
|
+
case 'ValidationError':
|
|
47
|
+
return `Validation failed for ${error.field}: ${error.constraint}`;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
// Helper function to create session with Effect-based error handling
|
|
51
|
+
const createSessionWithEffect = async (worktreePath, presetId) => {
|
|
52
|
+
const sessionEffect = devcontainerConfig
|
|
53
|
+
? sessionManager.createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId)
|
|
54
|
+
: sessionManager.createSessionWithPresetEffect(worktreePath, presetId);
|
|
55
|
+
// Execute the Effect and handle both success and failure cases
|
|
56
|
+
const result = await Effect.runPromise(Effect.either(sessionEffect));
|
|
57
|
+
if (result._tag === 'Left') {
|
|
58
|
+
// Handle error using pattern matching on _tag
|
|
59
|
+
const errorMessage = formatErrorMessage(result.left);
|
|
60
|
+
return {
|
|
61
|
+
success: false,
|
|
62
|
+
errorMessage: `Failed to create session: ${errorMessage}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// Success case - extract session from Right
|
|
66
|
+
return {
|
|
67
|
+
success: true,
|
|
68
|
+
session: result.right,
|
|
69
|
+
};
|
|
70
|
+
};
|
|
31
71
|
// Helper function to clear terminal screen
|
|
32
72
|
const clearScreen = () => {
|
|
33
73
|
if (process.stdout.isTTY) {
|
|
@@ -162,19 +202,16 @@ const App = ({ devcontainerConfig, multiProject }) => {
|
|
|
162
202
|
navigateWithClear('preset-selector');
|
|
163
203
|
return;
|
|
164
204
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
catch (error) {
|
|
175
|
-
setError(`Failed to create session: ${error}`);
|
|
205
|
+
// Set loading state before async operation
|
|
206
|
+
setView('creating-session');
|
|
207
|
+
// Use Effect-based session creation with default preset
|
|
208
|
+
const result = await createSessionWithEffect(worktree.path);
|
|
209
|
+
if (!result.success) {
|
|
210
|
+
setError(result.errorMessage);
|
|
211
|
+
navigateWithClear('menu');
|
|
176
212
|
return;
|
|
177
213
|
}
|
|
214
|
+
session = result.session;
|
|
178
215
|
}
|
|
179
216
|
setActiveSession(session);
|
|
180
217
|
navigateWithClear('session');
|
|
@@ -182,24 +219,20 @@ const App = ({ devcontainerConfig, multiProject }) => {
|
|
|
182
219
|
const handlePresetSelected = async (presetId) => {
|
|
183
220
|
if (!selectedWorktree)
|
|
184
221
|
return;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
else {
|
|
192
|
-
session = await sessionManager.createSessionWithPreset(selectedWorktree.path, presetId);
|
|
193
|
-
}
|
|
194
|
-
setActiveSession(session);
|
|
195
|
-
navigateWithClear('session');
|
|
196
|
-
setSelectedWorktree(null);
|
|
197
|
-
}
|
|
198
|
-
catch (error) {
|
|
199
|
-
setError(`Failed to create session: ${error}`);
|
|
222
|
+
// Set loading state before async operation
|
|
223
|
+
setView('creating-session-preset');
|
|
224
|
+
// Create session with selected preset using Effect
|
|
225
|
+
const result = await createSessionWithEffect(selectedWorktree.path, presetId);
|
|
226
|
+
if (!result.success) {
|
|
227
|
+
setError(result.errorMessage);
|
|
200
228
|
setView('menu');
|
|
201
229
|
setSelectedWorktree(null);
|
|
230
|
+
return;
|
|
202
231
|
}
|
|
232
|
+
// Success case
|
|
233
|
+
setActiveSession(result.session);
|
|
234
|
+
navigateWithClear('session');
|
|
235
|
+
setSelectedWorktree(null);
|
|
203
236
|
};
|
|
204
237
|
const handlePresetSelectorCancel = () => {
|
|
205
238
|
setSelectedWorktree(null);
|
|
@@ -228,18 +261,22 @@ const App = ({ devcontainerConfig, multiProject }) => {
|
|
|
228
261
|
});
|
|
229
262
|
};
|
|
230
263
|
const handleCreateWorktree = async (path, branch, baseBranch, copySessionData, copyClaudeDirectory) => {
|
|
264
|
+
// Set loading context before showing loading view
|
|
265
|
+
setLoadingContext({ copySessionData });
|
|
231
266
|
setView('creating-worktree');
|
|
232
267
|
setError(null);
|
|
233
|
-
// Create the worktree
|
|
234
|
-
const result = await worktreeService.
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
baseBranch,
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
268
|
+
// Create the worktree using Effect
|
|
269
|
+
const result = await Effect.runPromise(Effect.either(worktreeService.createWorktreeEffect(path, branch, baseBranch, copySessionData, copyClaudeDirectory)));
|
|
270
|
+
// Transform Effect result to legacy format for handleWorktreeCreationResult
|
|
271
|
+
if (result._tag === 'Left') {
|
|
272
|
+
// Handle error using pattern matching on _tag
|
|
273
|
+
const errorMessage = formatErrorMessage(result.left);
|
|
274
|
+
handleWorktreeCreationResult({ success: false, error: errorMessage }, { path, branch, baseBranch, copySessionData, copyClaudeDirectory });
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
// Success case
|
|
278
|
+
handleWorktreeCreationResult({ success: true }, { path, branch, baseBranch, copySessionData, copyClaudeDirectory });
|
|
279
|
+
}
|
|
243
280
|
};
|
|
244
281
|
const handleCancelNewWorktree = () => {
|
|
245
282
|
handleReturnToMenu();
|
|
@@ -251,18 +288,21 @@ const App = ({ devcontainerConfig, multiProject }) => {
|
|
|
251
288
|
const creationData = pendingWorktreeCreation;
|
|
252
289
|
setPendingWorktreeCreation(null);
|
|
253
290
|
// Retry worktree creation with the resolved base branch
|
|
291
|
+
// Set loading context before showing loading view
|
|
292
|
+
setLoadingContext({ copySessionData: creationData.copySessionData });
|
|
254
293
|
setView('creating-worktree');
|
|
255
294
|
setError(null);
|
|
256
|
-
const result = await worktreeService.
|
|
257
|
-
creationData.copySessionData, creationData.copyClaudeDirectory);
|
|
258
|
-
if (result.
|
|
259
|
-
//
|
|
260
|
-
|
|
295
|
+
const result = await Effect.runPromise(Effect.either(worktreeService.createWorktreeEffect(creationData.path, creationData.branch, selectedRemoteRef, // Use the selected remote reference
|
|
296
|
+
creationData.copySessionData, creationData.copyClaudeDirectory)));
|
|
297
|
+
if (result._tag === 'Left') {
|
|
298
|
+
// Handle error using pattern matching on _tag
|
|
299
|
+
const errorMessage = formatErrorMessage(result.left);
|
|
300
|
+
setError(errorMessage);
|
|
301
|
+
setView('new-worktree');
|
|
261
302
|
}
|
|
262
303
|
else {
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
setView('new-worktree');
|
|
304
|
+
// Success - return to menu
|
|
305
|
+
handleReturnToMenu();
|
|
266
306
|
}
|
|
267
307
|
};
|
|
268
308
|
const handleRemoteBranchSelectorCancel = () => {
|
|
@@ -271,15 +311,19 @@ const App = ({ devcontainerConfig, multiProject }) => {
|
|
|
271
311
|
setView('new-worktree');
|
|
272
312
|
};
|
|
273
313
|
const handleDeleteWorktrees = async (worktreePaths, deleteBranch) => {
|
|
314
|
+
// Set loading context before showing loading view
|
|
315
|
+
setLoadingContext({ deleteBranch });
|
|
274
316
|
setView('deleting-worktree');
|
|
275
317
|
setError(null);
|
|
276
|
-
// Delete the worktrees
|
|
318
|
+
// Delete the worktrees sequentially using Effect
|
|
277
319
|
let hasError = false;
|
|
278
320
|
for (const path of worktreePaths) {
|
|
279
|
-
const result = worktreeService.
|
|
280
|
-
if (
|
|
321
|
+
const result = await Effect.runPromise(Effect.either(worktreeService.deleteWorktreeEffect(path, { deleteBranch })));
|
|
322
|
+
if (result._tag === 'Left') {
|
|
323
|
+
// Handle error using pattern matching on _tag
|
|
281
324
|
hasError = true;
|
|
282
|
-
|
|
325
|
+
const errorMessage = formatErrorMessage(result.left);
|
|
326
|
+
setError(errorMessage);
|
|
283
327
|
break;
|
|
284
328
|
}
|
|
285
329
|
}
|
|
@@ -348,8 +392,12 @@ const App = ({ devcontainerConfig, multiProject }) => {
|
|
|
348
392
|
React.createElement(NewWorktree, { projectPath: selectedProject?.path || process.cwd(), onComplete: handleCreateWorktree, onCancel: handleCancelNewWorktree })));
|
|
349
393
|
}
|
|
350
394
|
if (view === 'creating-worktree') {
|
|
395
|
+
// Compose message based on loading context
|
|
396
|
+
const message = loadingContext.copySessionData
|
|
397
|
+
? 'Creating worktree and copying session data...'
|
|
398
|
+
: 'Creating worktree...';
|
|
351
399
|
return (React.createElement(Box, { flexDirection: "column" },
|
|
352
|
-
React.createElement(
|
|
400
|
+
React.createElement(LoadingSpinner, { message: message, color: "cyan" })));
|
|
353
401
|
}
|
|
354
402
|
if (view === 'delete-worktree') {
|
|
355
403
|
return (React.createElement(Box, { flexDirection: "column" },
|
|
@@ -360,8 +408,12 @@ const App = ({ devcontainerConfig, multiProject }) => {
|
|
|
360
408
|
React.createElement(DeleteWorktree, { onComplete: handleDeleteWorktrees, onCancel: handleCancelDeleteWorktree })));
|
|
361
409
|
}
|
|
362
410
|
if (view === 'deleting-worktree') {
|
|
411
|
+
// Compose message based on loading context
|
|
412
|
+
const message = loadingContext.deleteBranch
|
|
413
|
+
? 'Deleting worktrees and branches...'
|
|
414
|
+
: 'Deleting worktrees...';
|
|
363
415
|
return (React.createElement(Box, { flexDirection: "column" },
|
|
364
|
-
React.createElement(
|
|
416
|
+
React.createElement(LoadingSpinner, { message: message, color: "cyan" })));
|
|
365
417
|
}
|
|
366
418
|
if (view === 'merge-worktree') {
|
|
367
419
|
return (React.createElement(Box, { flexDirection: "column" },
|
|
@@ -380,6 +432,29 @@ const App = ({ devcontainerConfig, multiProject }) => {
|
|
|
380
432
|
if (view === 'remote-branch-selector' && pendingWorktreeCreation) {
|
|
381
433
|
return (React.createElement(RemoteBranchSelector, { branchName: pendingWorktreeCreation.ambiguousError.branchName, matches: pendingWorktreeCreation.ambiguousError.matches, onSelect: handleRemoteBranchSelected, onCancel: handleRemoteBranchSelectorCancel }));
|
|
382
434
|
}
|
|
435
|
+
if (view === 'creating-session') {
|
|
436
|
+
// Compose message based on devcontainerConfig presence
|
|
437
|
+
// Devcontainer operations take >5 seconds, so indicate extended duration
|
|
438
|
+
const message = devcontainerConfig
|
|
439
|
+
? 'Starting devcontainer (this may take a moment)...'
|
|
440
|
+
: 'Creating session...';
|
|
441
|
+
// Use yellow color for devcontainer operations (longer duration),
|
|
442
|
+
// cyan for standard session creation
|
|
443
|
+
const color = devcontainerConfig ? 'yellow' : 'cyan';
|
|
444
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
445
|
+
React.createElement(LoadingSpinner, { message: message, color: color })));
|
|
446
|
+
}
|
|
447
|
+
if (view === 'creating-session-preset') {
|
|
448
|
+
// Always display preset-specific message
|
|
449
|
+
// Devcontainer operations take >5 seconds, so indicate extended duration
|
|
450
|
+
const message = devcontainerConfig
|
|
451
|
+
? 'Creating session with preset (this may take a moment)...'
|
|
452
|
+
: 'Creating session with preset...';
|
|
453
|
+
// Use yellow color for devcontainer, cyan for standard
|
|
454
|
+
const color = devcontainerConfig ? 'yellow' : 'cyan';
|
|
455
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
456
|
+
React.createElement(LoadingSpinner, { message: message, color: color })));
|
|
457
|
+
}
|
|
383
458
|
if (view === 'clearing') {
|
|
384
459
|
// Render nothing during the clearing phase to ensure clean transition
|
|
385
460
|
return null;
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { beforeAll, beforeEach, afterEach, describe, expect, it, vi, } from 'vitest';
|
|
4
|
+
import { Effect } from 'effect';
|
|
5
|
+
import { ENV_VARS } from '../constants/env.js';
|
|
6
|
+
let App;
|
|
7
|
+
let menuProps;
|
|
8
|
+
let newWorktreeProps;
|
|
9
|
+
let deleteWorktreeProps;
|
|
10
|
+
let sessionProps;
|
|
11
|
+
const createWorktreeEffectMock = vi.fn();
|
|
12
|
+
const deleteWorktreeEffectMock = vi.fn();
|
|
13
|
+
const mockSession = {
|
|
14
|
+
id: 'session-1',
|
|
15
|
+
};
|
|
16
|
+
class MockSessionManager {
|
|
17
|
+
constructor() {
|
|
18
|
+
Object.defineProperty(this, "on", {
|
|
19
|
+
enumerable: true,
|
|
20
|
+
configurable: true,
|
|
21
|
+
writable: true,
|
|
22
|
+
value: vi.fn((_, __) => this)
|
|
23
|
+
});
|
|
24
|
+
Object.defineProperty(this, "off", {
|
|
25
|
+
enumerable: true,
|
|
26
|
+
configurable: true,
|
|
27
|
+
writable: true,
|
|
28
|
+
value: vi.fn((_, __) => this)
|
|
29
|
+
});
|
|
30
|
+
Object.defineProperty(this, "getSession", {
|
|
31
|
+
enumerable: true,
|
|
32
|
+
configurable: true,
|
|
33
|
+
writable: true,
|
|
34
|
+
value: vi.fn((_) => null)
|
|
35
|
+
});
|
|
36
|
+
Object.defineProperty(this, "getAllSessions", {
|
|
37
|
+
enumerable: true,
|
|
38
|
+
configurable: true,
|
|
39
|
+
writable: true,
|
|
40
|
+
value: vi.fn(() => [])
|
|
41
|
+
});
|
|
42
|
+
Object.defineProperty(this, "createSessionWithPresetEffect", {
|
|
43
|
+
enumerable: true,
|
|
44
|
+
configurable: true,
|
|
45
|
+
writable: true,
|
|
46
|
+
value: vi.fn((_, __) => Effect.succeed(mockSession))
|
|
47
|
+
});
|
|
48
|
+
Object.defineProperty(this, "createSessionWithDevcontainerEffect", {
|
|
49
|
+
enumerable: true,
|
|
50
|
+
configurable: true,
|
|
51
|
+
writable: true,
|
|
52
|
+
value: vi.fn((_, __) => Effect.succeed(mockSession))
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const sessionManagers = [];
|
|
57
|
+
const getManagerForProjectMock = vi.fn((_) => {
|
|
58
|
+
const manager = new MockSessionManager();
|
|
59
|
+
sessionManagers.push(manager);
|
|
60
|
+
return manager;
|
|
61
|
+
});
|
|
62
|
+
const configurationManagerMock = {
|
|
63
|
+
getSelectPresetOnStart: vi.fn(() => false),
|
|
64
|
+
};
|
|
65
|
+
const projectManagerMock = {
|
|
66
|
+
addRecentProject: vi.fn(),
|
|
67
|
+
};
|
|
68
|
+
function createInkMock(label, onRender) {
|
|
69
|
+
return async () => {
|
|
70
|
+
const ReactActual = await vi.importActual('react');
|
|
71
|
+
const { Text } = await vi.importActual('ink');
|
|
72
|
+
const Component = (props) => {
|
|
73
|
+
onRender?.(props);
|
|
74
|
+
return ReactActual.createElement(Text, null, label);
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
__esModule: true,
|
|
78
|
+
default: Component,
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
vi.mock('../services/sessionManager.js', () => ({
|
|
83
|
+
SessionManager: MockSessionManager,
|
|
84
|
+
}));
|
|
85
|
+
vi.mock('../services/globalSessionOrchestrator.js', () => ({
|
|
86
|
+
globalSessionOrchestrator: {
|
|
87
|
+
getManagerForProject: getManagerForProjectMock,
|
|
88
|
+
destroyAllSessions: vi.fn(),
|
|
89
|
+
},
|
|
90
|
+
}));
|
|
91
|
+
vi.mock('../services/projectManager.js', () => ({
|
|
92
|
+
projectManager: projectManagerMock,
|
|
93
|
+
}));
|
|
94
|
+
vi.mock('../services/configurationManager.js', () => ({
|
|
95
|
+
configurationManager: configurationManagerMock,
|
|
96
|
+
}));
|
|
97
|
+
vi.mock('../services/worktreeService.js', () => ({
|
|
98
|
+
WorktreeService: vi.fn().mockImplementation(() => ({
|
|
99
|
+
createWorktreeEffect: (...args) => createWorktreeEffectMock(...args),
|
|
100
|
+
deleteWorktreeEffect: (...args) => deleteWorktreeEffectMock(...args),
|
|
101
|
+
})),
|
|
102
|
+
}));
|
|
103
|
+
vi.mock('./Menu.js', createInkMock('Menu View', props => (menuProps = props)));
|
|
104
|
+
vi.mock('./ProjectList.js', createInkMock('Project List View', () => { }));
|
|
105
|
+
vi.mock('./NewWorktree.js', createInkMock('New Worktree View', props => {
|
|
106
|
+
newWorktreeProps = props;
|
|
107
|
+
}));
|
|
108
|
+
vi.mock('./DeleteWorktree.js', createInkMock('Delete Worktree View', props => {
|
|
109
|
+
deleteWorktreeProps = props;
|
|
110
|
+
}));
|
|
111
|
+
vi.mock('./Session.js', createInkMock('Session View', props => {
|
|
112
|
+
sessionProps = props;
|
|
113
|
+
}));
|
|
114
|
+
vi.mock('./MergeWorktree.js', createInkMock('Merge Worktree View'));
|
|
115
|
+
vi.mock('./Configuration.js', createInkMock('Configuration View'));
|
|
116
|
+
vi.mock('./PresetSelector.js', createInkMock('Preset Selector View'));
|
|
117
|
+
vi.mock('./RemoteBranchSelector.js', createInkMock('Remote Branch Selector View'));
|
|
118
|
+
vi.mock('./LoadingSpinner.js', async () => {
|
|
119
|
+
const ReactActual = await vi.importActual('react');
|
|
120
|
+
const { Text } = await vi.importActual('ink');
|
|
121
|
+
const Component = ({ message, color }) => {
|
|
122
|
+
return ReactActual.createElement(Text, null, `${message} [${color}]`);
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
__esModule: true,
|
|
126
|
+
default: Component,
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
beforeAll(async () => {
|
|
130
|
+
App = (await import('./App.js')).default;
|
|
131
|
+
});
|
|
132
|
+
const flush = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms));
|
|
133
|
+
const waitForCondition = async (condition, timeout = 200, interval = 5) => {
|
|
134
|
+
const deadline = Date.now() + timeout;
|
|
135
|
+
while (!condition()) {
|
|
136
|
+
if (Date.now() > deadline) {
|
|
137
|
+
throw new Error('Timed out waiting for condition');
|
|
138
|
+
}
|
|
139
|
+
await flush(interval);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
menuProps = undefined;
|
|
144
|
+
newWorktreeProps = undefined;
|
|
145
|
+
deleteWorktreeProps = undefined;
|
|
146
|
+
sessionProps = undefined;
|
|
147
|
+
createWorktreeEffectMock.mockReset();
|
|
148
|
+
deleteWorktreeEffectMock.mockReset();
|
|
149
|
+
createWorktreeEffectMock.mockImplementation(() => Effect.succeed(undefined));
|
|
150
|
+
deleteWorktreeEffectMock.mockImplementation(() => Effect.succeed(undefined));
|
|
151
|
+
sessionManagers.length = 0;
|
|
152
|
+
getManagerForProjectMock.mockClear();
|
|
153
|
+
configurationManagerMock.getSelectPresetOnStart.mockReset();
|
|
154
|
+
configurationManagerMock.getSelectPresetOnStart.mockReturnValue(false);
|
|
155
|
+
projectManagerMock.addRecentProject.mockReset();
|
|
156
|
+
});
|
|
157
|
+
afterEach(() => {
|
|
158
|
+
delete process.env[ENV_VARS.MULTI_PROJECT_ROOT];
|
|
159
|
+
});
|
|
160
|
+
describe('App component view state', () => {
|
|
161
|
+
it('renders the menu view by default', async () => {
|
|
162
|
+
const { lastFrame, unmount } = render(React.createElement(App, null));
|
|
163
|
+
await flush(40);
|
|
164
|
+
expect(lastFrame()).toContain('Menu View');
|
|
165
|
+
unmount();
|
|
166
|
+
});
|
|
167
|
+
it('renders the project list view first in multi-project mode', async () => {
|
|
168
|
+
const original = process.env[ENV_VARS.MULTI_PROJECT_ROOT];
|
|
169
|
+
process.env[ENV_VARS.MULTI_PROJECT_ROOT] = '/tmp/projects';
|
|
170
|
+
const { lastFrame, unmount } = render(React.createElement(App, { multiProject: true }));
|
|
171
|
+
await flush();
|
|
172
|
+
expect(lastFrame()).toContain('Project List View');
|
|
173
|
+
unmount();
|
|
174
|
+
if (original !== undefined) {
|
|
175
|
+
process.env[ENV_VARS.MULTI_PROJECT_ROOT] = original;
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
describe('App component loading state machine', () => {
|
|
180
|
+
it('displays copying message while creating a worktree with session data', async () => {
|
|
181
|
+
let resolveWorktree;
|
|
182
|
+
createWorktreeEffectMock.mockImplementation(() => Effect.tryPromise({
|
|
183
|
+
try: () => new Promise(resolve => {
|
|
184
|
+
resolveWorktree = resolve;
|
|
185
|
+
}),
|
|
186
|
+
catch: (error) => error,
|
|
187
|
+
}));
|
|
188
|
+
const { lastFrame, unmount } = render(React.createElement(App, null));
|
|
189
|
+
await waitForCondition(() => Boolean(menuProps));
|
|
190
|
+
const menu = menuProps;
|
|
191
|
+
const selectPromise = Promise.resolve(menu.onSelectWorktree({
|
|
192
|
+
path: '',
|
|
193
|
+
branch: '',
|
|
194
|
+
isMainWorktree: false,
|
|
195
|
+
hasSession: false,
|
|
196
|
+
}));
|
|
197
|
+
await waitForCondition(() => Boolean(newWorktreeProps));
|
|
198
|
+
const newWorktree = newWorktreeProps;
|
|
199
|
+
const createPromise = Promise.resolve(newWorktree.onComplete('/tmp/test', 'feature', 'main', true, false));
|
|
200
|
+
await flush();
|
|
201
|
+
expect(lastFrame()).toContain('Creating worktree and copying session data...');
|
|
202
|
+
resolveWorktree?.();
|
|
203
|
+
await createPromise;
|
|
204
|
+
await selectPromise;
|
|
205
|
+
await flush(20);
|
|
206
|
+
expect(lastFrame()).toContain('Menu View');
|
|
207
|
+
unmount();
|
|
208
|
+
});
|
|
209
|
+
it('displays branch deletion message while deleting worktrees', async () => {
|
|
210
|
+
let resolveDelete;
|
|
211
|
+
deleteWorktreeEffectMock.mockImplementation(() => Effect.tryPromise({
|
|
212
|
+
try: () => new Promise(resolve => {
|
|
213
|
+
resolveDelete = resolve;
|
|
214
|
+
}),
|
|
215
|
+
catch: (error) => error,
|
|
216
|
+
}));
|
|
217
|
+
const { lastFrame, unmount } = render(React.createElement(App, null));
|
|
218
|
+
await waitForCondition(() => Boolean(menuProps));
|
|
219
|
+
const menu = menuProps;
|
|
220
|
+
const selectPromise = Promise.resolve(menu.onSelectWorktree({
|
|
221
|
+
path: 'DELETE_WORKTREE',
|
|
222
|
+
branch: '',
|
|
223
|
+
isMainWorktree: false,
|
|
224
|
+
hasSession: false,
|
|
225
|
+
}));
|
|
226
|
+
await waitForCondition(() => Boolean(deleteWorktreeProps));
|
|
227
|
+
const deleteWorktree = deleteWorktreeProps;
|
|
228
|
+
const deletePromise = Promise.resolve(deleteWorktree.onComplete(['/tmp/test'], true));
|
|
229
|
+
await flush();
|
|
230
|
+
expect(lastFrame()).toContain('Deleting worktrees and branches...');
|
|
231
|
+
resolveDelete?.();
|
|
232
|
+
await deletePromise;
|
|
233
|
+
await selectPromise;
|
|
234
|
+
await flush(20);
|
|
235
|
+
expect(lastFrame()).toContain('Menu View');
|
|
236
|
+
unmount();
|
|
237
|
+
});
|
|
238
|
+
it('shows devcontainer spinner while creating a session with config', async () => {
|
|
239
|
+
let resolveSession;
|
|
240
|
+
const { lastFrame, unmount } = render(React.createElement(App, { devcontainerConfig: {
|
|
241
|
+
upCommand: 'podman up',
|
|
242
|
+
execCommand: 'podman exec',
|
|
243
|
+
} }));
|
|
244
|
+
await waitForCondition(() => Boolean(menuProps));
|
|
245
|
+
expect(menuProps).toBeDefined();
|
|
246
|
+
expect(sessionManagers).toHaveLength(1);
|
|
247
|
+
const sessionManager = sessionManagers[0];
|
|
248
|
+
sessionManager.createSessionWithDevcontainerEffect.mockImplementation(() => Effect.tryPromise({
|
|
249
|
+
try: () => new Promise(resolve => {
|
|
250
|
+
resolveSession = resolve;
|
|
251
|
+
}),
|
|
252
|
+
catch: (error) => error,
|
|
253
|
+
}));
|
|
254
|
+
const menu = menuProps;
|
|
255
|
+
const selectPromise = Promise.resolve(menu.onSelectWorktree({
|
|
256
|
+
path: '/project/worktree',
|
|
257
|
+
branch: 'feature',
|
|
258
|
+
isMainWorktree: false,
|
|
259
|
+
hasSession: false,
|
|
260
|
+
}));
|
|
261
|
+
await flush();
|
|
262
|
+
expect(lastFrame()).toContain('Starting devcontainer (this may take a moment)...');
|
|
263
|
+
resolveSession?.(mockSession);
|
|
264
|
+
await selectPromise;
|
|
265
|
+
await flush(20);
|
|
266
|
+
expect(lastFrame()).toContain('Session View');
|
|
267
|
+
expect(sessionProps?.session).toEqual(mockSession);
|
|
268
|
+
unmount();
|
|
269
|
+
});
|
|
270
|
+
});
|