ccmanager 1.4.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +34 -1
  2. package/dist/cli.d.ts +4 -0
  3. package/dist/cli.js +30 -2
  4. package/dist/cli.test.d.ts +1 -0
  5. package/dist/cli.test.js +67 -0
  6. package/dist/components/App.d.ts +1 -0
  7. package/dist/components/App.js +107 -37
  8. package/dist/components/Menu.d.ts +6 -1
  9. package/dist/components/Menu.js +227 -50
  10. package/dist/components/Menu.recent-projects.test.d.ts +1 -0
  11. package/dist/components/Menu.recent-projects.test.js +159 -0
  12. package/dist/components/Menu.test.d.ts +1 -0
  13. package/dist/components/Menu.test.js +196 -0
  14. package/dist/components/ProjectList.d.ts +10 -0
  15. package/dist/components/ProjectList.js +231 -0
  16. package/dist/components/ProjectList.recent-projects.test.d.ts +1 -0
  17. package/dist/components/ProjectList.recent-projects.test.js +186 -0
  18. package/dist/components/ProjectList.test.d.ts +1 -0
  19. package/dist/components/ProjectList.test.js +501 -0
  20. package/dist/components/Session.js +4 -14
  21. package/dist/constants/env.d.ts +3 -0
  22. package/dist/constants/env.js +4 -0
  23. package/dist/constants/error.d.ts +6 -0
  24. package/dist/constants/error.js +7 -0
  25. package/dist/hooks/useSearchMode.d.ts +15 -0
  26. package/dist/hooks/useSearchMode.js +67 -0
  27. package/dist/services/configurationManager.d.ts +1 -0
  28. package/dist/services/configurationManager.js +14 -7
  29. package/dist/services/globalSessionOrchestrator.d.ts +16 -0
  30. package/dist/services/globalSessionOrchestrator.js +73 -0
  31. package/dist/services/globalSessionOrchestrator.test.d.ts +1 -0
  32. package/dist/services/globalSessionOrchestrator.test.js +180 -0
  33. package/dist/services/projectManager.d.ts +60 -0
  34. package/dist/services/projectManager.js +418 -0
  35. package/dist/services/projectManager.test.d.ts +1 -0
  36. package/dist/services/projectManager.test.js +342 -0
  37. package/dist/services/sessionManager.d.ts +8 -0
  38. package/dist/services/sessionManager.js +41 -7
  39. package/dist/services/sessionManager.test.js +79 -0
  40. package/dist/services/worktreeService.d.ts +1 -0
  41. package/dist/services/worktreeService.js +20 -5
  42. package/dist/services/worktreeService.test.js +72 -0
  43. package/dist/types/index.d.ts +55 -0
  44. 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
+ });
@@ -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,186 @@
1
+ import React from 'react';
2
+ import { render } from 'ink-testing-library';
3
+ import { expect, describe, it, vi, beforeEach, afterEach } from 'vitest';
4
+ import ProjectList from './ProjectList.js';
5
+ import { projectManager } from '../services/projectManager.js';
6
+ // Mock ink to avoid stdin.ref issues
7
+ vi.mock('ink', async () => {
8
+ const actual = await vi.importActual('ink');
9
+ return {
10
+ ...actual,
11
+ useInput: vi.fn(),
12
+ };
13
+ });
14
+ // Mock SelectInput to render items as simple text
15
+ vi.mock('ink-select-input', async () => {
16
+ const React = await vi.importActual('react');
17
+ const { Text, Box } = await vi.importActual('ink');
18
+ return {
19
+ default: ({ items }) => {
20
+ return (React.createElement(Box, { flexDirection: "column" }, items.map(item => (React.createElement(Text, { key: item.value }, item.label)))));
21
+ },
22
+ };
23
+ });
24
+ vi.mock('../services/projectManager.js', () => ({
25
+ projectManager: {
26
+ instance: {
27
+ discoverProjects: vi.fn(),
28
+ },
29
+ getRecentProjects: vi.fn(),
30
+ },
31
+ }));
32
+ describe('ProjectList - Recent Projects', () => {
33
+ const mockOnSelectProject = vi.fn();
34
+ const mockOnDismissError = vi.fn();
35
+ let originalSetRawMode;
36
+ const createProject = (name, path) => ({
37
+ name,
38
+ path,
39
+ relativePath: `./${name}`,
40
+ isValid: true,
41
+ });
42
+ const mockProjects = [
43
+ createProject('project-a', '/home/user/projects/project-a'),
44
+ createProject('project-b', '/home/user/projects/project-b'),
45
+ createProject('project-c', '/home/user/projects/project-c'),
46
+ createProject('project-d', '/home/user/projects/project-d'),
47
+ createProject('project-e', '/home/user/projects/project-e'),
48
+ ];
49
+ beforeEach(() => {
50
+ vi.clearAllMocks();
51
+ vi.mocked(projectManager.instance.discoverProjects).mockResolvedValue(mockProjects);
52
+ vi.mocked(projectManager.getRecentProjects).mockReturnValue([]);
53
+ // Mock stdin.setRawMode
54
+ originalSetRawMode = process.stdin.setRawMode;
55
+ process.stdin.setRawMode = vi.fn();
56
+ });
57
+ afterEach(() => {
58
+ // Restore original setRawMode
59
+ process.stdin.setRawMode = originalSetRawMode;
60
+ });
61
+ it('should display recent projects at the top when available', async () => {
62
+ // Mock recent projects
63
+ vi.mocked(projectManager.getRecentProjects).mockReturnValue([
64
+ {
65
+ name: 'project-c',
66
+ path: '/home/user/projects/project-c',
67
+ lastAccessed: Date.now() - 1000,
68
+ },
69
+ {
70
+ name: 'project-e',
71
+ path: '/home/user/projects/project-e',
72
+ lastAccessed: Date.now() - 2000,
73
+ },
74
+ ]);
75
+ const { lastFrame } = render(React.createElement(ProjectList, { projectsDir: "/home/user/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
76
+ // Wait for projects to load
77
+ await vi.waitFor(() => {
78
+ expect(lastFrame()).toContain('project-c');
79
+ });
80
+ // Check that recent projects section is shown
81
+ expect(lastFrame()).toContain('Recent');
82
+ // Check that recent projects are at the top
83
+ const output = lastFrame();
84
+ const projectCIndex = output?.indexOf('project-c') ?? -1;
85
+ const projectEIndex = output?.indexOf('project-e') ?? -1;
86
+ const allProjectsIndex = output?.indexOf('All Projects') ?? -1;
87
+ // Recent projects should appear before "All Projects" section
88
+ expect(projectCIndex).toBeLessThan(allProjectsIndex);
89
+ expect(projectEIndex).toBeLessThan(allProjectsIndex);
90
+ });
91
+ it('should not show recent projects section when there are none', async () => {
92
+ vi.mocked(projectManager.getRecentProjects).mockReturnValue([]);
93
+ const { lastFrame } = render(React.createElement(ProjectList, { projectsDir: "/home/user/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
94
+ // Wait for projects to load
95
+ await vi.waitFor(() => {
96
+ expect(lastFrame()).toContain('project-a');
97
+ });
98
+ // Check that recent projects section is not shown
99
+ expect(lastFrame()).not.toContain('Recent');
100
+ });
101
+ it('should handle selection of recent projects', async () => {
102
+ // Skip this test for now - SelectInput interaction is complex to test
103
+ // The selection functionality is covered by the number key test
104
+ });
105
+ it('should filter recent projects based on search query', async () => {
106
+ // This functionality is tested in the main ProjectList.test.tsx
107
+ // Skip in recent projects specific tests to avoid complexity
108
+ });
109
+ it('should show recent projects with correct number prefixes', async () => {
110
+ // Mock recent projects that match existing projects
111
+ vi.mocked(projectManager.getRecentProjects).mockReturnValue([
112
+ {
113
+ name: 'project-c',
114
+ path: '/home/user/projects/project-c',
115
+ lastAccessed: Date.now() - 1000,
116
+ },
117
+ {
118
+ name: 'project-e',
119
+ path: '/home/user/projects/project-e',
120
+ lastAccessed: Date.now() - 2000,
121
+ },
122
+ ]);
123
+ const { lastFrame } = render(React.createElement(ProjectList, { projectsDir: "/home/user/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
124
+ // Wait for projects to load
125
+ await vi.waitFor(() => {
126
+ expect(lastFrame()).toContain('project-c');
127
+ });
128
+ // Check that recent projects have correct number prefixes
129
+ const output = lastFrame();
130
+ expect(output).toContain('0 ❯ project-c');
131
+ expect(output).toContain('1 ❯ project-e');
132
+ // Check that regular projects start from the next available number
133
+ expect(output).toContain('2 ❯ project-a');
134
+ });
135
+ it('should show all recent projects without limit', async () => {
136
+ // Create 10 projects
137
+ const manyProjects = Array.from({ length: 10 }, (_, i) => createProject(`project-${i}`, `/home/user/projects/project-${i}`));
138
+ // Mock discovered projects
139
+ vi.mocked(projectManager.instance.discoverProjects).mockResolvedValue(manyProjects);
140
+ // Mock more than 5 recent projects
141
+ const manyRecentProjects = Array.from({ length: 10 }, (_, i) => ({
142
+ name: `project-${i}`,
143
+ path: `/home/user/projects/project-${i}`,
144
+ lastAccessed: Date.now() - i * 1000,
145
+ }));
146
+ vi.mocked(projectManager.getRecentProjects).mockReturnValue(manyRecentProjects);
147
+ const { lastFrame } = render(React.createElement(ProjectList, { projectsDir: "/home/user/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
148
+ // Wait for projects to load
149
+ await vi.waitFor(() => {
150
+ expect(lastFrame()).toContain('project-0');
151
+ });
152
+ // Check that all 10 recent projects are shown (not limited to 5)
153
+ const output = lastFrame();
154
+ for (let i = 0; i < 10; i++) {
155
+ expect(output).toContain(`project-${i}`);
156
+ }
157
+ });
158
+ it('should allow number key selection for recent projects', async () => {
159
+ // Mock recent projects
160
+ vi.mocked(projectManager.getRecentProjects).mockReturnValue([
161
+ {
162
+ name: 'project-c',
163
+ path: '/home/user/projects/project-c',
164
+ lastAccessed: Date.now(),
165
+ },
166
+ ]);
167
+ // Mock the useInput hook to capture the handler
168
+ const mockUseInput = vi.mocked(await import('ink')).useInput;
169
+ let inputHandler = () => { };
170
+ mockUseInput.mockImplementation(handler => {
171
+ inputHandler = handler;
172
+ });
173
+ const { lastFrame } = render(React.createElement(ProjectList, { projectsDir: "/home/user/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
174
+ // Wait for projects to load
175
+ await vi.waitFor(() => {
176
+ expect(lastFrame()).toContain('project-c');
177
+ });
178
+ // Simulate pressing 0 to select first recent project
179
+ inputHandler('0');
180
+ // Check that the recent project was selected
181
+ expect(mockOnSelectProject).toHaveBeenCalledWith(expect.objectContaining({
182
+ name: 'project-c',
183
+ path: '/home/user/projects/project-c',
184
+ }));
185
+ });
186
+ });
@@ -0,0 +1 @@
1
+ export {};