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.
- package/dist/cli.test.js +13 -2
- package/dist/components/App.js +125 -50
- package/dist/components/App.test.js +270 -0
- package/dist/components/ConfigureShortcuts.js +82 -8
- package/dist/components/DeleteWorktree.js +39 -5
- package/dist/components/DeleteWorktree.test.d.ts +1 -0
- package/dist/components/DeleteWorktree.test.js +128 -0
- package/dist/components/LoadingSpinner.d.ts +8 -0
- package/dist/components/LoadingSpinner.js +37 -0
- package/dist/components/LoadingSpinner.test.d.ts +1 -0
- package/dist/components/LoadingSpinner.test.js +187 -0
- package/dist/components/Menu.js +64 -16
- package/dist/components/Menu.recent-projects.test.js +32 -11
- package/dist/components/Menu.test.js +136 -4
- package/dist/components/MergeWorktree.js +79 -18
- package/dist/components/MergeWorktree.test.d.ts +1 -0
- package/dist/components/MergeWorktree.test.js +227 -0
- package/dist/components/NewWorktree.js +88 -9
- package/dist/components/NewWorktree.test.d.ts +1 -0
- package/dist/components/NewWorktree.test.js +244 -0
- package/dist/components/ProjectList.js +44 -13
- package/dist/components/ProjectList.recent-projects.test.js +8 -3
- package/dist/components/ProjectList.test.js +105 -8
- package/dist/components/RemoteBranchSelector.test.js +3 -1
- package/dist/hooks/useGitStatus.d.ts +11 -0
- package/dist/hooks/useGitStatus.js +70 -12
- package/dist/hooks/useGitStatus.test.js +30 -23
- package/dist/services/configurationManager.d.ts +75 -0
- package/dist/services/configurationManager.effect.test.d.ts +1 -0
- package/dist/services/configurationManager.effect.test.js +407 -0
- package/dist/services/configurationManager.js +246 -0
- package/dist/services/globalSessionOrchestrator.test.js +0 -8
- package/dist/services/projectManager.d.ts +98 -2
- package/dist/services/projectManager.js +228 -59
- package/dist/services/projectManager.test.js +242 -2
- package/dist/services/sessionManager.d.ts +44 -2
- package/dist/services/sessionManager.effect.test.d.ts +1 -0
- package/dist/services/sessionManager.effect.test.js +321 -0
- package/dist/services/sessionManager.js +216 -65
- package/dist/services/sessionManager.statePersistence.test.js +18 -9
- package/dist/services/sessionManager.test.js +40 -36
- package/dist/services/worktreeService.d.ts +356 -26
- package/dist/services/worktreeService.js +793 -353
- package/dist/services/worktreeService.test.js +294 -313
- package/dist/types/errors.d.ts +74 -0
- package/dist/types/errors.js +31 -0
- package/dist/types/errors.test.d.ts +1 -0
- package/dist/types/errors.test.js +201 -0
- package/dist/types/index.d.ts +5 -17
- package/dist/utils/claudeDir.d.ts +58 -6
- package/dist/utils/claudeDir.js +103 -8
- package/dist/utils/claudeDir.test.d.ts +1 -0
- package/dist/utils/claudeDir.test.js +108 -0
- package/dist/utils/concurrencyLimit.d.ts +5 -0
- package/dist/utils/concurrencyLimit.js +11 -0
- package/dist/utils/concurrencyLimit.test.js +40 -1
- package/dist/utils/gitStatus.d.ts +36 -8
- package/dist/utils/gitStatus.js +170 -88
- package/dist/utils/gitStatus.test.js +12 -9
- package/dist/utils/hookExecutor.d.ts +41 -6
- package/dist/utils/hookExecutor.js +75 -32
- package/dist/utils/hookExecutor.test.js +73 -20
- package/dist/utils/terminalCapabilities.d.ts +18 -0
- package/dist/utils/terminalCapabilities.js +81 -0
- package/dist/utils/terminalCapabilities.test.d.ts +1 -0
- package/dist/utils/terminalCapabilities.test.js +104 -0
- package/dist/utils/testHelpers.d.ts +106 -0
- package/dist/utils/testHelpers.js +153 -0
- package/dist/utils/testHelpers.test.d.ts +1 -0
- package/dist/utils/testHelpers.test.js +114 -0
- package/dist/utils/worktreeConfig.d.ts +77 -2
- package/dist/utils/worktreeConfig.js +156 -16
- package/dist/utils/worktreeConfig.test.d.ts +1 -0
- package/dist/utils/worktreeConfig.test.js +39 -0
- package/package.json +4 -4
- package/dist/integration-tests/devcontainer.integration.test.js +0 -101
- /package/dist/{integration-tests/devcontainer.integration.test.d.ts → components/App.test.d.ts} +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { useState, useEffect } from 'react';
|
|
2
2
|
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import { Effect } from 'effect';
|
|
3
4
|
import SelectInput from 'ink-select-input';
|
|
4
5
|
import { projectManager } from '../services/projectManager.js';
|
|
5
6
|
import { MENU_ICONS } from '../constants/statusIcons.js';
|
|
@@ -14,31 +15,61 @@ const ProjectList = ({ projectsDir, onSelectProject, error, onDismissError, }) =
|
|
|
14
15
|
const [loading, setLoading] = useState(true);
|
|
15
16
|
const [loadError, setLoadError] = useState(null);
|
|
16
17
|
const limit = 10;
|
|
18
|
+
// Helper function to format error messages based on error type using _tag discrimination
|
|
19
|
+
const formatErrorMessage = (error) => {
|
|
20
|
+
switch (error._tag) {
|
|
21
|
+
case 'ProcessError':
|
|
22
|
+
return `Process error: ${error.message}`;
|
|
23
|
+
case 'ConfigError':
|
|
24
|
+
return `Configuration error (${error.reason}): ${error.details}`;
|
|
25
|
+
case 'GitError':
|
|
26
|
+
return `Git command failed: ${error.command} (exit ${error.exitCode})\n${error.stderr}`;
|
|
27
|
+
case 'FileSystemError':
|
|
28
|
+
return `File ${error.operation} failed for ${error.path}: ${error.cause}`;
|
|
29
|
+
case 'ValidationError':
|
|
30
|
+
return `Validation failed for ${error.field}: ${error.constraint}`;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
17
33
|
// Use the search mode hook
|
|
18
34
|
const displayError = error || loadError;
|
|
19
35
|
const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(items.length, {
|
|
20
36
|
isDisabled: !!displayError,
|
|
21
37
|
skipInTest: false,
|
|
22
38
|
});
|
|
23
|
-
|
|
39
|
+
// Helper function to load projects with Effect-based error handling
|
|
40
|
+
const loadProjectsEffect = async (checkCancellation) => {
|
|
24
41
|
setLoading(true);
|
|
25
42
|
setLoadError(null);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
43
|
+
// Use Effect-based project discovery
|
|
44
|
+
const projectsEffect = projectManager.instance.discoverProjectsEffect(projectsDir);
|
|
45
|
+
// Execute the Effect and handle both success and failure cases
|
|
46
|
+
const result = await Effect.runPromise(Effect.either(projectsEffect));
|
|
47
|
+
// Check cancellation flag before updating state (if provided)
|
|
48
|
+
if (checkCancellation && checkCancellation())
|
|
49
|
+
return;
|
|
50
|
+
if (result._tag === 'Left') {
|
|
51
|
+
// Handle error using pattern matching on _tag
|
|
52
|
+
const errorMessage = formatErrorMessage(result.left);
|
|
53
|
+
setLoadError(errorMessage);
|
|
37
54
|
setLoading(false);
|
|
55
|
+
return;
|
|
38
56
|
}
|
|
57
|
+
// Success case - extract projects from Right
|
|
58
|
+
const discoveredProjects = result.right;
|
|
59
|
+
setProjects(discoveredProjects);
|
|
60
|
+
// Load recent projects with no limit (pass 0)
|
|
61
|
+
const allRecentProjects = projectManager.getRecentProjects(0);
|
|
62
|
+
setRecentProjects(allRecentProjects);
|
|
63
|
+
setLoading(false);
|
|
39
64
|
};
|
|
65
|
+
const loadProjects = () => loadProjectsEffect();
|
|
40
66
|
useEffect(() => {
|
|
41
|
-
|
|
67
|
+
let cancelled = false;
|
|
68
|
+
loadProjectsEffect(() => cancelled);
|
|
69
|
+
// Cleanup function to set cancellation flag
|
|
70
|
+
return () => {
|
|
71
|
+
cancelled = true;
|
|
72
|
+
};
|
|
42
73
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
43
74
|
}, [projectsDir]);
|
|
44
75
|
useEffect(() => {
|
|
@@ -3,6 +3,11 @@ import { render } from 'ink-testing-library';
|
|
|
3
3
|
import { expect, describe, it, vi, beforeEach, afterEach } from 'vitest';
|
|
4
4
|
import ProjectList from './ProjectList.js';
|
|
5
5
|
import { projectManager } from '../services/projectManager.js';
|
|
6
|
+
import { Effect } from 'effect';
|
|
7
|
+
// Mock node-pty to avoid native module loading issues
|
|
8
|
+
vi.mock('node-pty', () => ({
|
|
9
|
+
spawn: vi.fn(),
|
|
10
|
+
}));
|
|
6
11
|
// Mock ink to avoid stdin.ref issues
|
|
7
12
|
vi.mock('ink', async () => {
|
|
8
13
|
const actual = await vi.importActual('ink');
|
|
@@ -24,7 +29,7 @@ vi.mock('ink-select-input', async () => {
|
|
|
24
29
|
vi.mock('../services/projectManager.js', () => ({
|
|
25
30
|
projectManager: {
|
|
26
31
|
instance: {
|
|
27
|
-
|
|
32
|
+
discoverProjectsEffect: vi.fn(),
|
|
28
33
|
},
|
|
29
34
|
getRecentProjects: vi.fn(),
|
|
30
35
|
},
|
|
@@ -48,7 +53,7 @@ describe('ProjectList - Recent Projects', () => {
|
|
|
48
53
|
];
|
|
49
54
|
beforeEach(() => {
|
|
50
55
|
vi.clearAllMocks();
|
|
51
|
-
vi.mocked(projectManager.instance.
|
|
56
|
+
vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(Effect.succeed(mockProjects));
|
|
52
57
|
vi.mocked(projectManager.getRecentProjects).mockReturnValue([]);
|
|
53
58
|
// Mock stdin.setRawMode
|
|
54
59
|
originalSetRawMode = process.stdin.setRawMode;
|
|
@@ -136,7 +141,7 @@ describe('ProjectList - Recent Projects', () => {
|
|
|
136
141
|
// Create 10 projects
|
|
137
142
|
const manyProjects = Array.from({ length: 10 }, (_, i) => createProject(`project-${i}`, `/home/user/projects/project-${i}`));
|
|
138
143
|
// Mock discovered projects
|
|
139
|
-
vi.mocked(projectManager.instance.
|
|
144
|
+
vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(Effect.succeed(manyProjects));
|
|
140
145
|
// Mock more than 5 recent projects
|
|
141
146
|
const manyRecentProjects = Array.from({ length: 10 }, (_, i) => ({
|
|
142
147
|
name: `project-${i}`,
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { render } from 'ink-testing-library';
|
|
3
3
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
|
+
// Mock node-pty to avoid native module loading issues
|
|
5
|
+
vi.mock('node-pty', () => ({
|
|
6
|
+
spawn: vi.fn(),
|
|
7
|
+
}));
|
|
4
8
|
// Import the actual component code but skip the useInput hook
|
|
5
9
|
vi.mock('ink', async () => {
|
|
6
10
|
const actual = await vi.importActual('ink');
|
|
@@ -29,11 +33,16 @@ vi.mock('./TextInputWrapper.js', async () => {
|
|
|
29
33
|
},
|
|
30
34
|
};
|
|
31
35
|
});
|
|
36
|
+
// Mock Effect for testing
|
|
37
|
+
vi.mock('effect', async () => {
|
|
38
|
+
const actual = await vi.importActual('effect');
|
|
39
|
+
return actual;
|
|
40
|
+
});
|
|
32
41
|
// Mock the projectManager
|
|
33
42
|
vi.mock('../services/projectManager.js', () => ({
|
|
34
43
|
projectManager: {
|
|
35
44
|
instance: {
|
|
36
|
-
|
|
45
|
+
discoverProjectsEffect: vi.fn(),
|
|
37
46
|
},
|
|
38
47
|
getRecentProjects: vi.fn().mockReturnValue([]),
|
|
39
48
|
},
|
|
@@ -41,6 +50,7 @@ vi.mock('../services/projectManager.js', () => ({
|
|
|
41
50
|
// Now import after mocking
|
|
42
51
|
const { default: ProjectList } = await import('./ProjectList.js');
|
|
43
52
|
const { projectManager } = await import('../services/projectManager.js');
|
|
53
|
+
const { Effect } = await import('effect');
|
|
44
54
|
describe('ProjectList', () => {
|
|
45
55
|
const mockOnSelectProject = vi.fn();
|
|
46
56
|
const mockOnDismissError = vi.fn();
|
|
@@ -66,7 +76,7 @@ describe('ProjectList', () => {
|
|
|
66
76
|
];
|
|
67
77
|
beforeEach(() => {
|
|
68
78
|
vi.clearAllMocks();
|
|
69
|
-
vi.mocked(projectManager.instance.
|
|
79
|
+
vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(Effect.succeed(mockProjects));
|
|
70
80
|
});
|
|
71
81
|
it('should render project list with correct title', () => {
|
|
72
82
|
const { lastFrame } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
@@ -74,8 +84,8 @@ describe('ProjectList', () => {
|
|
|
74
84
|
expect(lastFrame()).toContain('Select a project:');
|
|
75
85
|
});
|
|
76
86
|
it('should display loading state initially', () => {
|
|
77
|
-
// Create
|
|
78
|
-
vi.mocked(projectManager.instance.
|
|
87
|
+
// Create an Effect that never completes to keep loading state
|
|
88
|
+
vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(Effect.async(() => { }));
|
|
79
89
|
const { lastFrame } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
80
90
|
expect(lastFrame()).toContain('Loading projects...');
|
|
81
91
|
});
|
|
@@ -164,13 +174,17 @@ describe('ProjectList', () => {
|
|
|
164
174
|
expect(frame).toContain('Refresh');
|
|
165
175
|
});
|
|
166
176
|
it('should show empty state when no projects found', async () => {
|
|
167
|
-
vi.mocked(projectManager.instance.
|
|
177
|
+
vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(Effect.succeed([]));
|
|
168
178
|
const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
179
|
+
// Wait for loading to finish
|
|
180
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
181
|
+
// Force rerender
|
|
182
|
+
rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
169
183
|
// Wait for projects to load
|
|
170
184
|
await vi.waitFor(() => {
|
|
171
|
-
|
|
172
|
-
return
|
|
173
|
-
});
|
|
185
|
+
const frame = lastFrame();
|
|
186
|
+
return frame && !frame.includes('Loading projects...');
|
|
187
|
+
}, { timeout: 2000 });
|
|
174
188
|
expect(lastFrame()).toContain('No git repositories found in /projects');
|
|
175
189
|
});
|
|
176
190
|
it('should display error message when error prop is provided', () => {
|
|
@@ -498,4 +512,87 @@ describe('ProjectList', () => {
|
|
|
498
512
|
process.stdin.setRawMode = originalSetRawMode;
|
|
499
513
|
});
|
|
500
514
|
});
|
|
515
|
+
describe('Effect-based Project Discovery Error Handling', () => {
|
|
516
|
+
it('should handle FileSystemError from discoverProjectsEffect gracefully', async () => {
|
|
517
|
+
const { FileSystemError } = await import('../types/errors.js');
|
|
518
|
+
// Mock discoverProjectsEffect to return a failed Effect with FileSystemError
|
|
519
|
+
const fileSystemError = new FileSystemError({
|
|
520
|
+
operation: 'read',
|
|
521
|
+
path: '/projects',
|
|
522
|
+
cause: 'Directory not accessible',
|
|
523
|
+
});
|
|
524
|
+
vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(Effect.fail(fileSystemError));
|
|
525
|
+
const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
526
|
+
// Wait for loading to finish
|
|
527
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
528
|
+
// Force rerender
|
|
529
|
+
rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
530
|
+
// Wait for projects to attempt loading
|
|
531
|
+
await vi.waitFor(() => {
|
|
532
|
+
const frame = lastFrame();
|
|
533
|
+
return frame && !frame.includes('Loading projects...');
|
|
534
|
+
}, { timeout: 2000 });
|
|
535
|
+
// Should display error message with FileSystemError details
|
|
536
|
+
const frame = lastFrame();
|
|
537
|
+
expect(frame).toContain('Error:');
|
|
538
|
+
});
|
|
539
|
+
it.skip('should handle GitError from project validation failures', async () => {
|
|
540
|
+
const { GitError } = await import('../types/errors.js');
|
|
541
|
+
// Mock discoverProjectsEffect to return a failed Effect with GitError
|
|
542
|
+
const gitError = new GitError({
|
|
543
|
+
command: 'git rev-parse --show-toplevel',
|
|
544
|
+
exitCode: 128,
|
|
545
|
+
stderr: 'Not a git repository',
|
|
546
|
+
});
|
|
547
|
+
vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(
|
|
548
|
+
// @ts-expect-error - Test uses wrong error type (should be FileSystemError)
|
|
549
|
+
Effect.fail(gitError));
|
|
550
|
+
const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
551
|
+
// Wait for projects to attempt loading
|
|
552
|
+
await vi.waitFor(() => {
|
|
553
|
+
rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
554
|
+
return !lastFrame()?.includes('Loading projects...');
|
|
555
|
+
});
|
|
556
|
+
// Should display error message
|
|
557
|
+
const frame = lastFrame();
|
|
558
|
+
expect(frame).toContain('Error:');
|
|
559
|
+
});
|
|
560
|
+
it('should implement cancellation flag for cleanup on unmount', async () => {
|
|
561
|
+
vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(Effect.async(emit => {
|
|
562
|
+
const timeout = setTimeout(() => {
|
|
563
|
+
emit(Effect.succeed(mockProjects));
|
|
564
|
+
}, 500);
|
|
565
|
+
return Effect.sync(() => clearTimeout(timeout));
|
|
566
|
+
}));
|
|
567
|
+
const { unmount, lastFrame } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
568
|
+
// Wait a bit to ensure promise is pending
|
|
569
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
570
|
+
// Component should still be loading
|
|
571
|
+
expect(lastFrame()).toContain('Loading projects...');
|
|
572
|
+
// Unmount before promise resolves
|
|
573
|
+
unmount();
|
|
574
|
+
// Wait for promise to potentially resolve
|
|
575
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
576
|
+
// Component is unmounted, no state updates should occur
|
|
577
|
+
// This test verifies the cancellation flag prevents state updates after unmount
|
|
578
|
+
});
|
|
579
|
+
it('should successfully load projects using Effect execution', async () => {
|
|
580
|
+
vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(Effect.succeed(mockProjects));
|
|
581
|
+
const { lastFrame, rerender } = render(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
582
|
+
// Wait for loading to finish
|
|
583
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
584
|
+
// Force rerender
|
|
585
|
+
rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
|
|
586
|
+
// Wait for projects to load
|
|
587
|
+
await vi.waitFor(() => {
|
|
588
|
+
const frame = lastFrame();
|
|
589
|
+
return frame && frame.includes('project1');
|
|
590
|
+
}, { timeout: 2000 });
|
|
591
|
+
// Should display loaded projects
|
|
592
|
+
const frame = lastFrame();
|
|
593
|
+
expect(frame).toContain('0 ❯ project1');
|
|
594
|
+
expect(frame).toContain('1 ❯ project2');
|
|
595
|
+
expect(frame).toContain('2 ❯ project3');
|
|
596
|
+
});
|
|
597
|
+
});
|
|
501
598
|
});
|
|
@@ -62,7 +62,9 @@ describe('RemoteBranchSelector Component', () => {
|
|
|
62
62
|
const { lastFrame } = render(React.createElement(RemoteBranchSelector, { branchName: mockBranchName, matches: mockMatches, onSelect: onSelect, onCancel: onCancel }));
|
|
63
63
|
const output = lastFrame();
|
|
64
64
|
expect(output).toContain('⚠️ Ambiguous Branch Reference');
|
|
65
|
-
|
|
65
|
+
// The component renders the branch name and checks for the message
|
|
66
|
+
expect(output).toContain('feature/awesome-feature');
|
|
67
|
+
expect(output).toContain('exists in multiple remotes.');
|
|
66
68
|
});
|
|
67
69
|
it('should render all remote branch options', () => {
|
|
68
70
|
const { lastFrame } = render(React.createElement(RemoteBranchSelector, { branchName: mockBranchName, matches: mockMatches, onSelect: onSelect, onCancel: onCancel }));
|
|
@@ -1,2 +1,13 @@
|
|
|
1
1
|
import { Worktree } from '../types/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Custom hook for polling git status of worktrees with Effect-based execution
|
|
4
|
+
*
|
|
5
|
+
* Fetches git status for each worktree at regular intervals using Effect.runPromiseExit
|
|
6
|
+
* and updates worktree state with results. Handles cancellation via AbortController.
|
|
7
|
+
*
|
|
8
|
+
* @param worktrees - Array of worktrees to monitor
|
|
9
|
+
* @param defaultBranch - Default branch for comparisons (null disables polling)
|
|
10
|
+
* @param updateInterval - Polling interval in milliseconds (default: 5000)
|
|
11
|
+
* @returns Array of worktrees with updated gitStatus and gitStatusError fields
|
|
12
|
+
*/
|
|
2
13
|
export declare function useGitStatus(worktrees: Worktree[], defaultBranch: string | null, updateInterval?: number): Worktree[];
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Effect, Exit, Cause, Option } from 'effect';
|
|
2
3
|
import { getGitStatusLimited } from '../utils/gitStatus.js';
|
|
4
|
+
/**
|
|
5
|
+
* Custom hook for polling git status of worktrees with Effect-based execution
|
|
6
|
+
*
|
|
7
|
+
* Fetches git status for each worktree at regular intervals using Effect.runPromiseExit
|
|
8
|
+
* and updates worktree state with results. Handles cancellation via AbortController.
|
|
9
|
+
*
|
|
10
|
+
* @param worktrees - Array of worktrees to monitor
|
|
11
|
+
* @param defaultBranch - Default branch for comparisons (null disables polling)
|
|
12
|
+
* @param updateInterval - Polling interval in milliseconds (default: 5000)
|
|
13
|
+
* @returns Array of worktrees with updated gitStatus and gitStatusError fields
|
|
14
|
+
*/
|
|
3
15
|
export function useGitStatus(worktrees, defaultBranch, updateInterval = 5000) {
|
|
4
16
|
const [worktreesWithStatus, setWorktreesWithStatus] = useState(worktrees);
|
|
5
17
|
useEffect(() => {
|
|
@@ -10,22 +22,21 @@ export function useGitStatus(worktrees, defaultBranch, updateInterval = 5000) {
|
|
|
10
22
|
const activeRequests = new Map();
|
|
11
23
|
let isCleanedUp = false;
|
|
12
24
|
const fetchStatus = async (worktree, abortController) => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
22
|
-
// Ignore errors - the fetch failed or was aborted
|
|
23
|
-
}
|
|
25
|
+
// Execute the Effect to get git status with cancellation support
|
|
26
|
+
const exit = await Effect.runPromiseExit(getGitStatusLimited(worktree.path), {
|
|
27
|
+
signal: abortController.signal,
|
|
28
|
+
});
|
|
29
|
+
// Update worktree state based on exit result
|
|
30
|
+
handleStatusExit(exit, worktree.path, setWorktreesWithStatus);
|
|
24
31
|
};
|
|
25
32
|
const scheduleUpdate = (worktree) => {
|
|
26
33
|
const abortController = new AbortController();
|
|
27
34
|
activeRequests.set(worktree.path, abortController);
|
|
28
|
-
fetchStatus(worktree, abortController)
|
|
35
|
+
fetchStatus(worktree, abortController)
|
|
36
|
+
.catch(() => {
|
|
37
|
+
// Ignore errors - the fetch failed or was aborted
|
|
38
|
+
})
|
|
39
|
+
.finally(() => {
|
|
29
40
|
const isActive = () => !isCleanedUp && !abortController.signal.aborted;
|
|
30
41
|
if (isActive()) {
|
|
31
42
|
const timeout = setTimeout(() => {
|
|
@@ -50,3 +61,50 @@ export function useGitStatus(worktrees, defaultBranch, updateInterval = 5000) {
|
|
|
50
61
|
}, [worktrees, defaultBranch, updateInterval]);
|
|
51
62
|
return worktreesWithStatus;
|
|
52
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Handle the Exit result from Effect.runPromiseExit and update worktree state
|
|
66
|
+
*
|
|
67
|
+
* Uses pattern matching on Exit to distinguish between success, failure, and interruption.
|
|
68
|
+
* Success updates gitStatus, failure updates gitStatusError, interruption is ignored.
|
|
69
|
+
*
|
|
70
|
+
* @param exit - Exit result from Effect execution
|
|
71
|
+
* @param worktreePath - Path of the worktree being updated
|
|
72
|
+
* @param setWorktreesWithStatus - State setter function
|
|
73
|
+
*/
|
|
74
|
+
function handleStatusExit(exit, worktreePath, setWorktreesWithStatus) {
|
|
75
|
+
if (Exit.isSuccess(exit)) {
|
|
76
|
+
// Success: update gitStatus and clear error
|
|
77
|
+
const gitStatus = exit.value;
|
|
78
|
+
setWorktreesWithStatus(prev => prev.map(wt => wt.path === worktreePath
|
|
79
|
+
? { ...wt, gitStatus, gitStatusError: undefined }
|
|
80
|
+
: wt));
|
|
81
|
+
}
|
|
82
|
+
else if (Exit.isFailure(exit)) {
|
|
83
|
+
// Failure: extract error and update gitStatusError
|
|
84
|
+
const failure = Cause.failureOption(exit.cause);
|
|
85
|
+
if (Option.isSome(failure)) {
|
|
86
|
+
const gitError = failure.value;
|
|
87
|
+
const errorMessage = formatGitError(gitError);
|
|
88
|
+
setWorktreesWithStatus(prev => prev.map(wt => wt.path === worktreePath
|
|
89
|
+
? { ...wt, gitStatus: undefined, gitStatusError: errorMessage }
|
|
90
|
+
: wt));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Interruption: no state update - the request was cancelled
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Format GitError into a user-friendly error message
|
|
97
|
+
*
|
|
98
|
+
* @param error - GitError from failed git operation
|
|
99
|
+
* @returns Formatted error message string
|
|
100
|
+
*/
|
|
101
|
+
function formatGitError(error) {
|
|
102
|
+
const exitCode = Number.isFinite(error.exitCode) ? error.exitCode : -1;
|
|
103
|
+
const details = [error.stderr, error.stdout]
|
|
104
|
+
.filter(part => typeof part === 'string' && part.trim().length > 0)
|
|
105
|
+
.map(part => part.trim());
|
|
106
|
+
const detail = details[0] ?? '';
|
|
107
|
+
return detail
|
|
108
|
+
? `git command "${error.command}" failed (exit code ${exitCode}): ${detail}`
|
|
109
|
+
: `git command "${error.command}" failed (exit code ${exitCode})`;
|
|
110
|
+
}
|
|
@@ -2,8 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { render, cleanup } from 'ink-testing-library';
|
|
4
4
|
import { Text } from 'ink';
|
|
5
|
+
import { Effect, Exit } from 'effect';
|
|
5
6
|
import { useGitStatus } from './useGitStatus.js';
|
|
6
7
|
import { getGitStatusLimited } from '../utils/gitStatus.js';
|
|
8
|
+
import { GitError } from '../types/errors.js';
|
|
7
9
|
// Mock the gitStatus module
|
|
8
10
|
vi.mock('../utils/gitStatus.js', () => ({
|
|
9
11
|
getGitStatusLimited: vi.fn(),
|
|
@@ -37,11 +39,11 @@ describe('useGitStatus', () => {
|
|
|
37
39
|
const gitStatus1 = createGitStatus(5, 3);
|
|
38
40
|
const gitStatus2 = createGitStatus(2, 1);
|
|
39
41
|
let hookResult = [];
|
|
40
|
-
mockGetGitStatus.mockImplementation(
|
|
42
|
+
mockGetGitStatus.mockImplementation(path => {
|
|
41
43
|
if (path === '/path1') {
|
|
42
|
-
return
|
|
44
|
+
return Effect.succeed(gitStatus1);
|
|
43
45
|
}
|
|
44
|
-
return
|
|
46
|
+
return Effect.succeed(gitStatus2);
|
|
45
47
|
});
|
|
46
48
|
const TestComponent = () => {
|
|
47
49
|
hookResult = useGitStatus(worktrees, 'main', 100);
|
|
@@ -85,10 +87,12 @@ describe('useGitStatus', () => {
|
|
|
85
87
|
});
|
|
86
88
|
it('should continue polling after errors', async () => {
|
|
87
89
|
const worktrees = [createWorktree('/path1')];
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
const gitError = new GitError({
|
|
91
|
+
command: 'git status',
|
|
92
|
+
exitCode: 128,
|
|
93
|
+
stderr: 'Git error',
|
|
91
94
|
});
|
|
95
|
+
mockGetGitStatus.mockReturnValue(Effect.fail(gitError));
|
|
92
96
|
const TestComponent = () => {
|
|
93
97
|
useGitStatus(worktrees, 'main', 100);
|
|
94
98
|
return React.createElement(Text, null, 'test');
|
|
@@ -106,17 +110,17 @@ describe('useGitStatus', () => {
|
|
|
106
110
|
await vi.advanceTimersByTimeAsync(100);
|
|
107
111
|
expect(mockGetGitStatus).toHaveBeenCalledTimes(2);
|
|
108
112
|
// All calls should have been made despite continuous errors
|
|
109
|
-
expect(mockGetGitStatus).toHaveBeenCalledWith('/path1'
|
|
113
|
+
expect(mockGetGitStatus).toHaveBeenCalledWith('/path1');
|
|
110
114
|
});
|
|
111
115
|
it('should handle slow git operations that exceed update interval', async () => {
|
|
112
116
|
const worktrees = [createWorktree('/path1')];
|
|
113
117
|
let fetchCount = 0;
|
|
114
|
-
let
|
|
115
|
-
mockGetGitStatus.mockImplementation(
|
|
118
|
+
let resolveEffect = null;
|
|
119
|
+
mockGetGitStatus.mockImplementation(() => {
|
|
116
120
|
fetchCount++;
|
|
117
|
-
// Create
|
|
118
|
-
return
|
|
119
|
-
|
|
121
|
+
// Create an Effect that we can resolve manually
|
|
122
|
+
return Effect.async(resume => {
|
|
123
|
+
resolveEffect = resume;
|
|
120
124
|
});
|
|
121
125
|
});
|
|
122
126
|
const TestComponent = () => {
|
|
@@ -133,7 +137,7 @@ describe('useGitStatus', () => {
|
|
|
133
137
|
// Should not have started a second fetch yet
|
|
134
138
|
expect(mockGetGitStatus).toHaveBeenCalledTimes(1);
|
|
135
139
|
// Complete the first fetch
|
|
136
|
-
|
|
140
|
+
resolveEffect(Exit.succeed(createGitStatus(1, 0)));
|
|
137
141
|
// Wait for the promise to resolve
|
|
138
142
|
await vi.waitFor(() => {
|
|
139
143
|
expect(fetchCount).toBe(1);
|
|
@@ -147,15 +151,16 @@ describe('useGitStatus', () => {
|
|
|
147
151
|
});
|
|
148
152
|
it('should properly cleanup resources when worktrees change', async () => {
|
|
149
153
|
let activeRequests = 0;
|
|
150
|
-
const
|
|
151
|
-
mockGetGitStatus.mockImplementation(
|
|
154
|
+
const interruptedPaths = [];
|
|
155
|
+
mockGetGitStatus.mockImplementation(path => {
|
|
152
156
|
activeRequests++;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
157
|
+
// Create an Effect that never completes but handles interruption
|
|
158
|
+
return Effect.async(_resume => {
|
|
159
|
+
return Effect.sync(() => {
|
|
160
|
+
activeRequests--;
|
|
161
|
+
interruptedPaths.push(path);
|
|
162
|
+
});
|
|
156
163
|
});
|
|
157
|
-
// Simulate ongoing request
|
|
158
|
-
return new Promise(() => { });
|
|
159
164
|
});
|
|
160
165
|
const TestComponent = ({ worktrees }) => {
|
|
161
166
|
useGitStatus(worktrees, 'main', 100);
|
|
@@ -177,10 +182,12 @@ describe('useGitStatus', () => {
|
|
|
177
182
|
rerender(React.createElement(TestComponent, { worktrees: newWorktrees }));
|
|
178
183
|
// Wait for cleanup and new requests
|
|
179
184
|
await vi.waitFor(() => {
|
|
180
|
-
expect(
|
|
185
|
+
expect(interruptedPaths).toHaveLength(3);
|
|
181
186
|
expect(activeRequests).toBe(2);
|
|
182
187
|
});
|
|
183
|
-
// Verify all old
|
|
184
|
-
expect(
|
|
188
|
+
// Verify all old paths were interrupted
|
|
189
|
+
expect(interruptedPaths).toContain('/path1');
|
|
190
|
+
expect(interruptedPaths).toContain('/path2');
|
|
191
|
+
expect(interruptedPaths).toContain('/path3');
|
|
185
192
|
});
|
|
186
193
|
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { Effect, Either } from 'effect';
|
|
1
2
|
import { ConfigurationData, StatusHookConfig, WorktreeHookConfig, ShortcutConfig, WorktreeConfig, CommandConfig, CommandPreset, CommandPresetsConfig } from '../types/index.js';
|
|
3
|
+
import { FileSystemError, ConfigError, ValidationError } from '../types/errors.js';
|
|
2
4
|
export declare class ConfigurationManager {
|
|
3
5
|
private configPath;
|
|
4
6
|
private legacyShortcutsPath;
|
|
@@ -30,5 +32,78 @@ export declare class ConfigurationManager {
|
|
|
30
32
|
setDefaultPreset(id: string): void;
|
|
31
33
|
getSelectPresetOnStart(): boolean;
|
|
32
34
|
setSelectPresetOnStart(enabled: boolean): void;
|
|
35
|
+
/**
|
|
36
|
+
* Load configuration from file with Effect-based error handling
|
|
37
|
+
*
|
|
38
|
+
* @returns {Effect.Effect<ConfigurationData, FileSystemError | ConfigError, never>} Configuration data on success, errors on failure
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```typescript
|
|
42
|
+
* const result = await Effect.runPromise(
|
|
43
|
+
* configManager.loadConfigEffect()
|
|
44
|
+
* );
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
loadConfigEffect(): Effect.Effect<ConfigurationData, FileSystemError | ConfigError, never>;
|
|
48
|
+
/**
|
|
49
|
+
* Save configuration to file with Effect-based error handling
|
|
50
|
+
*
|
|
51
|
+
* @returns {Effect.Effect<void, FileSystemError, never>} Void on success, FileSystemError on write failure
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```typescript
|
|
55
|
+
* await Effect.runPromise(
|
|
56
|
+
* configManager.saveConfigEffect(config)
|
|
57
|
+
* );
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
saveConfigEffect(config: ConfigurationData): Effect.Effect<void, FileSystemError, never>;
|
|
61
|
+
/**
|
|
62
|
+
* Validate configuration structure
|
|
63
|
+
* Synchronous validation using Either
|
|
64
|
+
*/
|
|
65
|
+
validateConfig(config: unknown): Either.Either<ValidationError, ConfigurationData>;
|
|
66
|
+
/**
|
|
67
|
+
* Get preset by ID with Either-based error handling
|
|
68
|
+
* Synchronous lookup using Either
|
|
69
|
+
*/
|
|
70
|
+
getPresetByIdEffect(id: string): Either.Either<ValidationError, CommandPreset>;
|
|
71
|
+
/**
|
|
72
|
+
* Set shortcuts with Effect-based error handling
|
|
73
|
+
*
|
|
74
|
+
* @returns {Effect.Effect<void, FileSystemError, never>} Void on success, FileSystemError on save failure
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```typescript
|
|
78
|
+
* await Effect.runPromise(
|
|
79
|
+
* configManager.setShortcutsEffect(shortcuts)
|
|
80
|
+
* );
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
setShortcutsEffect(shortcuts: ShortcutConfig): Effect.Effect<void, FileSystemError, never>;
|
|
84
|
+
/**
|
|
85
|
+
* Set command presets with Effect-based error handling
|
|
86
|
+
*/
|
|
87
|
+
setCommandPresetsEffect(presets: CommandPresetsConfig): Effect.Effect<void, FileSystemError, never>;
|
|
88
|
+
/**
|
|
89
|
+
* Add or update preset with Effect-based error handling
|
|
90
|
+
*/
|
|
91
|
+
addPresetEffect(preset: CommandPreset): Effect.Effect<void, FileSystemError, never>;
|
|
92
|
+
/**
|
|
93
|
+
* Delete preset with Effect-based error handling
|
|
94
|
+
*/
|
|
95
|
+
deletePresetEffect(id: string): Effect.Effect<void, ValidationError | FileSystemError, never>;
|
|
96
|
+
/**
|
|
97
|
+
* Set default preset with Effect-based error handling
|
|
98
|
+
*/
|
|
99
|
+
setDefaultPresetEffect(id: string): Effect.Effect<void, ValidationError | FileSystemError, never>;
|
|
100
|
+
/**
|
|
101
|
+
* Apply default values to configuration
|
|
102
|
+
*/
|
|
103
|
+
private applyDefaults;
|
|
104
|
+
/**
|
|
105
|
+
* Synchronous legacy shortcuts migration helper
|
|
106
|
+
*/
|
|
107
|
+
private migrateLegacyShortcutsSync;
|
|
33
108
|
}
|
|
34
109
|
export declare const configurationManager: ConfigurationManager;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|