ccmanager 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.test.js +13 -2
- package/dist/components/App.js +125 -50
- package/dist/components/App.test.js +270 -0
- package/dist/components/ConfigureShortcuts.js +82 -8
- package/dist/components/DeleteWorktree.js +39 -5
- package/dist/components/DeleteWorktree.test.d.ts +1 -0
- package/dist/components/DeleteWorktree.test.js +128 -0
- package/dist/components/LoadingSpinner.d.ts +8 -0
- package/dist/components/LoadingSpinner.js +37 -0
- package/dist/components/LoadingSpinner.test.d.ts +1 -0
- package/dist/components/LoadingSpinner.test.js +187 -0
- package/dist/components/Menu.js +64 -16
- package/dist/components/Menu.recent-projects.test.js +32 -11
- package/dist/components/Menu.test.js +136 -4
- package/dist/components/MergeWorktree.js +79 -18
- package/dist/components/MergeWorktree.test.d.ts +1 -0
- package/dist/components/MergeWorktree.test.js +227 -0
- package/dist/components/NewWorktree.js +88 -9
- package/dist/components/NewWorktree.test.d.ts +1 -0
- package/dist/components/NewWorktree.test.js +244 -0
- package/dist/components/ProjectList.js +44 -13
- package/dist/components/ProjectList.recent-projects.test.js +8 -3
- package/dist/components/ProjectList.test.js +105 -8
- package/dist/components/RemoteBranchSelector.test.js +3 -1
- package/dist/hooks/useGitStatus.d.ts +11 -0
- package/dist/hooks/useGitStatus.js +70 -12
- package/dist/hooks/useGitStatus.test.js +30 -23
- package/dist/services/configurationManager.d.ts +75 -0
- package/dist/services/configurationManager.effect.test.d.ts +1 -0
- package/dist/services/configurationManager.effect.test.js +407 -0
- package/dist/services/configurationManager.js +246 -0
- package/dist/services/globalSessionOrchestrator.test.js +0 -8
- package/dist/services/projectManager.d.ts +98 -2
- package/dist/services/projectManager.js +228 -59
- package/dist/services/projectManager.test.js +242 -2
- package/dist/services/sessionManager.d.ts +44 -2
- package/dist/services/sessionManager.effect.test.d.ts +1 -0
- package/dist/services/sessionManager.effect.test.js +321 -0
- package/dist/services/sessionManager.js +216 -65
- package/dist/services/sessionManager.statePersistence.test.js +18 -9
- package/dist/services/sessionManager.test.js +40 -36
- package/dist/services/worktreeService.d.ts +356 -26
- package/dist/services/worktreeService.js +793 -353
- package/dist/services/worktreeService.test.js +294 -313
- package/dist/types/errors.d.ts +74 -0
- package/dist/types/errors.js +31 -0
- package/dist/types/errors.test.d.ts +1 -0
- package/dist/types/errors.test.js +201 -0
- package/dist/types/index.d.ts +5 -17
- package/dist/utils/claudeDir.d.ts +58 -6
- package/dist/utils/claudeDir.js +103 -8
- package/dist/utils/claudeDir.test.d.ts +1 -0
- package/dist/utils/claudeDir.test.js +108 -0
- package/dist/utils/concurrencyLimit.d.ts +5 -0
- package/dist/utils/concurrencyLimit.js +11 -0
- package/dist/utils/concurrencyLimit.test.js +40 -1
- package/dist/utils/gitStatus.d.ts +36 -8
- package/dist/utils/gitStatus.js +170 -88
- package/dist/utils/gitStatus.test.js +12 -9
- package/dist/utils/hookExecutor.d.ts +41 -6
- package/dist/utils/hookExecutor.js +75 -32
- package/dist/utils/hookExecutor.test.js +73 -20
- package/dist/utils/terminalCapabilities.d.ts +18 -0
- package/dist/utils/terminalCapabilities.js +81 -0
- package/dist/utils/terminalCapabilities.test.d.ts +1 -0
- package/dist/utils/terminalCapabilities.test.js +104 -0
- package/dist/utils/testHelpers.d.ts +106 -0
- package/dist/utils/testHelpers.js +153 -0
- package/dist/utils/testHelpers.test.d.ts +1 -0
- package/dist/utils/testHelpers.test.js +114 -0
- package/dist/utils/worktreeConfig.d.ts +77 -2
- package/dist/utils/worktreeConfig.js +156 -16
- package/dist/utils/worktreeConfig.test.d.ts +1 -0
- package/dist/utils/worktreeConfig.test.js +39 -0
- package/package.json +4 -4
- package/dist/integration-tests/devcontainer.integration.test.js +0 -101
- /package/dist/{integration-tests/devcontainer.integration.test.d.ts → components/App.test.d.ts} +0 -0
package/dist/components/Menu.js
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
//
|
|
46
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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, '
|
|
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, '
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
88
|
+
catch (err) {
|
|
60
89
|
// Merge failed, show error
|
|
61
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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 {};
|