ccmanager 0.1.15 → 0.2.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.
- package/dist/cli.js +3 -0
- package/dist/components/App.js +35 -1
- package/dist/components/ConfigureCommand.js +367 -121
- package/dist/components/Menu.js +18 -18
- package/dist/components/PresetSelector.d.ts +7 -0
- package/dist/components/PresetSelector.js +52 -0
- 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/configurationManager.d.ts +11 -1
- package/dist/services/configurationManager.js +111 -3
- package/dist/services/configurationManager.selectPresetOnStart.test.d.ts +1 -0
- package/dist/services/configurationManager.selectPresetOnStart.test.js +103 -0
- package/dist/services/configurationManager.test.d.ts +1 -0
- package/dist/services/configurationManager.test.js +313 -0
- package/dist/services/sessionManager.d.ts +1 -0
- package/dist/services/sessionManager.js +69 -0
- package/dist/services/sessionManager.test.js +103 -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 +16 -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/components/Menu.js
CHANGED
|
@@ -2,16 +2,21 @@ import React, { useState, useEffect } from 'react';
|
|
|
2
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,23 +39,18 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
|
|
|
34
39
|
};
|
|
35
40
|
}, [sessionManager]);
|
|
36
41
|
useEffect(() => {
|
|
37
|
-
//
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
const branchName = wt.branch
|
|
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
48
|
// Only show numbers for first 10 worktrees (0-9)
|
|
49
49
|
const numberPrefix = index < 10 ? `${index} ❯ ` : '❯ ';
|
|
50
50
|
return {
|
|
51
|
-
label:
|
|
52
|
-
value:
|
|
53
|
-
worktree:
|
|
51
|
+
label: numberPrefix + label,
|
|
52
|
+
value: item.worktree.path,
|
|
53
|
+
worktree: item.worktree,
|
|
54
54
|
};
|
|
55
55
|
});
|
|
56
56
|
// Add menu options
|
|
@@ -79,7 +79,7 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
|
|
|
79
79
|
value: 'exit',
|
|
80
80
|
});
|
|
81
81
|
setItems(menuItems);
|
|
82
|
-
}, [worktrees, sessions]);
|
|
82
|
+
}, [worktrees, sessions, defaultBranch]);
|
|
83
83
|
// Handle hotkeys
|
|
84
84
|
useInput((input, _key) => {
|
|
85
85
|
const keyPressed = input.toLowerCase();
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import SelectInput from 'ink-select-input';
|
|
4
|
+
import { configurationManager } from '../services/configurationManager.js';
|
|
5
|
+
const PresetSelector = ({ onSelect, onCancel, }) => {
|
|
6
|
+
const presetsConfig = configurationManager.getCommandPresets();
|
|
7
|
+
const [presets] = useState(presetsConfig.presets);
|
|
8
|
+
const defaultPresetId = presetsConfig.defaultPresetId;
|
|
9
|
+
const selectItems = presets.map(preset => {
|
|
10
|
+
const isDefault = preset.id === defaultPresetId;
|
|
11
|
+
const args = preset.args?.join(' ') || '';
|
|
12
|
+
const fallback = preset.fallbackArgs?.join(' ') || '';
|
|
13
|
+
let label = preset.name;
|
|
14
|
+
if (isDefault)
|
|
15
|
+
label += ' (default)';
|
|
16
|
+
label += `\n Command: ${preset.command}`;
|
|
17
|
+
if (args)
|
|
18
|
+
label += `\n Args: ${args}`;
|
|
19
|
+
if (fallback)
|
|
20
|
+
label += `\n Fallback: ${fallback}`;
|
|
21
|
+
return {
|
|
22
|
+
label,
|
|
23
|
+
value: preset.id,
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
// Add cancel option
|
|
27
|
+
selectItems.push({ label: '← Cancel', value: 'cancel' });
|
|
28
|
+
const handleSelectItem = (item) => {
|
|
29
|
+
if (item.value === 'cancel') {
|
|
30
|
+
onCancel();
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
onSelect(item.value);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
// Find initial index based on default preset
|
|
37
|
+
const initialIndex = selectItems.findIndex(item => item.value === defaultPresetId);
|
|
38
|
+
useInput((input, key) => {
|
|
39
|
+
if (key.escape) {
|
|
40
|
+
onCancel();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
44
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
45
|
+
React.createElement(Text, { bold: true, color: "green" }, "Select Command Preset")),
|
|
46
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
47
|
+
React.createElement(Text, { dimColor: true }, "Choose a preset to start the session with")),
|
|
48
|
+
React.createElement(SelectInput, { items: selectItems, onSelect: handleSelectItem, initialIndex: initialIndex >= 0 ? initialIndex : 0 }),
|
|
49
|
+
React.createElement(Box, { marginTop: 1 },
|
|
50
|
+
React.createElement(Text, { dimColor: true }, "Press \u2191\u2193 to navigate, Enter to select, ESC to cancel"))));
|
|
51
|
+
};
|
|
52
|
+
export default PresetSelector;
|
|
@@ -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
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigurationData, StatusHookConfig, ShortcutConfig, WorktreeConfig, CommandConfig } from '../types/index.js';
|
|
1
|
+
import { ConfigurationData, StatusHookConfig, ShortcutConfig, WorktreeConfig, CommandConfig, CommandPreset, CommandPresetsConfig } from '../types/index.js';
|
|
2
2
|
export declare class ConfigurationManager {
|
|
3
3
|
private configPath;
|
|
4
4
|
private legacyShortcutsPath;
|
|
@@ -17,5 +17,15 @@ export declare class ConfigurationManager {
|
|
|
17
17
|
setWorktreeConfig(worktreeConfig: WorktreeConfig): void;
|
|
18
18
|
getCommandConfig(): CommandConfig;
|
|
19
19
|
setCommandConfig(commandConfig: CommandConfig): void;
|
|
20
|
+
private migrateLegacyCommandToPresets;
|
|
21
|
+
getCommandPresets(): CommandPresetsConfig;
|
|
22
|
+
setCommandPresets(presets: CommandPresetsConfig): void;
|
|
23
|
+
getDefaultPreset(): CommandPreset;
|
|
24
|
+
getPresetById(id: string): CommandPreset | undefined;
|
|
25
|
+
addPreset(preset: CommandPreset): void;
|
|
26
|
+
deletePreset(id: string): void;
|
|
27
|
+
setDefaultPreset(id: string): void;
|
|
28
|
+
getSelectPresetOnStart(): boolean;
|
|
29
|
+
setSelectPresetOnStart(enabled: boolean): void;
|
|
20
30
|
}
|
|
21
31
|
export declare const configurationManager: ConfigurationManager;
|
|
@@ -73,6 +73,8 @@ export class ConfigurationManager {
|
|
|
73
73
|
command: 'claude',
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
|
+
// Migrate legacy command config to presets if needed
|
|
77
|
+
this.migrateLegacyCommandToPresets();
|
|
76
78
|
}
|
|
77
79
|
migrateLegacyShortcuts() {
|
|
78
80
|
if (existsSync(this.legacyShortcutsPath)) {
|
|
@@ -131,13 +133,119 @@ export class ConfigurationManager {
|
|
|
131
133
|
this.saveConfig();
|
|
132
134
|
}
|
|
133
135
|
getCommandConfig() {
|
|
134
|
-
return
|
|
135
|
-
|
|
136
|
-
|
|
136
|
+
// For backward compatibility, return the default preset as CommandConfig
|
|
137
|
+
const defaultPreset = this.getDefaultPreset();
|
|
138
|
+
return {
|
|
139
|
+
command: defaultPreset.command,
|
|
140
|
+
args: defaultPreset.args,
|
|
141
|
+
fallbackArgs: defaultPreset.fallbackArgs,
|
|
142
|
+
};
|
|
137
143
|
}
|
|
138
144
|
setCommandConfig(commandConfig) {
|
|
139
145
|
this.config.command = commandConfig;
|
|
146
|
+
// Also update the default preset for backward compatibility
|
|
147
|
+
if (this.config.commandPresets) {
|
|
148
|
+
const defaultPreset = this.config.commandPresets.presets.find(p => p.id === this.config.commandPresets.defaultPresetId);
|
|
149
|
+
if (defaultPreset) {
|
|
150
|
+
defaultPreset.command = commandConfig.command;
|
|
151
|
+
defaultPreset.args = commandConfig.args;
|
|
152
|
+
defaultPreset.fallbackArgs = commandConfig.fallbackArgs;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
this.saveConfig();
|
|
156
|
+
}
|
|
157
|
+
migrateLegacyCommandToPresets() {
|
|
158
|
+
// Only migrate if we have legacy command config but no presets
|
|
159
|
+
if (this.config.command && !this.config.commandPresets) {
|
|
160
|
+
const defaultPreset = {
|
|
161
|
+
id: '1',
|
|
162
|
+
name: 'Main',
|
|
163
|
+
command: this.config.command.command,
|
|
164
|
+
args: this.config.command.args,
|
|
165
|
+
fallbackArgs: this.config.command.fallbackArgs,
|
|
166
|
+
};
|
|
167
|
+
this.config.commandPresets = {
|
|
168
|
+
presets: [defaultPreset],
|
|
169
|
+
defaultPresetId: '1',
|
|
170
|
+
};
|
|
171
|
+
this.saveConfig();
|
|
172
|
+
}
|
|
173
|
+
// Ensure default presets if none exist
|
|
174
|
+
if (!this.config.commandPresets) {
|
|
175
|
+
this.config.commandPresets = {
|
|
176
|
+
presets: [
|
|
177
|
+
{
|
|
178
|
+
id: '1',
|
|
179
|
+
name: 'Main',
|
|
180
|
+
command: 'claude',
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
defaultPresetId: '1',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
getCommandPresets() {
|
|
188
|
+
if (!this.config.commandPresets) {
|
|
189
|
+
this.migrateLegacyCommandToPresets();
|
|
190
|
+
}
|
|
191
|
+
return this.config.commandPresets;
|
|
192
|
+
}
|
|
193
|
+
setCommandPresets(presets) {
|
|
194
|
+
this.config.commandPresets = presets;
|
|
140
195
|
this.saveConfig();
|
|
141
196
|
}
|
|
197
|
+
getDefaultPreset() {
|
|
198
|
+
const presets = this.getCommandPresets();
|
|
199
|
+
const defaultPreset = presets.presets.find(p => p.id === presets.defaultPresetId);
|
|
200
|
+
// If default preset not found, return the first one
|
|
201
|
+
return defaultPreset || presets.presets[0];
|
|
202
|
+
}
|
|
203
|
+
getPresetById(id) {
|
|
204
|
+
const presets = this.getCommandPresets();
|
|
205
|
+
return presets.presets.find(p => p.id === id);
|
|
206
|
+
}
|
|
207
|
+
addPreset(preset) {
|
|
208
|
+
const presets = this.getCommandPresets();
|
|
209
|
+
// Replace if exists, otherwise add
|
|
210
|
+
const existingIndex = presets.presets.findIndex(p => p.id === preset.id);
|
|
211
|
+
if (existingIndex >= 0) {
|
|
212
|
+
presets.presets[existingIndex] = preset;
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
presets.presets.push(preset);
|
|
216
|
+
}
|
|
217
|
+
this.setCommandPresets(presets);
|
|
218
|
+
}
|
|
219
|
+
deletePreset(id) {
|
|
220
|
+
const presets = this.getCommandPresets();
|
|
221
|
+
// Don't delete if it's the last preset
|
|
222
|
+
if (presets.presets.length <= 1) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// Remove the preset
|
|
226
|
+
presets.presets = presets.presets.filter(p => p.id !== id);
|
|
227
|
+
// Update default if needed
|
|
228
|
+
if (presets.defaultPresetId === id && presets.presets.length > 0) {
|
|
229
|
+
presets.defaultPresetId = presets.presets[0].id;
|
|
230
|
+
}
|
|
231
|
+
this.setCommandPresets(presets);
|
|
232
|
+
}
|
|
233
|
+
setDefaultPreset(id) {
|
|
234
|
+
const presets = this.getCommandPresets();
|
|
235
|
+
// Only update if preset exists
|
|
236
|
+
if (presets.presets.some(p => p.id === id)) {
|
|
237
|
+
presets.defaultPresetId = id;
|
|
238
|
+
this.setCommandPresets(presets);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
getSelectPresetOnStart() {
|
|
242
|
+
const presets = this.getCommandPresets();
|
|
243
|
+
return presets.selectPresetOnStart ?? false;
|
|
244
|
+
}
|
|
245
|
+
setSelectPresetOnStart(enabled) {
|
|
246
|
+
const presets = this.getCommandPresets();
|
|
247
|
+
presets.selectPresetOnStart = enabled;
|
|
248
|
+
this.setCommandPresets(presets);
|
|
249
|
+
}
|
|
142
250
|
}
|
|
143
251
|
export const configurationManager = new ConfigurationManager();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
import { ConfigurationManager } from './configurationManager.js';
|
|
4
|
+
// Mock fs module
|
|
5
|
+
vi.mock('fs', () => ({
|
|
6
|
+
existsSync: vi.fn(),
|
|
7
|
+
mkdirSync: vi.fn(),
|
|
8
|
+
readFileSync: vi.fn(),
|
|
9
|
+
writeFileSync: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
// Mock os module
|
|
12
|
+
vi.mock('os', () => ({
|
|
13
|
+
homedir: vi.fn(() => '/home/test'),
|
|
14
|
+
}));
|
|
15
|
+
describe('ConfigurationManager - selectPresetOnStart', () => {
|
|
16
|
+
let configManager;
|
|
17
|
+
let mockConfigData;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
// Reset all mocks
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
// Default mock config data
|
|
22
|
+
mockConfigData = {
|
|
23
|
+
shortcuts: {
|
|
24
|
+
returnToMenu: { ctrl: true, key: 'e' },
|
|
25
|
+
cancel: { key: 'escape' },
|
|
26
|
+
},
|
|
27
|
+
commandPresets: {
|
|
28
|
+
presets: [
|
|
29
|
+
{
|
|
30
|
+
id: '1',
|
|
31
|
+
name: 'Main',
|
|
32
|
+
command: 'claude',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: '2',
|
|
36
|
+
name: 'Development',
|
|
37
|
+
command: 'claude',
|
|
38
|
+
args: ['--resume'],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
defaultPresetId: '1',
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
// Mock file system operations
|
|
45
|
+
existsSync.mockImplementation((path) => {
|
|
46
|
+
return path.includes('config.json');
|
|
47
|
+
});
|
|
48
|
+
readFileSync.mockImplementation(() => {
|
|
49
|
+
return JSON.stringify(mockConfigData);
|
|
50
|
+
});
|
|
51
|
+
mkdirSync.mockImplementation(() => { });
|
|
52
|
+
writeFileSync.mockImplementation(() => { });
|
|
53
|
+
// Create new instance for each test
|
|
54
|
+
configManager = new ConfigurationManager();
|
|
55
|
+
});
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vi.resetAllMocks();
|
|
58
|
+
});
|
|
59
|
+
describe('getSelectPresetOnStart', () => {
|
|
60
|
+
it('should return false by default', () => {
|
|
61
|
+
const result = configManager.getSelectPresetOnStart();
|
|
62
|
+
expect(result).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
it('should return true when configured', () => {
|
|
65
|
+
mockConfigData.commandPresets.selectPresetOnStart = true;
|
|
66
|
+
configManager = new ConfigurationManager();
|
|
67
|
+
const result = configManager.getSelectPresetOnStart();
|
|
68
|
+
expect(result).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
it('should return false when explicitly set to false', () => {
|
|
71
|
+
mockConfigData.commandPresets.selectPresetOnStart = false;
|
|
72
|
+
configManager = new ConfigurationManager();
|
|
73
|
+
const result = configManager.getSelectPresetOnStart();
|
|
74
|
+
expect(result).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe('setSelectPresetOnStart', () => {
|
|
78
|
+
it('should set selectPresetOnStart to true', () => {
|
|
79
|
+
configManager.setSelectPresetOnStart(true);
|
|
80
|
+
const result = configManager.getSelectPresetOnStart();
|
|
81
|
+
expect(result).toBe(true);
|
|
82
|
+
// Verify that config was saved
|
|
83
|
+
expect(writeFileSync).toHaveBeenCalledWith(expect.stringContaining('config.json'), expect.stringContaining('"selectPresetOnStart": true'));
|
|
84
|
+
});
|
|
85
|
+
it('should set selectPresetOnStart to false', () => {
|
|
86
|
+
// First set to true
|
|
87
|
+
configManager.setSelectPresetOnStart(true);
|
|
88
|
+
// Then set to false
|
|
89
|
+
configManager.setSelectPresetOnStart(false);
|
|
90
|
+
const result = configManager.getSelectPresetOnStart();
|
|
91
|
+
expect(result).toBe(false);
|
|
92
|
+
// Verify that config was saved
|
|
93
|
+
expect(writeFileSync).toHaveBeenLastCalledWith(expect.stringContaining('config.json'), expect.stringContaining('"selectPresetOnStart": false'));
|
|
94
|
+
});
|
|
95
|
+
it('should preserve other preset configuration when setting selectPresetOnStart', () => {
|
|
96
|
+
configManager.setSelectPresetOnStart(true);
|
|
97
|
+
const presets = configManager.getCommandPresets();
|
|
98
|
+
expect(presets.presets).toHaveLength(2);
|
|
99
|
+
expect(presets.defaultPresetId).toBe('1');
|
|
100
|
+
expect(presets.selectPresetOnStart).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|