ccmanager 4.3.0 → 4.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/dist/components/App.js +46 -9
- package/dist/components/DeleteWorktree.js +2 -13
- package/dist/components/Menu.js +22 -16
- package/dist/components/Menu.test.js +37 -1
- package/dist/components/NewWorktree.js +66 -10
- package/dist/components/NewWorktree.test.js +37 -0
- package/dist/components/SessionActions.d.ts +18 -2
- package/dist/components/SessionActions.js +19 -17
- package/dist/components/SessionActions.test.d.ts +1 -0
- package/dist/components/SessionActions.test.js +94 -0
- package/dist/services/worktreeService.d.ts +28 -1
- package/dist/services/worktreeService.js +110 -1
- package/dist/services/worktreeService.test.js +227 -4
- package/dist/types/index.d.ts +29 -2
- package/dist/utils/worktreeInclude.d.ts +33 -0
- package/dist/utils/worktreeInclude.js +100 -0
- package/dist/utils/worktreeInclude.test.d.ts +1 -0
- package/dist/utils/worktreeInclude.test.js +91 -0
- package/dist/utils/worktreeUtils.d.ts +13 -0
- package/dist/utils/worktreeUtils.js +23 -0
- package/dist/utils/worktreeUtils.test.js +20 -1
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ https://github.com/user-attachments/assets/15914a88-e288-4ac9-94d5-8127f2e19dbf
|
|
|
15
15
|
- Visual status indicators for session states (busy, waiting, idle)
|
|
16
16
|
- Create, merge, and delete worktrees from within the app
|
|
17
17
|
- **Copy Claude Code session data** between worktrees to maintain conversation context
|
|
18
|
+
- **`.worktreeinclude` support**: carry gitignored project files (`.env`, local certs) into newly created worktrees
|
|
18
19
|
- Configurable keyboard shortcuts
|
|
19
20
|
- Command presets with automatic fallback support
|
|
20
21
|
- Configurable state detection strategies for different CLI tools
|
|
@@ -209,6 +210,17 @@ The default choice (copy or start fresh) will be pre-selected when creating new
|
|
|
209
210
|
- **Context Preservation**: Maintain long conversations across multiple development branches
|
|
210
211
|
|
|
211
212
|
|
|
213
|
+
## Copying Gitignored Files into New Worktrees
|
|
214
|
+
|
|
215
|
+
A new Git worktree contains only tracked files, so gitignored files a project needs in order to run — `.env`, local certificates, generated local config — do not come along. If the repository root has a `.worktreeinclude` file (gitignore syntax) listing those files, CCManager copies them into every worktree it creates.
|
|
216
|
+
|
|
217
|
+
- **Shared convention**: same file name and semantics as Claude Code, Conductor, OpenAI Codex, and `git-worktreeinclude`, so an existing `.worktreeinclude` works with CCManager unchanged
|
|
218
|
+
- **No configuration**: the copy runs whenever a `.worktreeinclude` file exists
|
|
219
|
+
- **Safe by construction**: a file is copied only if it both matches a pattern and is actually gitignored, so tracked files are never duplicated
|
|
220
|
+
- **Hook-friendly**: files are copied before the post-creation worktree hook runs, so hook commands can rely on them
|
|
221
|
+
|
|
222
|
+
For pattern syntax, the exact selection rule, and troubleshooting, see [docs/worktree-include.md](docs/worktree-include.md).
|
|
223
|
+
|
|
212
224
|
## Status Change Hooks
|
|
213
225
|
|
|
214
226
|
CCManager can execute custom commands when Claude Code session status changes. This enables powerful automation workflows like desktop notifications, logging, or integration with other tools.
|
package/dist/components/App.js
CHANGED
|
@@ -7,6 +7,7 @@ import Dashboard from './Dashboard.js';
|
|
|
7
7
|
import Session from './Session.js';
|
|
8
8
|
import NewWorktree from './NewWorktree.js';
|
|
9
9
|
import DeleteWorktree from './DeleteWorktree.js';
|
|
10
|
+
import DeleteConfirmation from './DeleteConfirmation.js';
|
|
10
11
|
import MergeWorktree from './MergeWorktree.js';
|
|
11
12
|
import Configuration from './Configuration.js';
|
|
12
13
|
import PresetSelector from './PresetSelector.js';
|
|
@@ -22,7 +23,7 @@ import { configReader } from '../services/config/configReader.js';
|
|
|
22
23
|
import { ENV_VARS } from '../constants/env.js';
|
|
23
24
|
import { MULTI_PROJECT_ERRORS } from '../constants/error.js';
|
|
24
25
|
import { projectManager } from '../services/projectManager.js';
|
|
25
|
-
import { generateWorktreeDirectory } from '../utils/worktreeUtils.js';
|
|
26
|
+
import { generateWorktreeDirectory, isDeletableWorktree, } from '../utils/worktreeUtils.js';
|
|
26
27
|
const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
27
28
|
const { exit } = useApp();
|
|
28
29
|
const [view, setView] = useState(multiProject ? 'project-list' : 'menu');
|
|
@@ -36,6 +37,8 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
36
37
|
const [selectedWorktree, setSelectedWorktree] = useState(null); // Store selected worktree for preset selection
|
|
37
38
|
const [renameTarget, setRenameTarget] = useState(null);
|
|
38
39
|
const [sessionActionsTarget, setSessionActionsTarget] = useState(null);
|
|
40
|
+
// Worktree awaiting confirmation of the per-row delete action
|
|
41
|
+
const [worktreeToDelete, setWorktreeToDelete] = useState(null);
|
|
39
42
|
const [selectedProject, setSelectedProject] = useState(null); // Store selected project in multi-project mode
|
|
40
43
|
const [configScope, setConfigScope] = useState('global'); // Store config scope for configuration view
|
|
41
44
|
const [pendingMenuSessionLaunch, setPendingMenuSessionLaunch] = useState(null);
|
|
@@ -303,8 +306,9 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
303
306
|
return;
|
|
304
307
|
case 'sessionActions':
|
|
305
308
|
setSessionActionsTarget({
|
|
309
|
+
worktreePath: action.worktree.path,
|
|
306
310
|
session: action.session,
|
|
307
|
-
|
|
311
|
+
worktree: action.worktree,
|
|
308
312
|
});
|
|
309
313
|
navigateWithClear('session-actions');
|
|
310
314
|
return;
|
|
@@ -520,7 +524,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
520
524
|
setPendingWorktreeCreation(null);
|
|
521
525
|
setView('new-worktree');
|
|
522
526
|
};
|
|
523
|
-
const handleDeleteWorktrees = async (worktreePaths, deleteBranch) => {
|
|
527
|
+
const handleDeleteWorktrees = async (worktreePaths, deleteBranch, options) => {
|
|
524
528
|
// Set loading context before showing loading view
|
|
525
529
|
setLoadingContext({ deleteBranch });
|
|
526
530
|
setView('deleting-worktree');
|
|
@@ -558,7 +562,12 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
558
562
|
}
|
|
559
563
|
else {
|
|
560
564
|
// Show error
|
|
561
|
-
|
|
565
|
+
if (options?.onError) {
|
|
566
|
+
options.onError();
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
setView('delete-worktree');
|
|
570
|
+
}
|
|
562
571
|
}
|
|
563
572
|
};
|
|
564
573
|
const handleCancelDeleteWorktree = () => {
|
|
@@ -670,10 +679,14 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
670
679
|
} }));
|
|
671
680
|
}
|
|
672
681
|
if (view === 'session-actions' && sessionActionsTarget) {
|
|
673
|
-
const { session: targetSession, worktreePath } = sessionActionsTarget;
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
682
|
+
const { session: targetSession, worktreePath, worktree: targetWorktree, } = sessionActionsTarget;
|
|
683
|
+
// A worktree row without a session has no session name to show; the
|
|
684
|
+
// worktree path is rendered on its own line by SessionActions.
|
|
685
|
+
const label = !targetSession
|
|
686
|
+
? undefined
|
|
687
|
+
: targetSession.sessionName
|
|
688
|
+
? targetSession.sessionName
|
|
689
|
+
: `Session #${targetSession.sessionNumber}`;
|
|
677
690
|
const handleSessionAction = async (action) => {
|
|
678
691
|
setSessionActionsTarget(null);
|
|
679
692
|
switch (action) {
|
|
@@ -686,6 +699,8 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
686
699
|
}, { forceNew: true });
|
|
687
700
|
return;
|
|
688
701
|
case 'rename':
|
|
702
|
+
if (!targetSession)
|
|
703
|
+
return;
|
|
689
704
|
setRenameTarget({
|
|
690
705
|
id: targetSession.id,
|
|
691
706
|
name: targetSession.sessionName,
|
|
@@ -693,16 +708,38 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
693
708
|
navigateWithClear('rename-session');
|
|
694
709
|
return;
|
|
695
710
|
case 'kill':
|
|
711
|
+
if (!targetSession)
|
|
712
|
+
return;
|
|
696
713
|
sessionManager.destroySession(targetSession.id);
|
|
697
714
|
handleReturnToMenu();
|
|
698
715
|
return;
|
|
716
|
+
case 'deleteWorktree':
|
|
717
|
+
if (!targetWorktree)
|
|
718
|
+
return;
|
|
719
|
+
setWorktreeToDelete(targetWorktree);
|
|
720
|
+
navigateWithClear('confirm-delete-worktree');
|
|
721
|
+
return;
|
|
699
722
|
}
|
|
700
723
|
};
|
|
701
|
-
return (_jsx(SessionActions, { sessionLabel: label, onSelect: handleSessionAction, onCancel: () => {
|
|
724
|
+
return (_jsx(SessionActions, { sessionLabel: label, worktreePath: worktreePath, hasSession: !!targetSession, canDeleteWorktree: !!targetWorktree && isDeletableWorktree(targetWorktree), onSelect: handleSessionAction, onCancel: () => {
|
|
702
725
|
setSessionActionsTarget(null);
|
|
703
726
|
handleReturnToMenu();
|
|
704
727
|
} }));
|
|
705
728
|
}
|
|
729
|
+
if (view === 'confirm-delete-worktree' && worktreeToDelete) {
|
|
730
|
+
const target = worktreeToDelete;
|
|
731
|
+
return (_jsx(DeleteConfirmation, { worktrees: [target], onConfirm: deleteBranch => {
|
|
732
|
+
setWorktreeToDelete(null);
|
|
733
|
+
void handleDeleteWorktrees([target.path], deleteBranch, {
|
|
734
|
+
// The multi-select delete screen was never opened in this flow,
|
|
735
|
+
// so surface the failure on the menu instead.
|
|
736
|
+
onError: handleReturnToMenu,
|
|
737
|
+
});
|
|
738
|
+
}, onCancel: () => {
|
|
739
|
+
setWorktreeToDelete(null);
|
|
740
|
+
handleReturnToMenu();
|
|
741
|
+
} }));
|
|
742
|
+
}
|
|
706
743
|
if (view === 'preset-selector') {
|
|
707
744
|
return (_jsx(PresetSelector, { onSelect: handlePresetSelected, onCancel: handlePresetSelectorCancel }));
|
|
708
745
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect } from 'react';
|
|
3
|
-
import path from 'path';
|
|
4
3
|
import { Box, Text, useInput } from 'ink';
|
|
5
4
|
import SelectInput from 'ink-select-input';
|
|
6
5
|
import { Effect } from 'effect';
|
|
@@ -11,6 +10,7 @@ import { useSearchMode } from '../hooks/useSearchMode.js';
|
|
|
11
10
|
import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
|
|
12
11
|
import { filterWorktreesByQuery } from '../utils/filterByQuery.js';
|
|
13
12
|
import SearchableList from './SearchableList.js';
|
|
13
|
+
import { isDeletableWorktree } from '../utils/worktreeUtils.js';
|
|
14
14
|
const DeleteWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
15
15
|
const [worktrees, setWorktrees] = useState([]);
|
|
16
16
|
const [selectedIndices, setSelectedIndices] = useState(new Set());
|
|
@@ -34,18 +34,7 @@ const DeleteWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
34
34
|
try {
|
|
35
35
|
const allWorktrees = await Effect.runPromise(worktreeService.getWorktreesEffect());
|
|
36
36
|
if (!cancelled) {
|
|
37
|
-
|
|
38
|
-
const resolvedCwd = path.resolve(process.cwd());
|
|
39
|
-
const deletableWorktrees = allWorktrees.filter(wt => {
|
|
40
|
-
if (wt.isMainWorktree)
|
|
41
|
-
return false;
|
|
42
|
-
const resolvedPath = path.resolve(wt.path);
|
|
43
|
-
if (resolvedCwd === resolvedPath ||
|
|
44
|
-
resolvedCwd.startsWith(resolvedPath + path.sep)) {
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
return true;
|
|
48
|
-
});
|
|
37
|
+
const deletableWorktrees = allWorktrees.filter(wt => isDeletableWorktree(wt));
|
|
49
38
|
setWorktrees(deletableWorktrees);
|
|
50
39
|
setIsLoading(false);
|
|
51
40
|
}
|
package/dist/components/Menu.js
CHANGED
|
@@ -41,11 +41,12 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
|
|
|
41
41
|
const worktrees = useGitStatus(baseWorktrees, defaultBranch);
|
|
42
42
|
// Seed from the in-memory session list so the cached snapshot renders with its
|
|
43
43
|
// sessions attached. Waiting for the async git load would leave every row
|
|
44
|
-
// session-less for that window,
|
|
44
|
+
// session-less for that window, hiding the session entries of the Space
|
|
45
|
+
// actions menu.
|
|
45
46
|
const [sessions, setSessions] = useState(() => sessionManager.getAllSessions());
|
|
46
47
|
const [items, setItems] = useState([]);
|
|
47
48
|
const [recentProjects, setRecentProjects] = useState([]);
|
|
48
|
-
const [
|
|
49
|
+
const [highlightedWorktree, setHighlightedWorktree] = useState(null);
|
|
49
50
|
const [highlightedSession, setHighlightedSession] = useState(undefined);
|
|
50
51
|
const [autoApprovalToggleCounter, setAutoApprovalToggleCounter] = useState(0);
|
|
51
52
|
const [stateFilter, setStateFilter] = useState('all');
|
|
@@ -288,16 +289,20 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
|
|
|
288
289
|
}
|
|
289
290
|
}
|
|
290
291
|
setItems(menuItems);
|
|
291
|
-
// Ensure highlighted worktree
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
menuItems.
|
|
295
|
-
|
|
292
|
+
// Ensure highlighted worktree is valid for hotkey support
|
|
293
|
+
setHighlightedWorktree(prev => {
|
|
294
|
+
const stillListed = prev
|
|
295
|
+
? menuItems.find(item => item.type === 'worktree' && item.worktree.path === prev.path)
|
|
296
|
+
: undefined;
|
|
297
|
+
if (stillListed && stillListed.type === 'worktree') {
|
|
298
|
+
// Re-read the item so the highlighted worktree keeps up with
|
|
299
|
+
// refreshed git status instead of pinning the stale object.
|
|
300
|
+
return stillListed.worktree;
|
|
296
301
|
}
|
|
297
302
|
const first = menuItems.find(item => item.type === 'worktree');
|
|
298
303
|
if (first && first.type === 'worktree') {
|
|
299
304
|
setHighlightedSession(first.session);
|
|
300
|
-
return first.worktree
|
|
305
|
+
return first.worktree;
|
|
301
306
|
}
|
|
302
307
|
setHighlightedSession(undefined);
|
|
303
308
|
return null;
|
|
@@ -371,18 +376,19 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
|
|
|
371
376
|
switch (keyPressed) {
|
|
372
377
|
case 'a':
|
|
373
378
|
// Toggle auto-approval for the currently highlighted worktree
|
|
374
|
-
if (configReader.isAutoApprovalEnabled() &&
|
|
375
|
-
sessionManager.toggleAutoApprovalForWorktree(
|
|
379
|
+
if (configReader.isAutoApprovalEnabled() && highlightedWorktree) {
|
|
380
|
+
sessionManager.toggleAutoApprovalForWorktree(highlightedWorktree.path);
|
|
376
381
|
setAutoApprovalToggleCounter(c => c + 1);
|
|
377
382
|
}
|
|
378
383
|
break;
|
|
379
384
|
case ' ':
|
|
380
|
-
// Open
|
|
381
|
-
|
|
385
|
+
// Open the row actions for the highlighted worktree. Rows without a
|
|
386
|
+
// session open the same menu with only the worktree-level entries.
|
|
387
|
+
if (highlightedWorktree) {
|
|
382
388
|
onMenuAction({
|
|
383
389
|
type: 'sessionActions',
|
|
390
|
+
worktree: highlightedWorktree,
|
|
384
391
|
session: highlightedSession,
|
|
385
|
-
worktreePath: highlightedWorktreePath,
|
|
386
392
|
});
|
|
387
393
|
}
|
|
388
394
|
break;
|
|
@@ -477,13 +483,13 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
|
|
|
477
483
|
if (!item)
|
|
478
484
|
return;
|
|
479
485
|
if (item.type === 'worktree') {
|
|
480
|
-
|
|
486
|
+
setHighlightedWorktree(item.worktree);
|
|
481
487
|
setHighlightedSession(item.session);
|
|
482
488
|
}
|
|
483
489
|
}, 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
|
|
484
490
|
? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
|
|
485
491
|
: searchQuery
|
|
486
|
-
? `Controls: ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select Tab-State Filter Space-
|
|
487
|
-
: `Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Tab-State Filter Space-
|
|
492
|
+
? `Controls: ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select Tab-State Filter Space-Worktree actions N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}`
|
|
493
|
+
: `Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Tab-State Filter Space-Worktree actions N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}` })] })] }));
|
|
488
494
|
};
|
|
489
495
|
export default Menu;
|
|
@@ -319,8 +319,44 @@ describe('Menu component Effect-based error handling', () => {
|
|
|
319
319
|
}
|
|
320
320
|
expect(onMenuAction).toHaveBeenCalledWith({
|
|
321
321
|
type: 'sessionActions',
|
|
322
|
+
worktree: cachedWorktree,
|
|
322
323
|
session: cachedSession,
|
|
323
|
-
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
it('should open the actions menu with Space on a worktree row that has no session', async () => {
|
|
327
|
+
const { Effect } = await import('effect');
|
|
328
|
+
const sessionlessWorktree = {
|
|
329
|
+
path: '/test/no-session',
|
|
330
|
+
branch: 'feature/no-session',
|
|
331
|
+
isMainWorktree: false,
|
|
332
|
+
hasSession: false,
|
|
333
|
+
};
|
|
334
|
+
vi.spyOn(sessionManager, 'getAllSessions').mockReturnValue([]);
|
|
335
|
+
vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(Effect.succeed([sessionlessWorktree]));
|
|
336
|
+
vi.spyOn(worktreeService, 'getDefaultBranchEffect').mockReturnValue(Effect.succeed('main'));
|
|
337
|
+
const onMenuAction = vi.fn();
|
|
338
|
+
vi.mocked(useInput).mockClear();
|
|
339
|
+
render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, initialSnapshot: {
|
|
340
|
+
worktrees: [sessionlessWorktree],
|
|
341
|
+
defaultBranch: 'main',
|
|
342
|
+
}, onMenuAction: onMenuAction, version: "test" }));
|
|
343
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
344
|
+
// Menu's hotkey handler bails out when raw mode is unavailable.
|
|
345
|
+
const origSetRawMode = process.stdin.setRawMode;
|
|
346
|
+
process.stdin.setRawMode = vi.fn();
|
|
347
|
+
try {
|
|
348
|
+
const calls = vi.mocked(useInput).mock.calls;
|
|
349
|
+
const handler = calls[calls.length - 1]?.[0];
|
|
350
|
+
expect(handler).toBeDefined();
|
|
351
|
+
handler(' ', makeKey());
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
process.stdin.setRawMode = origSetRawMode;
|
|
355
|
+
}
|
|
356
|
+
expect(onMenuAction).toHaveBeenCalledWith({
|
|
357
|
+
type: 'sessionActions',
|
|
358
|
+
worktree: sessionlessWorktree,
|
|
359
|
+
session: undefined,
|
|
324
360
|
});
|
|
325
361
|
});
|
|
326
362
|
});
|
|
@@ -10,6 +10,7 @@ import { WorktreeService } from '../services/worktreeService.js';
|
|
|
10
10
|
import { useSearchMode } from '../hooks/useSearchMode.js';
|
|
11
11
|
import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
|
|
12
12
|
import SearchableList from './SearchableList.js';
|
|
13
|
+
import RemoteBranchSelector from './RemoteBranchSelector.js';
|
|
13
14
|
import { Effect } from 'effect';
|
|
14
15
|
import { describePromptInjection, getPromptInjectionMethod, } from '../utils/presetPrompt.js';
|
|
15
16
|
const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
@@ -28,6 +29,10 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
28
29
|
const [path, setPath] = useState('');
|
|
29
30
|
const [branch, setBranch] = useState('');
|
|
30
31
|
const [baseBranch, setBaseBranch] = useState('');
|
|
32
|
+
// Short branch name to use when creating a local branch from baseBranch
|
|
33
|
+
// (differs from baseBranch when baseBranch is a remote ref like "origin/x")
|
|
34
|
+
const [baseBranchLocalName, setBaseBranchLocalName] = useState('');
|
|
35
|
+
const [ambiguousBase, setAmbiguousBase] = useState(null);
|
|
31
36
|
const [copyClaudeDirectory, setCopyClaudeDirectory] = useState(true);
|
|
32
37
|
const [copySessionData, setCopySessionData] = useState(worktreeConfig.copySessionData ?? true);
|
|
33
38
|
const [selectedPresetId, setSelectedPresetId] = useState(presetsConfig.defaultPresetId);
|
|
@@ -37,9 +42,10 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
37
42
|
const [branches, setBranches] = useState([]);
|
|
38
43
|
const [remoteBranches, setRemoteBranches] = useState([]);
|
|
39
44
|
const [defaultBranch, setDefaultBranch] = useState('main');
|
|
45
|
+
const worktreeService = useMemo(() => new WorktreeService(projectPath), [projectPath]);
|
|
40
46
|
useEffect(() => {
|
|
41
47
|
let cancelled = false;
|
|
42
|
-
const service =
|
|
48
|
+
const service = worktreeService;
|
|
43
49
|
const loadBranches = async () => {
|
|
44
50
|
const branchesEffect = includeRemoteBranches
|
|
45
51
|
? service.getBranchesWithRemotesEffect()
|
|
@@ -70,8 +76,15 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
70
76
|
setRemoteBranches(result.remote);
|
|
71
77
|
setDefaultBranch(result.defaultBranch);
|
|
72
78
|
setIsLoadingBranches(false);
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
// When the default branch is ambiguous across remotes we can't
|
|
80
|
+
// pick one silently, so keep the base-branch step and let the
|
|
81
|
+
// user choose.
|
|
82
|
+
const resolution = isAutoUseDefaultBranch && result.defaultBranch
|
|
83
|
+
? service.resolveBaseBranch(result.defaultBranch)
|
|
84
|
+
: null;
|
|
85
|
+
if (resolution && resolution.kind !== 'ambiguous') {
|
|
86
|
+
setBaseBranch(resolution.ref);
|
|
87
|
+
setBaseBranchLocalName(resolution.localName);
|
|
75
88
|
setStep(currentStep => currentStep === 'base-branch' ? 'creation-mode' : currentStep);
|
|
76
89
|
}
|
|
77
90
|
}
|
|
@@ -86,7 +99,7 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
86
99
|
return () => {
|
|
87
100
|
cancelled = true;
|
|
88
101
|
};
|
|
89
|
-
}, [
|
|
102
|
+
}, [worktreeService, isAutoUseDefaultBranch, includeRemoteBranches]);
|
|
90
103
|
const allBranchItems = useMemo(() => {
|
|
91
104
|
const defaultRemoteSuffix = `/${defaultBranch}`;
|
|
92
105
|
const defaultRemotes = remoteBranches.filter(br => br.endsWith(defaultRemoteSuffix));
|
|
@@ -123,6 +136,11 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
123
136
|
const selectedPreset = useMemo(() => presetsConfig.presets.find(preset => preset.id === selectedPresetId) ||
|
|
124
137
|
presetsConfig.presets[0], [selectedPresetId, presetsConfig.presets]);
|
|
125
138
|
useInput((input, key) => {
|
|
139
|
+
if (step === 'remote-branch-confirm') {
|
|
140
|
+
// RemoteBranchSelector handles its own cancel shortcut (returns to
|
|
141
|
+
// the base-branch list); don't also cancel the whole wizard here.
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
126
144
|
if (shortcutManager.matchesShortcut('cancel', input, key)) {
|
|
127
145
|
onCancel();
|
|
128
146
|
}
|
|
@@ -130,22 +148,57 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
130
148
|
return;
|
|
131
149
|
}
|
|
132
150
|
});
|
|
151
|
+
/**
|
|
152
|
+
* Resolves the selected base branch right away. Returns true when the
|
|
153
|
+
* selection is settled; returns false when the branch exists on multiple
|
|
154
|
+
* remotes, in which case the remote-branch-confirm step is shown so the
|
|
155
|
+
* user can disambiguate immediately (instead of failing later when the
|
|
156
|
+
* worktree is actually created).
|
|
157
|
+
*/
|
|
158
|
+
const applyBaseBranchSelection = (name) => {
|
|
159
|
+
const resolution = worktreeService.resolveBaseBranch(name);
|
|
160
|
+
if (resolution.kind === 'ambiguous') {
|
|
161
|
+
setAmbiguousBase({
|
|
162
|
+
branchName: resolution.branchName,
|
|
163
|
+
matches: resolution.matches,
|
|
164
|
+
});
|
|
165
|
+
setStep('remote-branch-confirm');
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
setBaseBranch(resolution.ref);
|
|
169
|
+
setBaseBranchLocalName(resolution.localName);
|
|
170
|
+
return true;
|
|
171
|
+
};
|
|
133
172
|
const handlePathSubmit = (value) => {
|
|
134
173
|
if (!value.trim())
|
|
135
174
|
return;
|
|
136
175
|
setPath(value.trim());
|
|
137
176
|
if (isAutoUseDefaultBranch && defaultBranch) {
|
|
138
|
-
|
|
139
|
-
|
|
177
|
+
if (applyBaseBranchSelection(defaultBranch)) {
|
|
178
|
+
setStep('creation-mode');
|
|
179
|
+
}
|
|
140
180
|
}
|
|
141
181
|
else {
|
|
142
182
|
setStep('base-branch');
|
|
143
183
|
}
|
|
144
184
|
};
|
|
145
185
|
const handleBaseBranchSelect = (item) => {
|
|
146
|
-
|
|
186
|
+
if (applyBaseBranchSelection(item.value)) {
|
|
187
|
+
setStep('creation-mode');
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
const handleAmbiguousBaseSelect = (selectedRemoteRef) => {
|
|
191
|
+
if (!ambiguousBase)
|
|
192
|
+
return;
|
|
193
|
+
setBaseBranch(selectedRemoteRef);
|
|
194
|
+
setBaseBranchLocalName(ambiguousBase.branchName);
|
|
195
|
+
setAmbiguousBase(null);
|
|
147
196
|
setStep('creation-mode');
|
|
148
197
|
};
|
|
198
|
+
const handleAmbiguousBaseCancel = () => {
|
|
199
|
+
setAmbiguousBase(null);
|
|
200
|
+
setStep('base-branch');
|
|
201
|
+
};
|
|
149
202
|
const handleCreationModeSelect = (item) => {
|
|
150
203
|
if (item.value === 'manual') {
|
|
151
204
|
setStep('branch-strategy');
|
|
@@ -156,7 +209,10 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
156
209
|
const handleBranchStrategySelect = (item) => {
|
|
157
210
|
const useExisting = item.value === 'existing';
|
|
158
211
|
if (useExisting) {
|
|
159
|
-
|
|
212
|
+
// Use the short branch name: when baseBranch is a remote ref
|
|
213
|
+
// (e.g. "origin/feature/x"), the local branch to attach/create is
|
|
214
|
+
// "feature/x", not a branch literally named "origin/feature/x".
|
|
215
|
+
setBranch(baseBranchLocalName || baseBranch);
|
|
160
216
|
setStep('copy-settings');
|
|
161
217
|
}
|
|
162
218
|
else {
|
|
@@ -264,7 +320,7 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
264
320
|
const promptMethod = selectedPreset
|
|
265
321
|
? getPromptInjectionMethod(selectedPreset)
|
|
266
322
|
: 'stdin';
|
|
267
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Create New Worktree" }) }), step === 'path' && !isAutoDirectory ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Enter worktree path (relative to repository root):" }) }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '> ' }), _jsx(TextInputWrapper, { value: path, onChange: setPath, onSubmit: handlePathSubmit, placeholder: "e.g., ../myproject-feature" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Tip: Enable "Auto Directory" in settings to generate paths automatically from branch names.' }) })] })) : null, step === 'base-branch' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Select base branch for the worktree:" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: branchItems, limit: limit, placeholder: "Type to filter branches...", noMatchMessage: "No branches match your search", children: _jsx(SelectInput, { items: branchItems, onSelect: handleBaseBranchSelect, initialIndex: selectedIndex, limit: limit, isFocused: !isSearchMode }) }), !isSearchMode && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press / to search" }) })), includeRemoteBranches && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Tip: If the branch list feels slow, disable \"Include Remote Branches\" in Configuration \u2192 Configure Worktree Settings." }) }))] })), step === 'creation-mode' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Base branch: ", _jsx(Text, { color: "cyan", children: baseBranch })] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "How do you want to create the new worktree?" }) }), _jsx(SelectInput, { items: [
|
|
323
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Create New Worktree" }) }), step === 'path' && !isAutoDirectory ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Enter worktree path (relative to repository root):" }) }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '> ' }), _jsx(TextInputWrapper, { value: path, onChange: setPath, onSubmit: handlePathSubmit, placeholder: "e.g., ../myproject-feature" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Tip: Enable "Auto Directory" in settings to generate paths automatically from branch names.' }) })] })) : null, step === 'base-branch' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Select base branch for the worktree:" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: branchItems, limit: limit, placeholder: "Type to filter branches...", noMatchMessage: "No branches match your search", children: _jsx(SelectInput, { items: branchItems, onSelect: handleBaseBranchSelect, initialIndex: selectedIndex, limit: limit, isFocused: !isSearchMode }) }), !isSearchMode && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press / to search" }) })), includeRemoteBranches && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Tip: If the branch list feels slow, disable \"Include Remote Branches\" in Configuration \u2192 Configure Worktree Settings." }) }))] })), step === 'remote-branch-confirm' && ambiguousBase && (_jsx(RemoteBranchSelector, { branchName: ambiguousBase.branchName, matches: ambiguousBase.matches, onSelect: handleAmbiguousBaseSelect, onCancel: handleAmbiguousBaseCancel })), step === 'creation-mode' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Base branch: ", _jsx(Text, { color: "cyan", children: baseBranch })] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "How do you want to create the new worktree?" }) }), _jsx(SelectInput, { items: [
|
|
268
324
|
{
|
|
269
325
|
label: '1. Choose the branch name yourself',
|
|
270
326
|
value: 'manual',
|
|
@@ -291,6 +347,6 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
291
347
|
], onSelect: handleCopySettingsSelect, initialIndex: 0 })] })), step === 'copy-session' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Copy Claude Code session data to the new worktree?" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "This will copy conversation history and context from the current worktree." }) }), isAutoDirectory && generatedPath ? (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["Worktree path preview:", ' ', _jsx(Text, { color: "green", children: generatedPath })] }) })) : null, _jsx(SelectInput, { items: [
|
|
292
348
|
{ label: '✅ Yes, copy session data', value: 'yes' },
|
|
293
349
|
{ label: '❌ No, start fresh', value: 'no' },
|
|
294
|
-
], onSelect: handleCopySessionSelect, initialIndex: copySessionData ? 0 : 1 })] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", shortcutManager.getShortcutDisplay('cancel'), " to cancel"] }) })] }));
|
|
350
|
+
], onSelect: handleCopySessionSelect, initialIndex: copySessionData ? 0 : 1 })] })), step !== 'remote-branch-confirm' && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", shortcutManager.getShortcutDisplay('cancel'), " to cancel"] }) }))] }));
|
|
295
351
|
};
|
|
296
352
|
export default NewWorktree;
|
|
@@ -289,6 +289,11 @@ describe('NewWorktree component Effect integration', () => {
|
|
|
289
289
|
return {
|
|
290
290
|
getAllBranchesEffect: vi.fn(() => Effect.succeed(mockBranches)),
|
|
291
291
|
getDefaultBranchEffect: vi.fn(() => Effect.succeed(mockDefaultBranch)),
|
|
292
|
+
resolveBaseBranch: vi.fn((name) => ({
|
|
293
|
+
kind: 'local',
|
|
294
|
+
ref: name,
|
|
295
|
+
localName: name,
|
|
296
|
+
})),
|
|
292
297
|
};
|
|
293
298
|
});
|
|
294
299
|
const onComplete = vi.fn();
|
|
@@ -305,6 +310,38 @@ describe('NewWorktree component Effect integration', () => {
|
|
|
305
310
|
expect(output).toContain('Choose the branch name yourself');
|
|
306
311
|
expect(output).toContain('Enter a prompt first');
|
|
307
312
|
});
|
|
313
|
+
it('should keep the base branch selection when the default branch is ambiguous across remotes', async () => {
|
|
314
|
+
const { Effect } = await import('effect');
|
|
315
|
+
const { WorktreeService } = await import('../services/worktreeService.js');
|
|
316
|
+
const { configReader } = await import('../services/config/configReader.js');
|
|
317
|
+
vi.spyOn(configReader, 'getWorktreeConfig').mockReturnValue({
|
|
318
|
+
autoDirectory: true,
|
|
319
|
+
autoDirectoryPattern: '../{project}-{branch}',
|
|
320
|
+
copySessionData: true,
|
|
321
|
+
autoUseDefaultBranch: true,
|
|
322
|
+
});
|
|
323
|
+
// Default branch has no local ref and exists on two remotes: it cannot
|
|
324
|
+
// be picked silently, so the base-branch step must not be skipped.
|
|
325
|
+
vi.mocked(WorktreeService).mockImplementation(function () {
|
|
326
|
+
return {
|
|
327
|
+
getAllBranchesEffect: vi.fn(() => Effect.succeed(['main', 'develop'])),
|
|
328
|
+
getDefaultBranchEffect: vi.fn(() => Effect.succeed('main')),
|
|
329
|
+
resolveBaseBranch: vi.fn(() => ({
|
|
330
|
+
kind: 'ambiguous',
|
|
331
|
+
branchName: 'main',
|
|
332
|
+
matches: [
|
|
333
|
+
{ remote: 'origin', branch: 'main', fullRef: 'origin/main' },
|
|
334
|
+
{ remote: 'upstream', branch: 'main', fullRef: 'upstream/main' },
|
|
335
|
+
],
|
|
336
|
+
})),
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
const { lastFrame } = render(_jsx(NewWorktree, { onComplete: vi.fn(), onCancel: vi.fn() }));
|
|
340
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
341
|
+
const output = lastFrame();
|
|
342
|
+
expect(output).toContain('Select base branch');
|
|
343
|
+
expect(output).not.toContain('How do you want to create the new worktree?');
|
|
344
|
+
});
|
|
308
345
|
it('should show base branch selection when autoUseDefaultBranch is disabled', async () => {
|
|
309
346
|
const { Effect } = await import('effect');
|
|
310
347
|
const { WorktreeService } = await import('../services/worktreeService.js');
|
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
export type SessionActionType = 'newSession' | 'rename' | 'kill';
|
|
2
|
+
export type SessionActionType = 'newSession' | 'rename' | 'kill' | 'deleteWorktree';
|
|
3
3
|
interface SessionActionsProps {
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Name of the session this menu was opened from. Absent for a worktree row
|
|
6
|
+
* that has no session yet.
|
|
7
|
+
*/
|
|
8
|
+
sessionLabel?: string;
|
|
9
|
+
worktreePath: string;
|
|
10
|
+
/**
|
|
11
|
+
* Whether the row this menu was opened from has a running session. Session
|
|
12
|
+
* specific actions (rename, close) are hidden when it does not.
|
|
13
|
+
*/
|
|
14
|
+
hasSession?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Whether the worktree of this row may be deleted; see isDeletableWorktree.
|
|
17
|
+
* The delete entry is hidden rather than shown-and-rejected so no
|
|
18
|
+
* unselectable option appears.
|
|
19
|
+
*/
|
|
20
|
+
canDeleteWorktree?: boolean;
|
|
5
21
|
onSelect: (action: SessionActionType) => void;
|
|
6
22
|
onCancel: () => void;
|
|
7
23
|
}
|
|
@@ -1,29 +1,31 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text, useInput } from 'ink';
|
|
3
3
|
import SelectInput from 'ink-select-input';
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
const buildItems = (hasSession, canDeleteWorktree) => {
|
|
5
|
+
const items = [
|
|
6
|
+
{ label: 'S New session in this worktree', value: 'newSession' },
|
|
7
|
+
];
|
|
8
|
+
if (hasSession) {
|
|
9
|
+
items.push({ label: 'R Rename this session', value: 'rename' });
|
|
10
|
+
items.push({ label: 'X Close session', value: 'kill' });
|
|
11
|
+
}
|
|
12
|
+
if (canDeleteWorktree) {
|
|
13
|
+
items.push({ label: 'D Delete this worktree', value: 'deleteWorktree' });
|
|
14
|
+
}
|
|
15
|
+
return items;
|
|
16
|
+
};
|
|
17
|
+
const SessionActions = ({ sessionLabel, worktreePath, hasSession = true, canDeleteWorktree = false, onSelect, onCancel, }) => {
|
|
18
|
+
const items = buildItems(hasSession, canDeleteWorktree);
|
|
10
19
|
useInput((input, key) => {
|
|
11
20
|
if (key.escape) {
|
|
12
21
|
onCancel();
|
|
13
22
|
return;
|
|
14
23
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
break;
|
|
19
|
-
case 'r':
|
|
20
|
-
onSelect('rename');
|
|
21
|
-
break;
|
|
22
|
-
case 'x':
|
|
23
|
-
onSelect('kill');
|
|
24
|
-
break;
|
|
24
|
+
const shortcut = items.find(item => item.label[0]?.toLowerCase() === input.toLowerCase());
|
|
25
|
+
if (shortcut) {
|
|
26
|
+
onSelect(shortcut.value);
|
|
25
27
|
}
|
|
26
28
|
});
|
|
27
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children:
|
|
29
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: hasSession ? 'Session Actions' : 'Worktree Actions' }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [sessionLabel && _jsx(Text, { dimColor: true, children: sessionLabel }), _jsxs(Text, { dimColor: true, children: ["Directory: ", worktreePath] })] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: items, onSelect: item => onSelect(item.value) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [items.map(item => item.label[0]).join('/'), " or arrow keys + Enter | Escape to cancel"] }) })] }));
|
|
28
30
|
};
|
|
29
31
|
export default SessionActions;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|