ccmanager 2.8.0 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/cli.test.js +13 -2
  2. package/dist/components/App.js +125 -50
  3. package/dist/components/App.test.js +270 -0
  4. package/dist/components/ConfigureShortcuts.js +82 -8
  5. package/dist/components/DeleteWorktree.js +39 -5
  6. package/dist/components/DeleteWorktree.test.d.ts +1 -0
  7. package/dist/components/DeleteWorktree.test.js +128 -0
  8. package/dist/components/LoadingSpinner.d.ts +8 -0
  9. package/dist/components/LoadingSpinner.js +37 -0
  10. package/dist/components/LoadingSpinner.test.d.ts +1 -0
  11. package/dist/components/LoadingSpinner.test.js +187 -0
  12. package/dist/components/Menu.js +64 -16
  13. package/dist/components/Menu.recent-projects.test.js +32 -11
  14. package/dist/components/Menu.test.js +136 -4
  15. package/dist/components/MergeWorktree.js +79 -18
  16. package/dist/components/MergeWorktree.test.d.ts +1 -0
  17. package/dist/components/MergeWorktree.test.js +227 -0
  18. package/dist/components/NewWorktree.js +88 -9
  19. package/dist/components/NewWorktree.test.d.ts +1 -0
  20. package/dist/components/NewWorktree.test.js +244 -0
  21. package/dist/components/ProjectList.js +44 -13
  22. package/dist/components/ProjectList.recent-projects.test.js +8 -3
  23. package/dist/components/ProjectList.test.js +105 -8
  24. package/dist/components/RemoteBranchSelector.test.js +3 -1
  25. package/dist/components/Session.js +11 -6
  26. package/dist/hooks/useGitStatus.d.ts +11 -0
  27. package/dist/hooks/useGitStatus.js +70 -12
  28. package/dist/hooks/useGitStatus.test.js +30 -23
  29. package/dist/services/configurationManager.d.ts +75 -0
  30. package/dist/services/configurationManager.effect.test.d.ts +1 -0
  31. package/dist/services/configurationManager.effect.test.js +407 -0
  32. package/dist/services/configurationManager.js +246 -0
  33. package/dist/services/globalSessionOrchestrator.test.js +0 -8
  34. package/dist/services/projectManager.d.ts +98 -2
  35. package/dist/services/projectManager.js +228 -59
  36. package/dist/services/projectManager.test.js +242 -2
  37. package/dist/services/sessionManager.d.ts +44 -2
  38. package/dist/services/sessionManager.effect.test.d.ts +1 -0
  39. package/dist/services/sessionManager.effect.test.js +321 -0
  40. package/dist/services/sessionManager.js +216 -65
  41. package/dist/services/sessionManager.statePersistence.test.js +18 -9
  42. package/dist/services/sessionManager.test.js +40 -36
  43. package/dist/services/shortcutManager.d.ts +2 -0
  44. package/dist/services/shortcutManager.js +53 -0
  45. package/dist/services/shortcutManager.test.d.ts +1 -0
  46. package/dist/services/shortcutManager.test.js +30 -0
  47. package/dist/services/worktreeService.d.ts +356 -26
  48. package/dist/services/worktreeService.js +793 -353
  49. package/dist/services/worktreeService.test.js +294 -313
  50. package/dist/types/errors.d.ts +74 -0
  51. package/dist/types/errors.js +31 -0
  52. package/dist/types/errors.test.d.ts +1 -0
  53. package/dist/types/errors.test.js +201 -0
  54. package/dist/types/index.d.ts +5 -17
  55. package/dist/utils/claudeDir.d.ts +58 -6
  56. package/dist/utils/claudeDir.js +103 -8
  57. package/dist/utils/claudeDir.test.d.ts +1 -0
  58. package/dist/utils/claudeDir.test.js +108 -0
  59. package/dist/utils/concurrencyLimit.d.ts +5 -0
  60. package/dist/utils/concurrencyLimit.js +11 -0
  61. package/dist/utils/concurrencyLimit.test.js +40 -1
  62. package/dist/utils/gitStatus.d.ts +36 -8
  63. package/dist/utils/gitStatus.js +170 -88
  64. package/dist/utils/gitStatus.test.js +12 -9
  65. package/dist/utils/hookExecutor.d.ts +41 -6
  66. package/dist/utils/hookExecutor.js +75 -32
  67. package/dist/utils/hookExecutor.test.js +73 -20
  68. package/dist/utils/terminalCapabilities.d.ts +18 -0
  69. package/dist/utils/terminalCapabilities.js +81 -0
  70. package/dist/utils/terminalCapabilities.test.d.ts +1 -0
  71. package/dist/utils/terminalCapabilities.test.js +104 -0
  72. package/dist/utils/testHelpers.d.ts +106 -0
  73. package/dist/utils/testHelpers.js +153 -0
  74. package/dist/utils/testHelpers.test.d.ts +1 -0
  75. package/dist/utils/testHelpers.test.js +114 -0
  76. package/dist/utils/worktreeConfig.d.ts +77 -2
  77. package/dist/utils/worktreeConfig.js +156 -16
  78. package/dist/utils/worktreeConfig.test.d.ts +1 -0
  79. package/dist/utils/worktreeConfig.test.js +39 -0
  80. package/package.json +4 -4
  81. package/dist/integration-tests/devcontainer.integration.test.js +0 -101
  82. /package/dist/{integration-tests/devcontainer.integration.test.d.ts → components/App.test.d.ts} +0 -0
@@ -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 }));
@@ -4,6 +4,11 @@ import { shortcutManager } from '../services/shortcutManager.js';
4
4
  const Session = ({ session, sessionManager, onReturnToMenu, }) => {
5
5
  const { stdout } = useStdout();
6
6
  const [isExiting, setIsExiting] = useState(false);
7
+ const stripOscColorSequences = (input) => {
8
+ // Remove default foreground/background color OSC sequences that Codex emits
9
+ // These sequences leak as literal text when replaying buffered output
10
+ return input.replace(/\x1B\](?:10|11);[^\x07\x1B]*(?:\x07|\x1B\\)/g, '');
11
+ };
7
12
  useEffect(() => {
8
13
  if (!stdout)
9
14
  return;
@@ -17,7 +22,7 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
17
22
  const buffer = restoredSession.outputHistory[i];
18
23
  if (!buffer)
19
24
  continue;
20
- const str = buffer.toString('utf8');
25
+ const str = stripOscColorSequences(buffer.toString('utf8'));
21
26
  // Skip clear screen sequences at the beginning
22
27
  if (i === 0 && (str.includes('\x1B[2J') || str.includes('\x1B[H'))) {
23
28
  // Skip this buffer or remove the clear sequence
@@ -25,11 +30,13 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
25
30
  .replace(/\x1B\[2J/g, '')
26
31
  .replace(/\x1B\[H/g, '');
27
32
  if (cleaned.length > 0) {
28
- stdout.write(Buffer.from(cleaned, 'utf8'));
33
+ stdout.write(cleaned);
29
34
  }
30
35
  }
31
36
  else {
32
- stdout.write(buffer);
37
+ if (str.length > 0) {
38
+ stdout.write(str);
39
+ }
33
40
  }
34
41
  }
35
42
  }
@@ -93,9 +100,7 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
93
100
  if (isExiting)
94
101
  return;
95
102
  // Check for return to menu shortcut
96
- const returnToMenuShortcut = shortcutManager.getShortcuts().returnToMenu;
97
- const shortcutCode = shortcutManager.getShortcutCode(returnToMenuShortcut);
98
- if (shortcutCode && data === shortcutCode) {
103
+ if (shortcutManager.matchesRawInput('returnToMenu', data)) {
99
104
  // Disable focus reporting mode before returning to menu
100
105
  if (stdout) {
101
106
  stdout.write('\x1b[?1004l');
@@ -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
  });