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.
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
@@ -7,6 +7,8 @@ import { configurationManager } from './configurationManager.js';
7
7
  import { executeStatusHook } from '../utils/hookExecutor.js';
8
8
  import { createStateDetector } from './stateDetector.js';
9
9
  import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
10
+ import { Effect } from 'effect';
11
+ import { ProcessError, ConfigError } from '../types/errors.js';
10
12
  const { Terminal } = pkg;
11
13
  const execAsync = promisify(exec);
12
14
  export class SessionManager extends EventEmitter {
@@ -86,29 +88,78 @@ export class SessionManager extends EventEmitter {
86
88
  this.emit('sessionCreated', session);
87
89
  return session;
88
90
  }
89
- async createSessionWithPreset(worktreePath, presetId) {
90
- // Check if session already exists
91
- const existing = this.sessions.get(worktreePath);
92
- if (existing) {
93
- return existing;
94
- }
95
- // Get preset configuration
96
- let preset = presetId ? configurationManager.getPresetById(presetId) : null;
97
- if (!preset) {
98
- preset = configurationManager.getDefaultPreset();
99
- }
100
- const command = preset.command;
101
- const args = preset.args || [];
102
- const commandConfig = {
103
- command: preset.command,
104
- args: preset.args,
105
- fallbackArgs: preset.fallbackArgs,
106
- };
107
- // Spawn the process - fallback will be handled by setupExitHandler
108
- const ptyProcess = await this.spawn(command, args, worktreePath);
109
- return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
110
- isPrimaryCommand: true,
111
- detectionStrategy: preset.detectionStrategy,
91
+ /**
92
+ * Create session with command preset using Effect-based error handling
93
+ *
94
+ * @param {string} worktreePath - Path to the worktree
95
+ * @param {string} [presetId] - Optional preset ID, uses default if not provided
96
+ * @returns {Effect.Effect<Session, ProcessError | ConfigError, never>} Effect that may fail with ProcessError (spawn failure) or ConfigError (invalid preset)
97
+ *
98
+ * @example
99
+ * ```typescript
100
+ * // Use Effect.match for type-safe error handling
101
+ * const result = await Effect.runPromise(
102
+ * Effect.match(effect, {
103
+ * onFailure: (error) => ({ type: 'error', message: error.message }),
104
+ * onSuccess: (session) => ({ type: 'success', data: session })
105
+ * })
106
+ * );
107
+ * ```
108
+ */
109
+ createSessionWithPresetEffect(worktreePath, presetId) {
110
+ return Effect.tryPromise({
111
+ try: async () => {
112
+ // Check if session already exists
113
+ const existing = this.sessions.get(worktreePath);
114
+ if (existing) {
115
+ return existing;
116
+ }
117
+ // Get preset configuration
118
+ let preset = presetId
119
+ ? configurationManager.getPresetById(presetId)
120
+ : null;
121
+ if (!preset) {
122
+ preset = configurationManager.getDefaultPreset();
123
+ }
124
+ // Validate preset exists
125
+ if (!preset) {
126
+ throw new ConfigError({
127
+ configPath: 'configuration',
128
+ reason: 'validation',
129
+ details: presetId
130
+ ? `Preset with ID '${presetId}' not found and no default preset available`
131
+ : 'No default preset available',
132
+ });
133
+ }
134
+ const command = preset.command;
135
+ const args = preset.args || [];
136
+ const commandConfig = {
137
+ command: preset.command,
138
+ args: preset.args,
139
+ fallbackArgs: preset.fallbackArgs,
140
+ };
141
+ // Spawn the process - fallback will be handled by setupExitHandler
142
+ const ptyProcess = await this.spawn(command, args, worktreePath);
143
+ return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
144
+ isPrimaryCommand: true,
145
+ detectionStrategy: preset.detectionStrategy,
146
+ });
147
+ },
148
+ catch: (error) => {
149
+ // If it's already a ConfigError, return it
150
+ if (error instanceof ConfigError) {
151
+ return error;
152
+ }
153
+ // Otherwise, wrap in ProcessError
154
+ return new ProcessError({
155
+ command: presetId
156
+ ? `createSessionWithPreset (preset: ${presetId})`
157
+ : 'createSessionWithPreset (default preset)',
158
+ message: error instanceof Error
159
+ ? error.message
160
+ : 'Failed to create session with preset',
161
+ });
162
+ },
112
163
  });
113
164
  }
114
165
  setupDataHandler(session) {
@@ -213,8 +264,8 @@ export class SessionManager extends EventEmitter {
213
264
  session.state = detectedState;
214
265
  session.pendingState = undefined;
215
266
  session.pendingStateStart = undefined;
216
- // Execute status hook asynchronously (non-blocking)
217
- void executeStatusHook(oldState, detectedState, session);
267
+ // Execute status hook asynchronously (non-blocking) using Effect
268
+ void Effect.runPromise(executeStatusHook(oldState, detectedState, session));
218
269
  this.emit('sessionStateChanged', session);
219
270
  }
220
271
  }
@@ -280,49 +331,149 @@ export class SessionManager extends EventEmitter {
280
331
  this.emit('sessionDestroyed', session);
281
332
  }
282
333
  }
334
+ /**
335
+ * Terminate session and cleanup resources using Effect-based error handling
336
+ *
337
+ * @param {string} worktreePath - Path to the worktree
338
+ * @returns {Effect.Effect<void, ProcessError, never>} Effect that may fail with ProcessError if session does not exist or cleanup fails
339
+ *
340
+ * @example
341
+ * ```typescript
342
+ * // Terminate session with error handling
343
+ * const result = await Effect.runPromise(
344
+ * Effect.match(effect, {
345
+ * onFailure: (error) => ({ type: 'error', message: error.message }),
346
+ * onSuccess: () => ({ type: 'success' })
347
+ * })
348
+ * );
349
+ * ```
350
+ */
351
+ terminateSessionEffect(worktreePath) {
352
+ return Effect.try({
353
+ try: () => {
354
+ const session = this.sessions.get(worktreePath);
355
+ if (!session) {
356
+ throw new ProcessError({
357
+ command: 'terminateSession',
358
+ message: `Session not found for worktree: ${worktreePath}`,
359
+ });
360
+ }
361
+ // Clear the state check interval
362
+ if (session.stateCheckInterval) {
363
+ clearInterval(session.stateCheckInterval);
364
+ }
365
+ // Try to kill the process - don't fail if process is already dead
366
+ try {
367
+ session.process.kill();
368
+ }
369
+ catch (_error) {
370
+ // Process might already be dead, this is acceptable
371
+ }
372
+ // Clean up any pending timer
373
+ const timer = this.busyTimers.get(worktreePath);
374
+ if (timer) {
375
+ clearTimeout(timer);
376
+ this.busyTimers.delete(worktreePath);
377
+ }
378
+ // Remove from sessions map and cleanup
379
+ this.sessions.delete(worktreePath);
380
+ this.waitingWithBottomBorder.delete(session.id);
381
+ this.emit('sessionDestroyed', session);
382
+ },
383
+ catch: (error) => {
384
+ // If it's already a ProcessError, return it
385
+ if (error instanceof ProcessError) {
386
+ return error;
387
+ }
388
+ // Otherwise, wrap in ProcessError
389
+ return new ProcessError({
390
+ command: 'terminateSession',
391
+ message: error instanceof Error
392
+ ? error.message
393
+ : `Failed to terminate session for ${worktreePath}`,
394
+ });
395
+ },
396
+ });
397
+ }
283
398
  getAllSessions() {
284
399
  return Array.from(this.sessions.values());
285
400
  }
286
- async createSessionWithDevcontainer(worktreePath, devcontainerConfig, presetId) {
287
- // Check if session already exists
288
- const existing = this.sessions.get(worktreePath);
289
- if (existing) {
290
- return existing;
291
- }
292
- // Execute devcontainer up command first
293
- try {
294
- await execAsync(devcontainerConfig.upCommand, { cwd: worktreePath });
295
- }
296
- catch (error) {
297
- throw new Error(`Failed to start devcontainer: ${error instanceof Error ? error.message : String(error)}`);
298
- }
299
- // Get preset configuration
300
- let preset = presetId ? configurationManager.getPresetById(presetId) : null;
301
- if (!preset) {
302
- preset = configurationManager.getDefaultPreset();
303
- }
304
- // Parse the exec command to extract arguments
305
- const execParts = devcontainerConfig.execCommand.split(/\s+/);
306
- const devcontainerCmd = execParts[0] || 'devcontainer'; // Should be 'devcontainer'
307
- const execArgs = execParts.slice(1); // Rest of the exec command args
308
- // Build the full command: devcontainer exec [args] -- [preset command] [preset args]
309
- const fullArgs = [
310
- ...execArgs,
311
- '--',
312
- preset.command,
313
- ...(preset.args || []),
314
- ];
315
- // Spawn the process within devcontainer - fallback will be handled by setupExitHandler
316
- const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath);
317
- const commandConfig = {
318
- command: preset.command,
319
- args: preset.args,
320
- fallbackArgs: preset.fallbackArgs,
321
- };
322
- return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
323
- isPrimaryCommand: true,
324
- detectionStrategy: preset.detectionStrategy,
325
- devcontainerConfig,
401
+ /**
402
+ * Create session with devcontainer integration using Effect-based error handling
403
+ * @returns Effect that may fail with ProcessError (container/spawn failure) or ConfigError (invalid preset)
404
+ */
405
+ createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId) {
406
+ return Effect.tryPromise({
407
+ try: async () => {
408
+ // Check if session already exists
409
+ const existing = this.sessions.get(worktreePath);
410
+ if (existing) {
411
+ return existing;
412
+ }
413
+ // Execute devcontainer up command first
414
+ try {
415
+ await execAsync(devcontainerConfig.upCommand, { cwd: worktreePath });
416
+ }
417
+ catch (error) {
418
+ throw new ProcessError({
419
+ command: devcontainerConfig.upCommand,
420
+ message: `Failed to start devcontainer: ${error instanceof Error ? error.message : String(error)}`,
421
+ });
422
+ }
423
+ // Get preset configuration
424
+ let preset = presetId
425
+ ? configurationManager.getPresetById(presetId)
426
+ : null;
427
+ if (!preset) {
428
+ preset = configurationManager.getDefaultPreset();
429
+ }
430
+ // Validate preset exists
431
+ if (!preset) {
432
+ throw new ConfigError({
433
+ configPath: 'configuration',
434
+ reason: 'validation',
435
+ details: presetId
436
+ ? `Preset with ID '${presetId}' not found and no default preset available`
437
+ : 'No default preset available',
438
+ });
439
+ }
440
+ // Parse the exec command to extract arguments
441
+ const execParts = devcontainerConfig.execCommand.split(/\s+/);
442
+ const devcontainerCmd = execParts[0] || 'devcontainer';
443
+ const execArgs = execParts.slice(1);
444
+ // Build the full command: devcontainer exec [args] -- [preset command] [preset args]
445
+ const fullArgs = [
446
+ ...execArgs,
447
+ '--',
448
+ preset.command,
449
+ ...(preset.args || []),
450
+ ];
451
+ // Spawn the process within devcontainer
452
+ const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath);
453
+ const commandConfig = {
454
+ command: preset.command,
455
+ args: preset.args,
456
+ fallbackArgs: preset.fallbackArgs,
457
+ };
458
+ return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
459
+ isPrimaryCommand: true,
460
+ detectionStrategy: preset.detectionStrategy,
461
+ devcontainerConfig,
462
+ });
463
+ },
464
+ catch: (error) => {
465
+ // If it's already a ConfigError or ProcessError, return it
466
+ if (error instanceof ConfigError || error instanceof ProcessError) {
467
+ return error;
468
+ }
469
+ // Otherwise, wrap in ProcessError
470
+ return new ProcessError({
471
+ command: `createSessionWithDevcontainer (${devcontainerConfig.execCommand})`,
472
+ message: error instanceof Error
473
+ ? error.message
474
+ : 'Failed to create session with devcontainer',
475
+ });
476
+ },
326
477
  });
327
478
  }
328
479
  destroy() {
@@ -3,7 +3,9 @@ import { SessionManager } from './sessionManager.js';
3
3
  import { spawn } from 'node-pty';
4
4
  import { EventEmitter } from 'events';
5
5
  import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
6
- vi.mock('node-pty');
6
+ vi.mock('node-pty', () => ({
7
+ spawn: vi.fn(),
8
+ }));
7
9
  vi.mock('./configurationManager.js', () => ({
8
10
  configurationManager: {
9
11
  getConfig: vi.fn().mockReturnValue({
@@ -72,7 +74,8 @@ describe('SessionManager - State Persistence', () => {
72
74
  vi.clearAllMocks();
73
75
  });
74
76
  it('should not change state immediately when detected state changes', async () => {
75
- const session = await sessionManager.createSessionWithPreset('/test/path');
77
+ const { Effect } = await import('effect');
78
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
76
79
  const eventEmitter = eventEmitters.get('/test/path');
77
80
  // Initial state should be busy
78
81
  expect(session.state).toBe('busy');
@@ -86,7 +89,8 @@ describe('SessionManager - State Persistence', () => {
86
89
  expect(session.pendingStateStart).toBeDefined();
87
90
  });
88
91
  it('should change state after persistence duration is met', async () => {
89
- const session = await sessionManager.createSessionWithPreset('/test/path');
92
+ const { Effect } = await import('effect');
93
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
90
94
  const eventEmitter = eventEmitters.get('/test/path');
91
95
  const stateChangeHandler = vi.fn();
92
96
  sessionManager.on('sessionStateChanged', stateChangeHandler);
@@ -107,7 +111,8 @@ describe('SessionManager - State Persistence', () => {
107
111
  expect(stateChangeHandler).toHaveBeenCalledWith(session);
108
112
  });
109
113
  it('should cancel pending state if detected state changes again before persistence', async () => {
110
- const session = await sessionManager.createSessionWithPreset('/test/path');
114
+ const { Effect } = await import('effect');
115
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
111
116
  const eventEmitter = eventEmitters.get('/test/path');
112
117
  // Initial state should be busy
113
118
  expect(session.state).toBe('busy');
@@ -125,7 +130,8 @@ describe('SessionManager - State Persistence', () => {
125
130
  expect(session.pendingState).toBe('waiting_input');
126
131
  });
127
132
  it('should clear pending state if detected state returns to current state', async () => {
128
- const session = await sessionManager.createSessionWithPreset('/test/path');
133
+ const { Effect } = await import('effect');
134
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
129
135
  const eventEmitter = eventEmitters.get('/test/path');
130
136
  // Initial state should be busy
131
137
  expect(session.state).toBe('busy');
@@ -145,7 +151,8 @@ describe('SessionManager - State Persistence', () => {
145
151
  expect(session.pendingStateStart).toBeUndefined();
146
152
  });
147
153
  it('should not confirm state changes that do not persist long enough', async () => {
148
- const session = await sessionManager.createSessionWithPreset('/test/path');
154
+ const { Effect } = await import('effect');
155
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
149
156
  const eventEmitter = eventEmitters.get('/test/path');
150
157
  const stateChangeHandler = vi.fn();
151
158
  sessionManager.on('sessionStateChanged', stateChangeHandler);
@@ -170,7 +177,8 @@ describe('SessionManager - State Persistence', () => {
170
177
  expect(stateChangeHandler).not.toHaveBeenCalled();
171
178
  });
172
179
  it('should properly clean up pending state when session is destroyed', async () => {
173
- const session = await sessionManager.createSessionWithPreset('/test/path');
180
+ const { Effect } = await import('effect');
181
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
174
182
  const eventEmitter = eventEmitters.get('/test/path');
175
183
  // Simulate output that would trigger idle state
176
184
  eventEmitter.emit('data', 'Some output without busy indicators');
@@ -185,8 +193,9 @@ describe('SessionManager - State Persistence', () => {
185
193
  expect(destroyedSession).toBeUndefined();
186
194
  });
187
195
  it('should handle multiple sessions with independent state persistence', async () => {
188
- const session1 = await sessionManager.createSessionWithPreset('/test/path1');
189
- const session2 = await sessionManager.createSessionWithPreset('/test/path2');
196
+ const { Effect } = await import('effect');
197
+ const session1 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path1'));
198
+ const session2 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path2'));
190
199
  const eventEmitter1 = eventEmitters.get('/test/path1');
191
200
  const eventEmitter2 = eventEmitters.get('/test/path2');
192
201
  // Both should start as busy