Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 106 additions & 2 deletions src/components/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {MenuSnapshot} from './Menu.js';
import type {SessionRecord} from '../services/sessionRestoreStore.js';
import {ENV_VARS} from '../constants/env.js';
import {ProcessError} from '../types/errors.js';
import {DEFAULT_SHORTCUTS} from '../types/index.js';

type AppComponent = typeof import('./App.js').default;

Expand Down Expand Up @@ -50,6 +51,10 @@ type NewWorktreeMockProps = {
onCancel: () => void | Promise<void>;
};

type DashboardMockProps = {
onSelectProject: (project: GitProject) => void | Promise<void>;
};

type DeleteWorktreeMockProps = {
onComplete: (
worktreePaths: string[],
Expand Down Expand Up @@ -80,6 +85,7 @@ type DeleteWorktreeEffect = (
let App: AppComponent;

let menuProps: MenuMockProps | undefined;
let dashboardProps: DashboardMockProps | undefined;
let newWorktreeProps: NewWorktreeMockProps | undefined;
let deleteWorktreeProps: DeleteWorktreeMockProps | undefined;
let sessionProps: SessionMockProps | undefined;
Expand Down Expand Up @@ -109,6 +115,9 @@ class MockSessionManager {

const sessionManagers: MockSessionManager[] = [];

const destroyAllSessionsMock = vi.fn();
const getAllActiveSessionsMock = vi.fn(() => [] as SessionType[]);

const getManagerForProjectMock = vi.fn((_: string | undefined) => {
const manager = new MockSessionManager();
sessionManagers.push(manager);
Expand All @@ -117,6 +126,7 @@ const getManagerForProjectMock = vi.fn((_: string | undefined) => {

const configReaderMock = {
getSelectPresetOnStart: vi.fn(() => false),
getShortcuts: vi.fn(() => DEFAULT_SHORTCUTS),
};

const projectManagerMock = {
Expand Down Expand Up @@ -178,7 +188,8 @@ vi.mock('../services/sessionManager.js', () => ({
vi.mock('../services/globalSessionOrchestrator.js', () => ({
globalSessionOrchestrator: {
getManagerForProject: getManagerForProjectMock,
destroyAllSessions: vi.fn(),
destroyAllSessions: destroyAllSessionsMock,
getAllActiveSessions: getAllActiveSessionsMock,
getProjectPaths: vi.fn(() => []),
getProjectSessions: vi.fn(() => []),
},
Expand Down Expand Up @@ -230,7 +241,10 @@ vi.mock(
);
vi.mock(
'./Dashboard.js',
createInkMock('Dashboard View', () => {}),
createInkMock<DashboardMockProps>(
'Dashboard View',
props => (dashboardProps = props),
),
);
vi.mock(
'./NewWorktree.js',
Expand Down Expand Up @@ -294,6 +308,7 @@ const waitForCondition = async (

beforeEach(() => {
menuProps = undefined;
dashboardProps = undefined;
newWorktreeProps = undefined;
deleteWorktreeProps = undefined;
sessionProps = undefined;
Expand All @@ -312,6 +327,9 @@ beforeEach(() => {
deleteWorktreeEffectMock.mockImplementation(() => Effect.succeed(undefined));
sessionManagers.length = 0;
getManagerForProjectMock.mockClear();
destroyAllSessionsMock.mockClear();
getAllActiveSessionsMock.mockReset();
getAllActiveSessionsMock.mockReturnValue([]);
configReaderMock.getSelectPresetOnStart.mockReset();
configReaderMock.getSelectPresetOnStart.mockReturnValue(false);
projectManagerMock.addRecentProject.mockReset();
Expand Down Expand Up @@ -433,6 +451,92 @@ describe('App component view state', () => {
process.env[ENV_VARS.MULTI_PROJECT_ROOT] = original;
}
});

it('asks for confirmation before exiting and returns to the menu on cancel', async () => {
const {lastFrame, stdin, unmount} = render(<App version="test" />);
await waitForCondition(() => Boolean(menuProps));

await Promise.resolve(menuProps!.onMenuAction({type: 'exit'}));
await waitForCondition(
() => (lastFrame() ?? '').includes('Exit CCManager'),
1000,
);

// Default focus is "Cancel", so Enter alone must not exit.
await flush(50);
stdin.write('\r');
await waitForCondition(
() => (lastFrame() ?? '').includes('Menu View'),
1000,
);

expect(destroyAllSessionsMock).not.toHaveBeenCalled();

unmount();
});

it('destroys sessions and exits once the user confirms', async () => {
const {lastFrame, stdin, unmount} = render(<App version="test" />);
await waitForCondition(() => Boolean(menuProps));

await Promise.resolve(menuProps!.onMenuAction({type: 'exit'}));
await waitForCondition(
() => (lastFrame() ?? '').includes('Exit CCManager'),
1000,
);

// Move focus up from "Cancel" to "Exit", then confirm.
await flush(50);
stdin.write('\u001B[A');
await flush(50);
stdin.write('\r');
await waitForCondition(
() => destroyAllSessionsMock.mock.calls.length > 0,
2000,
);

unmount();
});

it('asks for confirmation before exiting from the project list in multi-project mode', async () => {
const original = process.env[ENV_VARS.MULTI_PROJECT_ROOT];
process.env[ENV_VARS.MULTI_PROJECT_ROOT] = '/tmp/projects';

const {lastFrame, stdin, unmount} = render(
<App multiProject version="test" />,
);
await waitForCondition(() => Boolean(dashboardProps));

await Promise.resolve(
dashboardProps!.onSelectProject({
name: 'Exit',
path: 'EXIT_APPLICATION',
relativePath: 'EXIT_APPLICATION',
isValid: true,
}),
);
await waitForCondition(
() => (lastFrame() ?? '').includes('Exit CCManager'),
1000,
);

await flush(50);
stdin.write('\u001B[A');
await flush(50);
stdin.write('\r');
await waitForCondition(
() => destroyAllSessionsMock.mock.calls.length > 0,
2000,
);

unmount();

if (original === undefined) {
delete process.env[ENV_VARS.MULTI_PROJECT_ROOT];
} else {
process.env[ENV_VARS.MULTI_PROJECT_ROOT] = original;
}
});
});

describe('App component loading state machine', () => {
Expand Down
78 changes: 74 additions & 4 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Session from './Session.js';
import NewWorktree from './NewWorktree.js';
import DeleteWorktree from './DeleteWorktree.js';
import DeleteConfirmation from './DeleteConfirmation.js';
import Confirmation from './Confirmation.js';
import MergeWorktree from './MergeWorktree.js';
import Configuration from './Configuration.js';
import PresetSelector from './PresetSelector.js';
Expand All @@ -24,6 +25,7 @@ import {
generateFallbackBranchName,
} from '../services/worktreeNameGenerator.js';
import {logger} from '../utils/logger.js';
import {shortcutManager} from '../services/shortcutManager.js';
import {
Worktree,
Session as ISession,
Expand Down Expand Up @@ -71,6 +73,7 @@ type View =
| 'remote-branch-selector'
| 'rename-session'
| 'session-actions'
| 'confirm-exit'
| 'clearing';

interface AppProps {
Expand Down Expand Up @@ -140,6 +143,10 @@ const App: React.FC<AppProps> = ({
null,
); // Store selected project in multi-project mode
const [configScope, setConfigScope] = useState<ConfigScope>('global'); // Store config scope for configuration view
// Where to return to if the user cancels the exit confirmation
const [exitConfirmSource, setExitConfirmSource] = useState<
'menu' | 'project-list'
>('menu');
const [pendingMenuSessionLaunch, setPendingMenuSessionLaunch] = useState<{
worktree: Worktree;
presetId: string;
Expand Down Expand Up @@ -574,8 +581,8 @@ const App: React.FC<AppProps> = ({
if (multiProject && selectedProject) {
handleBackToProjectList();
} else {
globalSessionOrchestrator.destroyAllSessions();
exit();
setExitConfirmSource('menu');
navigateWithClear('confirm-exit');
}
return;
case 'selectWorktree':
Expand Down Expand Up @@ -923,8 +930,8 @@ const App: React.FC<AppProps> = ({
const handleSelectProject = (project: GitProject) => {
// Handle special exit case
if (project.path === 'EXIT_APPLICATION') {
globalSessionOrchestrator.destroyAllSessions();
exit();
setExitConfirmSource('project-list');
navigateWithClear('confirm-exit');
return;
}

Expand Down Expand Up @@ -969,6 +976,17 @@ const App: React.FC<AppProps> = ({
navigateWithClear('session-actions');
};

const handleConfirmExit = () => {
globalSessionOrchestrator.destroyAllSessions();
exit();
};

const handleCancelExit = () => {
navigateWithClear(exitConfirmSource, () => {
setMenuKey(prev => prev + 1);
});
};

const handleBackToProjectList = () => {
// Sessions persist in their project-specific managers
setSelectedProject(null);
Expand Down Expand Up @@ -1261,6 +1279,58 @@ const App: React.FC<AppProps> = ({
);
}

if (view === 'confirm-exit') {
const activeSessionCount =
globalSessionOrchestrator.getAllActiveSessions().length;

const exitMessage = (
<Box flexDirection="column">
<Text>Are you sure you want to exit CCManager?</Text>
{activeSessionCount > 0 && (
<Box marginTop={1}>
<Text>
{activeSessionCount} active session
{activeSessionCount === 1 ? '' : 's'} will be terminated. They can
be restored the next time CCManager starts.
</Text>
</Box>
)}
</Box>
);

const exitHint = (
<Text dimColor>
Use ↑↓/j/k to navigate, Enter to select,{' '}
{shortcutManager.getShortcutDisplay('cancel')} to cancel
</Text>
);

return (
<Confirmation
title={
<Text bold color="yellow">
Exit CCManager
</Text>
}
message={exitMessage}
options={[
{label: 'Exit', value: 'exit', color: 'red'},
{label: 'Cancel', value: 'cancel', color: 'green'},
]}
onSelect={value => {
if (value === 'exit') {
handleConfirmExit();
} else {
handleCancelExit();
}
}}
initialIndex={1} // Default to Cancel for safety
hint={exitHint}
onCancel={handleCancelExit}
/>
);
}

if (view === 'preset-selector') {
return (
<PresetSelector
Expand Down
Loading