ccmanager 3.9.0 → 3.11.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 (31) hide show
  1. package/dist/components/App.js +159 -44
  2. package/dist/components/App.test.js +96 -5
  3. package/dist/components/Dashboard.d.ts +12 -0
  4. package/dist/components/Dashboard.js +443 -0
  5. package/dist/components/Dashboard.test.js +348 -0
  6. package/dist/components/Menu.recent-projects.test.js +19 -19
  7. package/dist/components/NewWorktree.d.ts +20 -1
  8. package/dist/components/NewWorktree.js +103 -56
  9. package/dist/components/NewWorktree.test.js +17 -4
  10. package/dist/services/globalSessionOrchestrator.d.ts +1 -0
  11. package/dist/services/globalSessionOrchestrator.js +3 -0
  12. package/dist/services/projectManager.d.ts +7 -1
  13. package/dist/services/projectManager.js +26 -10
  14. package/dist/services/sessionManager.d.ts +3 -2
  15. package/dist/services/sessionManager.js +37 -40
  16. package/dist/services/sessionManager.test.js +38 -0
  17. package/dist/services/worktreeNameGenerator.d.ts +8 -0
  18. package/dist/services/worktreeNameGenerator.js +184 -0
  19. package/dist/services/worktreeNameGenerator.test.js +35 -0
  20. package/dist/utils/presetPrompt.d.ts +11 -0
  21. package/dist/utils/presetPrompt.js +71 -0
  22. package/dist/utils/presetPrompt.test.d.ts +1 -0
  23. package/dist/utils/presetPrompt.test.js +167 -0
  24. package/dist/utils/worktreeUtils.d.ts +1 -2
  25. package/package.json +6 -6
  26. package/dist/components/ProjectList.d.ts +0 -10
  27. package/dist/components/ProjectList.js +0 -233
  28. package/dist/components/ProjectList.recent-projects.test.js +0 -193
  29. package/dist/components/ProjectList.test.js +0 -620
  30. /package/dist/components/{ProjectList.recent-projects.test.d.ts → Dashboard.test.d.ts} +0 -0
  31. /package/dist/{components/ProjectList.test.d.ts → services/worktreeNameGenerator.test.d.ts} +0 -0
@@ -1,233 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useEffect } from 'react';
3
- import { Box, Text, useInput } from 'ink';
4
- import { Effect } from 'effect';
5
- import SelectInput from 'ink-select-input';
6
- import { projectManager } from '../services/projectManager.js';
7
- import { MENU_ICONS } from '../constants/statusIcons.js';
8
- import TextInputWrapper from './TextInputWrapper.js';
9
- import { useSearchMode } from '../hooks/useSearchMode.js';
10
- import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
11
- import { SessionManager } from '../services/sessionManager.js';
12
- const ProjectList = ({ projectsDir, onSelectProject, error, onDismissError, }) => {
13
- const [projects, setProjects] = useState([]);
14
- const [recentProjects, setRecentProjects] = useState([]);
15
- const [items, setItems] = useState([]);
16
- const [loading, setLoading] = useState(true);
17
- const [loadError, setLoadError] = useState(null);
18
- const limit = 10;
19
- // Helper function to format error messages based on error type using _tag discrimination
20
- const formatErrorMessage = (error) => {
21
- switch (error._tag) {
22
- case 'ProcessError':
23
- return `Process error: ${error.message}`;
24
- case 'ConfigError':
25
- return `Configuration error (${error.reason}): ${error.details}`;
26
- case 'GitError':
27
- return `Git command failed: ${error.command} (exit ${error.exitCode})\n${error.stderr}`;
28
- case 'FileSystemError':
29
- return `File ${error.operation} failed for ${error.path}: ${error.cause}`;
30
- case 'ValidationError':
31
- return `Validation failed for ${error.field}: ${error.constraint}`;
32
- }
33
- };
34
- // Use the search mode hook
35
- const displayError = error || loadError;
36
- const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(items.length, {
37
- isDisabled: !!displayError,
38
- skipInTest: false,
39
- });
40
- // Helper function to load projects with Effect-based error handling
41
- const loadProjectsEffect = async (checkCancellation) => {
42
- setLoading(true);
43
- setLoadError(null);
44
- // Use Effect-based project discovery
45
- const projectsEffect = projectManager.instance.discoverProjectsEffect(projectsDir);
46
- // Execute the Effect and handle both success and failure cases
47
- const result = await Effect.runPromise(Effect.either(projectsEffect));
48
- // Check cancellation flag before updating state (if provided)
49
- if (checkCancellation && checkCancellation())
50
- return;
51
- if (result._tag === 'Left') {
52
- // Handle error using pattern matching on _tag
53
- const errorMessage = formatErrorMessage(result.left);
54
- setLoadError(errorMessage);
55
- setLoading(false);
56
- return;
57
- }
58
- // Success case - extract projects from Right
59
- const discoveredProjects = result.right;
60
- setProjects(discoveredProjects);
61
- // Load recent projects with no limit (pass 0)
62
- const allRecentProjects = projectManager.getRecentProjects(0);
63
- setRecentProjects(allRecentProjects);
64
- setLoading(false);
65
- };
66
- const loadProjects = () => loadProjectsEffect();
67
- useEffect(() => {
68
- let cancelled = false;
69
- loadProjectsEffect(() => cancelled);
70
- // Cleanup function to set cancellation flag
71
- return () => {
72
- cancelled = true;
73
- };
74
- // eslint-disable-next-line react-hooks/exhaustive-deps
75
- }, [projectsDir]);
76
- useEffect(() => {
77
- const menuItems = [];
78
- let currentIndex = 0;
79
- // Filter recent projects based on search query
80
- const filteredRecentProjects = searchQuery
81
- ? recentProjects.filter(project => project.name.toLowerCase().includes(searchQuery.toLowerCase()))
82
- : recentProjects;
83
- // Add recent projects section if available and not in search mode
84
- if (filteredRecentProjects.length > 0) {
85
- // Add "Recent" separator only when not in search mode
86
- if (!isSearchMode) {
87
- menuItems.push({
88
- label: '── Recent ──',
89
- value: 'separator-recent',
90
- });
91
- }
92
- // Add recent projects
93
- filteredRecentProjects.forEach(recentProject => {
94
- // Find the full project data
95
- const fullProject = projects.find(p => p.path === recentProject.path);
96
- if (fullProject) {
97
- // Get session counts for this project
98
- const projectSessions = globalSessionOrchestrator.getProjectSessions(recentProject.path);
99
- const counts = SessionManager.getSessionCounts(projectSessions);
100
- const countsFormatted = SessionManager.formatSessionCounts(counts);
101
- const numberPrefix = !isSearchMode && currentIndex < 10 ? `${currentIndex} ❯ ` : '❯ ';
102
- menuItems.push({
103
- label: numberPrefix + recentProject.name + countsFormatted,
104
- value: recentProject.path,
105
- project: fullProject,
106
- });
107
- currentIndex++;
108
- }
109
- });
110
- }
111
- // Filter projects based on search query
112
- const filteredProjects = searchQuery
113
- ? projects.filter(project => project.name.toLowerCase().includes(searchQuery.toLowerCase()))
114
- : projects;
115
- // Filter out recent projects from all projects to avoid duplicates
116
- const recentPaths = new Set(filteredRecentProjects.map(rp => rp.path));
117
- const nonRecentProjects = filteredProjects.filter(project => !recentPaths.has(project.path));
118
- // Add "All Projects" separator if we have both recent and other projects
119
- if (filteredRecentProjects.length > 0 &&
120
- nonRecentProjects.length > 0 &&
121
- !isSearchMode) {
122
- menuItems.push({
123
- label: '── All Projects ──',
124
- value: 'separator-all',
125
- });
126
- }
127
- // Build menu items from filtered non-recent projects
128
- nonRecentProjects.forEach(project => {
129
- // Get session counts for this project
130
- const projectSessions = globalSessionOrchestrator.getProjectSessions(project.path);
131
- const counts = SessionManager.getSessionCounts(projectSessions);
132
- const countsFormatted = SessionManager.formatSessionCounts(counts);
133
- // Only show numbers for total items (0-9) when not in search mode
134
- const numberPrefix = !isSearchMode && currentIndex < 10 ? `${currentIndex} ❯ ` : '❯ ';
135
- menuItems.push({
136
- label: numberPrefix + project.name + countsFormatted,
137
- value: project.path,
138
- project,
139
- });
140
- currentIndex++;
141
- });
142
- // Add menu options only when not in search mode
143
- if (!isSearchMode) {
144
- if (projects.length > 0) {
145
- menuItems.push({
146
- label: '─────────────',
147
- value: 'separator',
148
- });
149
- }
150
- menuItems.push({
151
- label: `R 🔄 Refresh`,
152
- value: 'refresh',
153
- });
154
- menuItems.push({
155
- label: `Q ${MENU_ICONS.EXIT} Exit`,
156
- value: 'exit',
157
- });
158
- }
159
- setItems(menuItems);
160
- }, [projects, recentProjects, searchQuery, isSearchMode]);
161
- // Handle hotkeys
162
- useInput((input, _key) => {
163
- // Skip in test environment to avoid stdin.ref error
164
- if (!process.stdin.setRawMode) {
165
- return;
166
- }
167
- // Dismiss error on any key press when error is shown
168
- if (displayError && onDismissError) {
169
- onDismissError();
170
- return;
171
- }
172
- // Don't process other keys if in search mode (handled by useSearchMode)
173
- if (isSearchMode) {
174
- return;
175
- }
176
- const keyPressed = input.toLowerCase();
177
- // Handle number keys 0-9 for project selection
178
- if (/^[0-9]$/.test(keyPressed)) {
179
- const index = parseInt(keyPressed);
180
- // Get all selectable items (recent + non-recent projects)
181
- const selectableItems = items.filter(item => item.project);
182
- if (index < Math.min(10, selectableItems.length) &&
183
- selectableItems[index]?.project) {
184
- onSelectProject(selectableItems[index].project);
185
- }
186
- return;
187
- }
188
- switch (keyPressed) {
189
- case 'r':
190
- // Refresh project list
191
- loadProjects();
192
- break;
193
- case 'q':
194
- case 'x':
195
- // Trigger exit action
196
- onSelectProject({
197
- path: 'EXIT_APPLICATION',
198
- name: '',
199
- relativePath: '',
200
- isValid: false,
201
- });
202
- break;
203
- }
204
- });
205
- const handleSelect = (item) => {
206
- if (item.value.startsWith('separator')) {
207
- // Do nothing for separators
208
- }
209
- else if (item.value === 'refresh') {
210
- loadProjects();
211
- }
212
- else if (item.value === 'exit') {
213
- // Handle exit
214
- onSelectProject({
215
- path: 'EXIT_APPLICATION',
216
- name: '',
217
- relativePath: '',
218
- isValid: false,
219
- });
220
- }
221
- else if (item.project) {
222
- onSelectProject(item.project);
223
- }
224
- };
225
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "CCManager - Multi-Project Mode" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Select a project:" }) }), isSearchMode && (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { children: "Search: " }), _jsx(TextInputWrapper, { value: searchQuery, onChange: setSearchQuery, focus: true, placeholder: "Type to filter projects..." })] })), loading ? (_jsx(Box, { children: _jsx(Text, { color: "yellow", children: "Loading projects..." }) })) : projects.length === 0 && !displayError ? (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: ["No git repositories found in ", projectsDir] }) })) : isSearchMode && items.length === 0 ? (_jsx(Box, { children: _jsx(Text, { color: "yellow", children: "No projects match your search" }) })) : isSearchMode ? (
226
- // In search mode, show the items as a list without SelectInput
227
- _jsx(Box, { flexDirection: "column", children: items.slice(0, limit).map((item, index) => (_jsxs(Text, { color: index === selectedIndex ? 'green' : undefined, children: [index === selectedIndex ? '❯ ' : ' ', item.label] }, item.value))) })) : (_jsx(SelectInput, { items: items, onSelect: handleSelect, isFocused: !displayError, limit: limit, initialIndex: selectedIndex })), displayError && (_jsx(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red", children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", displayError] }), _jsx(Text, { color: "gray", dimColor: true, children: "Press any key to dismiss" })] }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [(isSearchMode || searchQuery) && (_jsxs(Text, { dimColor: true, children: ["Projects: ", items.filter(item => item.project).length, " of", ' ', projects.length, " shown"] })), _jsx(Text, { dimColor: true, children: isSearchMode
228
- ? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
229
- : searchQuery
230
- ? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select R-Refresh Q-Quit`
231
- : 'Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search R-Refresh Q-Quit' })] })] }));
232
- };
233
- export default ProjectList;
@@ -1,193 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
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
- import { Effect } from 'effect';
7
- // Mock bunTerminal to avoid native module loading issues
8
- vi.mock('../services/bunTerminal.js', () => ({
9
- spawn: vi.fn(function () {
10
- return null;
11
- }),
12
- }));
13
- // Mock ink to avoid stdin.ref issues
14
- vi.mock('ink', async () => {
15
- const actual = await vi.importActual('ink');
16
- return {
17
- ...actual,
18
- useInput: vi.fn(),
19
- };
20
- });
21
- // Mock SelectInput to render items as simple text
22
- vi.mock('ink-select-input', async () => {
23
- const React = await vi.importActual('react');
24
- const { Text, Box } = await vi.importActual('ink');
25
- return {
26
- default: ({ items }) => {
27
- return (_jsx(Box, { flexDirection: "column", children: items.map(item => (_jsx(Text, { children: item.label }, item.value))) }));
28
- },
29
- };
30
- });
31
- vi.mock('../services/projectManager.js', () => ({
32
- projectManager: {
33
- instance: {
34
- discoverProjectsEffect: vi.fn(),
35
- },
36
- getRecentProjects: vi.fn(),
37
- },
38
- }));
39
- describe('ProjectList - Recent Projects', () => {
40
- const mockOnSelectProject = vi.fn();
41
- const mockOnDismissError = vi.fn();
42
- let originalSetRawMode;
43
- const createProject = (name, path) => ({
44
- name,
45
- path,
46
- relativePath: `./${name}`,
47
- isValid: true,
48
- });
49
- const mockProjects = [
50
- createProject('project-a', '/home/user/projects/project-a'),
51
- createProject('project-b', '/home/user/projects/project-b'),
52
- createProject('project-c', '/home/user/projects/project-c'),
53
- createProject('project-d', '/home/user/projects/project-d'),
54
- createProject('project-e', '/home/user/projects/project-e'),
55
- ];
56
- beforeEach(() => {
57
- vi.clearAllMocks();
58
- vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(Effect.succeed(mockProjects));
59
- vi.mocked(projectManager.getRecentProjects).mockReturnValue([]);
60
- // Mock stdin.setRawMode
61
- originalSetRawMode = process.stdin.setRawMode;
62
- process.stdin.setRawMode = vi.fn();
63
- });
64
- afterEach(() => {
65
- // Restore original setRawMode
66
- process.stdin.setRawMode = originalSetRawMode;
67
- });
68
- it('should display recent projects at the top when available', async () => {
69
- // Mock recent projects
70
- vi.mocked(projectManager.getRecentProjects).mockReturnValue([
71
- {
72
- name: 'project-c',
73
- path: '/home/user/projects/project-c',
74
- lastAccessed: Date.now() - 1000,
75
- },
76
- {
77
- name: 'project-e',
78
- path: '/home/user/projects/project-e',
79
- lastAccessed: Date.now() - 2000,
80
- },
81
- ]);
82
- const { lastFrame } = render(_jsx(ProjectList, { projectsDir: "/home/user/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
83
- // Wait for projects to load
84
- await vi.waitFor(() => {
85
- expect(lastFrame()).toContain('project-c');
86
- });
87
- // Check that recent projects section is shown
88
- expect(lastFrame()).toContain('Recent');
89
- // Check that recent projects are at the top
90
- const output = lastFrame();
91
- const projectCIndex = output?.indexOf('project-c') ?? -1;
92
- const projectEIndex = output?.indexOf('project-e') ?? -1;
93
- const allProjectsIndex = output?.indexOf('All Projects') ?? -1;
94
- // Recent projects should appear before "All Projects" section
95
- expect(projectCIndex).toBeLessThan(allProjectsIndex);
96
- expect(projectEIndex).toBeLessThan(allProjectsIndex);
97
- });
98
- it('should not show recent projects section when there are none', async () => {
99
- vi.mocked(projectManager.getRecentProjects).mockReturnValue([]);
100
- const { lastFrame } = render(_jsx(ProjectList, { projectsDir: "/home/user/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
101
- // Wait for projects to load
102
- await vi.waitFor(() => {
103
- expect(lastFrame()).toContain('project-a');
104
- });
105
- // Check that recent projects section is not shown
106
- expect(lastFrame()).not.toContain('Recent');
107
- });
108
- it('should handle selection of recent projects', async () => {
109
- // Skip this test for now - SelectInput interaction is complex to test
110
- // The selection functionality is covered by the number key test
111
- });
112
- it('should filter recent projects based on search query', async () => {
113
- // This functionality is tested in the main ProjectList.test.tsx
114
- // Skip in recent projects specific tests to avoid complexity
115
- });
116
- it('should show recent projects with correct number prefixes', async () => {
117
- // Mock recent projects that match existing projects
118
- vi.mocked(projectManager.getRecentProjects).mockReturnValue([
119
- {
120
- name: 'project-c',
121
- path: '/home/user/projects/project-c',
122
- lastAccessed: Date.now() - 1000,
123
- },
124
- {
125
- name: 'project-e',
126
- path: '/home/user/projects/project-e',
127
- lastAccessed: Date.now() - 2000,
128
- },
129
- ]);
130
- const { lastFrame } = render(_jsx(ProjectList, { projectsDir: "/home/user/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
131
- // Wait for projects to load
132
- await vi.waitFor(() => {
133
- expect(lastFrame()).toContain('project-c');
134
- });
135
- // Check that recent projects have correct number prefixes
136
- const output = lastFrame();
137
- expect(output).toContain('0 ❯ project-c');
138
- expect(output).toContain('1 ❯ project-e');
139
- // Check that regular projects start from the next available number
140
- expect(output).toContain('2 ❯ project-a');
141
- });
142
- it('should show all recent projects without limit', async () => {
143
- // Create 10 projects
144
- const manyProjects = Array.from({ length: 10 }, (_, i) => createProject(`project-${i}`, `/home/user/projects/project-${i}`));
145
- // Mock discovered projects
146
- vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(Effect.succeed(manyProjects));
147
- // Mock more than 5 recent projects
148
- const manyRecentProjects = Array.from({ length: 10 }, (_, i) => ({
149
- name: `project-${i}`,
150
- path: `/home/user/projects/project-${i}`,
151
- lastAccessed: Date.now() - i * 1000,
152
- }));
153
- vi.mocked(projectManager.getRecentProjects).mockReturnValue(manyRecentProjects);
154
- const { lastFrame } = render(_jsx(ProjectList, { projectsDir: "/home/user/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
155
- // Wait for projects to load
156
- await vi.waitFor(() => {
157
- expect(lastFrame()).toContain('project-0');
158
- });
159
- // Check that all 10 recent projects are shown (not limited to 5)
160
- const output = lastFrame();
161
- for (let i = 0; i < 10; i++) {
162
- expect(output).toContain(`project-${i}`);
163
- }
164
- });
165
- it('should allow number key selection for recent projects', async () => {
166
- // Mock recent projects
167
- vi.mocked(projectManager.getRecentProjects).mockReturnValue([
168
- {
169
- name: 'project-c',
170
- path: '/home/user/projects/project-c',
171
- lastAccessed: Date.now(),
172
- },
173
- ]);
174
- // Mock the useInput hook to capture the handler
175
- const mockUseInput = vi.mocked(await import('ink')).useInput;
176
- let inputHandler = () => { };
177
- mockUseInput.mockImplementation(handler => {
178
- inputHandler = handler;
179
- });
180
- const { lastFrame } = render(_jsx(ProjectList, { projectsDir: "/home/user/projects", onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError }));
181
- // Wait for projects to load
182
- await vi.waitFor(() => {
183
- expect(lastFrame()).toContain('project-c');
184
- });
185
- // Simulate pressing 0 to select first recent project
186
- inputHandler('0');
187
- // Check that the recent project was selected
188
- expect(mockOnSelectProject).toHaveBeenCalledWith(expect.objectContaining({
189
- name: 'project-c',
190
- path: '/home/user/projects/project-c',
191
- }));
192
- });
193
- });