ccmanager 2.8.0 → 2.9.1

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 (82) 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/components/Session.js +11 -6
  26. package/dist/hooks/useGitStatus.d.ts +11 -0
  27. package/dist/hooks/useGitStatus.js +70 -12
  28. package/dist/hooks/useGitStatus.test.js +30 -23
  29. package/dist/services/configurationManager.d.ts +75 -0
  30. package/dist/services/configurationManager.effect.test.d.ts +1 -0
  31. package/dist/services/configurationManager.effect.test.js +407 -0
  32. package/dist/services/configurationManager.js +246 -0
  33. package/dist/services/globalSessionOrchestrator.test.js +0 -8
  34. package/dist/services/projectManager.d.ts +98 -2
  35. package/dist/services/projectManager.js +228 -59
  36. package/dist/services/projectManager.test.js +242 -2
  37. package/dist/services/sessionManager.d.ts +44 -2
  38. package/dist/services/sessionManager.effect.test.d.ts +1 -0
  39. package/dist/services/sessionManager.effect.test.js +321 -0
  40. package/dist/services/sessionManager.js +216 -65
  41. package/dist/services/sessionManager.statePersistence.test.js +18 -9
  42. package/dist/services/sessionManager.test.js +40 -36
  43. package/dist/services/shortcutManager.d.ts +2 -0
  44. package/dist/services/shortcutManager.js +53 -0
  45. package/dist/services/shortcutManager.test.d.ts +1 -0
  46. package/dist/services/shortcutManager.test.js +30 -0
  47. package/dist/services/worktreeService.d.ts +356 -26
  48. package/dist/services/worktreeService.js +793 -353
  49. package/dist/services/worktreeService.test.js +294 -313
  50. package/dist/types/errors.d.ts +74 -0
  51. package/dist/types/errors.js +31 -0
  52. package/dist/types/errors.test.d.ts +1 -0
  53. package/dist/types/errors.test.js +201 -0
  54. package/dist/types/index.d.ts +5 -17
  55. package/dist/utils/claudeDir.d.ts +58 -6
  56. package/dist/utils/claudeDir.js +103 -8
  57. package/dist/utils/claudeDir.test.d.ts +1 -0
  58. package/dist/utils/claudeDir.test.js +108 -0
  59. package/dist/utils/concurrencyLimit.d.ts +5 -0
  60. package/dist/utils/concurrencyLimit.js +11 -0
  61. package/dist/utils/concurrencyLimit.test.js +40 -1
  62. package/dist/utils/gitStatus.d.ts +36 -8
  63. package/dist/utils/gitStatus.js +170 -88
  64. package/dist/utils/gitStatus.test.js +12 -9
  65. package/dist/utils/hookExecutor.d.ts +41 -6
  66. package/dist/utils/hookExecutor.js +75 -32
  67. package/dist/utils/hookExecutor.test.js +73 -20
  68. package/dist/utils/terminalCapabilities.d.ts +18 -0
  69. package/dist/utils/terminalCapabilities.js +81 -0
  70. package/dist/utils/terminalCapabilities.test.d.ts +1 -0
  71. package/dist/utils/terminalCapabilities.test.js +104 -0
  72. package/dist/utils/testHelpers.d.ts +106 -0
  73. package/dist/utils/testHelpers.js +153 -0
  74. package/dist/utils/testHelpers.test.d.ts +1 -0
  75. package/dist/utils/testHelpers.test.js +114 -0
  76. package/dist/utils/worktreeConfig.d.ts +77 -2
  77. package/dist/utils/worktreeConfig.js +156 -16
  78. package/dist/utils/worktreeConfig.test.d.ts +1 -0
  79. package/dist/utils/worktreeConfig.test.js +39 -0
  80. package/package.json +4 -4
  81. package/dist/integration-tests/devcontainer.integration.test.js +0 -101
  82. /package/dist/{integration-tests/devcontainer.integration.test.d.ts → components/App.test.d.ts} +0 -0
@@ -4,6 +4,8 @@ import { promises as fs } from 'fs';
4
4
  import path from 'path';
5
5
  import { homedir } from 'os';
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
7
+ import { Effect } from 'effect';
8
+ import { FileSystemError, ConfigError } from '../types/errors.js';
7
9
  export class ProjectManager {
8
10
  constructor() {
9
11
  Object.defineProperty(this, "currentMode", {
@@ -111,24 +113,6 @@ export class ProjectManager {
111
113
  this.worktreeServiceCache.set(path, service);
112
114
  return service;
113
115
  }
114
- async refreshProjects() {
115
- if (!this.projectsDir) {
116
- throw new Error('Projects directory not configured');
117
- }
118
- // Discover projects
119
- this.projects = await this.discoverProjects(this.projectsDir);
120
- // Update current project if it still exists
121
- if (this.currentProject) {
122
- const updatedProject = this.projects.find(p => p.path === this.currentProject.path);
123
- if (updatedProject) {
124
- this.currentProject = updatedProject;
125
- }
126
- else {
127
- // Current project no longer exists
128
- this.currentProject = undefined;
129
- }
130
- }
131
- }
132
116
  // Helper methods
133
117
  isMultiProjectEnabled() {
134
118
  return !!process.env[ENV_VARS.MULTI_PROJECT_ROOT];
@@ -209,47 +193,6 @@ export class ProjectManager {
209
193
  this.saveRecentProjects();
210
194
  }
211
195
  // Multi-project discovery methods
212
- async discoverProjects(projectsDir) {
213
- const projects = [];
214
- const projectMap = new Map();
215
- try {
216
- // Verify the directory exists
217
- await fs.access(projectsDir);
218
- // Step 1: Fast concurrent directory discovery
219
- const directories = await this.discoverDirectories(projectsDir);
220
- // Step 2: Process directories in parallel to check if they're git repos
221
- const results = await this.processDirectoriesInParallel(directories, projectsDir);
222
- // Step 3: Create project objects (all results are valid git repos)
223
- for (const result of results) {
224
- // Handle name conflicts
225
- let displayName = result.name;
226
- if (projectMap.has(result.name)) {
227
- displayName = result.relativePath.replace(/[\\/\\\\]/g, '/');
228
- }
229
- const project = {
230
- name: displayName,
231
- path: result.path,
232
- relativePath: result.relativePath,
233
- isValid: true,
234
- error: result.error,
235
- };
236
- projectMap.set(displayName, project);
237
- }
238
- // Convert to array and sort
239
- projects.push(...projectMap.values());
240
- projects.sort((a, b) => a.name.localeCompare(b.name));
241
- // Cache results
242
- this.projectCache.clear();
243
- projects.forEach(p => this.projectCache.set(p.path, p));
244
- return projects;
245
- }
246
- catch (error) {
247
- if (error.code === 'ENOENT') {
248
- throw new Error(`Projects directory does not exist: ${projectsDir}`);
249
- }
250
- throw error;
251
- }
252
- }
253
196
  /**
254
197
  * Fast directory discovery - similar to ghq's approach
255
198
  */
@@ -384,6 +327,232 @@ export class ProjectManager {
384
327
  this.projectCache.set(projectPath, project);
385
328
  return project;
386
329
  }
330
+ // Effect-based API methods
331
+ /**
332
+ * Discover Git projects in the specified directory using Effect
333
+ *
334
+ * Recursively scans the directory for Git repositories with parallel processing.
335
+ * Caches results for improved performance.
336
+ *
337
+ * @param {string} projectsDir - Root directory to search for Git projects
338
+ * @returns {Effect.Effect<GitProject[], FileSystemError, never>} Effect containing discovered projects or FileSystemError
339
+ *
340
+ * @example
341
+ * ```typescript
342
+ * import {Effect} from 'effect';
343
+ * import {projectManager} from './services/projectManager.js';
344
+ *
345
+ * // Discover projects with error handling
346
+ * const projects = await Effect.runPromise(
347
+ * Effect.catchAll(
348
+ * projectManager.instance.discoverProjectsEffect('/home/user/projects'),
349
+ * (error) => {
350
+ * console.error(`Discovery failed: ${error.cause}`);
351
+ * return Effect.succeed([]); // Return empty array on error
352
+ * }
353
+ * )
354
+ * );
355
+ *
356
+ * console.log(`Found ${projects.length} git repositories`);
357
+ * ```
358
+ */
359
+ discoverProjectsEffect(projectsDir) {
360
+ return Effect.tryPromise({
361
+ try: async () => {
362
+ // Verify the directory exists
363
+ await fs.access(projectsDir);
364
+ // Step 1: Fast concurrent directory discovery
365
+ const directories = await this.discoverDirectories(projectsDir);
366
+ // Step 2: Process directories in parallel to check if they're git repos
367
+ const results = await this.processDirectoriesInParallel(directories, projectsDir);
368
+ // Step 3: Create project objects
369
+ const projects = [];
370
+ const projectMap = new Map();
371
+ for (const result of results) {
372
+ // Handle name conflicts
373
+ let displayName = result.name;
374
+ if (projectMap.has(result.name)) {
375
+ displayName = result.relativePath.replace(/[\\/\\\\]/g, '/');
376
+ }
377
+ const project = {
378
+ name: displayName,
379
+ path: result.path,
380
+ relativePath: result.relativePath,
381
+ isValid: true,
382
+ error: result.error,
383
+ };
384
+ projectMap.set(displayName, project);
385
+ }
386
+ // Convert to array and sort
387
+ projects.push(...projectMap.values());
388
+ projects.sort((a, b) => a.name.localeCompare(b.name));
389
+ // Cache results
390
+ this.projectCache.clear();
391
+ projects.forEach(p => this.projectCache.set(p.path, p));
392
+ return projects;
393
+ },
394
+ catch: error => {
395
+ if (error instanceof FileSystemError) {
396
+ return error;
397
+ }
398
+ const nodeError = error;
399
+ const cause = nodeError.code === 'ENOENT'
400
+ ? `Projects directory does not exist: ${projectsDir}`
401
+ : String(error);
402
+ return new FileSystemError({
403
+ operation: 'read',
404
+ path: projectsDir,
405
+ cause,
406
+ });
407
+ },
408
+ });
409
+ }
410
+ /**
411
+ * Load recent projects from cache using Effect
412
+ *
413
+ * Reads and parses the recent projects JSON file. Returns empty array if file doesn't exist.
414
+ *
415
+ * @returns {Effect.Effect<RecentProject[], FileSystemError | ConfigError, never>} Effect containing recent projects or error
416
+ *
417
+ * @example
418
+ * ```typescript
419
+ * import {Effect} from 'effect';
420
+ * import {projectManager} from './services/projectManager.js';
421
+ *
422
+ * // Load recent projects with error handling
423
+ * const recent = await Effect.runPromise(
424
+ * Effect.match(
425
+ * projectManager.instance.loadRecentProjectsEffect(),
426
+ * {
427
+ * onFailure: (error) => {
428
+ * if (error._tag === 'ConfigError') {
429
+ * console.error(`Parse error: ${error.details}`);
430
+ * } else {
431
+ * console.error(`File error: ${error.cause}`);
432
+ * }
433
+ * return [];
434
+ * },
435
+ * onSuccess: (projects) => projects
436
+ * }
437
+ * )
438
+ * );
439
+ * ```
440
+ */
441
+ loadRecentProjectsEffect() {
442
+ return Effect.try({
443
+ try: () => {
444
+ if (existsSync(this.dataPath)) {
445
+ const data = readFileSync(this.dataPath, 'utf-8');
446
+ try {
447
+ const parsed = JSON.parse(data);
448
+ return parsed || [];
449
+ }
450
+ catch (parseError) {
451
+ throw new ConfigError({
452
+ configPath: this.dataPath,
453
+ reason: 'parse',
454
+ details: String(parseError),
455
+ });
456
+ }
457
+ }
458
+ return [];
459
+ },
460
+ catch: error => {
461
+ if (error instanceof ConfigError) {
462
+ return error;
463
+ }
464
+ return new FileSystemError({
465
+ operation: 'read',
466
+ path: this.dataPath,
467
+ cause: String(error),
468
+ });
469
+ },
470
+ });
471
+ }
472
+ /**
473
+ * Save recent projects to cache using Effect
474
+ *
475
+ * Writes the recent projects array to JSON file.
476
+ *
477
+ * @param {RecentProject[]} projects - Recent projects to save
478
+ * @returns {Effect.Effect<void, FileSystemError, never>} Effect that succeeds or fails with FileSystemError
479
+ *
480
+ * @example
481
+ * ```typescript
482
+ * import {Effect} from 'effect';
483
+ * import {projectManager} from './services/projectManager.js';
484
+ *
485
+ * const recentProjects = [
486
+ * { path: '/home/user/project1', name: 'project1', lastAccessed: Date.now() }
487
+ * ];
488
+ *
489
+ * // Save with error recovery
490
+ * await Effect.runPromise(
491
+ * Effect.catchAll(
492
+ * projectManager.instance.saveRecentProjectsEffect(recentProjects),
493
+ * (error) => {
494
+ * console.error(`Failed to save: ${error.cause}`);
495
+ * return Effect.void; // Continue despite error
496
+ * }
497
+ * )
498
+ * );
499
+ * ```
500
+ */
501
+ saveRecentProjectsEffect(projects) {
502
+ return Effect.try({
503
+ try: () => {
504
+ writeFileSync(this.dataPath, JSON.stringify(projects, null, 2));
505
+ },
506
+ catch: error => {
507
+ return new FileSystemError({
508
+ operation: 'write',
509
+ path: this.dataPath,
510
+ cause: String(error),
511
+ });
512
+ },
513
+ });
514
+ }
515
+ /**
516
+ * Refresh projects list (Effect version)
517
+ * @returns Effect with void or FileSystemError
518
+ */
519
+ refreshProjectsEffect() {
520
+ return Effect.flatMap(Effect.try({
521
+ try: () => {
522
+ if (!this.projectsDir) {
523
+ throw new FileSystemError({
524
+ operation: 'read',
525
+ path: '',
526
+ cause: 'Projects directory not configured',
527
+ });
528
+ }
529
+ return this.projectsDir;
530
+ },
531
+ catch: error => {
532
+ if (error instanceof FileSystemError) {
533
+ return error;
534
+ }
535
+ return new FileSystemError({
536
+ operation: 'read',
537
+ path: '',
538
+ cause: String(error),
539
+ });
540
+ },
541
+ }), projectsDir => Effect.flatMap(this.discoverProjectsEffect(projectsDir), projects => Effect.sync(() => {
542
+ this.projects = projects;
543
+ // Update current project if it still exists
544
+ if (this.currentProject) {
545
+ const updatedProject = this.projects.find(p => p.path === this.currentProject.path);
546
+ if (updatedProject) {
547
+ this.currentProject = updatedProject;
548
+ }
549
+ else {
550
+ // Current project no longer exists
551
+ this.currentProject = undefined;
552
+ }
553
+ }
554
+ })));
555
+ }
387
556
  }
388
557
  // Recent projects
389
558
  Object.defineProperty(ProjectManager, "MAX_RECENT_PROJECTS", {
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
+ import { Effect, Either } from 'effect';
4
5
  // Mock modules before any other imports that might use them
5
6
  vi.mock('fs');
6
7
  vi.mock('os', () => ({
@@ -10,6 +11,7 @@ vi.mock('os', () => ({
10
11
  // Now import modules that depend on the mocked modules
11
12
  import { ProjectManager } from './projectManager.js';
12
13
  import { ENV_VARS } from '../constants/env.js';
14
+ import { FileSystemError, ConfigError } from '../types/errors.js';
13
15
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
16
  const mockFs = fs;
15
17
  describe('ProjectManager', () => {
@@ -78,7 +80,7 @@ describe('ProjectManager', () => {
78
80
  throw new Error('Not found');
79
81
  }),
80
82
  };
81
- await projectManager.refreshProjects();
83
+ await Effect.runPromise(projectManager.refreshProjectsEffect());
82
84
  expect(projectManager.projects).toHaveLength(2);
83
85
  expect(projectManager.projects[0]).toMatchObject({
84
86
  name: 'project1',
@@ -95,7 +97,15 @@ describe('ProjectManager', () => {
95
97
  mockFs.promises = {
96
98
  access: vi.fn().mockRejectedValue({ code: 'ENOENT' }),
97
99
  };
98
- await expect(projectManager.refreshProjects()).rejects.toThrow(`Projects directory does not exist: ${mockProjectsDir}`);
100
+ const result = await Effect.runPromise(Effect.either(projectManager.refreshProjectsEffect()));
101
+ expect(Either.isLeft(result)).toBe(true);
102
+ if (Either.isLeft(result)) {
103
+ const error = result.left;
104
+ expect(error._tag).toBe('FileSystemError');
105
+ if (error._tag === 'FileSystemError') {
106
+ expect(error.cause).toContain(`Projects directory does not exist: ${mockProjectsDir}`);
107
+ }
108
+ }
99
109
  });
100
110
  });
101
111
  describe('recent projects', () => {
@@ -338,4 +348,234 @@ describe('ProjectManager', () => {
338
348
  expect(isValid).toBe(false);
339
349
  });
340
350
  });
351
+ describe('Effect-based API', () => {
352
+ beforeEach(() => {
353
+ process.env[ENV_VARS.MULTI_PROJECT_ROOT] = mockProjectsDir;
354
+ projectManager = new ProjectManager();
355
+ });
356
+ describe('discoverProjectsEffect', () => {
357
+ it('should return Effect with projects on success', async () => {
358
+ // Mock file system for project discovery
359
+ mockFs.promises = {
360
+ access: vi.fn().mockResolvedValue(undefined),
361
+ readdir: vi.fn().mockImplementation((dir) => {
362
+ if (dir === mockProjectsDir) {
363
+ return Promise.resolve([
364
+ { name: 'project1', isDirectory: () => true },
365
+ { name: 'project2', isDirectory: () => true },
366
+ ]);
367
+ }
368
+ return Promise.resolve([]);
369
+ }),
370
+ stat: vi.fn().mockImplementation((path) => {
371
+ if (path.endsWith('.git')) {
372
+ return Promise.resolve({
373
+ isDirectory: () => true,
374
+ isFile: () => false,
375
+ });
376
+ }
377
+ throw new Error('Not found');
378
+ }),
379
+ };
380
+ const effect = projectManager.discoverProjectsEffect(mockProjectsDir);
381
+ const projects = await Effect.runPromise(effect);
382
+ expect(projects).toHaveLength(2);
383
+ expect(projects[0]).toMatchObject({
384
+ name: 'project1',
385
+ isValid: true,
386
+ });
387
+ });
388
+ it('should return Effect with FileSystemError when directory does not exist', async () => {
389
+ mockFs.promises = {
390
+ access: vi.fn().mockRejectedValue({ code: 'ENOENT' }),
391
+ };
392
+ const effect = projectManager.discoverProjectsEffect(mockProjectsDir);
393
+ const result = await Effect.runPromise(Effect.either(effect));
394
+ expect(Either.isLeft(result)).toBe(true);
395
+ if (Either.isLeft(result)) {
396
+ const error = result.left;
397
+ expect(error._tag).toBe('FileSystemError');
398
+ expect(error).toBeInstanceOf(FileSystemError);
399
+ expect(error.operation).toBe('read');
400
+ expect(error.path).toBe(mockProjectsDir);
401
+ }
402
+ });
403
+ it('should use Effect.all for parallel project scanning', async () => {
404
+ mockFs.promises = {
405
+ access: vi.fn().mockResolvedValue(undefined),
406
+ readdir: vi.fn().mockImplementation(() => {
407
+ return Promise.resolve([
408
+ { name: 'project1', isDirectory: () => true },
409
+ { name: 'project2', isDirectory: () => true },
410
+ { name: 'project3', isDirectory: () => true },
411
+ ]);
412
+ }),
413
+ stat: vi.fn().mockImplementation((path) => {
414
+ if (path.endsWith('.git')) {
415
+ return Promise.resolve({
416
+ isDirectory: () => true,
417
+ isFile: () => false,
418
+ });
419
+ }
420
+ throw new Error('Not found');
421
+ }),
422
+ };
423
+ const effect = projectManager.discoverProjectsEffect(mockProjectsDir);
424
+ const projects = await Effect.runPromise(effect);
425
+ expect(projects).toHaveLength(3);
426
+ });
427
+ });
428
+ describe('loadRecentProjectsEffect', () => {
429
+ it('should return Effect with recent projects on success', async () => {
430
+ const mockRecentProjects = [
431
+ {
432
+ path: '/path/to/project1',
433
+ name: 'project1',
434
+ lastAccessed: Date.now(),
435
+ },
436
+ {
437
+ path: '/path/to/project2',
438
+ name: 'project2',
439
+ lastAccessed: Date.now() - 1000,
440
+ },
441
+ ];
442
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(mockRecentProjects));
443
+ mockFs.existsSync.mockReturnValue(true);
444
+ // Re-create to load recent projects
445
+ projectManager = new ProjectManager();
446
+ const effect = projectManager.loadRecentProjectsEffect();
447
+ const projects = await Effect.runPromise(effect);
448
+ expect(projects).toHaveLength(2);
449
+ expect(projects[0]?.name).toBe('project1');
450
+ });
451
+ it('should return Effect with FileSystemError when file read fails', async () => {
452
+ mockFs.existsSync.mockReturnValue(true);
453
+ mockFs.readFileSync.mockImplementation(() => {
454
+ throw new Error('Permission denied');
455
+ });
456
+ projectManager = new ProjectManager();
457
+ const effect = projectManager.loadRecentProjectsEffect();
458
+ const result = await Effect.runPromise(Effect.either(effect));
459
+ expect(Either.isLeft(result)).toBe(true);
460
+ if (Either.isLeft(result)) {
461
+ const error = result.left;
462
+ expect(error._tag).toBe('FileSystemError');
463
+ expect(error).toBeInstanceOf(FileSystemError);
464
+ if (error._tag === 'FileSystemError') {
465
+ expect(error.operation).toBe('read');
466
+ }
467
+ }
468
+ });
469
+ it('should return Effect with ConfigError when JSON parse fails', async () => {
470
+ mockFs.existsSync.mockReturnValue(true);
471
+ mockFs.readFileSync.mockReturnValue('invalid json{');
472
+ projectManager = new ProjectManager();
473
+ const effect = projectManager.loadRecentProjectsEffect();
474
+ const result = await Effect.runPromise(Effect.either(effect));
475
+ expect(Either.isLeft(result)).toBe(true);
476
+ if (Either.isLeft(result)) {
477
+ const error = result.left;
478
+ expect(error._tag).toBe('ConfigError');
479
+ expect(error).toBeInstanceOf(ConfigError);
480
+ if (error._tag === 'ConfigError') {
481
+ expect(error.reason).toBe('parse');
482
+ }
483
+ }
484
+ });
485
+ it('should use Effect.catchAll for fallback to empty cache on error', async () => {
486
+ mockFs.existsSync.mockReturnValue(false);
487
+ projectManager = new ProjectManager();
488
+ const effect = projectManager.loadRecentProjectsEffect();
489
+ const projects = await Effect.runPromise(Effect.catchAll(effect, () => Effect.succeed([])));
490
+ expect(projects).toEqual([]);
491
+ });
492
+ });
493
+ describe('saveRecentProjectsEffect', () => {
494
+ it('should return Effect with void on success', async () => {
495
+ mockFs.writeFileSync.mockImplementation(() => { });
496
+ projectManager = new ProjectManager();
497
+ const projects = [
498
+ {
499
+ path: '/path/to/project1',
500
+ name: 'project1',
501
+ lastAccessed: Date.now(),
502
+ },
503
+ ];
504
+ const effect = projectManager.saveRecentProjectsEffect(projects);
505
+ await Effect.runPromise(effect);
506
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(mockRecentProjectsPath, expect.any(String));
507
+ });
508
+ it('should return Effect with FileSystemError when write fails', async () => {
509
+ mockFs.writeFileSync.mockImplementation(() => {
510
+ throw new Error('Disk full');
511
+ });
512
+ projectManager = new ProjectManager();
513
+ const projects = [
514
+ {
515
+ path: '/path/to/project1',
516
+ name: 'project1',
517
+ lastAccessed: Date.now(),
518
+ },
519
+ ];
520
+ const effect = projectManager.saveRecentProjectsEffect(projects);
521
+ const result = await Effect.runPromise(Effect.either(effect));
522
+ expect(Either.isLeft(result)).toBe(true);
523
+ if (Either.isLeft(result)) {
524
+ const error = result.left;
525
+ expect(error._tag).toBe('FileSystemError');
526
+ expect(error).toBeInstanceOf(FileSystemError);
527
+ expect(error.operation).toBe('write');
528
+ }
529
+ });
530
+ });
531
+ describe('refreshProjectsEffect', () => {
532
+ it('should return Effect with void on success', async () => {
533
+ mockFs.promises = {
534
+ access: vi.fn().mockResolvedValue(undefined),
535
+ readdir: vi.fn().mockImplementation(() => {
536
+ return Promise.resolve([
537
+ { name: 'project1', isDirectory: () => true },
538
+ ]);
539
+ }),
540
+ stat: vi.fn().mockImplementation((path) => {
541
+ if (path.endsWith('.git')) {
542
+ return Promise.resolve({
543
+ isDirectory: () => true,
544
+ isFile: () => false,
545
+ });
546
+ }
547
+ throw new Error('Not found');
548
+ }),
549
+ };
550
+ const effect = projectManager.refreshProjectsEffect();
551
+ await Effect.runPromise(effect);
552
+ expect(projectManager.projects).toHaveLength(1);
553
+ });
554
+ it('should return Effect with FileSystemError when projects directory not configured', async () => {
555
+ // Create without multi-project root
556
+ delete process.env[ENV_VARS.MULTI_PROJECT_ROOT];
557
+ projectManager = new ProjectManager();
558
+ const effect = projectManager.refreshProjectsEffect();
559
+ const result = await Effect.runPromise(Effect.either(effect));
560
+ expect(Either.isLeft(result)).toBe(true);
561
+ if (Either.isLeft(result)) {
562
+ const error = result.left;
563
+ expect(error._tag).toBe('FileSystemError');
564
+ expect(error).toBeInstanceOf(FileSystemError);
565
+ }
566
+ });
567
+ it('should return Effect with FileSystemError or GitError on discovery failure', async () => {
568
+ mockFs.promises = {
569
+ access: vi.fn().mockRejectedValue({ code: 'ENOENT' }),
570
+ };
571
+ const effect = projectManager.refreshProjectsEffect();
572
+ const result = await Effect.runPromise(Effect.either(effect));
573
+ expect(Either.isLeft(result)).toBe(true);
574
+ if (Either.isLeft(result)) {
575
+ const error = result.left;
576
+ expect(['FileSystemError', 'GitError']).toContain(error._tag);
577
+ }
578
+ });
579
+ });
580
+ });
341
581
  });
@@ -1,5 +1,7 @@
1
1
  import { Session, SessionManager as ISessionManager, SessionState, DevcontainerConfig } from '../types/index.js';
2
2
  import { EventEmitter } from 'events';
3
+ import { Effect } from 'effect';
4
+ import { ProcessError, ConfigError } from '../types/errors.js';
3
5
  export interface SessionCounts {
4
6
  idle: number;
5
7
  busy: number;
@@ -16,7 +18,25 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
16
18
  private createSessionId;
17
19
  private createTerminal;
18
20
  private createSessionInternal;
19
- createSessionWithPreset(worktreePath: string, presetId?: string): Promise<Session>;
21
+ /**
22
+ * Create session with command preset using Effect-based error handling
23
+ *
24
+ * @param {string} worktreePath - Path to the worktree
25
+ * @param {string} [presetId] - Optional preset ID, uses default if not provided
26
+ * @returns {Effect.Effect<Session, ProcessError | ConfigError, never>} Effect that may fail with ProcessError (spawn failure) or ConfigError (invalid preset)
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * // Use Effect.match for type-safe error handling
31
+ * const result = await Effect.runPromise(
32
+ * Effect.match(effect, {
33
+ * onFailure: (error) => ({ type: 'error', message: error.message }),
34
+ * onSuccess: (session) => ({ type: 'success', data: session })
35
+ * })
36
+ * );
37
+ * ```
38
+ */
39
+ createSessionWithPresetEffect(worktreePath: string, presetId?: string): Effect.Effect<Session, ProcessError | ConfigError, never>;
20
40
  private setupDataHandler;
21
41
  /**
22
42
  * Sets up exit handler for the session process.
@@ -31,8 +51,30 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
31
51
  getSession(worktreePath: string): Session | undefined;
32
52
  setSessionActive(worktreePath: string, active: boolean): void;
33
53
  destroySession(worktreePath: string): void;
54
+ /**
55
+ * Terminate session and cleanup resources using Effect-based error handling
56
+ *
57
+ * @param {string} worktreePath - Path to the worktree
58
+ * @returns {Effect.Effect<void, ProcessError, never>} Effect that may fail with ProcessError if session does not exist or cleanup fails
59
+ *
60
+ * @example
61
+ * ```typescript
62
+ * // Terminate session with error handling
63
+ * const result = await Effect.runPromise(
64
+ * Effect.match(effect, {
65
+ * onFailure: (error) => ({ type: 'error', message: error.message }),
66
+ * onSuccess: () => ({ type: 'success' })
67
+ * })
68
+ * );
69
+ * ```
70
+ */
71
+ terminateSessionEffect(worktreePath: string): Effect.Effect<void, ProcessError, never>;
34
72
  getAllSessions(): Session[];
35
- createSessionWithDevcontainer(worktreePath: string, devcontainerConfig: DevcontainerConfig, presetId?: string): Promise<Session>;
73
+ /**
74
+ * Create session with devcontainer integration using Effect-based error handling
75
+ * @returns Effect that may fail with ProcessError (container/spawn failure) or ConfigError (invalid preset)
76
+ */
77
+ createSessionWithDevcontainerEffect(worktreePath: string, devcontainerConfig: DevcontainerConfig, presetId?: string): Effect.Effect<Session, ProcessError | ConfigError, never>;
36
78
  destroy(): void;
37
79
  static getSessionCounts(sessions: Session[]): SessionCounts;
38
80
  static formatSessionCounts(counts: SessionCounts): string;
@@ -0,0 +1 @@
1
+ export {};