ccmanager 2.8.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.
Files changed (77) hide show
  1. package/dist/cli.test.js +13 -2
  2. package/dist/components/App.js +125 -50
  3. package/dist/components/App.test.js +270 -0
  4. package/dist/components/ConfigureShortcuts.js +82 -8
  5. package/dist/components/DeleteWorktree.js +39 -5
  6. package/dist/components/DeleteWorktree.test.d.ts +1 -0
  7. package/dist/components/DeleteWorktree.test.js +128 -0
  8. package/dist/components/LoadingSpinner.d.ts +8 -0
  9. package/dist/components/LoadingSpinner.js +37 -0
  10. package/dist/components/LoadingSpinner.test.d.ts +1 -0
  11. package/dist/components/LoadingSpinner.test.js +187 -0
  12. package/dist/components/Menu.js +64 -16
  13. package/dist/components/Menu.recent-projects.test.js +32 -11
  14. package/dist/components/Menu.test.js +136 -4
  15. package/dist/components/MergeWorktree.js +79 -18
  16. package/dist/components/MergeWorktree.test.d.ts +1 -0
  17. package/dist/components/MergeWorktree.test.js +227 -0
  18. package/dist/components/NewWorktree.js +88 -9
  19. package/dist/components/NewWorktree.test.d.ts +1 -0
  20. package/dist/components/NewWorktree.test.js +244 -0
  21. package/dist/components/ProjectList.js +44 -13
  22. package/dist/components/ProjectList.recent-projects.test.js +8 -3
  23. package/dist/components/ProjectList.test.js +105 -8
  24. package/dist/components/RemoteBranchSelector.test.js +3 -1
  25. package/dist/hooks/useGitStatus.d.ts +11 -0
  26. package/dist/hooks/useGitStatus.js +70 -12
  27. package/dist/hooks/useGitStatus.test.js +30 -23
  28. package/dist/services/configurationManager.d.ts +75 -0
  29. package/dist/services/configurationManager.effect.test.d.ts +1 -0
  30. package/dist/services/configurationManager.effect.test.js +407 -0
  31. package/dist/services/configurationManager.js +246 -0
  32. package/dist/services/globalSessionOrchestrator.test.js +0 -8
  33. package/dist/services/projectManager.d.ts +98 -2
  34. package/dist/services/projectManager.js +228 -59
  35. package/dist/services/projectManager.test.js +242 -2
  36. package/dist/services/sessionManager.d.ts +44 -2
  37. package/dist/services/sessionManager.effect.test.d.ts +1 -0
  38. package/dist/services/sessionManager.effect.test.js +321 -0
  39. package/dist/services/sessionManager.js +216 -65
  40. package/dist/services/sessionManager.statePersistence.test.js +18 -9
  41. package/dist/services/sessionManager.test.js +40 -36
  42. package/dist/services/worktreeService.d.ts +356 -26
  43. package/dist/services/worktreeService.js +793 -353
  44. package/dist/services/worktreeService.test.js +294 -313
  45. package/dist/types/errors.d.ts +74 -0
  46. package/dist/types/errors.js +31 -0
  47. package/dist/types/errors.test.d.ts +1 -0
  48. package/dist/types/errors.test.js +201 -0
  49. package/dist/types/index.d.ts +5 -17
  50. package/dist/utils/claudeDir.d.ts +58 -6
  51. package/dist/utils/claudeDir.js +103 -8
  52. package/dist/utils/claudeDir.test.d.ts +1 -0
  53. package/dist/utils/claudeDir.test.js +108 -0
  54. package/dist/utils/concurrencyLimit.d.ts +5 -0
  55. package/dist/utils/concurrencyLimit.js +11 -0
  56. package/dist/utils/concurrencyLimit.test.js +40 -1
  57. package/dist/utils/gitStatus.d.ts +36 -8
  58. package/dist/utils/gitStatus.js +170 -88
  59. package/dist/utils/gitStatus.test.js +12 -9
  60. package/dist/utils/hookExecutor.d.ts +41 -6
  61. package/dist/utils/hookExecutor.js +75 -32
  62. package/dist/utils/hookExecutor.test.js +73 -20
  63. package/dist/utils/terminalCapabilities.d.ts +18 -0
  64. package/dist/utils/terminalCapabilities.js +81 -0
  65. package/dist/utils/terminalCapabilities.test.d.ts +1 -0
  66. package/dist/utils/terminalCapabilities.test.js +104 -0
  67. package/dist/utils/testHelpers.d.ts +106 -0
  68. package/dist/utils/testHelpers.js +153 -0
  69. package/dist/utils/testHelpers.test.d.ts +1 -0
  70. package/dist/utils/testHelpers.test.js +114 -0
  71. package/dist/utils/worktreeConfig.d.ts +77 -2
  72. package/dist/utils/worktreeConfig.js +156 -16
  73. package/dist/utils/worktreeConfig.test.d.ts +1 -0
  74. package/dist/utils/worktreeConfig.test.js +39 -0
  75. package/package.json +4 -4
  76. package/dist/integration-tests/devcontainer.integration.test.js +0 -101
  77. /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 => {
@@ -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
- try {
166
- // Use preset-based session creation with default preset
167
- if (devcontainerConfig) {
168
- session = await sessionManager.createSessionWithDevcontainer(worktree.path, devcontainerConfig);
169
- }
170
- else {
171
- session = await sessionManager.createSessionWithPreset(worktree.path);
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
- try {
186
- // Create session with selected preset
187
- let session;
188
- if (devcontainerConfig) {
189
- session = await sessionManager.createSessionWithDevcontainer(selectedWorktree.path, devcontainerConfig, presetId);
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.createWorktree(path, branch, baseBranch, copySessionData, copyClaudeDirectory);
235
- // Handle the result using the helper function
236
- handleWorktreeCreationResult(result, {
237
- path,
238
- branch,
239
- baseBranch,
240
- copySessionData,
241
- copyClaudeDirectory,
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.createWorktree(creationData.path, creationData.branch, selectedRemoteRef, // Use the selected remote reference
257
- creationData.copySessionData, creationData.copyClaudeDirectory);
258
- if (result.success) {
259
- // Success - return to menu
260
- handleReturnToMenu();
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
- // Show error and return to new worktree form
264
- setError(result.error || 'Failed to create worktree');
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.deleteWorktree(path, { deleteBranch });
280
- if (!result.success) {
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
- setError(result.error || 'Failed to delete worktree');
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(Text, { color: "green" }, "Creating worktree...")));
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(Text, { color: "red" }, "Deleting worktrees...")));
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
+ });