ccmanager 3.12.6 → 4.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.
- package/dist/components/App.js +137 -63
- package/dist/components/App.test.js +16 -30
- package/dist/components/Dashboard.js +3 -3
- package/dist/components/Menu.d.ts +2 -2
- package/dist/components/Menu.js +66 -140
- package/dist/components/Menu.recent-projects.test.js +8 -8
- package/dist/components/Menu.test.js +17 -17
- package/dist/components/Session.js +3 -3
- package/dist/components/SessionActions.d.ts +9 -0
- package/dist/components/SessionActions.js +29 -0
- package/dist/components/SessionRename.d.ts +8 -0
- package/dist/components/SessionRename.js +18 -0
- package/dist/constants/statusIcons.d.ts +3 -0
- package/dist/constants/statusIcons.js +3 -0
- package/dist/services/globalSessionOrchestrator.test.js +11 -5
- package/dist/services/sessionManager.autoApproval.test.js +1 -4
- package/dist/services/sessionManager.d.ts +7 -7
- package/dist/services/sessionManager.effect.test.js +17 -16
- package/dist/services/sessionManager.js +43 -48
- package/dist/services/sessionManager.statePersistence.test.js +3 -6
- package/dist/services/sessionManager.test.js +21 -24
- package/dist/services/worktreeService.d.ts +1 -15
- package/dist/services/worktreeService.js +1 -39
- package/dist/services/worktreeService.sort.test.js +141 -303
- package/dist/types/index.d.ts +37 -6
- package/dist/utils/hookExecutor.test.js +8 -0
- package/dist/utils/worktreeUtils.d.ts +12 -6
- package/dist/utils/worktreeUtils.js +116 -50
- package/dist/utils/worktreeUtils.test.js +9 -7
- package/package.json +6 -6
package/dist/components/Menu.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Effect } from 'effect';
|
|
|
6
6
|
import { SessionManager } from '../services/sessionManager.js';
|
|
7
7
|
import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, } from '../constants/statusIcons.js';
|
|
8
8
|
import { useGitStatus } from '../hooks/useGitStatus.js';
|
|
9
|
-
import {
|
|
9
|
+
import { prepareSessionItems, calculateColumnPositions, assembleSessionLabel, } from '../utils/worktreeUtils.js';
|
|
10
10
|
import { projectManager } from '../services/projectManager.js';
|
|
11
11
|
import { useSearchMode } from '../hooks/useSearchMode.js';
|
|
12
12
|
import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
|
|
@@ -29,7 +29,7 @@ const createSeparatorWithText = (text, totalWidth = 35) => {
|
|
|
29
29
|
const formatGitError = (error) => {
|
|
30
30
|
return `Git command failed: ${error.command} (exit ${error.exitCode})\n${error.stderr}`;
|
|
31
31
|
};
|
|
32
|
-
const Menu = ({ sessionManager, worktreeService,
|
|
32
|
+
const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentProject, error, onDismissError, projectName, multiProject = false, version, }) => {
|
|
33
33
|
const [baseWorktrees, setBaseWorktrees] = useState([]);
|
|
34
34
|
const [defaultBranch, setDefaultBranch] = useState(null);
|
|
35
35
|
const [loadError, setLoadError] = useState(null);
|
|
@@ -38,6 +38,7 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
|
|
|
38
38
|
const [items, setItems] = useState([]);
|
|
39
39
|
const [recentProjects, setRecentProjects] = useState([]);
|
|
40
40
|
const [highlightedWorktreePath, setHighlightedWorktreePath] = useState(null);
|
|
41
|
+
const [highlightedSession, setHighlightedSession] = useState(undefined);
|
|
41
42
|
const [autoApprovalToggleCounter, setAutoApprovalToggleCounter] = useState(0);
|
|
42
43
|
// Use the search mode hook
|
|
43
44
|
const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(items.length, {
|
|
@@ -53,9 +54,7 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
|
|
|
53
54
|
let cancelled = false;
|
|
54
55
|
// Load worktrees and default branch using Effect composition
|
|
55
56
|
// Chain getWorktreesEffect and getDefaultBranchEffect using Effect.flatMap
|
|
56
|
-
const loadWorktreesAndBranch = Effect.flatMap(worktreeService.getWorktreesEffect({
|
|
57
|
-
sortByLastSession: worktreeConfig.sortByLastSession,
|
|
58
|
-
}), worktrees => Effect.map(worktreeService.getDefaultBranchEffect(), defaultBranch => ({
|
|
57
|
+
const loadWorktreesAndBranch = Effect.flatMap(worktreeService.getWorktreesEffect(), worktrees => Effect.map(worktreeService.getDefaultBranchEffect(), defaultBranch => ({
|
|
59
58
|
worktrees,
|
|
60
59
|
defaultBranch,
|
|
61
60
|
})));
|
|
@@ -118,15 +117,12 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
|
|
|
118
117
|
sessionManager.off('sessionDestroyed', handleSessionChange);
|
|
119
118
|
sessionManager.off('sessionStateChanged', handleSessionChange);
|
|
120
119
|
};
|
|
121
|
-
}, [
|
|
122
|
-
sessionManager,
|
|
123
|
-
worktreeService,
|
|
124
|
-
multiProject,
|
|
125
|
-
worktreeConfig.sortByLastSession,
|
|
126
|
-
]);
|
|
120
|
+
}, [sessionManager, worktreeService, multiProject]);
|
|
127
121
|
useEffect(() => {
|
|
128
122
|
// Prepare worktree items and calculate layout
|
|
129
|
-
const items =
|
|
123
|
+
const items = prepareSessionItems(worktrees, sessions, {
|
|
124
|
+
sortByLastSession: worktreeConfig.sortByLastSession,
|
|
125
|
+
});
|
|
130
126
|
const columnPositions = calculateColumnPositions(items);
|
|
131
127
|
// Filter worktrees based on search query
|
|
132
128
|
const filteredWorktrees = filterWorktreesByQuery(items.map(item => item.worktree), searchQuery);
|
|
@@ -134,18 +130,23 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
|
|
|
134
130
|
const filteredItems = items.filter(item => filteredWorktreeSet.has(item.worktree));
|
|
135
131
|
// Build menu items with proper alignment
|
|
136
132
|
const menuItems = filteredItems.map((item, index) => {
|
|
137
|
-
const baseLabel =
|
|
133
|
+
const baseLabel = assembleSessionLabel(item, columnPositions);
|
|
138
134
|
const aaDisabled = configReader.isAutoApprovalEnabled() &&
|
|
139
135
|
sessionManager.isAutoApprovalDisabledForWorktree(item.worktree.path);
|
|
140
136
|
const label = baseLabel + (aaDisabled ? ' [Auto Approval Off]' : '');
|
|
141
137
|
// Only show numbers for worktrees (0-9) when not in search mode
|
|
142
138
|
// Use fixed-width prefix to prevent flicker at scroll boundary
|
|
143
139
|
const numberPrefix = !isSearchMode && index < 10 ? `${index} ❯ ` : ' ❯ ';
|
|
140
|
+
// Use session id for value if present, otherwise worktree path
|
|
141
|
+
const value = item.session
|
|
142
|
+
? `session:${item.session.id}`
|
|
143
|
+
: item.worktree.path;
|
|
144
144
|
return {
|
|
145
145
|
type: 'worktree',
|
|
146
146
|
label: numberPrefix + label,
|
|
147
|
-
value
|
|
147
|
+
value,
|
|
148
148
|
worktree: item.worktree,
|
|
149
|
+
session: item.session,
|
|
149
150
|
};
|
|
150
151
|
});
|
|
151
152
|
// Filter recent projects based on search query
|
|
@@ -257,14 +258,18 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
|
|
|
257
258
|
}
|
|
258
259
|
setItems(menuItems);
|
|
259
260
|
// Ensure highlighted worktree path is valid for hotkey support
|
|
260
|
-
// (e.g., on initial render or when returning from a session view)
|
|
261
261
|
setHighlightedWorktreePath(prev => {
|
|
262
262
|
if (prev &&
|
|
263
|
-
menuItems.some(item => item.type === 'worktree' && item.
|
|
263
|
+
menuItems.some(item => item.type === 'worktree' && item.worktree.path === prev)) {
|
|
264
264
|
return prev;
|
|
265
265
|
}
|
|
266
266
|
const first = menuItems.find(item => item.type === 'worktree');
|
|
267
|
-
|
|
267
|
+
if (first && first.type === 'worktree') {
|
|
268
|
+
setHighlightedSession(first.session);
|
|
269
|
+
return first.worktree.path;
|
|
270
|
+
}
|
|
271
|
+
setHighlightedSession(undefined);
|
|
272
|
+
return null;
|
|
268
273
|
});
|
|
269
274
|
}, [
|
|
270
275
|
worktrees,
|
|
@@ -277,6 +282,7 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
|
|
|
277
282
|
isSearchMode,
|
|
278
283
|
autoApprovalToggleCounter,
|
|
279
284
|
sessionManager,
|
|
285
|
+
worktreeConfig.sortByLastSession,
|
|
280
286
|
]);
|
|
281
287
|
// Handle hotkeys
|
|
282
288
|
useInput((input, _key) => {
|
|
@@ -307,7 +313,11 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
|
|
|
307
313
|
const projectItems = items.filter(item => item.type === 'project');
|
|
308
314
|
// Check if it's a worktree
|
|
309
315
|
if (index < worktreeItems.length && worktreeItems[index]) {
|
|
310
|
-
|
|
316
|
+
onMenuAction({
|
|
317
|
+
type: 'selectWorktree',
|
|
318
|
+
worktree: worktreeItems[index].worktree,
|
|
319
|
+
session: worktreeItems[index].session,
|
|
320
|
+
});
|
|
311
321
|
return;
|
|
312
322
|
}
|
|
313
323
|
// Check if it's a recent project (when worktrees < 10)
|
|
@@ -329,86 +339,49 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
|
|
|
329
339
|
setAutoApprovalToggleCounter(c => c + 1);
|
|
330
340
|
}
|
|
331
341
|
break;
|
|
342
|
+
case ' ':
|
|
343
|
+
// Open session actions for highlighted session
|
|
344
|
+
if (highlightedSession && highlightedWorktreePath) {
|
|
345
|
+
onMenuAction({
|
|
346
|
+
type: 'sessionActions',
|
|
347
|
+
session: highlightedSession,
|
|
348
|
+
worktreePath: highlightedWorktreePath,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
break;
|
|
332
352
|
case 'n':
|
|
333
|
-
|
|
334
|
-
onSelectWorktree({
|
|
335
|
-
path: '',
|
|
336
|
-
branch: '',
|
|
337
|
-
isMainWorktree: false,
|
|
338
|
-
hasSession: false,
|
|
339
|
-
});
|
|
353
|
+
onMenuAction({ type: 'newWorktree' });
|
|
340
354
|
break;
|
|
341
355
|
case 'm':
|
|
342
|
-
|
|
343
|
-
onSelectWorktree({
|
|
344
|
-
path: 'MERGE_WORKTREE',
|
|
345
|
-
branch: '',
|
|
346
|
-
isMainWorktree: false,
|
|
347
|
-
hasSession: false,
|
|
348
|
-
});
|
|
356
|
+
onMenuAction({ type: 'mergeWorktree' });
|
|
349
357
|
break;
|
|
350
358
|
case 'd':
|
|
351
|
-
|
|
352
|
-
onSelectWorktree({
|
|
353
|
-
path: 'DELETE_WORKTREE',
|
|
354
|
-
branch: '',
|
|
355
|
-
isMainWorktree: false,
|
|
356
|
-
hasSession: false,
|
|
357
|
-
});
|
|
359
|
+
onMenuAction({ type: 'deleteWorktree' });
|
|
358
360
|
break;
|
|
359
361
|
case 'p':
|
|
360
362
|
// Trigger project configuration action (only in single-project mode)
|
|
361
363
|
if (!multiProject) {
|
|
362
|
-
|
|
363
|
-
path: 'CONFIGURATION_PROJECT',
|
|
364
|
-
branch: '',
|
|
365
|
-
isMainWorktree: false,
|
|
366
|
-
hasSession: false,
|
|
367
|
-
});
|
|
364
|
+
onMenuAction({ type: 'configuration', scope: 'project' });
|
|
368
365
|
}
|
|
369
366
|
break;
|
|
370
367
|
case 'c':
|
|
371
|
-
|
|
372
|
-
if (multiProject) {
|
|
373
|
-
// In multi-project mode, 'c' opens global configuration (backward compatible)
|
|
374
|
-
onSelectWorktree({
|
|
375
|
-
path: 'CONFIGURATION',
|
|
376
|
-
branch: '',
|
|
377
|
-
isMainWorktree: false,
|
|
378
|
-
hasSession: false,
|
|
379
|
-
});
|
|
380
|
-
}
|
|
381
|
-
else {
|
|
382
|
-
// In single-project mode, 'c' opens global configuration
|
|
383
|
-
onSelectWorktree({
|
|
384
|
-
path: 'CONFIGURATION_GLOBAL',
|
|
385
|
-
branch: '',
|
|
386
|
-
isMainWorktree: false,
|
|
387
|
-
hasSession: false,
|
|
388
|
-
});
|
|
389
|
-
}
|
|
368
|
+
onMenuAction({ type: 'configuration', scope: 'global' });
|
|
390
369
|
break;
|
|
391
370
|
case 'b':
|
|
392
371
|
// In multi-project mode, go back to project list
|
|
393
372
|
if (projectName) {
|
|
394
|
-
|
|
395
|
-
path: 'EXIT_APPLICATION',
|
|
396
|
-
branch: '',
|
|
397
|
-
isMainWorktree: false,
|
|
398
|
-
hasSession: false,
|
|
399
|
-
});
|
|
373
|
+
onMenuAction({ type: 'exit' });
|
|
400
374
|
}
|
|
401
375
|
break;
|
|
402
|
-
case 'q':
|
|
403
376
|
case 'x':
|
|
377
|
+
if (!projectName) {
|
|
378
|
+
onMenuAction({ type: 'exit' });
|
|
379
|
+
}
|
|
380
|
+
break;
|
|
381
|
+
case 'q':
|
|
404
382
|
// Trigger exit action (only in single-project mode)
|
|
405
383
|
if (!projectName) {
|
|
406
|
-
|
|
407
|
-
path: 'EXIT_APPLICATION',
|
|
408
|
-
branch: '',
|
|
409
|
-
isMainWorktree: false,
|
|
410
|
-
hasSession: false,
|
|
411
|
-
});
|
|
384
|
+
onMenuAction({ type: 'exit' });
|
|
412
385
|
}
|
|
413
386
|
break;
|
|
414
387
|
}
|
|
@@ -418,7 +391,6 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
|
|
|
418
391
|
// Do nothing for separators and headers
|
|
419
392
|
}
|
|
420
393
|
else if (item.type === 'project') {
|
|
421
|
-
// Handle recent project selection
|
|
422
394
|
if (onSelectRecentProject) {
|
|
423
395
|
const project = {
|
|
424
396
|
path: item.recentProject.path,
|
|
@@ -430,79 +402,32 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
|
|
|
430
402
|
}
|
|
431
403
|
}
|
|
432
404
|
else if (item.value === 'new-worktree') {
|
|
433
|
-
|
|
434
|
-
onSelectWorktree({
|
|
435
|
-
path: '',
|
|
436
|
-
branch: '',
|
|
437
|
-
isMainWorktree: false,
|
|
438
|
-
hasSession: false,
|
|
439
|
-
});
|
|
405
|
+
onMenuAction({ type: 'newWorktree' });
|
|
440
406
|
}
|
|
441
407
|
else if (item.value === 'merge-worktree') {
|
|
442
|
-
|
|
443
|
-
onSelectWorktree({
|
|
444
|
-
path: 'MERGE_WORKTREE',
|
|
445
|
-
branch: '',
|
|
446
|
-
isMainWorktree: false,
|
|
447
|
-
hasSession: false,
|
|
448
|
-
});
|
|
408
|
+
onMenuAction({ type: 'mergeWorktree' });
|
|
449
409
|
}
|
|
450
410
|
else if (item.value === 'delete-worktree') {
|
|
451
|
-
|
|
452
|
-
onSelectWorktree({
|
|
453
|
-
path: 'DELETE_WORKTREE',
|
|
454
|
-
branch: '',
|
|
455
|
-
isMainWorktree: false,
|
|
456
|
-
hasSession: false,
|
|
457
|
-
});
|
|
411
|
+
onMenuAction({ type: 'deleteWorktree' });
|
|
458
412
|
}
|
|
459
413
|
else if (item.value === 'configuration') {
|
|
460
|
-
|
|
461
|
-
onSelectWorktree({
|
|
462
|
-
path: 'CONFIGURATION',
|
|
463
|
-
branch: '',
|
|
464
|
-
isMainWorktree: false,
|
|
465
|
-
hasSession: false,
|
|
466
|
-
});
|
|
414
|
+
onMenuAction({ type: 'configuration', scope: 'global' });
|
|
467
415
|
}
|
|
468
416
|
else if (item.value === 'configuration-project') {
|
|
469
|
-
|
|
470
|
-
onSelectWorktree({
|
|
471
|
-
path: 'CONFIGURATION_PROJECT',
|
|
472
|
-
branch: '',
|
|
473
|
-
isMainWorktree: false,
|
|
474
|
-
hasSession: false,
|
|
475
|
-
});
|
|
417
|
+
onMenuAction({ type: 'configuration', scope: 'project' });
|
|
476
418
|
}
|
|
477
419
|
else if (item.value === 'configuration-global') {
|
|
478
|
-
|
|
479
|
-
onSelectWorktree({
|
|
480
|
-
path: 'CONFIGURATION_GLOBAL',
|
|
481
|
-
branch: '',
|
|
482
|
-
isMainWorktree: false,
|
|
483
|
-
hasSession: false,
|
|
484
|
-
});
|
|
420
|
+
onMenuAction({ type: 'configuration', scope: 'global' });
|
|
485
421
|
}
|
|
486
|
-
else if (item.value === 'exit') {
|
|
487
|
-
|
|
488
|
-
onSelectWorktree({
|
|
489
|
-
path: 'EXIT_APPLICATION',
|
|
490
|
-
branch: '',
|
|
491
|
-
isMainWorktree: false,
|
|
492
|
-
hasSession: false,
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
else if (item.value === 'back-to-projects') {
|
|
496
|
-
// Handle in parent component - use special marker
|
|
497
|
-
onSelectWorktree({
|
|
498
|
-
path: 'EXIT_APPLICATION',
|
|
499
|
-
branch: '',
|
|
500
|
-
isMainWorktree: false,
|
|
501
|
-
hasSession: false,
|
|
502
|
-
});
|
|
422
|
+
else if (item.value === 'exit' || item.value === 'back-to-projects') {
|
|
423
|
+
onMenuAction({ type: 'exit' });
|
|
503
424
|
}
|
|
504
425
|
else if (item.type === 'worktree') {
|
|
505
|
-
|
|
426
|
+
onMenuAction({
|
|
427
|
+
type: 'selectWorktree',
|
|
428
|
+
worktree: item.worktree,
|
|
429
|
+
session: item.session,
|
|
430
|
+
});
|
|
506
431
|
}
|
|
507
432
|
};
|
|
508
433
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", children: ["CCManager - Claude Code Worktree Manager v", version] }), projectName && (_jsx(Text, { bold: true, color: "green", children: projectName }))] }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Select a worktree to start or resume a Claude Code session:" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: items, limit: limit, placeholder: "Type to filter worktrees...", noMatchMessage: "No worktrees match your search", children: _jsx(SelectInput, { items: items, onSelect: item => handleSelect(item), onHighlight: item => {
|
|
@@ -514,11 +439,12 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
|
|
|
514
439
|
const menuItem = item;
|
|
515
440
|
if (menuItem.type === 'worktree') {
|
|
516
441
|
setHighlightedWorktreePath(menuItem.worktree.path);
|
|
442
|
+
setHighlightedSession(menuItem.session);
|
|
517
443
|
}
|
|
518
444
|
}, isFocused: !error, initialIndex: selectedIndex, limit: limit }) }), (error || loadError) && (_jsx(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red", children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", error || loadError] }), _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, configReader.isAutoApprovalEnabled() && (_jsxs(_Fragment, { children: [' | ', _jsx(Text, { color: "green", children: "Auto Approval Enabled" })] }))] }), _jsx(Text, { dimColor: true, children: isSearchMode
|
|
519
445
|
? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
|
|
520
446
|
: searchQuery
|
|
521
|
-
? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}`
|
|
522
|
-
: `Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}` })] })] }));
|
|
447
|
+
? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select Space-Session actions (session rows only) N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}`
|
|
448
|
+
: `Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Space-Session actions (session rows only) N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}` })] })] }));
|
|
523
449
|
};
|
|
524
450
|
export default Menu;
|
|
@@ -108,7 +108,7 @@ describe('Menu - Recent Projects', () => {
|
|
|
108
108
|
vi.mocked(projectManager.getRecentProjects).mockReturnValue([
|
|
109
109
|
{ path: '/project1', name: 'Project 1', lastAccessed: 1000 },
|
|
110
110
|
]);
|
|
111
|
-
const { lastFrame } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService,
|
|
111
|
+
const { lastFrame } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onMenuAction: vi.fn(), multiProject: false, version: "test" }));
|
|
112
112
|
const output = lastFrame();
|
|
113
113
|
expect(output).not.toContain('─ Recent ─');
|
|
114
114
|
expect(output).not.toContain('Project 1');
|
|
@@ -129,9 +129,9 @@ describe('Menu - Recent Projects', () => {
|
|
|
129
129
|
teamMembers: 0,
|
|
130
130
|
});
|
|
131
131
|
vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
|
|
132
|
-
const { lastFrame, rerender } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService,
|
|
132
|
+
const { lastFrame, rerender } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onMenuAction: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
|
|
133
133
|
// Force a rerender to ensure all effects have run
|
|
134
|
-
rerender(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService,
|
|
134
|
+
rerender(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onMenuAction: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
|
|
135
135
|
await vi.waitFor(() => {
|
|
136
136
|
const output = lastFrame();
|
|
137
137
|
expect(output).toContain('─ Recent ─');
|
|
@@ -141,7 +141,7 @@ describe('Menu - Recent Projects', () => {
|
|
|
141
141
|
});
|
|
142
142
|
it('should not show recent projects section when no recent projects', () => {
|
|
143
143
|
vi.mocked(projectManager.getRecentProjects).mockReturnValue([]);
|
|
144
|
-
const { lastFrame } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService,
|
|
144
|
+
const { lastFrame } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onMenuAction: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
|
|
145
145
|
const output = lastFrame();
|
|
146
146
|
expect(output).not.toContain('─ Recent ─');
|
|
147
147
|
});
|
|
@@ -163,7 +163,7 @@ describe('Menu - Recent Projects', () => {
|
|
|
163
163
|
teamMembers: 0,
|
|
164
164
|
});
|
|
165
165
|
vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
|
|
166
|
-
const { lastFrame } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService,
|
|
166
|
+
const { lastFrame } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onMenuAction: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
|
|
167
167
|
await vi.waitFor(() => {
|
|
168
168
|
const output = lastFrame();
|
|
169
169
|
expect(output).toContain('─ Recent ─');
|
|
@@ -183,9 +183,9 @@ describe('Menu - Recent Projects', () => {
|
|
|
183
183
|
...mockWorktreeService,
|
|
184
184
|
getGitRootPath: vi.fn().mockReturnValue('/current/project'),
|
|
185
185
|
};
|
|
186
|
-
const { lastFrame, rerender } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: worktreeServiceWithGitRoot,
|
|
186
|
+
const { lastFrame, rerender } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: worktreeServiceWithGitRoot, onMenuAction: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
|
|
187
187
|
// Force a rerender to ensure all effects have run
|
|
188
|
-
rerender(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: worktreeServiceWithGitRoot,
|
|
188
|
+
rerender(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: worktreeServiceWithGitRoot, onMenuAction: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
|
|
189
189
|
await vi.waitFor(() => {
|
|
190
190
|
const output = lastFrame();
|
|
191
191
|
expect(output).toContain('─ Recent ─');
|
|
@@ -204,7 +204,7 @@ describe('Menu - Recent Projects', () => {
|
|
|
204
204
|
]);
|
|
205
205
|
// Mock getGitRootPath to return the current project path
|
|
206
206
|
vi.mocked(mockWorktreeService.getGitRootPath).mockReturnValue('/current/project');
|
|
207
|
-
const { lastFrame } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService,
|
|
207
|
+
const { lastFrame } = render(_jsx(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onMenuAction: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true, version: "test" }));
|
|
208
208
|
const output = lastFrame();
|
|
209
209
|
expect(output).not.toContain('─ Recent ─');
|
|
210
210
|
expect(output).not.toContain('Current Project');
|
|
@@ -78,7 +78,7 @@ describe('Menu component Effect-based error handling', () => {
|
|
|
78
78
|
it('should handle GitError from getWorktreesEffect and display error message', async () => {
|
|
79
79
|
const { Effect } = await import('effect');
|
|
80
80
|
const { GitError } = await import('../types/errors.js');
|
|
81
|
-
const
|
|
81
|
+
const onMenuAction = vi.fn();
|
|
82
82
|
const onDismissError = vi.fn();
|
|
83
83
|
// Mock getWorktreesEffect to return a failing Effect
|
|
84
84
|
const gitError = new GitError({
|
|
@@ -88,7 +88,7 @@ describe('Menu component Effect-based error handling', () => {
|
|
|
88
88
|
stdout: '',
|
|
89
89
|
});
|
|
90
90
|
vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(Effect.fail(gitError));
|
|
91
|
-
const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService,
|
|
91
|
+
const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: onMenuAction, onDismissError: onDismissError, version: "test" }));
|
|
92
92
|
// Wait for Effect to execute
|
|
93
93
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
94
94
|
const output = lastFrame();
|
|
@@ -99,7 +99,7 @@ describe('Menu component Effect-based error handling', () => {
|
|
|
99
99
|
});
|
|
100
100
|
it('should successfully load worktrees using getWorktreesEffect', async () => {
|
|
101
101
|
const { Effect } = await import('effect');
|
|
102
|
-
const
|
|
102
|
+
const onMenuAction = vi.fn();
|
|
103
103
|
const mockWorktrees = [
|
|
104
104
|
{
|
|
105
105
|
path: '/test/main',
|
|
@@ -118,7 +118,7 @@ describe('Menu component Effect-based error handling', () => {
|
|
|
118
118
|
vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(Effect.succeed(mockWorktrees));
|
|
119
119
|
// Mock getDefaultBranchEffect to return successful Effect
|
|
120
120
|
vi.spyOn(worktreeService, 'getDefaultBranchEffect').mockReturnValue(Effect.succeed('main'));
|
|
121
|
-
const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService,
|
|
121
|
+
const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: onMenuAction, version: "test" }));
|
|
122
122
|
// Wait for Effect to execute
|
|
123
123
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
124
124
|
const output = lastFrame();
|
|
@@ -129,7 +129,7 @@ describe('Menu component Effect-based error handling', () => {
|
|
|
129
129
|
it('should handle GitError from getDefaultBranchEffect and display error message', async () => {
|
|
130
130
|
const { Effect } = await import('effect');
|
|
131
131
|
const { GitError } = await import('../types/errors.js');
|
|
132
|
-
const
|
|
132
|
+
const onMenuAction = vi.fn();
|
|
133
133
|
const onDismissError = vi.fn();
|
|
134
134
|
const mockWorktrees = [
|
|
135
135
|
{
|
|
@@ -149,7 +149,7 @@ describe('Menu component Effect-based error handling', () => {
|
|
|
149
149
|
stdout: '',
|
|
150
150
|
});
|
|
151
151
|
vi.spyOn(worktreeService, 'getDefaultBranchEffect').mockReturnValue(Effect.fail(gitError));
|
|
152
|
-
const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService,
|
|
152
|
+
const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: onMenuAction, onDismissError: onDismissError, version: "test" }));
|
|
153
153
|
// Wait for Effect to execute
|
|
154
154
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
155
155
|
const output = lastFrame();
|
|
@@ -160,7 +160,7 @@ describe('Menu component Effect-based error handling', () => {
|
|
|
160
160
|
});
|
|
161
161
|
it('should use Effect composition to load worktrees and default branch together', async () => {
|
|
162
162
|
const { Effect } = await import('effect');
|
|
163
|
-
const
|
|
163
|
+
const onMenuAction = vi.fn();
|
|
164
164
|
const mockWorktrees = [
|
|
165
165
|
{
|
|
166
166
|
path: '/test/main',
|
|
@@ -176,7 +176,7 @@ describe('Menu component Effect-based error handling', () => {
|
|
|
176
176
|
const getDefaultBranchSpy = vi
|
|
177
177
|
.spyOn(worktreeService, 'getDefaultBranchEffect')
|
|
178
178
|
.mockReturnValue(Effect.succeed('main'));
|
|
179
|
-
render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService,
|
|
179
|
+
render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: onMenuAction, version: "test" }));
|
|
180
180
|
// Wait for Effect to execute
|
|
181
181
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
182
182
|
// Verify both Effect-based methods were called (Effect composition)
|
|
@@ -203,9 +203,9 @@ describe('Menu component rendering', () => {
|
|
|
203
203
|
vi.restoreAllMocks();
|
|
204
204
|
});
|
|
205
205
|
it('should not render duplicate title when re-rendered with new key', async () => {
|
|
206
|
-
const
|
|
206
|
+
const onMenuAction = vi.fn();
|
|
207
207
|
// First render
|
|
208
|
-
const { unmount, lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService,
|
|
208
|
+
const { unmount, lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: onMenuAction, version: "test" }, 1));
|
|
209
209
|
// Wait for async operations
|
|
210
210
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
211
211
|
const firstRenderOutput = lastFrame();
|
|
@@ -215,7 +215,7 @@ describe('Menu component rendering', () => {
|
|
|
215
215
|
expect(titleCount).toBe(1);
|
|
216
216
|
// Unmount and re-render with new key
|
|
217
217
|
unmount();
|
|
218
|
-
const { lastFrame: lastFrame2 } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService,
|
|
218
|
+
const { lastFrame: lastFrame2 } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: onMenuAction, version: "test" }, 2));
|
|
219
219
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
220
220
|
const secondRenderOutput = lastFrame2();
|
|
221
221
|
const titleCount2 = (secondRenderOutput?.match(/CCManager - Claude Code Worktree Manager/g) ||
|
|
@@ -223,8 +223,8 @@ describe('Menu component rendering', () => {
|
|
|
223
223
|
expect(titleCount2).toBe(1);
|
|
224
224
|
});
|
|
225
225
|
it('should render title and description only once', async () => {
|
|
226
|
-
const
|
|
227
|
-
const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService,
|
|
226
|
+
const onMenuAction = vi.fn();
|
|
227
|
+
const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: onMenuAction, version: "test" }));
|
|
228
228
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
229
229
|
const output = lastFrame();
|
|
230
230
|
// Check title appears only once
|
|
@@ -236,7 +236,7 @@ describe('Menu component rendering', () => {
|
|
|
236
236
|
});
|
|
237
237
|
it('should display number shortcuts for recent projects when worktrees < 10', async () => {
|
|
238
238
|
const { Effect } = await import('effect');
|
|
239
|
-
const
|
|
239
|
+
const onMenuAction = vi.fn();
|
|
240
240
|
const onSelectRecentProject = vi.fn();
|
|
241
241
|
// Setup: 3 worktrees
|
|
242
242
|
const mockWorktrees = [
|
|
@@ -280,7 +280,7 @@ describe('Menu component rendering', () => {
|
|
|
280
280
|
teamMembers: 0,
|
|
281
281
|
});
|
|
282
282
|
vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
|
|
283
|
-
const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService,
|
|
283
|
+
const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: onMenuAction, onSelectRecentProject: onSelectRecentProject, multiProject: true, version: "test" }));
|
|
284
284
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
285
285
|
const output = lastFrame();
|
|
286
286
|
// Check that worktrees have numbers 0-2
|
|
@@ -294,7 +294,7 @@ describe('Menu component rendering', () => {
|
|
|
294
294
|
});
|
|
295
295
|
it('should not display number shortcuts for recent projects when worktrees >= 10', async () => {
|
|
296
296
|
const { Effect } = await import('effect');
|
|
297
|
-
const
|
|
297
|
+
const onMenuAction = vi.fn();
|
|
298
298
|
const onSelectRecentProject = vi.fn();
|
|
299
299
|
// Setup: 10 worktrees
|
|
300
300
|
const mockWorktrees = Array.from({ length: 10 }, (_, i) => ({
|
|
@@ -323,7 +323,7 @@ describe('Menu component rendering', () => {
|
|
|
323
323
|
teamMembers: 0,
|
|
324
324
|
});
|
|
325
325
|
vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
|
|
326
|
-
const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService,
|
|
326
|
+
const { lastFrame } = render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: onMenuAction, onSelectRecentProject: onSelectRecentProject, multiProject: true, version: "test" }));
|
|
327
327
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
328
328
|
const output = lastFrame();
|
|
329
329
|
// Check that recent projects don't have numbers (just ❯ prefix)
|
|
@@ -76,7 +76,7 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
76
76
|
// Listen for restore event first
|
|
77
77
|
sessionManager.on('sessionRestore', handleSessionRestore);
|
|
78
78
|
// Mark session as active (this will trigger the restore event)
|
|
79
|
-
sessionManager.setSessionActive(session.
|
|
79
|
+
sessionManager.setSessionActive(session.id, true);
|
|
80
80
|
// Immediately resize the PTY and terminal to current dimensions
|
|
81
81
|
// This fixes rendering issues when terminal width changed while in menu
|
|
82
82
|
// https://github.com/kbwo/ccmanager/issues/2
|
|
@@ -142,7 +142,7 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
142
142
|
return;
|
|
143
143
|
}
|
|
144
144
|
if (session.stateMutex.getSnapshot().state === 'pending_auto_approval') {
|
|
145
|
-
sessionManager.cancelAutoApproval(session.
|
|
145
|
+
sessionManager.cancelAutoApproval(session.id, 'User input received during auto-approval');
|
|
146
146
|
}
|
|
147
147
|
// Pass all other input directly to the PTY
|
|
148
148
|
session.process.write(data);
|
|
@@ -156,7 +156,7 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
156
156
|
resetTerminalInputModes();
|
|
157
157
|
}
|
|
158
158
|
// Mark session as inactive
|
|
159
|
-
sessionManager.setSessionActive(session.
|
|
159
|
+
sessionManager.setSessionActive(session.id, false);
|
|
160
160
|
// Remove event listeners
|
|
161
161
|
sessionManager.off('sessionRestore', handleSessionRestore);
|
|
162
162
|
sessionManager.off('sessionData', handleSessionData);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export type SessionActionType = 'newSession' | 'rename' | 'kill';
|
|
3
|
+
interface SessionActionsProps {
|
|
4
|
+
sessionLabel: string;
|
|
5
|
+
onSelect: (action: SessionActionType) => void;
|
|
6
|
+
onCancel: () => void;
|
|
7
|
+
}
|
|
8
|
+
declare const SessionActions: React.FC<SessionActionsProps>;
|
|
9
|
+
export default SessionActions;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import SelectInput from 'ink-select-input';
|
|
4
|
+
const items = [
|
|
5
|
+
{ label: 'S New session in same directory', value: 'newSession' },
|
|
6
|
+
{ label: 'R Rename this session', value: 'rename' },
|
|
7
|
+
{ label: 'X Close session', value: 'kill' },
|
|
8
|
+
];
|
|
9
|
+
const SessionActions = ({ sessionLabel, onSelect, onCancel, }) => {
|
|
10
|
+
useInput((input, key) => {
|
|
11
|
+
if (key.escape) {
|
|
12
|
+
onCancel();
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
switch (input.toLowerCase()) {
|
|
16
|
+
case 's':
|
|
17
|
+
onSelect('newSession');
|
|
18
|
+
break;
|
|
19
|
+
case 'r':
|
|
20
|
+
onSelect('rename');
|
|
21
|
+
break;
|
|
22
|
+
case 'x':
|
|
23
|
+
onSelect('kill');
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Session Actions" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: sessionLabel }) }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: items, onSelect: item => onSelect(item.value) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "S/R/X or arrow keys + Enter | Escape to cancel" }) })] }));
|
|
28
|
+
};
|
|
29
|
+
export default SessionActions;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import TextInput from 'ink-text-input';
|
|
5
|
+
const SessionRename = ({ currentName, onRename, onCancel, }) => {
|
|
6
|
+
const [name, setName] = useState(currentName || '');
|
|
7
|
+
useInput((_input, key) => {
|
|
8
|
+
if (key.escape) {
|
|
9
|
+
onCancel();
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
const handleSubmit = () => {
|
|
13
|
+
const trimmed = name.trim();
|
|
14
|
+
onRename(trimmed || undefined);
|
|
15
|
+
};
|
|
16
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Rename Session" }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { children: "Name: " }), _jsx(TextInput, { value: name, onChange: setName, onSubmit: handleSubmit, placeholder: "Enter session name (empty to clear)" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter to confirm, Escape to cancel" }) })] }));
|
|
17
|
+
};
|
|
18
|
+
export default SessionRename;
|