ccmanager 2.7.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,6 +1,7 @@
1
1
  import React, { useState, useEffect } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import SelectInput from 'ink-select-input';
4
+ import { Effect } from 'effect';
4
5
  import { SessionManager } from '../services/sessionManager.js';
5
6
  import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, } from '../constants/statusIcons.js';
6
7
  import { useGitStatus } from '../hooks/useGitStatus.js';
@@ -17,9 +18,17 @@ const createSeparatorWithText = (text, totalWidth = 35) => {
17
18
  const rightDashes = Math.ceil(remainingWidth / 2);
18
19
  return '─'.repeat(leftDashes) + textWithSpaces + '─'.repeat(rightDashes);
19
20
  };
21
+ /**
22
+ * Format GitError for display
23
+ * Extracts relevant error information using pattern matching
24
+ */
25
+ const formatGitError = (error) => {
26
+ return `Git command failed: ${error.command} (exit ${error.exitCode})\n${error.stderr}`;
27
+ };
20
28
  const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecentProject, error, onDismissError, projectName, multiProject = false, }) => {
21
29
  const [baseWorktrees, setBaseWorktrees] = useState([]);
22
30
  const [defaultBranch, setDefaultBranch] = useState(null);
31
+ const [loadError, setLoadError] = useState(null);
23
32
  const worktrees = useGitStatus(baseWorktrees, defaultBranch);
24
33
  const [sessions, setSessions] = useState([]);
25
34
  const [items, setItems] = useState([]);
@@ -27,13 +36,53 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
27
36
  const limit = 10;
28
37
  // Use the search mode hook
29
38
  const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(items.length, {
30
- isDisabled: !!error,
39
+ isDisabled: !!error || !!loadError,
31
40
  });
32
41
  useEffect(() => {
33
- // Load worktrees
34
- const loadedWorktrees = worktreeService.getWorktrees();
35
- setBaseWorktrees(loadedWorktrees);
36
- setDefaultBranch(worktreeService.getDefaultBranch());
42
+ let cancelled = false;
43
+ // Load worktrees and default branch using Effect composition
44
+ // Chain getWorktreesEffect and getDefaultBranchEffect using Effect.flatMap
45
+ const loadWorktreesAndBranch = Effect.flatMap(worktreeService.getWorktreesEffect(), worktrees => Effect.map(worktreeService.getDefaultBranchEffect(), defaultBranch => ({
46
+ worktrees,
47
+ defaultBranch,
48
+ })));
49
+ Effect.runPromise(Effect.match(loadWorktreesAndBranch, {
50
+ onFailure: (error) => ({
51
+ success: false,
52
+ error,
53
+ }),
54
+ onSuccess: ({ worktrees, defaultBranch }) => ({
55
+ success: true,
56
+ worktrees,
57
+ defaultBranch,
58
+ }),
59
+ }))
60
+ .then(result => {
61
+ if (!cancelled) {
62
+ if (result.success) {
63
+ setBaseWorktrees(result.worktrees);
64
+ setDefaultBranch(result.defaultBranch);
65
+ setLoadError(null);
66
+ // Update sessions after worktrees are loaded
67
+ const allSessions = sessionManager.getAllSessions();
68
+ setSessions(allSessions);
69
+ // Update worktree session status
70
+ result.worktrees.forEach(wt => {
71
+ wt.hasSession = allSessions.some(s => s.worktreePath === wt.path);
72
+ });
73
+ }
74
+ else {
75
+ // Handle GitError with pattern matching
76
+ setLoadError(formatGitError(result.error));
77
+ }
78
+ }
79
+ })
80
+ .catch((err) => {
81
+ // This catch should not normally be reached with Effect.match
82
+ if (!cancelled) {
83
+ setLoadError(String(err));
84
+ }
85
+ });
37
86
  // Load recent projects if in multi-project mode
38
87
  if (multiProject) {
39
88
  // Filter out the current project from recent projects
@@ -42,22 +91,16 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
42
91
  const filteredProjects = allRecentProjects.filter((project) => project.path !== currentProjectPath);
43
92
  setRecentProjects(filteredProjects);
44
93
  }
45
- // Update sessions
46
- const updateSessions = () => {
94
+ // Listen for session changes
95
+ const handleSessionChange = () => {
47
96
  const allSessions = sessionManager.getAllSessions();
48
97
  setSessions(allSessions);
49
- // Update worktree session status
50
- loadedWorktrees.forEach(wt => {
51
- wt.hasSession = allSessions.some(s => s.worktreePath === wt.path);
52
- });
53
98
  };
54
- updateSessions();
55
- // Listen for session changes
56
- const handleSessionChange = () => updateSessions();
57
99
  sessionManager.on('sessionCreated', handleSessionChange);
58
100
  sessionManager.on('sessionDestroyed', handleSessionChange);
59
101
  sessionManager.on('sessionStateChanged', handleSessionChange);
60
102
  return () => {
103
+ cancelled = true;
61
104
  sessionManager.off('sessionCreated', handleSessionChange);
62
105
  sessionManager.off('sessionDestroyed', handleSessionChange);
63
106
  sessionManager.off('sessionStateChanged', handleSessionChange);
@@ -200,6 +243,11 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
200
243
  onDismissError();
201
244
  return;
202
245
  }
246
+ // Dismiss load error on any key press when load error is shown
247
+ if (loadError) {
248
+ setLoadError(null);
249
+ return;
250
+ }
203
251
  // Don't process other keys if in search mode (handled by useSearchMode)
204
252
  if (isSearchMode) {
205
253
  return;
@@ -378,11 +426,11 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
378
426
  React.createElement(Box, { flexDirection: "column" }, items.slice(0, limit).map((item, index) => (React.createElement(Text, { key: item.value, color: index === selectedIndex ? 'green' : undefined },
379
427
  index === selectedIndex ? '❯ ' : ' ',
380
428
  item.label))))) : (React.createElement(SelectInput, { items: items, onSelect: item => handleSelect(item), isFocused: !error, initialIndex: selectedIndex, limit: limit })),
381
- error && (React.createElement(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red" },
429
+ (error || loadError) && (React.createElement(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red" },
382
430
  React.createElement(Box, { flexDirection: "column" },
383
431
  React.createElement(Text, { color: "red", bold: true },
384
432
  "Error: ",
385
- error),
433
+ error || loadError),
386
434
  React.createElement(Text, { color: "gray", dimColor: true }, "Press any key to dismiss")))),
387
435
  React.createElement(Box, { marginTop: 1, flexDirection: "column" },
388
436
  React.createElement(Text, { dimColor: true },
@@ -1,7 +1,9 @@
1
1
  import React from 'react';
2
2
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
3
  import { render } from 'ink-testing-library';
4
+ import { Effect } from 'effect';
4
5
  import Menu from './Menu.js';
6
+ import { SessionManager } from '../services/sessionManager.js';
5
7
  import { projectManager } from '../services/projectManager.js';
6
8
  // Import the actual component code but skip the useInput hook
7
9
  vi.mock('ink', async () => {
@@ -30,6 +32,11 @@ vi.mock('../services/projectManager.js', () => ({
30
32
  getRecentProjects: vi.fn(),
31
33
  },
32
34
  }));
35
+ vi.mock('../services/globalSessionOrchestrator.js', () => ({
36
+ globalSessionOrchestrator: {
37
+ getProjectSessions: vi.fn().mockReturnValue([]),
38
+ },
39
+ }));
33
40
  vi.mock('../services/shortcutManager.js', () => ({
34
41
  shortcutManager: {
35
42
  getShortcutDisplay: vi.fn().mockReturnValue('Ctrl+C'),
@@ -57,15 +64,15 @@ describe('Menu - Recent Projects', () => {
57
64
  destroy: vi.fn(),
58
65
  };
59
66
  mockWorktreeService = {
60
- getWorktrees: vi.fn().mockReturnValue([
67
+ getWorktreesEffect: vi.fn().mockReturnValue(Effect.succeed([
61
68
  {
62
69
  path: '/workspace/main',
63
70
  branch: 'main',
64
71
  isMainWorktree: true,
65
72
  hasSession: false,
66
73
  },
67
- ]),
68
- getDefaultBranch: vi.fn().mockReturnValue('main'),
74
+ ])),
75
+ getDefaultBranchEffect: vi.fn().mockReturnValue(Effect.succeed('main')),
69
76
  getGitRootPath: vi.fn().mockReturnValue('/default/project'),
70
77
  };
71
78
  vi.mocked(projectManager.getRecentProjects).mockReturnValue([]);
@@ -82,12 +89,22 @@ describe('Menu - Recent Projects', () => {
82
89
  expect(output).not.toContain('─ Recent ─');
83
90
  expect(output).not.toContain('Project 1');
84
91
  });
85
- it('should show recent projects in multi-project mode', () => {
92
+ it('should show recent projects in multi-project mode', async () => {
86
93
  vi.mocked(projectManager.getRecentProjects).mockReturnValue([
87
94
  { path: '/project1', name: 'Project 1', lastAccessed: 2000 },
88
95
  { path: '/project2', name: 'Project 2', lastAccessed: 1000 },
89
96
  ]);
97
+ // Mock SessionManager static methods
98
+ vi.spyOn(SessionManager, 'getSessionCounts').mockReturnValue({
99
+ idle: 0,
100
+ busy: 0,
101
+ waiting_input: 0,
102
+ total: 0,
103
+ });
104
+ vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
90
105
  const { lastFrame } = render(React.createElement(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onSelectWorktree: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true }));
106
+ // Wait for Effect to execute
107
+ await new Promise(resolve => setTimeout(resolve, 100));
91
108
  const output = lastFrame();
92
109
  expect(output).toContain('─ Recent ─');
93
110
  expect(output).toContain('Project 1');
@@ -99,25 +116,29 @@ describe('Menu - Recent Projects', () => {
99
116
  const output = lastFrame();
100
117
  expect(output).not.toContain('─ Recent ─');
101
118
  });
102
- it('should show up to 5 recent projects', () => {
119
+ it('should show up to 5 recent projects', async () => {
103
120
  const manyProjects = Array.from({ length: 5 }, (_, i) => ({
104
121
  path: `/project${i}`,
105
122
  name: `Project ${i}`,
106
123
  lastAccessed: i * 1000,
107
124
  }));
108
125
  vi.mocked(projectManager.getRecentProjects).mockReturnValue(manyProjects);
126
+ // Mock SessionManager static methods
127
+ vi.spyOn(SessionManager, 'getSessionCounts').mockReturnValue({
128
+ idle: 0,
129
+ busy: 0,
130
+ waiting_input: 0,
131
+ total: 0,
132
+ });
133
+ vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
109
134
  const { lastFrame } = render(React.createElement(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onSelectWorktree: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true }));
135
+ // Wait for Effect to execute
136
+ await new Promise(resolve => setTimeout(resolve, 100));
110
137
  const output = lastFrame();
111
138
  expect(output).toContain('─ Recent ─');
112
139
  expect(output).toContain('Project 0');
113
140
  expect(output).toContain('Project 4');
114
141
  });
115
- it('should show recent projects between worktrees and New Worktree', () => {
116
- // This test validates that recent projects appear in the correct order
117
- // Since all other tests pass, we can consider this behavior verified
118
- // by the other test cases that check for Recent Projects rendering
119
- expect(true).toBe(true);
120
- });
121
142
  it('should filter out current project from recent projects', async () => {
122
143
  // Setup the initial recent projects
123
144
  vi.mocked(projectManager.getRecentProjects).mockReturnValue([
@@ -4,6 +4,10 @@ import Menu from './Menu.js';
4
4
  import { SessionManager } from '../services/sessionManager.js';
5
5
  import { WorktreeService } from '../services/worktreeService.js';
6
6
  import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
7
+ // Mock node-pty to avoid native module issues in tests
8
+ vi.mock('node-pty', () => ({
9
+ spawn: vi.fn(),
10
+ }));
7
11
  // Mock ink to avoid stdin issues
8
12
  vi.mock('ink', async () => {
9
13
  const actual = await vi.importActual('ink');
@@ -55,13 +59,139 @@ vi.mock('../hooks/useSearchMode.js', () => ({
55
59
  handleKey: vi.fn(),
56
60
  }),
57
61
  }));
58
- describe('Menu component rendering', () => {
62
+ describe('Menu component Effect-based error handling', () => {
59
63
  let sessionManager;
60
64
  let worktreeService;
61
65
  beforeEach(() => {
62
66
  sessionManager = new SessionManager();
63
67
  worktreeService = new WorktreeService();
64
- vi.spyOn(worktreeService, 'getWorktrees').mockReturnValue([]);
68
+ // Mock EventEmitter methods
69
+ vi.spyOn(sessionManager, 'on').mockImplementation(() => sessionManager);
70
+ vi.spyOn(sessionManager, 'off').mockImplementation(() => sessionManager);
71
+ vi.spyOn(sessionManager, 'getAllSessions').mockReturnValue([]);
72
+ });
73
+ afterEach(() => {
74
+ vi.restoreAllMocks();
75
+ });
76
+ it('should handle GitError from getWorktreesEffect and display error message', async () => {
77
+ const { Effect } = await import('effect');
78
+ const { GitError } = await import('../types/errors.js');
79
+ const onSelectWorktree = vi.fn();
80
+ const onDismissError = vi.fn();
81
+ // Mock getWorktreesEffect to return a failing Effect
82
+ const gitError = new GitError({
83
+ command: 'git worktree list --porcelain',
84
+ exitCode: 128,
85
+ stderr: 'fatal: not a git repository',
86
+ stdout: '',
87
+ });
88
+ vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(Effect.fail(gitError));
89
+ const { lastFrame } = render(React.createElement(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree, onDismissError: onDismissError }));
90
+ // Wait for Effect to execute
91
+ await new Promise(resolve => setTimeout(resolve, 100));
92
+ const output = lastFrame();
93
+ // Should display error with GitError information
94
+ expect(output).toContain('Error:');
95
+ expect(output).toContain('git worktree list --porcelain');
96
+ expect(output).toContain('fatal: not a git repository');
97
+ });
98
+ it('should successfully load worktrees using getWorktreesEffect', async () => {
99
+ const { Effect } = await import('effect');
100
+ const onSelectWorktree = vi.fn();
101
+ const mockWorktrees = [
102
+ {
103
+ path: '/test/main',
104
+ branch: 'main',
105
+ isMainWorktree: true,
106
+ hasSession: false,
107
+ },
108
+ {
109
+ path: '/test/feature',
110
+ branch: 'feature-branch',
111
+ isMainWorktree: false,
112
+ hasSession: false,
113
+ },
114
+ ];
115
+ // Mock getWorktreesEffect to return successful Effect
116
+ vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(Effect.succeed(mockWorktrees));
117
+ // Mock getDefaultBranchEffect to return successful Effect
118
+ vi.spyOn(worktreeService, 'getDefaultBranchEffect').mockReturnValue(Effect.succeed('main'));
119
+ const { lastFrame } = render(React.createElement(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree }));
120
+ // Wait for Effect to execute
121
+ await new Promise(resolve => setTimeout(resolve, 100));
122
+ const output = lastFrame();
123
+ // Should display worktrees
124
+ expect(output).toContain('main');
125
+ expect(output).toContain('feature-branch');
126
+ });
127
+ it('should handle GitError from getDefaultBranchEffect and display error message', async () => {
128
+ const { Effect } = await import('effect');
129
+ const { GitError } = await import('../types/errors.js');
130
+ const onSelectWorktree = vi.fn();
131
+ const onDismissError = vi.fn();
132
+ const mockWorktrees = [
133
+ {
134
+ path: '/test/main',
135
+ branch: 'main',
136
+ isMainWorktree: true,
137
+ hasSession: false,
138
+ },
139
+ ];
140
+ // Mock getWorktreesEffect to succeed
141
+ vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(Effect.succeed(mockWorktrees));
142
+ // Mock getDefaultBranchEffect to fail
143
+ const gitError = new GitError({
144
+ command: 'git symbolic-ref refs/remotes/origin/HEAD',
145
+ exitCode: 128,
146
+ stderr: 'fatal: ref refs/remotes/origin/HEAD is not a symbolic ref',
147
+ stdout: '',
148
+ });
149
+ vi.spyOn(worktreeService, 'getDefaultBranchEffect').mockReturnValue(Effect.fail(gitError));
150
+ const { lastFrame } = render(React.createElement(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree, onDismissError: onDismissError }));
151
+ // Wait for Effect to execute
152
+ await new Promise(resolve => setTimeout(resolve, 100));
153
+ const output = lastFrame();
154
+ // Should display error with GitError information
155
+ expect(output).toContain('Error:');
156
+ expect(output).toContain('git symbolic-ref');
157
+ expect(output).toContain('fatal: ref refs/remotes/origin/HEAD is not a symbolic ref');
158
+ });
159
+ it('should use Effect composition to load worktrees and default branch together', async () => {
160
+ const { Effect } = await import('effect');
161
+ const onSelectWorktree = vi.fn();
162
+ const mockWorktrees = [
163
+ {
164
+ path: '/test/main',
165
+ branch: 'main',
166
+ isMainWorktree: true,
167
+ hasSession: false,
168
+ },
169
+ ];
170
+ // Track that both Effects are called
171
+ const getWorktreesSpy = vi
172
+ .spyOn(worktreeService, 'getWorktreesEffect')
173
+ .mockReturnValue(Effect.succeed(mockWorktrees));
174
+ const getDefaultBranchSpy = vi
175
+ .spyOn(worktreeService, 'getDefaultBranchEffect')
176
+ .mockReturnValue(Effect.succeed('main'));
177
+ render(React.createElement(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree }));
178
+ // Wait for Effect to execute
179
+ await new Promise(resolve => setTimeout(resolve, 100));
180
+ // Verify both Effect-based methods were called (Effect composition)
181
+ expect(getWorktreesSpy).toHaveBeenCalled();
182
+ expect(getDefaultBranchSpy).toHaveBeenCalled();
183
+ });
184
+ });
185
+ describe('Menu component rendering', () => {
186
+ let sessionManager;
187
+ let worktreeService;
188
+ beforeEach(async () => {
189
+ const { Effect } = await import('effect');
190
+ sessionManager = new SessionManager();
191
+ worktreeService = new WorktreeService();
192
+ // Mock Effect-based methods
193
+ vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(Effect.succeed([]));
194
+ vi.spyOn(worktreeService, 'getDefaultBranchEffect').mockReturnValue(Effect.succeed('main'));
65
195
  vi.spyOn(sessionManager, 'getAllSessions').mockReturnValue([]);
66
196
  // Mock EventEmitter methods
67
197
  vi.spyOn(sessionManager, 'on').mockImplementation(() => sessionManager);
@@ -103,6 +233,7 @@ describe('Menu component rendering', () => {
103
233
  expect(descMatches.length).toBe(1);
104
234
  });
105
235
  it('should display number shortcuts for recent projects when worktrees < 10', async () => {
236
+ const { Effect } = await import('effect');
106
237
  const onSelectWorktree = vi.fn();
107
238
  const onSelectRecentProject = vi.fn();
108
239
  // Setup: 3 worktrees
@@ -132,7 +263,7 @@ describe('Menu component rendering', () => {
132
263
  { name: 'Project B', path: '/test/project-b', lastAccessed: Date.now() },
133
264
  { name: 'Project C', path: '/test/project-c', lastAccessed: Date.now() },
134
265
  ];
135
- vi.spyOn(worktreeService, 'getWorktrees').mockReturnValue(mockWorktrees);
266
+ vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(Effect.succeed(mockWorktrees));
136
267
  vi.spyOn(worktreeService, 'getGitRootPath').mockReturnValue('/test/current');
137
268
  const { projectManager } = await import('../services/projectManager.js');
138
269
  vi.mocked(projectManager.getRecentProjects).mockReturnValue(mockRecentProjects);
@@ -157,6 +288,7 @@ describe('Menu component rendering', () => {
157
288
  expect(output).toContain('5 ❯ Project C');
158
289
  });
159
290
  it('should not display number shortcuts for recent projects when worktrees >= 10', async () => {
291
+ const { Effect } = await import('effect');
160
292
  const onSelectWorktree = vi.fn();
161
293
  const onSelectRecentProject = vi.fn();
162
294
  // Setup: 10 worktrees
@@ -171,7 +303,7 @@ describe('Menu component rendering', () => {
171
303
  { name: 'Project A', path: '/test/project-a', lastAccessed: Date.now() },
172
304
  { name: 'Project B', path: '/test/project-b', lastAccessed: Date.now() },
173
305
  ];
174
- vi.spyOn(worktreeService, 'getWorktrees').mockReturnValue(mockWorktrees);
306
+ vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(Effect.succeed(mockWorktrees));
175
307
  vi.spyOn(worktreeService, 'getGitRootPath').mockReturnValue('/test/current');
176
308
  const { projectManager } = await import('../services/projectManager.js');
177
309
  vi.mocked(projectManager.getRecentProjects).mockReturnValue(mockRecentProjects);
@@ -1,9 +1,11 @@
1
1
  import React, { useState, useEffect } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import SelectInput from 'ink-select-input';
4
+ import { Effect } from 'effect';
4
5
  import { WorktreeService } from '../services/worktreeService.js';
5
6
  import Confirmation, { SimpleConfirmation } from './Confirmation.js';
6
7
  import { shortcutManager } from '../services/shortcutManager.js';
8
+ import { GitError } from '../types/errors.js';
7
9
  const MergeWorktree = ({ onComplete, onCancel, }) => {
8
10
  const [step, setStep] = useState('select-source');
9
11
  const [sourceBranch, setSourceBranch] = useState('');
@@ -13,16 +15,43 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
13
15
  const [useRebase, setUseRebase] = useState(false);
14
16
  const [mergeError, setMergeError] = useState(null);
15
17
  const [worktreeService] = useState(() => new WorktreeService());
18
+ const [isLoading, setIsLoading] = useState(true);
19
+ const [loadError, setLoadError] = useState(null);
16
20
  useEffect(() => {
17
- const loadedWorktrees = worktreeService.getWorktrees();
18
- // Create branch items for selection
19
- const items = loadedWorktrees.map(wt => ({
20
- label: (wt.branch ? wt.branch.replace('refs/heads/', '') : 'detached') +
21
- (wt.isMainWorktree ? ' (main)' : ''),
22
- value: wt.branch ? wt.branch.replace('refs/heads/', '') : 'detached',
23
- }));
24
- setBranchItems(items);
25
- setOriginalBranchItems(items);
21
+ let cancelled = false;
22
+ const loadWorktrees = async () => {
23
+ try {
24
+ const loadedWorktrees = await Effect.runPromise(worktreeService.getWorktreesEffect());
25
+ if (!cancelled) {
26
+ // Create branch items for selection
27
+ const items = loadedWorktrees.map(wt => ({
28
+ label: (wt.branch ? wt.branch.replace('refs/heads/', '') : 'detached') +
29
+ (wt.isMainWorktree ? ' (main)' : ''),
30
+ value: wt.branch
31
+ ? wt.branch.replace('refs/heads/', '')
32
+ : 'detached',
33
+ }));
34
+ setBranchItems(items);
35
+ setOriginalBranchItems(items);
36
+ setIsLoading(false);
37
+ }
38
+ }
39
+ catch (err) {
40
+ if (!cancelled) {
41
+ const errorMessage = err instanceof GitError
42
+ ? `Git error: ${err.stderr}`
43
+ : err instanceof Error
44
+ ? err.message
45
+ : String(err);
46
+ setLoadError(errorMessage);
47
+ setIsLoading(false);
48
+ }
49
+ }
50
+ };
51
+ loadWorktrees();
52
+ return () => {
53
+ cancelled = true;
54
+ };
26
55
  }, [worktreeService]);
27
56
  useInput((input, key) => {
28
57
  if (shortcutManager.matchesShortcut('cancel', input, key)) {
@@ -51,19 +80,38 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
51
80
  if (step !== 'executing-merge')
52
81
  return;
53
82
  const performMerge = async () => {
54
- const result = worktreeService.mergeWorktree(sourceBranch, targetBranch, useRebase);
55
- if (result.success) {
83
+ try {
84
+ await Effect.runPromise(worktreeService.mergeWorktreeEffect(sourceBranch, targetBranch, useRebase));
56
85
  // Merge successful, ask about deleting source branch
57
86
  setStep('delete-confirm');
58
87
  }
59
- else {
88
+ catch (err) {
60
89
  // Merge failed, show error
61
- setMergeError(result.error || 'Merge operation failed');
90
+ const errorMessage = err instanceof GitError
91
+ ? `${err.command} failed: ${err.stderr}`
92
+ : err instanceof Error
93
+ ? err.message
94
+ : 'Merge operation failed';
95
+ setMergeError(errorMessage);
62
96
  setStep('merge-error');
63
97
  }
64
98
  };
65
99
  performMerge();
66
100
  }, [step, sourceBranch, targetBranch, useRebase, worktreeService]);
101
+ if (isLoading) {
102
+ return (React.createElement(Box, { flexDirection: "column" },
103
+ React.createElement(Text, { color: "cyan" }, "Loading worktrees...")));
104
+ }
105
+ if (loadError) {
106
+ return (React.createElement(Box, { flexDirection: "column" },
107
+ React.createElement(Text, { color: "red" }, "Error loading worktrees:"),
108
+ React.createElement(Text, { color: "red" }, loadError),
109
+ React.createElement(Box, { marginTop: 1 },
110
+ React.createElement(Text, { dimColor: true },
111
+ "Press ",
112
+ shortcutManager.getShortcutDisplay('cancel'),
113
+ " to return to menu"))));
114
+ }
67
115
  if (step === 'select-source') {
68
116
  return (React.createElement(Box, { flexDirection: "column" },
69
117
  React.createElement(Box, { marginBottom: 1 },
@@ -160,13 +208,26 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
160
208
  React.createElement(Text, { color: "yellow" }, sourceBranch),
161
209
  ' ',
162
210
  "and its worktree?")));
163
- return (React.createElement(SimpleConfirmation, { message: deleteMessage, onConfirm: () => {
164
- const deleteResult = worktreeService.deleteWorktreeByBranch(sourceBranch);
165
- if (deleteResult.success) {
211
+ return (React.createElement(SimpleConfirmation, { message: deleteMessage, onConfirm: async () => {
212
+ try {
213
+ // Find the worktree path for the source branch
214
+ const worktrees = await Effect.runPromise(worktreeService.getWorktreesEffect());
215
+ const sourceWorktree = worktrees.find(wt => wt.branch &&
216
+ wt.branch.replace('refs/heads/', '') === sourceBranch);
217
+ if (sourceWorktree) {
218
+ await Effect.runPromise(worktreeService.deleteWorktreeEffect(sourceWorktree.path, {
219
+ deleteBranch: true,
220
+ }));
221
+ }
166
222
  onComplete();
167
223
  }
168
- else {
169
- setMergeError(deleteResult.error || 'Failed to delete worktree');
224
+ catch (err) {
225
+ const errorMessage = err instanceof GitError
226
+ ? `Delete failed: ${err.stderr}`
227
+ : err instanceof Error
228
+ ? err.message
229
+ : 'Failed to delete worktree';
230
+ setMergeError(errorMessage);
170
231
  setStep('merge-error');
171
232
  }
172
233
  }, onCancel: () => {
@@ -0,0 +1 @@
1
+ export {};