ccmanager 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/cli.test.js +13 -2
  2. package/dist/components/App.js +125 -50
  3. package/dist/components/App.test.js +270 -0
  4. package/dist/components/ConfigureShortcuts.js +82 -8
  5. package/dist/components/DeleteWorktree.js +39 -5
  6. package/dist/components/DeleteWorktree.test.d.ts +1 -0
  7. package/dist/components/DeleteWorktree.test.js +128 -0
  8. package/dist/components/LoadingSpinner.d.ts +8 -0
  9. package/dist/components/LoadingSpinner.js +37 -0
  10. package/dist/components/LoadingSpinner.test.d.ts +1 -0
  11. package/dist/components/LoadingSpinner.test.js +187 -0
  12. package/dist/components/Menu.js +64 -16
  13. package/dist/components/Menu.recent-projects.test.js +32 -11
  14. package/dist/components/Menu.test.js +136 -4
  15. package/dist/components/MergeWorktree.js +79 -18
  16. package/dist/components/MergeWorktree.test.d.ts +1 -0
  17. package/dist/components/MergeWorktree.test.js +227 -0
  18. package/dist/components/NewWorktree.js +88 -9
  19. package/dist/components/NewWorktree.test.d.ts +1 -0
  20. package/dist/components/NewWorktree.test.js +244 -0
  21. package/dist/components/ProjectList.js +44 -13
  22. package/dist/components/ProjectList.recent-projects.test.js +8 -3
  23. package/dist/components/ProjectList.test.js +105 -8
  24. package/dist/components/RemoteBranchSelector.test.js +3 -1
  25. package/dist/hooks/useGitStatus.d.ts +11 -0
  26. package/dist/hooks/useGitStatus.js +70 -12
  27. package/dist/hooks/useGitStatus.test.js +30 -23
  28. package/dist/services/configurationManager.d.ts +75 -0
  29. package/dist/services/configurationManager.effect.test.d.ts +1 -0
  30. package/dist/services/configurationManager.effect.test.js +407 -0
  31. package/dist/services/configurationManager.js +246 -0
  32. package/dist/services/globalSessionOrchestrator.test.js +0 -8
  33. package/dist/services/projectManager.d.ts +98 -2
  34. package/dist/services/projectManager.js +228 -59
  35. package/dist/services/projectManager.test.js +242 -2
  36. package/dist/services/sessionManager.d.ts +44 -2
  37. package/dist/services/sessionManager.effect.test.d.ts +1 -0
  38. package/dist/services/sessionManager.effect.test.js +321 -0
  39. package/dist/services/sessionManager.js +216 -65
  40. package/dist/services/sessionManager.statePersistence.test.js +18 -9
  41. package/dist/services/sessionManager.test.js +40 -36
  42. package/dist/services/worktreeService.d.ts +356 -26
  43. package/dist/services/worktreeService.js +793 -353
  44. package/dist/services/worktreeService.test.js +294 -313
  45. package/dist/types/errors.d.ts +74 -0
  46. package/dist/types/errors.js +31 -0
  47. package/dist/types/errors.test.d.ts +1 -0
  48. package/dist/types/errors.test.js +201 -0
  49. package/dist/types/index.d.ts +5 -17
  50. package/dist/utils/claudeDir.d.ts +58 -6
  51. package/dist/utils/claudeDir.js +103 -8
  52. package/dist/utils/claudeDir.test.d.ts +1 -0
  53. package/dist/utils/claudeDir.test.js +108 -0
  54. package/dist/utils/concurrencyLimit.d.ts +5 -0
  55. package/dist/utils/concurrencyLimit.js +11 -0
  56. package/dist/utils/concurrencyLimit.test.js +40 -1
  57. package/dist/utils/gitStatus.d.ts +36 -8
  58. package/dist/utils/gitStatus.js +170 -88
  59. package/dist/utils/gitStatus.test.js +12 -9
  60. package/dist/utils/hookExecutor.d.ts +41 -6
  61. package/dist/utils/hookExecutor.js +75 -32
  62. package/dist/utils/hookExecutor.test.js +73 -20
  63. package/dist/utils/terminalCapabilities.d.ts +18 -0
  64. package/dist/utils/terminalCapabilities.js +81 -0
  65. package/dist/utils/terminalCapabilities.test.d.ts +1 -0
  66. package/dist/utils/terminalCapabilities.test.js +104 -0
  67. package/dist/utils/testHelpers.d.ts +106 -0
  68. package/dist/utils/testHelpers.js +153 -0
  69. package/dist/utils/testHelpers.test.d.ts +1 -0
  70. package/dist/utils/testHelpers.test.js +114 -0
  71. package/dist/utils/worktreeConfig.d.ts +77 -2
  72. package/dist/utils/worktreeConfig.js +156 -16
  73. package/dist/utils/worktreeConfig.test.d.ts +1 -0
  74. package/dist/utils/worktreeConfig.test.js +39 -0
  75. package/package.json +4 -4
  76. package/dist/integration-tests/devcontainer.integration.test.js +0 -101
  77. /package/dist/{integration-tests/devcontainer.integration.test.d.ts → components/App.test.d.ts} +0 -0
@@ -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
- const loadProjects = async () => {
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
- try {
27
- const discoveredProjects = await projectManager.instance.discoverProjects(projectsDir);
28
- setProjects(discoveredProjects);
29
- // Load recent projects with no limit (pass 0)
30
- const allRecentProjects = projectManager.getRecentProjects(0);
31
- setRecentProjects(allRecentProjects);
32
- }
33
- catch (err) {
34
- setLoadError(err.message);
35
- }
36
- finally {
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
- loadProjects();
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
- discoverProjects: vi.fn(),
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.discoverProjects).mockResolvedValue(mockProjects);
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.discoverProjects).mockResolvedValue(manyProjects);
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
- discoverProjects: vi.fn(),
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.discoverProjects).mockResolvedValue(mockProjects);
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 a promise that never resolves to keep loading state
78
- vi.mocked(projectManager.instance.discoverProjects).mockReturnValue(new Promise(() => { }));
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.discoverProjects).mockResolvedValue([]);
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
- rerender(React.createElement(ProjectList, { projectsDir: "/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
172
- return lastFrame()?.includes('No git repositories found') ?? false;
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
- expect(output).toContain(`Branch 'feature/awesome-feature' exists in multiple remotes`);
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
- try {
14
- const result = await getGitStatusLimited(worktree.path, abortController.signal);
15
- if (result.data || result.error) {
16
- setWorktreesWithStatus(prev => prev.map(wt => wt.path === worktree.path
17
- ? { ...wt, gitStatus: result.data, gitStatusError: result.error }
18
- : wt));
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).finally(() => {
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(async (path) => {
42
+ mockGetGitStatus.mockImplementation(path => {
41
43
  if (path === '/path1') {
42
- return { success: true, data: gitStatus1 };
44
+ return Effect.succeed(gitStatus1);
43
45
  }
44
- return { success: true, data: gitStatus2 };
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
- mockGetGitStatus.mockResolvedValue({
89
- success: false,
90
- error: 'Git error',
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', expect.any(AbortSignal));
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 resolveFetch = null;
115
- mockGetGitStatus.mockImplementation(async () => {
118
+ let resolveEffect = null;
119
+ mockGetGitStatus.mockImplementation(() => {
116
120
  fetchCount++;
117
- // Create a promise that we can resolve manually
118
- return new Promise(resolve => {
119
- resolveFetch = resolve;
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
- resolveFetch({ success: true, data: createGitStatus(1, 0) });
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 abortedSignals = [];
151
- mockGetGitStatus.mockImplementation(async (path, signal) => {
154
+ const interruptedPaths = [];
155
+ mockGetGitStatus.mockImplementation(path => {
152
156
  activeRequests++;
153
- signal.addEventListener('abort', () => {
154
- activeRequests--;
155
- abortedSignals.push(signal);
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(abortedSignals).toHaveLength(3);
185
+ expect(interruptedPaths).toHaveLength(3);
181
186
  expect(activeRequests).toBe(2);
182
187
  });
183
- // Verify all old signals were aborted
184
- expect(abortedSignals.every(signal => signal.aborted)).toBe(true);
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;