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
@@ -0,0 +1,443 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState, useEffect, useMemo } from 'react';
3
+ import { Box, Text, useInput, useStdout } from 'ink';
4
+ import { Effect } from 'effect';
5
+ import SelectInput from 'ink-select-input';
6
+ import stripAnsi from 'strip-ansi';
7
+ import { projectManager } from '../services/projectManager.js';
8
+ import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
9
+ import { SessionManager } from '../services/sessionManager.js';
10
+ import { WorktreeService } from '../services/worktreeService.js';
11
+ import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, getStatusDisplay, } from '../constants/statusIcons.js';
12
+ import { useSearchMode } from '../hooks/useSearchMode.js';
13
+ import { useGitStatus } from '../hooks/useGitStatus.js';
14
+ import { truncateString, calculateColumnPositions, assembleWorktreeLabel, } from '../utils/worktreeUtils.js';
15
+ import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from '../utils/gitStatus.js';
16
+ import TextInputWrapper from './TextInputWrapper.js';
17
+ const MAX_BRANCH_NAME_LENGTH = 70;
18
+ const createSeparatorWithText = (text, totalWidth = 35) => {
19
+ const textWithSpaces = ` ${text} `;
20
+ const textLength = textWithSpaces.length;
21
+ const remainingWidth = totalWidth - textLength;
22
+ const leftDashes = Math.floor(remainingWidth / 2);
23
+ const rightDashes = Math.ceil(remainingWidth / 2);
24
+ return '─'.repeat(leftDashes) + textWithSpaces + '─'.repeat(rightDashes);
25
+ };
26
+ const formatErrorMessage = (error) => {
27
+ switch (error._tag) {
28
+ case 'ProcessError':
29
+ return `Process error: ${error.message}`;
30
+ case 'ConfigError':
31
+ return `Configuration error (${error.reason}): ${error.details}`;
32
+ case 'GitError':
33
+ return `Git command failed: ${error.command} (exit ${error.exitCode})\n${error.stderr}`;
34
+ case 'FileSystemError':
35
+ return `File ${error.operation} failed for ${error.path}: ${error.cause}`;
36
+ case 'ValidationError':
37
+ return `Validation failed for ${error.field}: ${error.constraint}`;
38
+ }
39
+ };
40
+ /** Sort sessions: busy first, then waiting/pending, then idle. Within same state, by lastActivity desc. */
41
+ function sessionSortKey(session) {
42
+ const stateData = session.stateMutex.getSnapshot();
43
+ switch (stateData.state) {
44
+ case 'busy':
45
+ return 0;
46
+ case 'waiting_input':
47
+ case 'pending_auto_approval':
48
+ return 1;
49
+ case 'idle':
50
+ return 2;
51
+ }
52
+ }
53
+ /** Resolve the display name for a project, using relativePath if names collide. */
54
+ function resolveProjectDisplayNames(projects) {
55
+ const nameCount = new Map();
56
+ for (const p of projects) {
57
+ nameCount.set(p.name, (nameCount.get(p.name) || 0) + 1);
58
+ }
59
+ const displayNames = new Map();
60
+ for (const p of projects) {
61
+ displayNames.set(p.path, nameCount.get(p.name) > 1 ? p.relativePath : p.name);
62
+ }
63
+ return displayNames;
64
+ }
65
+ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDismissError, version, }) => {
66
+ const [projects, setProjects] = useState([]);
67
+ const [recentProjects, setRecentProjects] = useState([]);
68
+ const [loading, setLoading] = useState(true);
69
+ const [loadError, setLoadError] = useState(null);
70
+ const [items, setItems] = useState([]);
71
+ // Session-related state
72
+ const [sessionEntries, setSessionEntries] = useState([]);
73
+ const [baseSessionWorktrees, setBaseSessionWorktrees] = useState([]);
74
+ const [sessionRefreshKey, setSessionRefreshKey] = useState(0);
75
+ const { stdout } = useStdout();
76
+ const fixedRows = 6;
77
+ const displayError = error || loadError;
78
+ const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(items.length, {
79
+ isDisabled: !!displayError,
80
+ skipInTest: false,
81
+ });
82
+ const limit = Math.max(5, stdout.rows - fixedRows - (isSearchMode ? 1 : 0) - (displayError ? 3 : 0));
83
+ // Git status polling for session worktrees
84
+ const enrichedWorktrees = useGitStatus(baseSessionWorktrees, baseSessionWorktrees.length > 0 ? 'main' : null);
85
+ // Discover projects on mount
86
+ useEffect(() => {
87
+ let cancelled = false;
88
+ const loadProjects = async () => {
89
+ setLoading(true);
90
+ setLoadError(null);
91
+ const result = await Effect.runPromise(Effect.either(projectManager.instance.discoverProjectsEffect(projectsDir)));
92
+ if (cancelled)
93
+ return;
94
+ if (result._tag === 'Left') {
95
+ setLoadError(formatErrorMessage(result.left));
96
+ setLoading(false);
97
+ return;
98
+ }
99
+ setProjects(result.right);
100
+ setRecentProjects(projectManager.getRecentProjects(0));
101
+ setLoading(false);
102
+ };
103
+ loadProjects();
104
+ return () => {
105
+ cancelled = true;
106
+ };
107
+ }, [projectsDir]);
108
+ // Load session worktree data on mount + sessionRefreshKey
109
+ useEffect(() => {
110
+ let cancelled = false;
111
+ const loadSessionData = async () => {
112
+ const projectPaths = globalSessionOrchestrator.getProjectPaths();
113
+ const entries = [];
114
+ const worktrees = [];
115
+ // Build a project lookup map
116
+ const projectByPath = new Map();
117
+ for (const p of projects) {
118
+ projectByPath.set(p.path, p);
119
+ }
120
+ const displayNames = resolveProjectDisplayNames(projects);
121
+ for (const projectPath of projectPaths) {
122
+ const sessions = globalSessionOrchestrator.getProjectSessions(projectPath);
123
+ if (sessions.length === 0)
124
+ continue;
125
+ // Load worktrees for this project to resolve branch names
126
+ const ws = new WorktreeService(projectPath);
127
+ const result = await Effect.runPromise(Effect.either(ws.getWorktreesEffect()));
128
+ if (cancelled)
129
+ return;
130
+ if (result._tag === 'Left')
131
+ continue;
132
+ const projectWorktrees = result.right;
133
+ const project = projectByPath.get(projectPath);
134
+ const projectName = displayNames.get(projectPath) ||
135
+ project?.name ||
136
+ projectPath.split('/').pop() ||
137
+ projectPath;
138
+ // Mark worktrees that have sessions
139
+ for (const wt of projectWorktrees) {
140
+ wt.hasSession = sessions.some(s => s.worktreePath === wt.path);
141
+ }
142
+ for (const session of sessions) {
143
+ const wt = projectWorktrees.find(w => w.path === session.worktreePath);
144
+ if (!wt)
145
+ continue;
146
+ entries.push({
147
+ session,
148
+ projectPath,
149
+ projectName,
150
+ worktree: wt,
151
+ });
152
+ worktrees.push(wt);
153
+ }
154
+ }
155
+ if (cancelled)
156
+ return;
157
+ // Sort sessions: busy > waiting > idle, then by lastActivity desc
158
+ entries.sort((a, b) => {
159
+ const keyA = sessionSortKey(a.session);
160
+ const keyB = sessionSortKey(b.session);
161
+ if (keyA !== keyB)
162
+ return keyA - keyB;
163
+ return (b.session.lastActivity.getTime() - a.session.lastActivity.getTime());
164
+ });
165
+ setSessionEntries(entries);
166
+ setBaseSessionWorktrees(prev => {
167
+ // Avoid restarting git status polling if the set of paths hasn't changed
168
+ const prevPaths = prev
169
+ .map(w => w.path)
170
+ .sort()
171
+ .join('\0');
172
+ const newPaths = worktrees
173
+ .map(w => w.path)
174
+ .sort()
175
+ .join('\0');
176
+ return prevPaths === newPaths ? prev : worktrees;
177
+ });
178
+ };
179
+ loadSessionData();
180
+ return () => {
181
+ cancelled = true;
182
+ };
183
+ }, [sessionRefreshKey, projects]);
184
+ // Subscribe to session events from all managers
185
+ useEffect(() => {
186
+ const refresh = () => setSessionRefreshKey(k => k + 1);
187
+ const projectPaths = globalSessionOrchestrator.getProjectPaths();
188
+ const managers = projectPaths.map(p => globalSessionOrchestrator.getManagerForProject(p));
189
+ for (const mgr of managers) {
190
+ mgr.on('sessionCreated', refresh);
191
+ mgr.on('sessionDestroyed', refresh);
192
+ mgr.on('sessionStateChanged', refresh);
193
+ }
194
+ return () => {
195
+ for (const mgr of managers) {
196
+ mgr.off('sessionCreated', refresh);
197
+ mgr.off('sessionDestroyed', refresh);
198
+ mgr.off('sessionStateChanged', refresh);
199
+ }
200
+ };
201
+ }, [sessionRefreshKey]);
202
+ // Build display items
203
+ const projectDisplayNames = useMemo(() => resolveProjectDisplayNames(projects), [projects]);
204
+ useEffect(() => {
205
+ const menuItems = [];
206
+ let currentIndex = 0;
207
+ // --- Active Sessions section ---
208
+ if (sessionEntries.length > 0) {
209
+ // Build WorktreeItems for column alignment
210
+ const sessionWorkItems = sessionEntries.map(entry => {
211
+ // Use enriched worktree if available (has git status)
212
+ const wt = enrichedWorktrees.find(w => w.path === entry.worktree.path) ||
213
+ entry.worktree;
214
+ const stateData = entry.session.stateMutex.getSnapshot();
215
+ const status = ` [${getStatusDisplay(stateData.state, stateData.backgroundTaskCount, stateData.teamMemberCount)}]`;
216
+ const fullBranchName = wt.branch
217
+ ? wt.branch.replace('refs/heads/', '')
218
+ : wt.path.split('/').pop() || 'detached';
219
+ const branchName = truncateString(fullBranchName, MAX_BRANCH_NAME_LENGTH);
220
+ const isMain = wt.isMainWorktree ? ' (main)' : '';
221
+ const baseLabel = `${entry.projectName} :: ${branchName}${isMain}${status}`;
222
+ let fileChanges = '';
223
+ let aheadBehind = '';
224
+ let parentBranch = '';
225
+ let itemError = '';
226
+ if (wt.gitStatus) {
227
+ fileChanges = formatGitFileChanges(wt.gitStatus);
228
+ aheadBehind = formatGitAheadBehind(wt.gitStatus);
229
+ parentBranch = formatParentBranch(wt.gitStatus.parentBranch, fullBranchName);
230
+ }
231
+ else if (wt.gitStatusError) {
232
+ itemError = `\x1b[31m[git error]\x1b[0m`;
233
+ }
234
+ else {
235
+ fileChanges = '\x1b[90m[fetching...]\x1b[0m';
236
+ }
237
+ return {
238
+ worktree: wt,
239
+ session: entry.session,
240
+ baseLabel,
241
+ fileChanges,
242
+ aheadBehind,
243
+ parentBranch,
244
+ error: itemError,
245
+ lengths: {
246
+ base: stripAnsi(baseLabel).length,
247
+ fileChanges: stripAnsi(fileChanges).length,
248
+ aheadBehind: stripAnsi(aheadBehind).length,
249
+ parentBranch: stripAnsi(parentBranch).length,
250
+ },
251
+ };
252
+ });
253
+ const columns = calculateColumnPositions(sessionWorkItems);
254
+ if (!isSearchMode) {
255
+ menuItems.push({
256
+ type: 'common',
257
+ label: createSeparatorWithText('Active Sessions'),
258
+ value: 'separator-sessions',
259
+ });
260
+ }
261
+ // Filter by search query
262
+ const filteredEntries = searchQuery
263
+ ? sessionEntries.filter((_entry, i) => {
264
+ const item = sessionWorkItems[i];
265
+ return stripAnsi(item.baseLabel)
266
+ .toLowerCase()
267
+ .includes(searchQuery.toLowerCase());
268
+ })
269
+ : sessionEntries;
270
+ filteredEntries.forEach(entry => {
271
+ const itemIndex = sessionEntries.indexOf(entry);
272
+ const workItem = sessionWorkItems[itemIndex];
273
+ const label = assembleWorktreeLabel(workItem, columns);
274
+ const numberPrefix = !isSearchMode && currentIndex < 10 ? `${currentIndex} ❯ ` : '❯ ';
275
+ const project = {
276
+ path: entry.projectPath,
277
+ name: entry.projectName,
278
+ relativePath: projects.find(p => p.path === entry.projectPath)?.relativePath ||
279
+ entry.projectPath,
280
+ isValid: true,
281
+ };
282
+ menuItems.push({
283
+ type: 'session',
284
+ label: numberPrefix + label,
285
+ value: `session-${entry.session.id}`,
286
+ session: entry.session,
287
+ project,
288
+ });
289
+ currentIndex++;
290
+ });
291
+ }
292
+ // --- Projects section ---
293
+ const filteredRecentProjects = searchQuery
294
+ ? recentProjects.filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
295
+ : recentProjects;
296
+ const filteredProjects = searchQuery
297
+ ? projects.filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
298
+ : projects;
299
+ // Deduplicate: recent projects first, then remaining
300
+ const recentPaths = new Set(filteredRecentProjects.map(rp => rp.path));
301
+ const nonRecentProjects = filteredProjects.filter(p => !recentPaths.has(p.path));
302
+ // Build ordered project list: recent first, then alphabetical
303
+ const orderedProjects = [];
304
+ for (const rp of filteredRecentProjects) {
305
+ const full = projects.find(p => p.path === rp.path);
306
+ if (full)
307
+ orderedProjects.push(full);
308
+ }
309
+ orderedProjects.push(...nonRecentProjects);
310
+ if (orderedProjects.length > 0 && !isSearchMode) {
311
+ menuItems.push({
312
+ type: 'common',
313
+ label: createSeparatorWithText('Projects'),
314
+ value: 'separator-projects',
315
+ });
316
+ }
317
+ orderedProjects.forEach(project => {
318
+ const projectSessions = globalSessionOrchestrator.getProjectSessions(project.path);
319
+ const counts = SessionManager.getSessionCounts(projectSessions);
320
+ const countsFormatted = SessionManager.formatSessionCounts(counts);
321
+ const displayName = projectDisplayNames.get(project.path) || project.name;
322
+ const numberPrefix = !isSearchMode && currentIndex < 10 ? `${currentIndex} ❯ ` : '❯ ';
323
+ menuItems.push({
324
+ type: 'project',
325
+ label: numberPrefix + displayName + countsFormatted,
326
+ value: project.path,
327
+ project,
328
+ });
329
+ currentIndex++;
330
+ });
331
+ // --- Other section ---
332
+ if (!isSearchMode) {
333
+ menuItems.push({
334
+ type: 'common',
335
+ label: createSeparatorWithText('Other'),
336
+ value: 'separator-other',
337
+ });
338
+ menuItems.push({
339
+ type: 'common',
340
+ label: `R 🔄 Refresh`,
341
+ value: 'refresh',
342
+ });
343
+ menuItems.push({
344
+ type: 'common',
345
+ label: `Q ${MENU_ICONS.EXIT} Exit`,
346
+ value: 'exit',
347
+ });
348
+ }
349
+ setItems(menuItems);
350
+ }, [
351
+ sessionEntries,
352
+ enrichedWorktrees,
353
+ projects,
354
+ recentProjects,
355
+ projectDisplayNames,
356
+ searchQuery,
357
+ isSearchMode,
358
+ ]);
359
+ // Refresh handler
360
+ const refreshAll = () => {
361
+ setLoading(true);
362
+ setLoadError(null);
363
+ Effect.runPromise(Effect.either(projectManager.instance.discoverProjectsEffect(projectsDir))).then(result => {
364
+ if (result._tag === 'Left') {
365
+ setLoadError(formatErrorMessage(result.left));
366
+ setLoading(false);
367
+ return;
368
+ }
369
+ setProjects(result.right);
370
+ setRecentProjects(projectManager.getRecentProjects(0));
371
+ setLoading(false);
372
+ setSessionRefreshKey(k => k + 1);
373
+ });
374
+ };
375
+ // Handle hotkeys
376
+ useInput((input, _key) => {
377
+ if (!process.stdin.setRawMode)
378
+ return;
379
+ if (displayError && onDismissError) {
380
+ onDismissError();
381
+ return;
382
+ }
383
+ if (isSearchMode)
384
+ return;
385
+ const keyPressed = input.toLowerCase();
386
+ // Number keys 0-9 for quick selection
387
+ if (/^[0-9]$/.test(keyPressed)) {
388
+ const index = parseInt(keyPressed);
389
+ const selectableItems = items.filter(item => item.type === 'session' || item.type === 'project');
390
+ if (index < selectableItems.length && selectableItems[index]) {
391
+ const selected = selectableItems[index];
392
+ if (selected.type === 'session') {
393
+ onSelectSession(selected.session, selected.project);
394
+ }
395
+ else if (selected.type === 'project') {
396
+ onSelectProject(selected.project);
397
+ }
398
+ }
399
+ return;
400
+ }
401
+ switch (keyPressed) {
402
+ case 'r':
403
+ refreshAll();
404
+ break;
405
+ case 'q':
406
+ case 'x':
407
+ onSelectProject({
408
+ path: 'EXIT_APPLICATION',
409
+ name: '',
410
+ relativePath: '',
411
+ isValid: false,
412
+ });
413
+ break;
414
+ }
415
+ });
416
+ const handleSelect = (item) => {
417
+ if (item.value.startsWith('separator'))
418
+ return;
419
+ if (item.type === 'session') {
420
+ onSelectSession(item.session, item.project);
421
+ }
422
+ else if (item.type === 'project') {
423
+ onSelectProject(item.project);
424
+ }
425
+ else if (item.value === 'refresh') {
426
+ refreshAll();
427
+ }
428
+ else if (item.value === 'exit') {
429
+ onSelectProject({
430
+ path: 'EXIT_APPLICATION',
431
+ name: '',
432
+ relativePath: '',
433
+ isValid: false,
434
+ });
435
+ }
436
+ };
437
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsxs(Text, { bold: true, color: "green", children: ["CCManager - Dashboard v", version] }) }), isSearchMode && (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { children: "Search: " }), _jsx(TextInputWrapper, { value: searchQuery, onChange: setSearchQuery, focus: true, placeholder: "Type to filter..." })] })), loading ? (_jsx(Box, { children: _jsx(Text, { color: "yellow", children: "Discovering 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 matches found" }) })) : isSearchMode ? (_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: item => handleSelect(item), 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: [_jsxs(Text, { dimColor: true, children: ["Status: ", STATUS_ICONS.BUSY, " ", STATUS_LABELS.BUSY, ' ', STATUS_ICONS.WAITING, " ", STATUS_LABELS.WAITING, " ", STATUS_ICONS.IDLE, ' ', STATUS_LABELS.IDLE] }), _jsx(Text, { dimColor: true, children: isSearchMode
438
+ ? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
439
+ : searchQuery
440
+ ? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select R-Refresh Q-Quit`
441
+ : 'Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search R-Refresh Q-Quit' })] })] }));
442
+ };
443
+ export default Dashboard;