ccmanager 4.3.1 → 4.4.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/README.md +54 -0
- package/dist/components/App.js +92 -30
- package/dist/components/App.test.js +86 -0
- package/dist/components/Dashboard.js +10 -3
- package/dist/components/DeleteWorktree.js +2 -13
- package/dist/components/Menu.js +28 -17
- package/dist/components/Menu.test.js +37 -1
- package/dist/components/RestoreSessions.d.ts +19 -0
- package/dist/components/RestoreSessions.js +28 -0
- package/dist/components/SessionActions.d.ts +17 -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/hooks/useAvailableLabelWidth.d.ts +6 -0
- package/dist/hooks/useAvailableLabelWidth.js +15 -0
- package/dist/services/config/globalConfigManager.js +3 -12
- package/dist/services/globalSessionOrchestrator.d.ts +7 -0
- package/dist/services/globalSessionOrchestrator.js +40 -0
- package/dist/services/globalSessionOrchestrator.restore.test.d.ts +1 -0
- package/dist/services/globalSessionOrchestrator.restore.test.js +76 -0
- package/dist/services/projectManager.js +3 -11
- package/dist/services/sessionManager.d.ts +6 -0
- package/dist/services/sessionManager.js +16 -0
- package/dist/services/sessionRestoreStore.d.ts +75 -0
- package/dist/services/sessionRestoreStore.js +138 -0
- package/dist/services/sessionRestoreStore.test.d.ts +1 -0
- package/dist/services/sessionRestoreStore.test.js +92 -0
- package/dist/services/sessionRestorer.d.ts +46 -0
- package/dist/services/sessionRestorer.js +123 -0
- package/dist/services/sessionRestorer.test.d.ts +1 -0
- package/dist/services/sessionRestorer.test.js +163 -0
- package/dist/services/worktreeService.d.ts +12 -0
- package/dist/services/worktreeService.js +30 -0
- package/dist/services/worktreeService.test.js +55 -1
- package/dist/types/index.d.ts +3 -2
- package/dist/utils/configDir.d.ts +10 -0
- package/dist/utils/configDir.js +31 -0
- package/dist/utils/errorMessage.d.ts +7 -0
- package/dist/utils/errorMessage.js +19 -0
- package/dist/utils/filterByQuery.test.js +2 -0
- package/dist/utils/gitUtils.d.ts +1 -0
- package/dist/utils/gitUtils.js +15 -0
- package/dist/utils/hookExecutor.test.js +4 -0
- 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 +38 -4
- package/dist/utils/worktreeUtils.js +76 -17
- package/dist/utils/worktreeUtils.test.js +82 -5
- package/package.json +6 -6
|
@@ -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
|
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { SessionRecord } from '../services/sessionRestoreStore.js';
|
|
3
|
+
interface RestoreSessionsProps {
|
|
4
|
+
sessions: SessionRecord[];
|
|
5
|
+
/** Whether to show which project each session belongs to. */
|
|
6
|
+
showProject?: boolean;
|
|
7
|
+
onRestore: () => void;
|
|
8
|
+
onDiscard: () => void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Startup offer to launch the sessions that were open when ccmanager last ran.
|
|
12
|
+
*
|
|
13
|
+
* Restoring runs each session's command preset in its worktree again; the
|
|
14
|
+
* previous terminal output and conversation are not brought back. Declining
|
|
15
|
+
* forgets the listed sessions, so the offer does not reappear on the next
|
|
16
|
+
* start.
|
|
17
|
+
*/
|
|
18
|
+
declare const RestoreSessions: React.FC<RestoreSessionsProps>;
|
|
19
|
+
export default RestoreSessions;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import Confirmation from './Confirmation.js';
|
|
5
|
+
import { describeRecordPreset } from '../services/sessionRestorer.js';
|
|
6
|
+
/**
|
|
7
|
+
* Startup offer to launch the sessions that were open when ccmanager last ran.
|
|
8
|
+
*
|
|
9
|
+
* Restoring runs each session's command preset in its worktree again; the
|
|
10
|
+
* previous terminal output and conversation are not brought back. Declining
|
|
11
|
+
* forgets the listed sessions, so the offer does not reappear on the next
|
|
12
|
+
* start.
|
|
13
|
+
*/
|
|
14
|
+
const RestoreSessions = ({ sessions, showProject = false, onRestore, onDiscard, }) => {
|
|
15
|
+
const message = (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Found ", sessions.length, " session", sessions.length === 1 ? '' : 's', " from the last time ccmanager ran. Start", ' ', sessions.length === 1 ? 'it' : 'them', " again?"] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: sessions.map(session => (_jsxs(Text, { children: [' ', _jsx(Text, { color: "green", children: path.basename(session.worktreePath) }), session.sessionName ? ` (${session.sessionName})` : '', _jsxs(Text, { dimColor: true, children: [' ', "\u2014 ", describeRecordPreset(session), showProject ? ` — ${path.basename(session.projectPath)}` : ''] })] }, session.id))) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Each session runs its command again in its worktree. Previous output and conversation are not restored." }) })] }));
|
|
16
|
+
return (_jsx(Confirmation, { title: _jsx(Text, { bold: true, children: "Restore previous sessions" }), message: message, options: [
|
|
17
|
+
{ label: 'Restore', value: 'restore', color: 'green' },
|
|
18
|
+
{ label: "Don't restore", value: 'discard', color: 'red' },
|
|
19
|
+
], onSelect: value => {
|
|
20
|
+
if (value === 'restore') {
|
|
21
|
+
onRestore();
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
onDiscard();
|
|
25
|
+
}
|
|
26
|
+
}, hint: _jsx(Text, { dimColor: true, children: "Use \u2191\u2193 to navigate, Enter to select. Declining forgets these sessions." }) }));
|
|
27
|
+
};
|
|
28
|
+
export default RestoreSessions;
|
|
@@ -1,8 +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;
|
|
5
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;
|
|
6
21
|
onSelect: (action: SessionActionType) => void;
|
|
7
22
|
onCancel: () => void;
|
|
8
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 {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { useInput } from 'ink';
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
5
|
+
import SessionActions from './SessionActions.js';
|
|
6
|
+
const makeKey = (overrides = {}) => ({
|
|
7
|
+
upArrow: false,
|
|
8
|
+
downArrow: false,
|
|
9
|
+
leftArrow: false,
|
|
10
|
+
rightArrow: false,
|
|
11
|
+
pageDown: false,
|
|
12
|
+
pageUp: false,
|
|
13
|
+
home: false,
|
|
14
|
+
end: false,
|
|
15
|
+
return: false,
|
|
16
|
+
escape: false,
|
|
17
|
+
ctrl: false,
|
|
18
|
+
shift: false,
|
|
19
|
+
tab: false,
|
|
20
|
+
backspace: false,
|
|
21
|
+
delete: false,
|
|
22
|
+
meta: false,
|
|
23
|
+
...overrides,
|
|
24
|
+
});
|
|
25
|
+
// Mock ink to avoid stdin issues and to capture the hotkey handler
|
|
26
|
+
vi.mock('ink', async () => {
|
|
27
|
+
const actual = await vi.importActual('ink');
|
|
28
|
+
return {
|
|
29
|
+
...actual,
|
|
30
|
+
useInput: vi.fn(),
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
// Mock SelectInput to render items as simple text
|
|
34
|
+
vi.mock('ink-select-input', async () => {
|
|
35
|
+
const React = await vi.importActual('react');
|
|
36
|
+
const { Text, Box } = await vi.importActual('ink');
|
|
37
|
+
return {
|
|
38
|
+
default: ({ items }) => React.createElement(Box, { flexDirection: 'column' }, items.map((item, index) => React.createElement(Text, { key: index }, item.label))),
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
const getLastInputHandler = () => {
|
|
42
|
+
const calls = vi.mocked(useInput).mock.calls;
|
|
43
|
+
const handler = calls[calls.length - 1]?.[0];
|
|
44
|
+
expect(handler).toBeDefined();
|
|
45
|
+
return handler;
|
|
46
|
+
};
|
|
47
|
+
describe('SessionActions', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.mocked(useInput).mockClear();
|
|
50
|
+
});
|
|
51
|
+
it('should show session actions and the delete entry for a deletable worktree', () => {
|
|
52
|
+
const { lastFrame } = render(_jsx(SessionActions, { sessionLabel: "Session #1", worktreePath: "/repo/worktrees/feature", hasSession: true, canDeleteWorktree: true, onSelect: vi.fn(), onCancel: vi.fn() }));
|
|
53
|
+
const frame = lastFrame();
|
|
54
|
+
expect(frame).toContain('Session Actions');
|
|
55
|
+
expect(frame).toContain('New session in this worktree');
|
|
56
|
+
expect(frame).toContain('Rename this session');
|
|
57
|
+
expect(frame).toContain('Close session');
|
|
58
|
+
expect(frame).toContain('Delete this worktree');
|
|
59
|
+
});
|
|
60
|
+
it('should hide session-specific actions for a worktree without a session', () => {
|
|
61
|
+
const { lastFrame } = render(_jsx(SessionActions, { worktreePath: "/repo/worktrees/feature", hasSession: false, canDeleteWorktree: true, onSelect: vi.fn(), onCancel: vi.fn() }));
|
|
62
|
+
const frame = lastFrame();
|
|
63
|
+
expect(frame).toContain('Worktree Actions');
|
|
64
|
+
expect(frame).toContain('New session in this worktree');
|
|
65
|
+
expect(frame).toContain('Delete this worktree');
|
|
66
|
+
expect(frame).not.toContain('Rename this session');
|
|
67
|
+
expect(frame).not.toContain('Close session');
|
|
68
|
+
});
|
|
69
|
+
it('should hide the delete entry when the worktree cannot be deleted', () => {
|
|
70
|
+
const { lastFrame } = render(_jsx(SessionActions, { sessionLabel: "Session #1", worktreePath: "/repo", hasSession: true, canDeleteWorktree: false, onSelect: vi.fn(), onCancel: vi.fn() }));
|
|
71
|
+
expect(lastFrame()).not.toContain('Delete this worktree');
|
|
72
|
+
});
|
|
73
|
+
it('should dispatch deleteWorktree on the D hotkey when deletion is offered', () => {
|
|
74
|
+
const onSelect = vi.fn();
|
|
75
|
+
render(_jsx(SessionActions, { worktreePath: "/repo/worktrees/feature", hasSession: false, canDeleteWorktree: true, onSelect: onSelect, onCancel: vi.fn() }));
|
|
76
|
+
getLastInputHandler()('d', makeKey());
|
|
77
|
+
expect(onSelect).toHaveBeenCalledWith('deleteWorktree');
|
|
78
|
+
});
|
|
79
|
+
it('should ignore hotkeys of actions that are not offered', () => {
|
|
80
|
+
const onSelect = vi.fn();
|
|
81
|
+
render(_jsx(SessionActions, { sessionLabel: "Session #1", worktreePath: "/repo", hasSession: true, canDeleteWorktree: false, onSelect: onSelect, onCancel: vi.fn() }));
|
|
82
|
+
const handler = getLastInputHandler();
|
|
83
|
+
handler('d', makeKey());
|
|
84
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
85
|
+
handler('x', makeKey());
|
|
86
|
+
expect(onSelect).toHaveBeenCalledWith('kill');
|
|
87
|
+
});
|
|
88
|
+
it('should cancel on Escape', () => {
|
|
89
|
+
const onCancel = vi.fn();
|
|
90
|
+
render(_jsx(SessionActions, { sessionLabel: "Session #1", worktreePath: "/repo", hasSession: true, canDeleteWorktree: true, onSelect: vi.fn(), onCancel: onCancel }));
|
|
91
|
+
getLastInputHandler()('', makeKey({ escape: true }));
|
|
92
|
+
expect(onCancel).toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Number of terminal columns a worktree/session row label may occupy.
|
|
3
|
+
* Passed to calculateColumnPositions, which drops the aligned session-state
|
|
4
|
+
* column when the resulting layout would not fit in it.
|
|
5
|
+
*/
|
|
6
|
+
export declare function useAvailableLabelWidth(): number;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useStdout } from 'ink';
|
|
2
|
+
// Columns a list row spends before the assembled label: the SelectInput
|
|
3
|
+
// indicator ("❯ " or two spaces) plus the number prefix ("0 ❯ " / " ❯ ").
|
|
4
|
+
const ROW_PREFIX_WIDTH = 6;
|
|
5
|
+
// Terminal width assumed when stdout reports none (e.g. a non-TTY test stream).
|
|
6
|
+
const FALLBACK_TERMINAL_WIDTH = 80;
|
|
7
|
+
/**
|
|
8
|
+
* Number of terminal columns a worktree/session row label may occupy.
|
|
9
|
+
* Passed to calculateColumnPositions, which drops the aligned session-state
|
|
10
|
+
* column when the resulting layout would not fit in it.
|
|
11
|
+
*/
|
|
12
|
+
export function useAvailableLabelWidth() {
|
|
13
|
+
const { stdout } = useStdout();
|
|
14
|
+
return Math.max(0, (stdout.columns || FALLBACK_TERMINAL_WIDTH) - ROW_PREFIX_WIDTH);
|
|
15
|
+
}
|
|
@@ -3,27 +3,18 @@
|
|
|
3
3
|
* This module is for internal use within the config directory only.
|
|
4
4
|
* External code should use ConfigEditor or ConfigReader instead.
|
|
5
5
|
*/
|
|
6
|
-
import { homedir } from 'os';
|
|
7
6
|
import { join } from 'path';
|
|
8
|
-
import { existsSync,
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
9
8
|
import { DEFAULT_SHORTCUTS, } from '../../types/index.js';
|
|
10
9
|
import { DEFAULT_TIMEOUT_SECONDS } from '../../constants/autoApproval.js';
|
|
10
|
+
import { ensureConfigDir } from '../../utils/configDir.js';
|
|
11
11
|
class GlobalConfigManager {
|
|
12
12
|
configPath;
|
|
13
13
|
legacyShortcutsPath;
|
|
14
14
|
configDir;
|
|
15
15
|
config = {};
|
|
16
16
|
constructor() {
|
|
17
|
-
|
|
18
|
-
const homeDir = homedir();
|
|
19
|
-
this.configDir =
|
|
20
|
-
process.platform === 'win32'
|
|
21
|
-
? join(process.env['APPDATA'] || join(homeDir, 'AppData', 'Roaming'), 'ccmanager')
|
|
22
|
-
: join(homeDir, '.config', 'ccmanager');
|
|
23
|
-
// Ensure config directory exists
|
|
24
|
-
if (!existsSync(this.configDir)) {
|
|
25
|
-
mkdirSync(this.configDir, { recursive: true });
|
|
26
|
-
}
|
|
17
|
+
this.configDir = ensureConfigDir();
|
|
27
18
|
this.configPath = join(this.configDir, 'config.json');
|
|
28
19
|
this.legacyShortcutsPath = join(this.configDir, 'shortcuts.json');
|
|
29
20
|
this.loadConfig();
|
|
@@ -12,6 +12,13 @@ declare class GlobalSessionOrchestrator {
|
|
|
12
12
|
destroyProjectSessions(projectPath: string): void;
|
|
13
13
|
getProjectPaths(): string[];
|
|
14
14
|
getProjectSessions(projectPath: string): Session[];
|
|
15
|
+
/**
|
|
16
|
+
* Keep the durable session record in step with one manager's sessions, so a
|
|
17
|
+
* later ccmanager run can offer to launch them again. This orchestrator is
|
|
18
|
+
* the only place that knows which project a manager belongs to, which is why
|
|
19
|
+
* the wiring lives here rather than inside SessionManager.
|
|
20
|
+
*/
|
|
21
|
+
private trackSessionsForRestore;
|
|
15
22
|
}
|
|
16
23
|
export declare const globalSessionOrchestrator: GlobalSessionOrchestrator;
|
|
17
24
|
export {};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { SessionManager } from './sessionManager.js';
|
|
2
|
+
import { getCurrentRepositoryRoot } from '../utils/gitUtils.js';
|
|
3
|
+
import { sessionRestoreStore } from './sessionRestoreStore.js';
|
|
2
4
|
class GlobalSessionOrchestrator {
|
|
3
5
|
static instance;
|
|
4
6
|
projectManagers = new Map();
|
|
@@ -6,6 +8,7 @@ class GlobalSessionOrchestrator {
|
|
|
6
8
|
constructor() {
|
|
7
9
|
// Create a global session manager for single-project mode
|
|
8
10
|
this.globalManager = new SessionManager();
|
|
11
|
+
this.trackSessionsForRestore(this.globalManager);
|
|
9
12
|
}
|
|
10
13
|
static getInstance() {
|
|
11
14
|
if (!GlobalSessionOrchestrator.instance) {
|
|
@@ -22,6 +25,7 @@ class GlobalSessionOrchestrator {
|
|
|
22
25
|
let manager = this.projectManagers.get(projectPath);
|
|
23
26
|
if (!manager) {
|
|
24
27
|
manager = new SessionManager();
|
|
28
|
+
this.trackSessionsForRestore(manager, projectPath);
|
|
25
29
|
this.projectManagers.set(projectPath, manager);
|
|
26
30
|
}
|
|
27
31
|
return manager;
|
|
@@ -37,6 +41,11 @@ class GlobalSessionOrchestrator {
|
|
|
37
41
|
return sessions;
|
|
38
42
|
}
|
|
39
43
|
destroyAllSessions() {
|
|
44
|
+
// Every caller of this method is quitting ccmanager, and the sessions
|
|
45
|
+
// being torn down here are exactly the ones the next run should offer to
|
|
46
|
+
// restore. Stop updating the durable record first so the teardown does
|
|
47
|
+
// not erase them.
|
|
48
|
+
sessionRestoreStore.suspendTracking();
|
|
40
49
|
// Destroy sessions in global manager
|
|
41
50
|
this.globalManager.destroy();
|
|
42
51
|
// Destroy sessions in all project managers
|
|
@@ -63,5 +72,36 @@ class GlobalSessionOrchestrator {
|
|
|
63
72
|
}
|
|
64
73
|
return [];
|
|
65
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Keep the durable session record in step with one manager's sessions, so a
|
|
77
|
+
* later ccmanager run can offer to launch them again. This orchestrator is
|
|
78
|
+
* the only place that knows which project a manager belongs to, which is why
|
|
79
|
+
* the wiring lives here rather than inside SessionManager.
|
|
80
|
+
*/
|
|
81
|
+
trackSessionsForRestore(manager, projectPath) {
|
|
82
|
+
manager.on('sessionCreated', (session) => {
|
|
83
|
+
sessionRestoreStore.record({
|
|
84
|
+
id: session.id,
|
|
85
|
+
// The global manager has no project path of its own: its sessions
|
|
86
|
+
// belong to the repository ccmanager was started in.
|
|
87
|
+
projectPath: projectPath ?? getCurrentRepositoryRoot(),
|
|
88
|
+
worktreePath: session.worktreePath,
|
|
89
|
+
presetId: session.presetId,
|
|
90
|
+
sessionName: session.sessionName,
|
|
91
|
+
devcontainerConfig: session.devcontainerConfig,
|
|
92
|
+
ownerPid: process.pid,
|
|
93
|
+
createdAt: Date.now(),
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
manager.on('sessionRenamed', (session) => {
|
|
97
|
+
sessionRestoreStore.rename(session.id, session.sessionName);
|
|
98
|
+
});
|
|
99
|
+
// A destroyed session is one that should stay gone: either the user
|
|
100
|
+
// killed it or the launched command exited on its own. The exception is
|
|
101
|
+
// the teardown on quit, which suspends tracking beforehand.
|
|
102
|
+
manager.on('sessionDestroyed', (session) => {
|
|
103
|
+
sessionRestoreStore.forget(session.id);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
66
106
|
}
|
|
67
107
|
export const globalSessionOrchestrator = GlobalSessionOrchestrator.getInstance();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
const recordMock = vi.fn();
|
|
4
|
+
const forgetMock = vi.fn();
|
|
5
|
+
const renameMock = vi.fn();
|
|
6
|
+
const suspendTrackingMock = vi.fn();
|
|
7
|
+
vi.mock('./sessionRestoreStore.js', () => ({
|
|
8
|
+
sessionRestoreStore: {
|
|
9
|
+
record: (...args) => recordMock(...args),
|
|
10
|
+
forget: (...args) => forgetMock(...args),
|
|
11
|
+
rename: (...args) => renameMock(...args),
|
|
12
|
+
suspendTracking: () => suspendTrackingMock(),
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('../utils/gitUtils.js', () => ({
|
|
16
|
+
getCurrentRepositoryRoot: () => '/current/repo',
|
|
17
|
+
}));
|
|
18
|
+
class MockSessionManager extends EventEmitter {
|
|
19
|
+
getAllSessions() {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
destroy() { }
|
|
23
|
+
}
|
|
24
|
+
vi.mock('./sessionManager.js', () => ({
|
|
25
|
+
SessionManager: MockSessionManager,
|
|
26
|
+
}));
|
|
27
|
+
const { globalSessionOrchestrator } = await import('./globalSessionOrchestrator.js');
|
|
28
|
+
const session = (overrides = {}) => ({
|
|
29
|
+
id: 'session-1',
|
|
30
|
+
worktreePath: '/repo/worktrees/feature',
|
|
31
|
+
presetId: 'preset-1',
|
|
32
|
+
sessionName: undefined,
|
|
33
|
+
devcontainerConfig: undefined,
|
|
34
|
+
...overrides,
|
|
35
|
+
});
|
|
36
|
+
describe('GlobalSessionOrchestrator session record', () => {
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
recordMock.mockClear();
|
|
39
|
+
forgetMock.mockClear();
|
|
40
|
+
renameMock.mockClear();
|
|
41
|
+
suspendTrackingMock.mockClear();
|
|
42
|
+
});
|
|
43
|
+
it('records a session of the current repository when there is no project path', () => {
|
|
44
|
+
const manager = globalSessionOrchestrator.getManagerForProject();
|
|
45
|
+
manager.emit('sessionCreated', session());
|
|
46
|
+
expect(recordMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
47
|
+
id: 'session-1',
|
|
48
|
+
projectPath: '/current/repo',
|
|
49
|
+
worktreePath: '/repo/worktrees/feature',
|
|
50
|
+
presetId: 'preset-1',
|
|
51
|
+
ownerPid: process.pid,
|
|
52
|
+
}));
|
|
53
|
+
});
|
|
54
|
+
it('records a session under the project its manager belongs to', () => {
|
|
55
|
+
const manager = globalSessionOrchestrator.getManagerForProject('/other/project');
|
|
56
|
+
manager.emit('sessionCreated', session({ id: 'session-2' }));
|
|
57
|
+
expect(recordMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
58
|
+
id: 'session-2',
|
|
59
|
+
projectPath: '/other/project',
|
|
60
|
+
}));
|
|
61
|
+
});
|
|
62
|
+
it('forgets a session that was killed or exited', () => {
|
|
63
|
+
const manager = globalSessionOrchestrator.getManagerForProject();
|
|
64
|
+
manager.emit('sessionDestroyed', session());
|
|
65
|
+
expect(forgetMock).toHaveBeenCalledWith('session-1');
|
|
66
|
+
});
|
|
67
|
+
it('keeps the record in step with a rename', () => {
|
|
68
|
+
const manager = globalSessionOrchestrator.getManagerForProject();
|
|
69
|
+
manager.emit('sessionRenamed', session({ sessionName: 'review' }));
|
|
70
|
+
expect(renameMock).toHaveBeenCalledWith('session-1', 'review');
|
|
71
|
+
});
|
|
72
|
+
it('stops tracking before quitting, so the sessions stay restorable', () => {
|
|
73
|
+
globalSessionOrchestrator.destroyAllSessions();
|
|
74
|
+
expect(suspendTrackingMock).toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -2,10 +2,10 @@ import { WorktreeService } from './worktreeService.js';
|
|
|
2
2
|
import { ENV_VARS } from '../constants/env.js';
|
|
3
3
|
import { promises as fs } from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
|
-
import {
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
7
6
|
import { Effect } from 'effect';
|
|
8
7
|
import { FileSystemError, ConfigError } from '../types/errors.js';
|
|
8
|
+
import { ensureConfigDir } from '../utils/configDir.js';
|
|
9
9
|
export class ProjectManager {
|
|
10
10
|
currentMode;
|
|
11
11
|
currentProject;
|
|
@@ -31,15 +31,7 @@ export class ProjectManager {
|
|
|
31
31
|
this.currentMode = 'normal';
|
|
32
32
|
}
|
|
33
33
|
// Initialize recent projects
|
|
34
|
-
|
|
35
|
-
this.configDir =
|
|
36
|
-
process.platform === 'win32'
|
|
37
|
-
? path.join(process.env['APPDATA'] || path.join(homeDir, 'AppData', 'Roaming'), 'ccmanager')
|
|
38
|
-
: path.join(homeDir, '.config', 'ccmanager');
|
|
39
|
-
// Ensure config directory exists
|
|
40
|
-
if (!existsSync(this.configDir)) {
|
|
41
|
-
mkdirSync(this.configDir, { recursive: true });
|
|
42
|
-
}
|
|
34
|
+
this.configDir = ensureConfigDir();
|
|
43
35
|
this.dataPath = path.join(this.configDir, 'recent-projects.json');
|
|
44
36
|
this.loadRecentProjects();
|
|
45
37
|
}
|
|
@@ -83,6 +83,12 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
83
83
|
private setupBackgroundHandler;
|
|
84
84
|
private cleanupSession;
|
|
85
85
|
getSessionById(id: string): Session | undefined;
|
|
86
|
+
/**
|
|
87
|
+
* Assign a user-facing name to a session. Goes through the manager rather
|
|
88
|
+
* than mutating the session object directly so that listeners (currently the
|
|
89
|
+
* restore record) learn about the new name.
|
|
90
|
+
*/
|
|
91
|
+
renameSession(sessionId: string, sessionName?: string): void;
|
|
86
92
|
getSessionsForWorktree(worktreePath: string): Session[];
|
|
87
93
|
setSessionActive(sessionId: string, active: boolean): void;
|
|
88
94
|
private emitRestoreSnapshot;
|
|
@@ -342,6 +342,7 @@ export class SessionManager extends EventEmitter {
|
|
|
342
342
|
stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
|
|
343
343
|
isPrimaryCommand: options.isPrimaryCommand ?? true,
|
|
344
344
|
presetName: options.presetName,
|
|
345
|
+
presetId: options.presetId,
|
|
345
346
|
detectionStrategy,
|
|
346
347
|
devcontainerConfig: options.devcontainerConfig ?? undefined,
|
|
347
348
|
stateMutex: new Mutex(createInitialSessionStateData()),
|
|
@@ -385,6 +386,7 @@ export class SessionManager extends EventEmitter {
|
|
|
385
386
|
command,
|
|
386
387
|
fallbackArgs: preset.fallbackArgs,
|
|
387
388
|
presetName: preset.name,
|
|
389
|
+
presetId: preset.id,
|
|
388
390
|
detectionStrategy: preset.detectionStrategy,
|
|
389
391
|
});
|
|
390
392
|
if (launch.stdinPayload) {
|
|
@@ -569,6 +571,19 @@ export class SessionManager extends EventEmitter {
|
|
|
569
571
|
getSessionById(id) {
|
|
570
572
|
return this.sessions.get(id);
|
|
571
573
|
}
|
|
574
|
+
/**
|
|
575
|
+
* Assign a user-facing name to a session. Goes through the manager rather
|
|
576
|
+
* than mutating the session object directly so that listeners (currently the
|
|
577
|
+
* restore record) learn about the new name.
|
|
578
|
+
*/
|
|
579
|
+
renameSession(sessionId, sessionName) {
|
|
580
|
+
const session = this.sessions.get(sessionId);
|
|
581
|
+
if (!session) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
session.sessionName = sessionName;
|
|
585
|
+
this.emit('sessionRenamed', session);
|
|
586
|
+
}
|
|
572
587
|
getSessionsForWorktree(worktreePath) {
|
|
573
588
|
return Array.from(this.sessions.values()).filter(s => s.worktreePath === worktreePath);
|
|
574
589
|
}
|
|
@@ -878,6 +893,7 @@ export class SessionManager extends EventEmitter {
|
|
|
878
893
|
command: preset.command,
|
|
879
894
|
fallbackArgs: preset.fallbackArgs,
|
|
880
895
|
presetName: preset.name,
|
|
896
|
+
presetId: preset.id,
|
|
881
897
|
detectionStrategy: preset.detectionStrategy,
|
|
882
898
|
devcontainerConfig,
|
|
883
899
|
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { DevcontainerConfig } from '../types/index.js';
|
|
2
|
+
/** Format version of the on-disk file, so future changes can be detected. */
|
|
3
|
+
export declare const SESSION_RECORD_VERSION = 1;
|
|
4
|
+
export declare const SESSION_RECORD_FILE_NAME = "sessions.json";
|
|
5
|
+
/** A single session, described well enough to launch it again. */
|
|
6
|
+
export interface SessionRecord {
|
|
7
|
+
/** Id of the in-memory session this record was written for. */
|
|
8
|
+
id: string;
|
|
9
|
+
/**
|
|
10
|
+
* Git repository root the session belongs to. Used to only offer sessions
|
|
11
|
+
* of the repository the user is actually opening.
|
|
12
|
+
*/
|
|
13
|
+
projectPath: string;
|
|
14
|
+
worktreePath: string;
|
|
15
|
+
/**
|
|
16
|
+
* Id of the command preset the session was launched with. Absent when the
|
|
17
|
+
* preset could not be determined; the default preset is then used on
|
|
18
|
+
* restore. Only the id is stored — the command itself stays owned by the
|
|
19
|
+
* preset configuration.
|
|
20
|
+
*/
|
|
21
|
+
presetId?: string;
|
|
22
|
+
/** User-assigned session name, if the user renamed the session. */
|
|
23
|
+
sessionName?: string;
|
|
24
|
+
/** Devcontainer commands the session was launched through, if any. */
|
|
25
|
+
devcontainerConfig?: DevcontainerConfig;
|
|
26
|
+
/**
|
|
27
|
+
* Process id of the ccmanager run that owns this session. A record whose
|
|
28
|
+
* owner process is still running belongs to another live ccmanager and must
|
|
29
|
+
* not be restored, or the same session would end up running twice.
|
|
30
|
+
*/
|
|
31
|
+
ownerPid: number;
|
|
32
|
+
createdAt: number;
|
|
33
|
+
}
|
|
34
|
+
export declare class SessionRestoreStore {
|
|
35
|
+
private explicitFilePath?;
|
|
36
|
+
/**
|
|
37
|
+
* While true, mutations are ignored. Set just before ccmanager tears its
|
|
38
|
+
* sessions down on exit: those sessions are exactly the ones the next run
|
|
39
|
+
* should offer to restore, so their records must survive the teardown.
|
|
40
|
+
*/
|
|
41
|
+
private trackingSuspended;
|
|
42
|
+
constructor(filePath?: string);
|
|
43
|
+
/**
|
|
44
|
+
* Resolved lazily rather than in the constructor so that merely importing
|
|
45
|
+
* this module does not create the configuration directory.
|
|
46
|
+
*/
|
|
47
|
+
private get filePath();
|
|
48
|
+
/** All recorded sessions, including ones owned by other ccmanager runs. */
|
|
49
|
+
list(): SessionRecord[];
|
|
50
|
+
/** Add a session to the record, replacing any earlier record with the same id. */
|
|
51
|
+
record(session: SessionRecord): void;
|
|
52
|
+
/**
|
|
53
|
+
* Drop sessions from the record, so they are not offered on the next run.
|
|
54
|
+
* Called when a session ends for a reason that means it should stay ended:
|
|
55
|
+
* the user killed it, or the launched command exited by itself.
|
|
56
|
+
*/
|
|
57
|
+
forget(...ids: string[]): void;
|
|
58
|
+
/**
|
|
59
|
+
* Keep a renamed session's name in the record. An absent name means the
|
|
60
|
+
* user cleared it.
|
|
61
|
+
*/
|
|
62
|
+
rename(id: string, sessionName?: string): void;
|
|
63
|
+
/**
|
|
64
|
+
* Stop applying further mutations. Used when ccmanager is shutting down and
|
|
65
|
+
* destroys its sessions: without this the teardown would erase precisely the
|
|
66
|
+
* records the next run needs.
|
|
67
|
+
*/
|
|
68
|
+
suspendTracking(): void;
|
|
69
|
+
/** Resume applying mutations. Counterpart of {@link suspendTracking}. */
|
|
70
|
+
resumeTracking(): void;
|
|
71
|
+
isTrackingSuspended(): boolean;
|
|
72
|
+
private read;
|
|
73
|
+
private write;
|
|
74
|
+
}
|
|
75
|
+
export declare const sessionRestoreStore: SessionRestoreStore;
|