ccmanager 1.4.5 → 2.1.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/README.md +34 -1
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +30 -2
- package/dist/cli.test.d.ts +1 -0
- package/dist/cli.test.js +67 -0
- package/dist/components/App.d.ts +1 -0
- package/dist/components/App.js +107 -37
- package/dist/components/Menu.d.ts +6 -1
- package/dist/components/Menu.js +228 -50
- package/dist/components/Menu.recent-projects.test.d.ts +1 -0
- package/dist/components/Menu.recent-projects.test.js +159 -0
- package/dist/components/Menu.test.d.ts +1 -0
- package/dist/components/Menu.test.js +196 -0
- package/dist/components/NewWorktree.js +30 -2
- package/dist/components/ProjectList.d.ts +10 -0
- package/dist/components/ProjectList.js +231 -0
- package/dist/components/ProjectList.recent-projects.test.d.ts +1 -0
- package/dist/components/ProjectList.recent-projects.test.js +186 -0
- package/dist/components/ProjectList.test.d.ts +1 -0
- package/dist/components/ProjectList.test.js +501 -0
- package/dist/constants/env.d.ts +3 -0
- package/dist/constants/env.js +4 -0
- package/dist/constants/error.d.ts +6 -0
- package/dist/constants/error.js +7 -0
- package/dist/hooks/useSearchMode.d.ts +15 -0
- package/dist/hooks/useSearchMode.js +67 -0
- package/dist/services/configurationManager.d.ts +1 -0
- package/dist/services/configurationManager.js +14 -7
- package/dist/services/globalSessionOrchestrator.d.ts +16 -0
- package/dist/services/globalSessionOrchestrator.js +73 -0
- package/dist/services/globalSessionOrchestrator.test.d.ts +1 -0
- package/dist/services/globalSessionOrchestrator.test.js +180 -0
- package/dist/services/projectManager.d.ts +60 -0
- package/dist/services/projectManager.js +418 -0
- package/dist/services/projectManager.test.d.ts +1 -0
- package/dist/services/projectManager.test.js +342 -0
- package/dist/services/sessionManager.d.ts +8 -0
- package/dist/services/sessionManager.js +38 -0
- package/dist/services/sessionManager.test.js +79 -0
- package/dist/services/worktreeService.d.ts +1 -0
- package/dist/services/worktreeService.js +20 -5
- package/dist/services/worktreeService.test.js +72 -0
- package/dist/types/index.d.ts +55 -0
- package/package.json +1 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import Menu from './Menu.js';
|
|
4
|
+
import { SessionManager } from '../services/sessionManager.js';
|
|
5
|
+
import { WorktreeService } from '../services/worktreeService.js';
|
|
6
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
// Mock ink to avoid stdin issues
|
|
8
|
+
vi.mock('ink', async () => {
|
|
9
|
+
const actual = await vi.importActual('ink');
|
|
10
|
+
return {
|
|
11
|
+
...actual,
|
|
12
|
+
useInput: vi.fn(),
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
// Mock SelectInput to render items as simple text
|
|
16
|
+
vi.mock('ink-select-input', async () => {
|
|
17
|
+
const React = await vi.importActual('react');
|
|
18
|
+
const { Text, Box } = await vi.importActual('ink');
|
|
19
|
+
return {
|
|
20
|
+
default: ({ items }) => {
|
|
21
|
+
return React.createElement(Box, { flexDirection: 'column' }, items.map((item, index) => React.createElement(Text, { key: index }, item.label)));
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
// Mock dependencies
|
|
26
|
+
vi.mock('../hooks/useGitStatus.js', () => ({
|
|
27
|
+
useGitStatus: (worktrees) => worktrees,
|
|
28
|
+
}));
|
|
29
|
+
vi.mock('../services/projectManager.js', () => ({
|
|
30
|
+
projectManager: {
|
|
31
|
+
getRecentProjects: vi.fn().mockReturnValue([]),
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
vi.mock('../services/globalSessionOrchestrator.js', () => ({
|
|
35
|
+
globalSessionOrchestrator: {
|
|
36
|
+
getProjectSessions: vi.fn().mockReturnValue([]),
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
vi.mock('../services/shortcutManager.js', () => ({
|
|
40
|
+
shortcutManager: {
|
|
41
|
+
getShortcutDisplay: vi.fn().mockReturnValue('Ctrl+C'),
|
|
42
|
+
getShortcuts: vi.fn().mockReturnValue({
|
|
43
|
+
refresh: { key: 'r' },
|
|
44
|
+
newWorktree: { key: 'n' },
|
|
45
|
+
quit: { key: 'q', ctrl: true },
|
|
46
|
+
}),
|
|
47
|
+
matchesShortcut: vi.fn().mockReturnValue(false),
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
vi.mock('../hooks/useSearchMode.js', () => ({
|
|
51
|
+
useSearchMode: () => ({
|
|
52
|
+
isSearchMode: false,
|
|
53
|
+
searchQuery: '',
|
|
54
|
+
setSearchQuery: vi.fn(),
|
|
55
|
+
handleKey: vi.fn(),
|
|
56
|
+
}),
|
|
57
|
+
}));
|
|
58
|
+
describe('Menu component rendering', () => {
|
|
59
|
+
let sessionManager;
|
|
60
|
+
let worktreeService;
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
sessionManager = new SessionManager();
|
|
63
|
+
worktreeService = new WorktreeService();
|
|
64
|
+
vi.spyOn(worktreeService, 'getWorktrees').mockReturnValue([]);
|
|
65
|
+
vi.spyOn(sessionManager, 'getAllSessions').mockReturnValue([]);
|
|
66
|
+
// Mock EventEmitter methods
|
|
67
|
+
vi.spyOn(sessionManager, 'on').mockImplementation(() => sessionManager);
|
|
68
|
+
vi.spyOn(sessionManager, 'off').mockImplementation(() => sessionManager);
|
|
69
|
+
});
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
vi.restoreAllMocks();
|
|
72
|
+
});
|
|
73
|
+
it('should not render duplicate title when re-rendered with new key', async () => {
|
|
74
|
+
const onSelectWorktree = vi.fn();
|
|
75
|
+
// First render
|
|
76
|
+
const { unmount, lastFrame } = render(React.createElement(Menu, { key: 1, sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree }));
|
|
77
|
+
// Wait for async operations
|
|
78
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
79
|
+
const firstRenderOutput = lastFrame();
|
|
80
|
+
// Count occurrences of the title
|
|
81
|
+
const titleCount = (firstRenderOutput?.match(/CCManager - Claude Code Worktree Manager/g) ||
|
|
82
|
+
[]).length;
|
|
83
|
+
expect(titleCount).toBe(1);
|
|
84
|
+
// Unmount and re-render with new key
|
|
85
|
+
unmount();
|
|
86
|
+
const { lastFrame: lastFrame2 } = render(React.createElement(Menu, { key: 2, sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree }));
|
|
87
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
88
|
+
const secondRenderOutput = lastFrame2();
|
|
89
|
+
const titleCount2 = (secondRenderOutput?.match(/CCManager - Claude Code Worktree Manager/g) ||
|
|
90
|
+
[]).length;
|
|
91
|
+
expect(titleCount2).toBe(1);
|
|
92
|
+
});
|
|
93
|
+
it('should render title and description only once', async () => {
|
|
94
|
+
const onSelectWorktree = vi.fn();
|
|
95
|
+
const { lastFrame } = render(React.createElement(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree }));
|
|
96
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
97
|
+
const output = lastFrame();
|
|
98
|
+
// Check title appears only once
|
|
99
|
+
const titleMatches = output?.match(/CCManager - Claude Code Worktree Manager/g) || [];
|
|
100
|
+
expect(titleMatches.length).toBe(1);
|
|
101
|
+
// Check description appears only once
|
|
102
|
+
const descMatches = output?.match(/Select a worktree to start or resume a Claude Code session:/g) || [];
|
|
103
|
+
expect(descMatches.length).toBe(1);
|
|
104
|
+
});
|
|
105
|
+
it('should display number shortcuts for recent projects when worktrees < 10', async () => {
|
|
106
|
+
const onSelectWorktree = vi.fn();
|
|
107
|
+
const onSelectRecentProject = vi.fn();
|
|
108
|
+
// Setup: 3 worktrees
|
|
109
|
+
const mockWorktrees = [
|
|
110
|
+
{
|
|
111
|
+
path: '/test/wt1',
|
|
112
|
+
branch: 'feature-1',
|
|
113
|
+
isMainWorktree: false,
|
|
114
|
+
hasSession: false,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
path: '/test/wt2',
|
|
118
|
+
branch: 'feature-2',
|
|
119
|
+
isMainWorktree: false,
|
|
120
|
+
hasSession: false,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
path: '/test/wt3',
|
|
124
|
+
branch: 'feature-3',
|
|
125
|
+
isMainWorktree: false,
|
|
126
|
+
hasSession: false,
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
// Setup: 3 recent projects
|
|
130
|
+
const mockRecentProjects = [
|
|
131
|
+
{ name: 'Project A', path: '/test/project-a', lastAccessed: Date.now() },
|
|
132
|
+
{ name: 'Project B', path: '/test/project-b', lastAccessed: Date.now() },
|
|
133
|
+
{ name: 'Project C', path: '/test/project-c', lastAccessed: Date.now() },
|
|
134
|
+
];
|
|
135
|
+
vi.spyOn(worktreeService, 'getWorktrees').mockReturnValue(mockWorktrees);
|
|
136
|
+
vi.spyOn(worktreeService, 'getGitRootPath').mockReturnValue('/test/current');
|
|
137
|
+
const { projectManager } = await import('../services/projectManager.js');
|
|
138
|
+
vi.mocked(projectManager.getRecentProjects).mockReturnValue(mockRecentProjects);
|
|
139
|
+
// Mock session counts
|
|
140
|
+
vi.spyOn(SessionManager, 'getSessionCounts').mockReturnValue({
|
|
141
|
+
idle: 0,
|
|
142
|
+
busy: 0,
|
|
143
|
+
waiting_input: 0,
|
|
144
|
+
total: 0,
|
|
145
|
+
});
|
|
146
|
+
vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
|
|
147
|
+
const { lastFrame } = render(React.createElement(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree, onSelectRecentProject: onSelectRecentProject, multiProject: true }));
|
|
148
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
149
|
+
const output = lastFrame();
|
|
150
|
+
// Check that worktrees have numbers 0-2
|
|
151
|
+
expect(output).toContain('0 ❯');
|
|
152
|
+
expect(output).toContain('1 ❯');
|
|
153
|
+
expect(output).toContain('2 ❯');
|
|
154
|
+
// Check that recent projects have numbers 3-5
|
|
155
|
+
expect(output).toContain('3 ❯ Project A');
|
|
156
|
+
expect(output).toContain('4 ❯ Project B');
|
|
157
|
+
expect(output).toContain('5 ❯ Project C');
|
|
158
|
+
});
|
|
159
|
+
it('should not display number shortcuts for recent projects when worktrees >= 10', async () => {
|
|
160
|
+
const onSelectWorktree = vi.fn();
|
|
161
|
+
const onSelectRecentProject = vi.fn();
|
|
162
|
+
// Setup: 10 worktrees
|
|
163
|
+
const mockWorktrees = Array.from({ length: 10 }, (_, i) => ({
|
|
164
|
+
path: `/test/wt${i}`,
|
|
165
|
+
branch: `feature-${i}`,
|
|
166
|
+
isMainWorktree: false,
|
|
167
|
+
hasSession: false,
|
|
168
|
+
}));
|
|
169
|
+
// Setup: 2 recent projects
|
|
170
|
+
const mockRecentProjects = [
|
|
171
|
+
{ name: 'Project A', path: '/test/project-a', lastAccessed: Date.now() },
|
|
172
|
+
{ name: 'Project B', path: '/test/project-b', lastAccessed: Date.now() },
|
|
173
|
+
];
|
|
174
|
+
vi.spyOn(worktreeService, 'getWorktrees').mockReturnValue(mockWorktrees);
|
|
175
|
+
vi.spyOn(worktreeService, 'getGitRootPath').mockReturnValue('/test/current');
|
|
176
|
+
const { projectManager } = await import('../services/projectManager.js');
|
|
177
|
+
vi.mocked(projectManager.getRecentProjects).mockReturnValue(mockRecentProjects);
|
|
178
|
+
// Mock session counts
|
|
179
|
+
vi.spyOn(SessionManager, 'getSessionCounts').mockReturnValue({
|
|
180
|
+
idle: 0,
|
|
181
|
+
busy: 0,
|
|
182
|
+
waiting_input: 0,
|
|
183
|
+
total: 0,
|
|
184
|
+
});
|
|
185
|
+
vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
|
|
186
|
+
const { lastFrame } = render(React.createElement(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree, onSelectRecentProject: onSelectRecentProject, multiProject: true }));
|
|
187
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
188
|
+
const output = lastFrame();
|
|
189
|
+
// Check that recent projects don't have numbers (just ❯ prefix)
|
|
190
|
+
expect(output).toContain('❯ Project A');
|
|
191
|
+
expect(output).toContain('❯ Project B');
|
|
192
|
+
// Make sure they don't have number prefixes
|
|
193
|
+
expect(output).not.toContain('10 ❯ Project A');
|
|
194
|
+
expect(output).not.toContain('11 ❯ Project B');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -6,9 +6,11 @@ import { shortcutManager } from '../services/shortcutManager.js';
|
|
|
6
6
|
import { configurationManager } from '../services/configurationManager.js';
|
|
7
7
|
import { generateWorktreeDirectory } from '../utils/worktreeUtils.js';
|
|
8
8
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
9
|
+
import { useSearchMode } from '../hooks/useSearchMode.js';
|
|
9
10
|
const NewWorktree = ({ onComplete, onCancel }) => {
|
|
10
11
|
const worktreeConfig = configurationManager.getWorktreeConfig();
|
|
11
12
|
const isAutoDirectory = worktreeConfig.autoDirectory;
|
|
13
|
+
const limit = 10;
|
|
12
14
|
// Adjust initial step based on auto directory mode
|
|
13
15
|
const [step, setStep] = useState(isAutoDirectory ? 'branch' : 'path');
|
|
14
16
|
const [path, setPath] = useState('');
|
|
@@ -27,16 +29,32 @@ const NewWorktree = ({ onComplete, onCancel }) => {
|
|
|
27
29
|
};
|
|
28
30
|
}, []); // Empty deps array - only initialize once
|
|
29
31
|
// Create branch items with default branch first (memoized)
|
|
30
|
-
const
|
|
32
|
+
const allBranchItems = useMemo(() => [
|
|
31
33
|
{ label: `${defaultBranch} (default)`, value: defaultBranch },
|
|
32
34
|
...branches
|
|
33
35
|
.filter(br => br !== defaultBranch)
|
|
34
36
|
.map(br => ({ label: br, value: br })),
|
|
35
37
|
], [branches, defaultBranch]);
|
|
38
|
+
// Use search mode for base branch selection
|
|
39
|
+
const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(allBranchItems.length, {
|
|
40
|
+
isDisabled: step !== 'base-branch',
|
|
41
|
+
});
|
|
42
|
+
// Filter branch items based on search query
|
|
43
|
+
const branchItems = useMemo(() => {
|
|
44
|
+
if (!searchQuery)
|
|
45
|
+
return allBranchItems;
|
|
46
|
+
return allBranchItems.filter(item => item.value.toLowerCase().includes(searchQuery.toLowerCase()));
|
|
47
|
+
}, [allBranchItems, searchQuery]);
|
|
36
48
|
useInput((input, key) => {
|
|
37
49
|
if (shortcutManager.matchesShortcut('cancel', input, key)) {
|
|
38
50
|
onCancel();
|
|
39
51
|
}
|
|
52
|
+
// Handle arrow key navigation in search mode for base branch selection
|
|
53
|
+
if (step === 'base-branch' && isSearchMode) {
|
|
54
|
+
// Don't handle any keys here - let useSearchMode handle them
|
|
55
|
+
// The hook will handle arrow keys for navigation and Enter to exit search mode
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
40
58
|
});
|
|
41
59
|
const handlePathSubmit = (value) => {
|
|
42
60
|
if (value.trim()) {
|
|
@@ -109,7 +127,17 @@ const NewWorktree = ({ onComplete, onCancel }) => {
|
|
|
109
127
|
"Select base branch for ",
|
|
110
128
|
React.createElement(Text, { color: "cyan" }, branch),
|
|
111
129
|
":")),
|
|
112
|
-
React.createElement(
|
|
130
|
+
isSearchMode && (React.createElement(Box, { marginBottom: 1 },
|
|
131
|
+
React.createElement(Text, null, "Search: "),
|
|
132
|
+
React.createElement(TextInputWrapper, { value: searchQuery, onChange: setSearchQuery, focus: true, placeholder: "Type to filter branches..." }))),
|
|
133
|
+
isSearchMode && branchItems.length === 0 ? (React.createElement(Box, null,
|
|
134
|
+
React.createElement(Text, { color: "yellow" }, "No branches match your search"))) : isSearchMode ? (
|
|
135
|
+
// In search mode, show the items as a list without SelectInput
|
|
136
|
+
React.createElement(Box, { flexDirection: "column" }, branchItems.slice(0, limit).map((item, index) => (React.createElement(Text, { key: item.value, color: index === selectedIndex ? 'green' : undefined },
|
|
137
|
+
index === selectedIndex ? '❯ ' : ' ',
|
|
138
|
+
item.label))))) : (React.createElement(SelectInput, { items: branchItems, onSelect: handleBaseBranchSelect, initialIndex: selectedIndex, limit: limit, isFocused: !isSearchMode })),
|
|
139
|
+
!isSearchMode && (React.createElement(Box, { marginTop: 1 },
|
|
140
|
+
React.createElement(Text, { dimColor: true }, "Press / to search"))))),
|
|
113
141
|
step === 'copy-settings' && (React.createElement(Box, { flexDirection: "column" },
|
|
114
142
|
React.createElement(Box, { marginBottom: 1 },
|
|
115
143
|
React.createElement(Text, null,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { GitProject } from '../types/index.js';
|
|
3
|
+
interface ProjectListProps {
|
|
4
|
+
projectsDir: string;
|
|
5
|
+
onSelectProject: (project: GitProject) => void;
|
|
6
|
+
error: string | null;
|
|
7
|
+
onDismissError: () => void;
|
|
8
|
+
}
|
|
9
|
+
declare const ProjectList: React.FC<ProjectListProps>;
|
|
10
|
+
export default ProjectList;
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import SelectInput from 'ink-select-input';
|
|
4
|
+
import { projectManager } from '../services/projectManager.js';
|
|
5
|
+
import { MENU_ICONS } from '../constants/statusIcons.js';
|
|
6
|
+
import TextInputWrapper from './TextInputWrapper.js';
|
|
7
|
+
import { useSearchMode } from '../hooks/useSearchMode.js';
|
|
8
|
+
import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
|
|
9
|
+
import { SessionManager } from '../services/sessionManager.js';
|
|
10
|
+
const ProjectList = ({ projectsDir, onSelectProject, error, onDismissError, }) => {
|
|
11
|
+
const [projects, setProjects] = useState([]);
|
|
12
|
+
const [recentProjects, setRecentProjects] = useState([]);
|
|
13
|
+
const [items, setItems] = useState([]);
|
|
14
|
+
const [loading, setLoading] = useState(true);
|
|
15
|
+
const [loadError, setLoadError] = useState(null);
|
|
16
|
+
const limit = 10;
|
|
17
|
+
// Use the search mode hook
|
|
18
|
+
const displayError = error || loadError;
|
|
19
|
+
const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(items.length, {
|
|
20
|
+
isDisabled: !!displayError,
|
|
21
|
+
skipInTest: false,
|
|
22
|
+
});
|
|
23
|
+
const loadProjects = async () => {
|
|
24
|
+
setLoading(true);
|
|
25
|
+
setLoadError(null);
|
|
26
|
+
try {
|
|
27
|
+
const discoveredProjects = await projectManager.instance.discoverProjects(projectsDir);
|
|
28
|
+
setProjects(discoveredProjects);
|
|
29
|
+
// Load recent projects with no limit (pass 0)
|
|
30
|
+
const allRecentProjects = projectManager.getRecentProjects(0);
|
|
31
|
+
setRecentProjects(allRecentProjects);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
setLoadError(err.message);
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
setLoading(false);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
loadProjects();
|
|
42
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
43
|
+
}, [projectsDir]);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const menuItems = [];
|
|
46
|
+
let currentIndex = 0;
|
|
47
|
+
// Filter recent projects based on search query
|
|
48
|
+
const filteredRecentProjects = searchQuery
|
|
49
|
+
? recentProjects.filter(project => project.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
50
|
+
: recentProjects;
|
|
51
|
+
// Add recent projects section if available and not in search mode
|
|
52
|
+
if (filteredRecentProjects.length > 0) {
|
|
53
|
+
// Add "Recent" separator only when not in search mode
|
|
54
|
+
if (!isSearchMode) {
|
|
55
|
+
menuItems.push({
|
|
56
|
+
label: '── Recent ──',
|
|
57
|
+
value: 'separator-recent',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
// Add recent projects
|
|
61
|
+
filteredRecentProjects.forEach(recentProject => {
|
|
62
|
+
// Find the full project data
|
|
63
|
+
const fullProject = projects.find(p => p.path === recentProject.path);
|
|
64
|
+
if (fullProject) {
|
|
65
|
+
// Get session counts for this project
|
|
66
|
+
const projectSessions = globalSessionOrchestrator.getProjectSessions(recentProject.path);
|
|
67
|
+
const counts = SessionManager.getSessionCounts(projectSessions);
|
|
68
|
+
const countsFormatted = SessionManager.formatSessionCounts(counts);
|
|
69
|
+
const numberPrefix = !isSearchMode && currentIndex < 10 ? `${currentIndex} ❯ ` : '❯ ';
|
|
70
|
+
menuItems.push({
|
|
71
|
+
label: numberPrefix + recentProject.name + countsFormatted,
|
|
72
|
+
value: recentProject.path,
|
|
73
|
+
project: fullProject,
|
|
74
|
+
});
|
|
75
|
+
currentIndex++;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
// Filter projects based on search query
|
|
80
|
+
const filteredProjects = searchQuery
|
|
81
|
+
? projects.filter(project => project.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
82
|
+
: projects;
|
|
83
|
+
// Filter out recent projects from all projects to avoid duplicates
|
|
84
|
+
const recentPaths = new Set(filteredRecentProjects.map(rp => rp.path));
|
|
85
|
+
const nonRecentProjects = filteredProjects.filter(project => !recentPaths.has(project.path));
|
|
86
|
+
// Add "All Projects" separator if we have both recent and other projects
|
|
87
|
+
if (filteredRecentProjects.length > 0 &&
|
|
88
|
+
nonRecentProjects.length > 0 &&
|
|
89
|
+
!isSearchMode) {
|
|
90
|
+
menuItems.push({
|
|
91
|
+
label: '── All Projects ──',
|
|
92
|
+
value: 'separator-all',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
// Build menu items from filtered non-recent projects
|
|
96
|
+
nonRecentProjects.forEach(project => {
|
|
97
|
+
// Get session counts for this project
|
|
98
|
+
const projectSessions = globalSessionOrchestrator.getProjectSessions(project.path);
|
|
99
|
+
const counts = SessionManager.getSessionCounts(projectSessions);
|
|
100
|
+
const countsFormatted = SessionManager.formatSessionCounts(counts);
|
|
101
|
+
// Only show numbers for total items (0-9) when not in search mode
|
|
102
|
+
const numberPrefix = !isSearchMode && currentIndex < 10 ? `${currentIndex} ❯ ` : '❯ ';
|
|
103
|
+
menuItems.push({
|
|
104
|
+
label: numberPrefix + project.name + countsFormatted,
|
|
105
|
+
value: project.path,
|
|
106
|
+
project,
|
|
107
|
+
});
|
|
108
|
+
currentIndex++;
|
|
109
|
+
});
|
|
110
|
+
// Add menu options only when not in search mode
|
|
111
|
+
if (!isSearchMode) {
|
|
112
|
+
if (projects.length > 0) {
|
|
113
|
+
menuItems.push({
|
|
114
|
+
label: '─────────────',
|
|
115
|
+
value: 'separator',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
menuItems.push({
|
|
119
|
+
label: `R 🔄 Refresh`,
|
|
120
|
+
value: 'refresh',
|
|
121
|
+
});
|
|
122
|
+
menuItems.push({
|
|
123
|
+
label: `Q ${MENU_ICONS.EXIT} Exit`,
|
|
124
|
+
value: 'exit',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
setItems(menuItems);
|
|
128
|
+
}, [projects, recentProjects, searchQuery, isSearchMode]);
|
|
129
|
+
// Handle hotkeys
|
|
130
|
+
useInput((input, _key) => {
|
|
131
|
+
// Skip in test environment to avoid stdin.ref error
|
|
132
|
+
if (!process.stdin.setRawMode) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// Dismiss error on any key press when error is shown
|
|
136
|
+
if (displayError && onDismissError) {
|
|
137
|
+
onDismissError();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// Don't process other keys if in search mode (handled by useSearchMode)
|
|
141
|
+
if (isSearchMode) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const keyPressed = input.toLowerCase();
|
|
145
|
+
// Handle number keys 0-9 for project selection
|
|
146
|
+
if (/^[0-9]$/.test(keyPressed)) {
|
|
147
|
+
const index = parseInt(keyPressed);
|
|
148
|
+
// Get all selectable items (recent + non-recent projects)
|
|
149
|
+
const selectableItems = items.filter(item => item.project);
|
|
150
|
+
if (index < Math.min(10, selectableItems.length) &&
|
|
151
|
+
selectableItems[index]?.project) {
|
|
152
|
+
onSelectProject(selectableItems[index].project);
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
switch (keyPressed) {
|
|
157
|
+
case 'r':
|
|
158
|
+
// Refresh project list
|
|
159
|
+
loadProjects();
|
|
160
|
+
break;
|
|
161
|
+
case 'q':
|
|
162
|
+
case 'x':
|
|
163
|
+
// Trigger exit action
|
|
164
|
+
onSelectProject({
|
|
165
|
+
path: 'EXIT_APPLICATION',
|
|
166
|
+
name: '',
|
|
167
|
+
relativePath: '',
|
|
168
|
+
isValid: false,
|
|
169
|
+
});
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
const handleSelect = (item) => {
|
|
174
|
+
if (item.value.startsWith('separator')) {
|
|
175
|
+
// Do nothing for separators
|
|
176
|
+
}
|
|
177
|
+
else if (item.value === 'refresh') {
|
|
178
|
+
loadProjects();
|
|
179
|
+
}
|
|
180
|
+
else if (item.value === 'exit') {
|
|
181
|
+
// Handle exit
|
|
182
|
+
onSelectProject({
|
|
183
|
+
path: 'EXIT_APPLICATION',
|
|
184
|
+
name: '',
|
|
185
|
+
relativePath: '',
|
|
186
|
+
isValid: false,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
else if (item.project) {
|
|
190
|
+
onSelectProject(item.project);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
194
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
195
|
+
React.createElement(Text, { bold: true, color: "green" }, "CCManager - Multi-Project Mode")),
|
|
196
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
197
|
+
React.createElement(Text, { dimColor: true }, "Select a project:")),
|
|
198
|
+
isSearchMode && (React.createElement(Box, { marginBottom: 1 },
|
|
199
|
+
React.createElement(Text, null, "Search: "),
|
|
200
|
+
React.createElement(TextInputWrapper, { value: searchQuery, onChange: setSearchQuery, focus: true, placeholder: "Type to filter projects..." }))),
|
|
201
|
+
loading ? (React.createElement(Box, null,
|
|
202
|
+
React.createElement(Text, { color: "yellow" }, "Loading projects..."))) : projects.length === 0 && !displayError ? (React.createElement(Box, null,
|
|
203
|
+
React.createElement(Text, { color: "yellow" },
|
|
204
|
+
"No git repositories found in ",
|
|
205
|
+
projectsDir))) : isSearchMode && items.length === 0 ? (React.createElement(Box, null,
|
|
206
|
+
React.createElement(Text, { color: "yellow" }, "No projects match your search"))) : isSearchMode ? (
|
|
207
|
+
// In search mode, show the items as a list without SelectInput
|
|
208
|
+
React.createElement(Box, { flexDirection: "column" }, items.slice(0, limit).map((item, index) => (React.createElement(Text, { key: item.value, color: index === selectedIndex ? 'green' : undefined },
|
|
209
|
+
index === selectedIndex ? '❯ ' : ' ',
|
|
210
|
+
item.label))))) : (React.createElement(SelectInput, { items: items, onSelect: handleSelect, isFocused: !displayError, limit: limit, initialIndex: selectedIndex })),
|
|
211
|
+
displayError && (React.createElement(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red" },
|
|
212
|
+
React.createElement(Box, { flexDirection: "column" },
|
|
213
|
+
React.createElement(Text, { color: "red", bold: true },
|
|
214
|
+
"Error: ",
|
|
215
|
+
displayError),
|
|
216
|
+
React.createElement(Text, { color: "gray", dimColor: true }, "Press any key to dismiss")))),
|
|
217
|
+
React.createElement(Box, { marginTop: 1, flexDirection: "column" },
|
|
218
|
+
(isSearchMode || searchQuery) && (React.createElement(Text, { dimColor: true },
|
|
219
|
+
"Projects: ",
|
|
220
|
+
items.filter(item => item.project).length,
|
|
221
|
+
" of",
|
|
222
|
+
' ',
|
|
223
|
+
projects.length,
|
|
224
|
+
" shown")),
|
|
225
|
+
React.createElement(Text, { dimColor: true }, isSearchMode
|
|
226
|
+
? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
|
|
227
|
+
: searchQuery
|
|
228
|
+
? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select R-Refresh Q-Quit`
|
|
229
|
+
: 'Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search R-Refresh Q-Quit'))));
|
|
230
|
+
};
|
|
231
|
+
export default ProjectList;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|