ccmanager 0.1.14 → 0.2.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.js +3 -0
- package/dist/components/Configuration.js +34 -6
- package/dist/components/Menu.js +87 -25
- package/dist/hooks/useGitStatus.d.ts +2 -0
- package/dist/hooks/useGitStatus.js +52 -0
- package/dist/hooks/useGitStatus.test.d.ts +1 -0
- package/dist/hooks/useGitStatus.test.js +186 -0
- package/dist/services/worktreeConfigManager.d.ts +10 -0
- package/dist/services/worktreeConfigManager.js +27 -0
- package/dist/services/worktreeService.js +8 -0
- package/dist/services/worktreeService.test.js +8 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/utils/concurrencyLimit.d.ts +4 -0
- package/dist/utils/concurrencyLimit.js +30 -0
- package/dist/utils/concurrencyLimit.test.d.ts +1 -0
- package/dist/utils/concurrencyLimit.test.js +63 -0
- package/dist/utils/gitStatus.d.ts +19 -0
- package/dist/utils/gitStatus.js +146 -0
- package/dist/utils/gitStatus.test.d.ts +1 -0
- package/dist/utils/gitStatus.test.js +141 -0
- package/dist/utils/worktreeConfig.d.ts +3 -0
- package/dist/utils/worktreeConfig.js +43 -0
- package/dist/utils/worktreeUtils.d.ts +37 -0
- package/dist/utils/worktreeUtils.js +114 -0
- package/dist/utils/worktreeUtils.test.js +105 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import React from 'react';
|
|
|
3
3
|
import { render } from 'ink';
|
|
4
4
|
import meow from 'meow';
|
|
5
5
|
import App from './components/App.js';
|
|
6
|
+
import { worktreeConfigManager } from './services/worktreeConfigManager.js';
|
|
6
7
|
meow(`
|
|
7
8
|
Usage
|
|
8
9
|
$ ccmanager
|
|
@@ -21,4 +22,6 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
|
21
22
|
console.error('Error: ccmanager must be run in an interactive terminal (TTY)');
|
|
22
23
|
process.exit(1);
|
|
23
24
|
}
|
|
25
|
+
// Initialize worktree config manager
|
|
26
|
+
worktreeConfigManager.initialize();
|
|
24
27
|
render(React.createElement(App, null));
|
|
@@ -1,31 +1,32 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
3
|
import SelectInput from 'ink-select-input';
|
|
4
4
|
import ConfigureShortcuts from './ConfigureShortcuts.js';
|
|
5
5
|
import ConfigureHooks from './ConfigureHooks.js';
|
|
6
6
|
import ConfigureWorktree from './ConfigureWorktree.js';
|
|
7
7
|
import ConfigureCommand from './ConfigureCommand.js';
|
|
8
|
+
import { shortcutManager } from '../services/shortcutManager.js';
|
|
8
9
|
const Configuration = ({ onComplete }) => {
|
|
9
10
|
const [view, setView] = useState('menu');
|
|
10
11
|
const menuItems = [
|
|
11
12
|
{
|
|
12
|
-
label: '⌨ Configure Shortcuts',
|
|
13
|
+
label: 'S ⌨ Configure Shortcuts',
|
|
13
14
|
value: 'shortcuts',
|
|
14
15
|
},
|
|
15
16
|
{
|
|
16
|
-
label: '🔧 Configure Status Hooks',
|
|
17
|
+
label: 'H 🔧 Configure Status Hooks',
|
|
17
18
|
value: 'hooks',
|
|
18
19
|
},
|
|
19
20
|
{
|
|
20
|
-
label: '📁 Configure Worktree Settings',
|
|
21
|
+
label: 'W 📁 Configure Worktree Settings',
|
|
21
22
|
value: 'worktree',
|
|
22
23
|
},
|
|
23
24
|
{
|
|
24
|
-
label: '🚀 Configure Command',
|
|
25
|
+
label: 'C 🚀 Configure Command',
|
|
25
26
|
value: 'command',
|
|
26
27
|
},
|
|
27
28
|
{
|
|
28
|
-
label: '← Back to Main Menu',
|
|
29
|
+
label: 'B ← Back to Main Menu',
|
|
29
30
|
value: 'back',
|
|
30
31
|
},
|
|
31
32
|
];
|
|
@@ -49,6 +50,33 @@ const Configuration = ({ onComplete }) => {
|
|
|
49
50
|
const handleSubMenuComplete = () => {
|
|
50
51
|
setView('menu');
|
|
51
52
|
};
|
|
53
|
+
// Handle hotkeys (only when in menu view)
|
|
54
|
+
useInput((input, key) => {
|
|
55
|
+
if (view !== 'menu')
|
|
56
|
+
return; // Only handle hotkeys in menu view
|
|
57
|
+
const keyPressed = input.toLowerCase();
|
|
58
|
+
switch (keyPressed) {
|
|
59
|
+
case 's':
|
|
60
|
+
setView('shortcuts');
|
|
61
|
+
break;
|
|
62
|
+
case 'h':
|
|
63
|
+
setView('hooks');
|
|
64
|
+
break;
|
|
65
|
+
case 'w':
|
|
66
|
+
setView('worktree');
|
|
67
|
+
break;
|
|
68
|
+
case 'c':
|
|
69
|
+
setView('command');
|
|
70
|
+
break;
|
|
71
|
+
case 'b':
|
|
72
|
+
onComplete();
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
// Handle escape key
|
|
76
|
+
if (shortcutManager.matchesShortcut('cancel', input, key)) {
|
|
77
|
+
onComplete();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
52
80
|
if (view === 'shortcuts') {
|
|
53
81
|
return React.createElement(ConfigureShortcuts, { onComplete: handleSubMenuComplete });
|
|
54
82
|
}
|
package/dist/components/Menu.js
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import React, { useState, useEffect } from 'react';
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
3
|
import SelectInput from 'ink-select-input';
|
|
4
4
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
5
|
-
import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS,
|
|
5
|
+
import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, } from '../constants/statusIcons.js';
|
|
6
|
+
import { useGitStatus } from '../hooks/useGitStatus.js';
|
|
7
|
+
import { prepareWorktreeItems, calculateColumnPositions, assembleWorktreeLabel, } from '../utils/worktreeUtils.js';
|
|
6
8
|
const Menu = ({ sessionManager, onSelectWorktree }) => {
|
|
7
|
-
const [
|
|
9
|
+
const [baseWorktrees, setBaseWorktrees] = useState([]);
|
|
10
|
+
const [defaultBranch, setDefaultBranch] = useState(null);
|
|
11
|
+
const worktrees = useGitStatus(baseWorktrees, defaultBranch);
|
|
8
12
|
const [sessions, setSessions] = useState([]);
|
|
9
13
|
const [items, setItems] = useState([]);
|
|
10
14
|
useEffect(() => {
|
|
11
15
|
// Load worktrees
|
|
12
16
|
const worktreeService = new WorktreeService();
|
|
13
17
|
const loadedWorktrees = worktreeService.getWorktrees();
|
|
14
|
-
|
|
18
|
+
setBaseWorktrees(loadedWorktrees);
|
|
19
|
+
setDefaultBranch(worktreeService.getDefaultBranch());
|
|
15
20
|
// Update sessions
|
|
16
21
|
const updateSessions = () => {
|
|
17
22
|
const allSessions = sessionManager.getAllSessions();
|
|
@@ -34,21 +39,18 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
|
|
|
34
39
|
};
|
|
35
40
|
}, [sessionManager]);
|
|
36
41
|
useEffect(() => {
|
|
37
|
-
//
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
? wt.branch.replace('refs/heads/', '')
|
|
46
|
-
: 'detached';
|
|
47
|
-
const isMain = wt.isMainWorktree ? ' (main)' : '';
|
|
42
|
+
// Prepare worktree items and calculate layout
|
|
43
|
+
const items = prepareWorktreeItems(worktrees, sessions);
|
|
44
|
+
const columnPositions = calculateColumnPositions(items);
|
|
45
|
+
// Build menu items with proper alignment
|
|
46
|
+
const menuItems = items.map((item, index) => {
|
|
47
|
+
const label = assembleWorktreeLabel(item, columnPositions);
|
|
48
|
+
// Only show numbers for first 10 worktrees (0-9)
|
|
49
|
+
const numberPrefix = index < 10 ? `${index} ❯ ` : '❯ ';
|
|
48
50
|
return {
|
|
49
|
-
label:
|
|
50
|
-
value:
|
|
51
|
-
worktree:
|
|
51
|
+
label: numberPrefix + label,
|
|
52
|
+
value: item.worktree.path,
|
|
53
|
+
worktree: item.worktree,
|
|
52
54
|
};
|
|
53
55
|
});
|
|
54
56
|
// Add menu options
|
|
@@ -57,27 +59,87 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
|
|
|
57
59
|
value: 'separator',
|
|
58
60
|
});
|
|
59
61
|
menuItems.push({
|
|
60
|
-
label:
|
|
62
|
+
label: `N ${MENU_ICONS.NEW_WORKTREE} New Worktree`,
|
|
61
63
|
value: 'new-worktree',
|
|
62
64
|
});
|
|
63
65
|
menuItems.push({
|
|
64
|
-
label:
|
|
66
|
+
label: `M ${MENU_ICONS.MERGE_WORKTREE} Merge Worktree`,
|
|
65
67
|
value: 'merge-worktree',
|
|
66
68
|
});
|
|
67
69
|
menuItems.push({
|
|
68
|
-
label:
|
|
70
|
+
label: `D ${MENU_ICONS.DELETE_WORKTREE} Delete Worktree`,
|
|
69
71
|
value: 'delete-worktree',
|
|
70
72
|
});
|
|
71
73
|
menuItems.push({
|
|
72
|
-
label:
|
|
74
|
+
label: `C ${MENU_ICONS.CONFIGURE_SHORTCUTS} Configuration`,
|
|
73
75
|
value: 'configuration',
|
|
74
76
|
});
|
|
75
77
|
menuItems.push({
|
|
76
|
-
label:
|
|
78
|
+
label: `Q ${MENU_ICONS.EXIT} Exit`,
|
|
77
79
|
value: 'exit',
|
|
78
80
|
});
|
|
79
81
|
setItems(menuItems);
|
|
80
|
-
}, [worktrees, sessions]);
|
|
82
|
+
}, [worktrees, sessions, defaultBranch]);
|
|
83
|
+
// Handle hotkeys
|
|
84
|
+
useInput((input, _key) => {
|
|
85
|
+
const keyPressed = input.toLowerCase();
|
|
86
|
+
// Handle number keys 0-9 for worktree selection (first 10 only)
|
|
87
|
+
if (/^[0-9]$/.test(keyPressed)) {
|
|
88
|
+
const index = parseInt(keyPressed);
|
|
89
|
+
if (index < Math.min(10, worktrees.length) && worktrees[index]) {
|
|
90
|
+
onSelectWorktree(worktrees[index]);
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
switch (keyPressed) {
|
|
95
|
+
case 'n':
|
|
96
|
+
// Trigger new worktree action
|
|
97
|
+
onSelectWorktree({
|
|
98
|
+
path: '',
|
|
99
|
+
branch: '',
|
|
100
|
+
isMainWorktree: false,
|
|
101
|
+
hasSession: false,
|
|
102
|
+
});
|
|
103
|
+
break;
|
|
104
|
+
case 'm':
|
|
105
|
+
// Trigger merge worktree action
|
|
106
|
+
onSelectWorktree({
|
|
107
|
+
path: 'MERGE_WORKTREE',
|
|
108
|
+
branch: '',
|
|
109
|
+
isMainWorktree: false,
|
|
110
|
+
hasSession: false,
|
|
111
|
+
});
|
|
112
|
+
break;
|
|
113
|
+
case 'd':
|
|
114
|
+
// Trigger delete worktree action
|
|
115
|
+
onSelectWorktree({
|
|
116
|
+
path: 'DELETE_WORKTREE',
|
|
117
|
+
branch: '',
|
|
118
|
+
isMainWorktree: false,
|
|
119
|
+
hasSession: false,
|
|
120
|
+
});
|
|
121
|
+
break;
|
|
122
|
+
case 'c':
|
|
123
|
+
// Trigger configuration action
|
|
124
|
+
onSelectWorktree({
|
|
125
|
+
path: 'CONFIGURATION',
|
|
126
|
+
branch: '',
|
|
127
|
+
isMainWorktree: false,
|
|
128
|
+
hasSession: false,
|
|
129
|
+
});
|
|
130
|
+
break;
|
|
131
|
+
case 'q':
|
|
132
|
+
case 'x':
|
|
133
|
+
// Trigger exit action
|
|
134
|
+
onSelectWorktree({
|
|
135
|
+
path: 'EXIT_APPLICATION',
|
|
136
|
+
branch: '',
|
|
137
|
+
isMainWorktree: false,
|
|
138
|
+
hasSession: false,
|
|
139
|
+
});
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
81
143
|
const handleSelect = (item) => {
|
|
82
144
|
if (item.value === 'separator') {
|
|
83
145
|
// Do nothing for separator
|
|
@@ -151,6 +213,6 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
|
|
|
151
213
|
STATUS_ICONS.IDLE,
|
|
152
214
|
' ',
|
|
153
215
|
STATUS_LABELS.IDLE),
|
|
154
|
-
React.createElement(Text, { dimColor: true }, "Controls: \u2191\u2193 Navigate Enter Select"))));
|
|
216
|
+
React.createElement(Text, { dimColor: true }, "Controls: \u2191\u2193 Navigate Enter Select | Hotkeys: 0-9 Quick Select (first 10) N-New M-Merge D-Delete C-Config Q-Quit"))));
|
|
155
217
|
};
|
|
156
218
|
export default Menu;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { getGitStatusLimited } from '../utils/gitStatus.js';
|
|
3
|
+
export function useGitStatus(worktrees, defaultBranch, updateInterval = 5000) {
|
|
4
|
+
const [worktreesWithStatus, setWorktreesWithStatus] = useState(worktrees);
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
if (!defaultBranch) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
const timeouts = new Map();
|
|
10
|
+
const activeRequests = new Map();
|
|
11
|
+
let isCleanedUp = false;
|
|
12
|
+
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
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const scheduleUpdate = (worktree) => {
|
|
26
|
+
const abortController = new AbortController();
|
|
27
|
+
activeRequests.set(worktree.path, abortController);
|
|
28
|
+
fetchStatus(worktree, abortController).finally(() => {
|
|
29
|
+
const isActive = () => !isCleanedUp && !abortController.signal.aborted;
|
|
30
|
+
if (isActive()) {
|
|
31
|
+
const timeout = setTimeout(() => {
|
|
32
|
+
if (isActive()) {
|
|
33
|
+
scheduleUpdate(worktree);
|
|
34
|
+
}
|
|
35
|
+
}, updateInterval);
|
|
36
|
+
timeouts.set(worktree.path, timeout);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
setWorktreesWithStatus(worktrees);
|
|
41
|
+
// Start fetching for each worktree
|
|
42
|
+
worktrees.forEach(worktree => {
|
|
43
|
+
scheduleUpdate(worktree);
|
|
44
|
+
});
|
|
45
|
+
return () => {
|
|
46
|
+
isCleanedUp = true;
|
|
47
|
+
timeouts.forEach(timeout => clearTimeout(timeout));
|
|
48
|
+
activeRequests.forEach(controller => controller.abort());
|
|
49
|
+
};
|
|
50
|
+
}, [worktrees, defaultBranch, updateInterval]);
|
|
51
|
+
return worktreesWithStatus;
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { render, cleanup } from 'ink-testing-library';
|
|
4
|
+
import { Text } from 'ink';
|
|
5
|
+
import { useGitStatus } from './useGitStatus.js';
|
|
6
|
+
import { getGitStatusLimited } from '../utils/gitStatus.js';
|
|
7
|
+
// Mock the gitStatus module
|
|
8
|
+
vi.mock('../utils/gitStatus.js', () => ({
|
|
9
|
+
getGitStatusLimited: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
describe('useGitStatus', () => {
|
|
12
|
+
const mockGetGitStatus = getGitStatusLimited;
|
|
13
|
+
const createWorktree = (path) => ({
|
|
14
|
+
path,
|
|
15
|
+
branch: 'main',
|
|
16
|
+
isMainWorktree: false,
|
|
17
|
+
hasSession: false,
|
|
18
|
+
});
|
|
19
|
+
const createGitStatus = (added = 1, deleted = 0) => ({
|
|
20
|
+
filesAdded: added,
|
|
21
|
+
filesDeleted: deleted,
|
|
22
|
+
aheadCount: 0,
|
|
23
|
+
behindCount: 0,
|
|
24
|
+
parentBranch: 'main',
|
|
25
|
+
});
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.useFakeTimers();
|
|
28
|
+
mockGetGitStatus.mockClear();
|
|
29
|
+
});
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.useRealTimers();
|
|
32
|
+
cleanup();
|
|
33
|
+
});
|
|
34
|
+
// Main behavioral test
|
|
35
|
+
it('should fetch and update git status for worktrees', async () => {
|
|
36
|
+
const worktrees = [createWorktree('/path1'), createWorktree('/path2')];
|
|
37
|
+
const gitStatus1 = createGitStatus(5, 3);
|
|
38
|
+
const gitStatus2 = createGitStatus(2, 1);
|
|
39
|
+
let hookResult = [];
|
|
40
|
+
mockGetGitStatus.mockImplementation(async (path) => {
|
|
41
|
+
if (path === '/path1') {
|
|
42
|
+
return { success: true, data: gitStatus1 };
|
|
43
|
+
}
|
|
44
|
+
return { success: true, data: gitStatus2 };
|
|
45
|
+
});
|
|
46
|
+
const TestComponent = () => {
|
|
47
|
+
hookResult = useGitStatus(worktrees, 'main', 100);
|
|
48
|
+
return React.createElement(Text, null, 'test');
|
|
49
|
+
};
|
|
50
|
+
render(React.createElement(TestComponent));
|
|
51
|
+
// Should return worktrees immediately
|
|
52
|
+
expect(hookResult).toEqual(worktrees);
|
|
53
|
+
// Wait for status updates
|
|
54
|
+
await vi.waitFor(() => {
|
|
55
|
+
expect(hookResult[0]?.gitStatus).toBeDefined();
|
|
56
|
+
expect(hookResult[1]?.gitStatus).toBeDefined();
|
|
57
|
+
});
|
|
58
|
+
// Should have correct status for each worktree
|
|
59
|
+
expect(hookResult[0]?.gitStatus).toEqual(gitStatus1);
|
|
60
|
+
expect(hookResult[1]?.gitStatus).toEqual(gitStatus2);
|
|
61
|
+
});
|
|
62
|
+
it('should handle empty worktree array', () => {
|
|
63
|
+
let hookResult = [];
|
|
64
|
+
const TestComponent = () => {
|
|
65
|
+
hookResult = useGitStatus([], 'main');
|
|
66
|
+
return React.createElement(Text, null, 'test');
|
|
67
|
+
};
|
|
68
|
+
render(React.createElement(TestComponent));
|
|
69
|
+
expect(hookResult).toEqual([]);
|
|
70
|
+
expect(mockGetGitStatus).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
it('should not fetch when defaultBranch is null', async () => {
|
|
73
|
+
const worktrees = [createWorktree('/path1'), createWorktree('/path2')];
|
|
74
|
+
let hookResult = [];
|
|
75
|
+
const TestComponent = () => {
|
|
76
|
+
hookResult = useGitStatus(worktrees, null);
|
|
77
|
+
return React.createElement(Text, null, 'test');
|
|
78
|
+
};
|
|
79
|
+
render(React.createElement(TestComponent));
|
|
80
|
+
// Should return worktrees immediately without modification
|
|
81
|
+
expect(hookResult).toEqual(worktrees);
|
|
82
|
+
// Wait to ensure no fetches occur
|
|
83
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
84
|
+
expect(mockGetGitStatus).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
it('should continue polling after errors', async () => {
|
|
87
|
+
const worktrees = [createWorktree('/path1')];
|
|
88
|
+
mockGetGitStatus.mockResolvedValue({
|
|
89
|
+
success: false,
|
|
90
|
+
error: 'Git error',
|
|
91
|
+
});
|
|
92
|
+
const TestComponent = () => {
|
|
93
|
+
useGitStatus(worktrees, 'main', 100);
|
|
94
|
+
return React.createElement(Text, null, 'test');
|
|
95
|
+
};
|
|
96
|
+
render(React.createElement(TestComponent));
|
|
97
|
+
// Wait for initial fetch
|
|
98
|
+
await vi.waitFor(() => {
|
|
99
|
+
expect(mockGetGitStatus).toHaveBeenCalledTimes(1);
|
|
100
|
+
});
|
|
101
|
+
// Clear to track subsequent calls
|
|
102
|
+
mockGetGitStatus.mockClear();
|
|
103
|
+
// Advance time and verify polling continues despite errors
|
|
104
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
105
|
+
expect(mockGetGitStatus).toHaveBeenCalledTimes(1);
|
|
106
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
107
|
+
expect(mockGetGitStatus).toHaveBeenCalledTimes(2);
|
|
108
|
+
// All calls should have been made despite continuous errors
|
|
109
|
+
expect(mockGetGitStatus).toHaveBeenCalledWith('/path1', expect.any(AbortSignal));
|
|
110
|
+
});
|
|
111
|
+
it('should handle slow git operations that exceed update interval', async () => {
|
|
112
|
+
const worktrees = [createWorktree('/path1')];
|
|
113
|
+
let fetchCount = 0;
|
|
114
|
+
let resolveFetch = null;
|
|
115
|
+
mockGetGitStatus.mockImplementation(async () => {
|
|
116
|
+
fetchCount++;
|
|
117
|
+
// Create a promise that we can resolve manually
|
|
118
|
+
return new Promise(resolve => {
|
|
119
|
+
resolveFetch = resolve;
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
const TestComponent = () => {
|
|
123
|
+
useGitStatus(worktrees, 'main', 100);
|
|
124
|
+
return React.createElement(Text, null, 'test');
|
|
125
|
+
};
|
|
126
|
+
render(React.createElement(TestComponent));
|
|
127
|
+
// Wait for initial fetch to start
|
|
128
|
+
await vi.waitFor(() => {
|
|
129
|
+
expect(mockGetGitStatus).toHaveBeenCalledTimes(1);
|
|
130
|
+
});
|
|
131
|
+
// Advance time past the update interval while fetch is still pending
|
|
132
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
133
|
+
// Should not have started a second fetch yet
|
|
134
|
+
expect(mockGetGitStatus).toHaveBeenCalledTimes(1);
|
|
135
|
+
// Complete the first fetch
|
|
136
|
+
resolveFetch({ success: true, data: createGitStatus(1, 0) });
|
|
137
|
+
// Wait for the promise to resolve
|
|
138
|
+
await vi.waitFor(() => {
|
|
139
|
+
expect(fetchCount).toBe(1);
|
|
140
|
+
});
|
|
141
|
+
// Now advance time by the update interval
|
|
142
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
143
|
+
// Should have started the second fetch
|
|
144
|
+
await vi.waitFor(() => {
|
|
145
|
+
expect(mockGetGitStatus).toHaveBeenCalledTimes(2);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
it('should properly cleanup resources when worktrees change', async () => {
|
|
149
|
+
let activeRequests = 0;
|
|
150
|
+
const abortedSignals = [];
|
|
151
|
+
mockGetGitStatus.mockImplementation(async (path, signal) => {
|
|
152
|
+
activeRequests++;
|
|
153
|
+
signal.addEventListener('abort', () => {
|
|
154
|
+
activeRequests--;
|
|
155
|
+
abortedSignals.push(signal);
|
|
156
|
+
});
|
|
157
|
+
// Simulate ongoing request
|
|
158
|
+
return new Promise(() => { });
|
|
159
|
+
});
|
|
160
|
+
const TestComponent = ({ worktrees }) => {
|
|
161
|
+
useGitStatus(worktrees, 'main', 100);
|
|
162
|
+
return React.createElement(Text, null, 'test');
|
|
163
|
+
};
|
|
164
|
+
// Start with 3 worktrees
|
|
165
|
+
const initialWorktrees = [
|
|
166
|
+
createWorktree('/path1'),
|
|
167
|
+
createWorktree('/path2'),
|
|
168
|
+
createWorktree('/path3'),
|
|
169
|
+
];
|
|
170
|
+
const { rerender } = render(React.createElement(TestComponent, { worktrees: initialWorktrees }));
|
|
171
|
+
// Should have 3 active requests
|
|
172
|
+
await vi.waitFor(() => {
|
|
173
|
+
expect(activeRequests).toBe(3);
|
|
174
|
+
});
|
|
175
|
+
// Change to 2 different worktrees
|
|
176
|
+
const newWorktrees = [createWorktree('/path4'), createWorktree('/path5')];
|
|
177
|
+
rerender(React.createElement(TestComponent, { worktrees: newWorktrees }));
|
|
178
|
+
// Wait for cleanup and new requests
|
|
179
|
+
await vi.waitFor(() => {
|
|
180
|
+
expect(abortedSignals).toHaveLength(3);
|
|
181
|
+
expect(activeRequests).toBe(2);
|
|
182
|
+
});
|
|
183
|
+
// Verify all old signals were aborted
|
|
184
|
+
expect(abortedSignals.every(signal => signal.aborted)).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
declare class WorktreeConfigManager {
|
|
2
|
+
private static instance;
|
|
3
|
+
private isExtensionAvailable;
|
|
4
|
+
private constructor();
|
|
5
|
+
static getInstance(): WorktreeConfigManager;
|
|
6
|
+
initialize(gitPath?: string): void;
|
|
7
|
+
isAvailable(): boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare const worktreeConfigManager: WorktreeConfigManager;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { isWorktreeConfigEnabled } from '../utils/worktreeConfig.js';
|
|
2
|
+
class WorktreeConfigManager {
|
|
3
|
+
constructor() {
|
|
4
|
+
Object.defineProperty(this, "isExtensionAvailable", {
|
|
5
|
+
enumerable: true,
|
|
6
|
+
configurable: true,
|
|
7
|
+
writable: true,
|
|
8
|
+
value: null
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
static getInstance() {
|
|
12
|
+
if (!WorktreeConfigManager.instance) {
|
|
13
|
+
WorktreeConfigManager.instance = new WorktreeConfigManager();
|
|
14
|
+
}
|
|
15
|
+
return WorktreeConfigManager.instance;
|
|
16
|
+
}
|
|
17
|
+
initialize(gitPath) {
|
|
18
|
+
this.isExtensionAvailable = isWorktreeConfigEnabled(gitPath);
|
|
19
|
+
}
|
|
20
|
+
isAvailable() {
|
|
21
|
+
if (this.isExtensionAvailable === null) {
|
|
22
|
+
throw new Error('WorktreeConfigManager not initialized');
|
|
23
|
+
}
|
|
24
|
+
return this.isExtensionAvailable;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export const worktreeConfigManager = WorktreeConfigManager.getInstance();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execSync } from 'child_process';
|
|
2
2
|
import { existsSync } from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import { setWorktreeParentBranch } from '../utils/worktreeConfig.js';
|
|
4
5
|
export class WorktreeService {
|
|
5
6
|
constructor(rootPath) {
|
|
6
7
|
Object.defineProperty(this, "rootPath", {
|
|
@@ -201,6 +202,13 @@ export class WorktreeService {
|
|
|
201
202
|
cwd: this.gitRootPath, // Execute from git root to ensure proper resolution
|
|
202
203
|
encoding: 'utf8',
|
|
203
204
|
});
|
|
205
|
+
// Store the parent branch in worktree config
|
|
206
|
+
try {
|
|
207
|
+
setWorktreeParentBranch(resolvedPath, baseBranch);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
console.error('Warning: Failed to set parent branch in worktree config:', error);
|
|
211
|
+
}
|
|
204
212
|
return { success: true };
|
|
205
213
|
}
|
|
206
214
|
catch (error) {
|
|
@@ -3,6 +3,14 @@ import { WorktreeService } from './worktreeService.js';
|
|
|
3
3
|
import { execSync } from 'child_process';
|
|
4
4
|
// Mock child_process module
|
|
5
5
|
vi.mock('child_process');
|
|
6
|
+
// Mock worktreeConfigManager
|
|
7
|
+
vi.mock('./worktreeConfigManager.js', () => ({
|
|
8
|
+
worktreeConfigManager: {
|
|
9
|
+
initialize: vi.fn(),
|
|
10
|
+
isAvailable: vi.fn(() => true),
|
|
11
|
+
reset: vi.fn(),
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
6
14
|
// Get the mocked function with proper typing
|
|
7
15
|
const mockedExecSync = vi.mocked(execSync);
|
|
8
16
|
describe('WorktreeService', () => {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { IPty } from 'node-pty';
|
|
2
2
|
import type pkg from '@xterm/headless';
|
|
3
|
+
import { GitStatus } from '../utils/gitStatus.js';
|
|
3
4
|
export type Terminal = InstanceType<typeof pkg.Terminal>;
|
|
4
5
|
export type SessionState = 'idle' | 'busy' | 'waiting_input';
|
|
5
6
|
export interface Worktree {
|
|
@@ -7,6 +8,8 @@ export interface Worktree {
|
|
|
7
8
|
branch?: string;
|
|
8
9
|
isMainWorktree: boolean;
|
|
9
10
|
hasSession: boolean;
|
|
11
|
+
gitStatus?: GitStatus;
|
|
12
|
+
gitStatusError?: string;
|
|
10
13
|
}
|
|
11
14
|
export interface Session {
|
|
12
15
|
id: string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create a function that limits concurrent executions
|
|
3
|
+
*/
|
|
4
|
+
export function createConcurrencyLimited(fn, maxConcurrent) {
|
|
5
|
+
if (maxConcurrent < 1) {
|
|
6
|
+
throw new RangeError('maxConcurrent must be at least 1');
|
|
7
|
+
}
|
|
8
|
+
let activeCount = 0;
|
|
9
|
+
const queue = [];
|
|
10
|
+
return async (...args) => {
|
|
11
|
+
// Wait for a slot if at capacity
|
|
12
|
+
if (activeCount >= maxConcurrent) {
|
|
13
|
+
await new Promise(resolve => {
|
|
14
|
+
queue.push(resolve);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
activeCount++;
|
|
18
|
+
try {
|
|
19
|
+
return await fn(...args);
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
activeCount--;
|
|
23
|
+
// Release the next waiter in queue
|
|
24
|
+
const next = queue.shift();
|
|
25
|
+
if (next) {
|
|
26
|
+
next();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|