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.
- 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/components/Session.js +11 -6
- 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/shortcutManager.d.ts +2 -0
- package/dist/services/shortcutManager.js +53 -0
- package/dist/services/shortcutManager.test.d.ts +1 -0
- package/dist/services/shortcutManager.test.js +30 -0
- 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
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {};
|