mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-09-03 12:21:21 +00:00
fix: install menu escape navigation
This commit is contained in:
+45
-6
@@ -63,15 +63,47 @@ interface FlashMessage {
|
||||
color: 'green' | 'red';
|
||||
}
|
||||
|
||||
type AppScreen = 'main'
|
||||
| 'lines'
|
||||
| 'items'
|
||||
| 'colorLines'
|
||||
| 'colors'
|
||||
| 'terminalWidth'
|
||||
| 'terminalConfig'
|
||||
| 'globalOverrides'
|
||||
| 'confirm'
|
||||
| 'powerline'
|
||||
| 'install';
|
||||
|
||||
interface ConfirmDialogState {
|
||||
message: string;
|
||||
action: () => Promise<void>;
|
||||
cancelScreen?: Exclude<AppScreen, 'confirm'>;
|
||||
}
|
||||
|
||||
export function getConfirmCancelScreen(confirmDialog: ConfirmDialogState | null): Exclude<AppScreen, 'confirm'> {
|
||||
return confirmDialog?.cancelScreen ?? 'main';
|
||||
}
|
||||
|
||||
export function clearInstallMenuSelection(menuSelections: Record<string, number>): Record<string, number> {
|
||||
if (menuSelections.install === undefined) {
|
||||
return menuSelections;
|
||||
}
|
||||
|
||||
const next = { ...menuSelections };
|
||||
delete next.install;
|
||||
return next;
|
||||
}
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const { exit } = useApp();
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [originalSettings, setOriginalSettings] = useState<Settings | null>(null);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [screen, setScreen] = useState<'main' | 'lines' | 'items' | 'colorLines' | 'colors' | 'terminalWidth' | 'terminalConfig' | 'globalOverrides' | 'confirm' | 'powerline' | 'install'>('main');
|
||||
const [screen, setScreen] = useState<AppScreen>('main');
|
||||
const [selectedLine, setSelectedLine] = useState(0);
|
||||
const [menuSelections, setMenuSelections] = useState<Record<string, number>>({});
|
||||
const [confirmDialog, setConfirmDialog] = useState<{ message: string; action: () => Promise<void> } | null>(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState<ConfirmDialogState | null>(null);
|
||||
const [isClaudeInstalled, setIsClaudeInstalled] = useState(false);
|
||||
const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80);
|
||||
const [powerlineFontStatus, setPowerlineFontStatus] = useState<PowerlineFontStatus>({ installed: false });
|
||||
@@ -163,6 +195,7 @@ export const App: React.FC = () => {
|
||||
|
||||
setConfirmDialog({
|
||||
message,
|
||||
cancelScreen: 'install',
|
||||
action: async () => {
|
||||
await installStatusLine(useBunx);
|
||||
setIsClaudeInstalled(true);
|
||||
@@ -176,13 +209,20 @@ export const App: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
const handleNpxInstall = useCallback(() => {
|
||||
setMenuSelections(prev => ({ ...prev, install: 0 }));
|
||||
handleInstallSelection(CCSTATUSLINE_COMMANDS.NPM, 'npx', false);
|
||||
}, [handleInstallSelection]);
|
||||
|
||||
const handleBunxInstall = useCallback(() => {
|
||||
setMenuSelections(prev => ({ ...prev, install: 1 }));
|
||||
handleInstallSelection(CCSTATUSLINE_COMMANDS.BUNX, 'bunx', true);
|
||||
}, [handleInstallSelection]);
|
||||
|
||||
const handleInstallMenuCancel = useCallback(() => {
|
||||
setMenuSelections(clearInstallMenuSelection);
|
||||
setScreen('main');
|
||||
}, []);
|
||||
|
||||
if (!settings) {
|
||||
return <Text>Loading settings...</Text>;
|
||||
}
|
||||
@@ -440,7 +480,7 @@ export const App: React.FC = () => {
|
||||
message={confirmDialog.message}
|
||||
onConfirm={() => void confirmDialog.action()}
|
||||
onCancel={() => {
|
||||
setScreen('main');
|
||||
setScreen(getConfirmCancelScreen(confirmDialog));
|
||||
setConfirmDialog(null);
|
||||
}}
|
||||
/>
|
||||
@@ -451,9 +491,8 @@ export const App: React.FC = () => {
|
||||
existingStatusLine={existingStatusLine}
|
||||
onSelectNpx={handleNpxInstall}
|
||||
onSelectBunx={handleBunxInstall}
|
||||
onCancel={() => {
|
||||
setScreen('main');
|
||||
}}
|
||||
onCancel={handleInstallMenuCancel}
|
||||
initialSelection={menuSelections.install}
|
||||
/>
|
||||
)}
|
||||
{screen === 'powerline' && (
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
clearInstallMenuSelection,
|
||||
getConfirmCancelScreen
|
||||
} from '../App';
|
||||
|
||||
describe('App confirm navigation helpers', () => {
|
||||
it('defaults confirmation cancel navigation to the main menu', () => {
|
||||
expect(getConfirmCancelScreen(null)).toBe('main');
|
||||
expect(getConfirmCancelScreen({
|
||||
message: 'Confirm install?',
|
||||
action: () => Promise.resolve()
|
||||
})).toBe('main');
|
||||
});
|
||||
|
||||
it('returns to the install menu when the confirm dialog requests it', () => {
|
||||
expect(getConfirmCancelScreen({
|
||||
message: 'Confirm install?',
|
||||
action: () => Promise.resolve(),
|
||||
cancelScreen: 'install'
|
||||
})).toBe('install');
|
||||
});
|
||||
|
||||
it('clears saved install selection when leaving the install menu', () => {
|
||||
expect(clearInstallMenuSelection({
|
||||
main: 5,
|
||||
install: 1
|
||||
})).toEqual({ main: 5 });
|
||||
|
||||
const menuSelections = { main: 5 };
|
||||
|
||||
expect(clearInstallMenuSelection(menuSelections)).toBe(menuSelections);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Box,
|
||||
Text
|
||||
Text,
|
||||
useInput
|
||||
} from 'ink';
|
||||
import React from 'react';
|
||||
|
||||
@@ -14,6 +15,7 @@ export interface InstallMenuProps {
|
||||
onSelectNpx: () => void;
|
||||
onSelectBunx: () => void;
|
||||
onCancel: () => void;
|
||||
initialSelection?: number;
|
||||
}
|
||||
|
||||
export const InstallMenu: React.FC<InstallMenuProps> = ({
|
||||
@@ -21,8 +23,15 @@ export const InstallMenu: React.FC<InstallMenuProps> = ({
|
||||
existingStatusLine,
|
||||
onSelectNpx,
|
||||
onSelectBunx,
|
||||
onCancel
|
||||
onCancel,
|
||||
initialSelection = 0
|
||||
}) => {
|
||||
useInput((_, key) => {
|
||||
if (key.escape) {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
|
||||
function onSelect(value: string) {
|
||||
switch (value) {
|
||||
case 'npx':
|
||||
@@ -82,6 +91,7 @@ export const InstallMenu: React.FC<InstallMenuProps> = ({
|
||||
|
||||
onSelect(line);
|
||||
}}
|
||||
initialSelection={initialSelection}
|
||||
showBackButton={true}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { render } from 'ink';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import React from 'react';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { InstallMenu } from '../InstallMenu';
|
||||
|
||||
class MockTtyStream extends PassThrough {
|
||||
isTTY = true;
|
||||
columns = 120;
|
||||
rows = 40;
|
||||
|
||||
setRawMode() {
|
||||
return this;
|
||||
}
|
||||
|
||||
ref() {
|
||||
return this;
|
||||
}
|
||||
|
||||
unref() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string }
|
||||
|
||||
function createMockStdin(): NodeJS.ReadStream {
|
||||
return new MockTtyStream() as unknown as NodeJS.ReadStream;
|
||||
}
|
||||
|
||||
function createMockStdout(): CapturedWriteStream {
|
||||
const stream = new MockTtyStream();
|
||||
const chunks: string[] = [];
|
||||
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
chunks.push(chunk.toString());
|
||||
});
|
||||
|
||||
return Object.assign(stream as unknown as NodeJS.WriteStream, {
|
||||
getOutput() {
|
||||
return stripAnsi(chunks.join(''));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function flushInk() {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
}
|
||||
|
||||
describe('InstallMenu', () => {
|
||||
it('calls onCancel when escape is pressed', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const onCancel = vi.fn();
|
||||
const instance = render(
|
||||
React.createElement(InstallMenu, {
|
||||
bunxAvailable: true,
|
||||
existingStatusLine: null,
|
||||
onSelectNpx: vi.fn(),
|
||||
onSelectBunx: vi.fn(),
|
||||
onCancel
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
|
||||
stdin.write('\u001B');
|
||||
await flushInk();
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('respects the provided initial selection', async () => {
|
||||
const stdin = createMockStdin();
|
||||
const stdout = createMockStdout();
|
||||
const stderr = createMockStdout();
|
||||
const instance = render(
|
||||
React.createElement(InstallMenu, {
|
||||
bunxAvailable: true,
|
||||
existingStatusLine: null,
|
||||
onSelectNpx: vi.fn(),
|
||||
onSelectBunx: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
initialSelection: 1
|
||||
}),
|
||||
{
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
debug: true,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await flushInk();
|
||||
|
||||
expect(stdout.getOutput()).toContain('▶ bunx - Bun Package Execute');
|
||||
expect(stdout.getOutput()).not.toContain('▶ npx - Node Package Execute');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
stderr.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user