Skip to content
Open
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
96 changes: 94 additions & 2 deletions src/tui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,15 @@ import {
} from '../utils/claude-settings';
import { cloneSettings } from '../utils/clone-settings';
import {
applyImport,
exportConfig,
getConfigPath,
isCustomConfigPath,
loadSettings,
saveInstallationMetadata,
saveSettings
saveSettings,
validateImportFile,
type ImportValidationResult
} from '../utils/config';
import {
inspectGlobalCommandResolution,
Expand Down Expand Up @@ -72,7 +76,10 @@ import { loadClaudeStatusLineState } from './claude-status';
import {
ColorMenu,
ConfirmDialog,
ExportConfigDialog,
GlobalOverridesMenu,
ImportConfigDialog,
ImportPreviewDialog,
InstallMenu,
ItemsEditor,
LineSelector,
Expand Down Expand Up @@ -118,7 +125,10 @@ type AppScreen = 'main'
| 'manageInstallation'
| 'uninstallOptions'
| 'updates'
| 'refreshInterval';
| 'refreshInterval'
| 'exportConfig'
| 'importConfig'
| 'importPreview';

type PinnedVersionMismatchAction = 'update' | 'exit';

Expand Down Expand Up @@ -439,6 +449,7 @@ export const App: React.FC = () => {
const [updatesReturnScreen, setUpdatesReturnScreen] = useState<'main' | 'manageInstallation'>('main');
const [hasLoadedClaudeStatus, setHasLoadedClaudeStatus] = useState(false);
const [hasLoadedInstalledState, setHasLoadedInstalledState] = useState(false);
const [importValidation, setImportValidation] = useState<ImportValidationResult | null>(null);

useEffect(() => {
void loadClaudeStatusLineState()
Expand Down Expand Up @@ -698,6 +709,55 @@ export const App: React.FC = () => {
setScreen('confirm');
}, [getGlobalResolutionWarning]);

const handleExportConfig = useCallback(async (filePath: string) => {
try {
await exportConfig(filePath);
setFlashMessage({ text: `Config exported to ${filePath}`, color: 'green' });
} catch (err) {
setFlowNotice({
title: 'Export Failed',
message: err instanceof Error ? err.message : String(err),
color: 'red',
continueScreen: 'main'
});
setScreen('flowNotice');
return;
}
setScreen('main');
}, []);

const handleImportFileChosen = useCallback(async (filePath: string) => {
const result = await validateImportFile(filePath);
if (result.status === 'invalid') {
setFlowNotice({
title: 'Import Failed',
message: result.reason,
color: 'red',
continueScreen: 'main'
});
setScreen('flowNotice');
} else {
setImportValidation(result);
setScreen('importPreview');
}
}, []);

const handleImportApply = useCallback((mode: 'replace' | 'merge') => {
if (importValidation?.status !== 'valid') {
return;
}
setSettings((prev) => {
if (!prev) {
return prev;
}
return applyImport(prev, importValidation.data, mode);
});
setHasChanges(true);
setImportValidation(null);
setFlashMessage({ text: 'Config imported — review and save', color: 'green' });
setScreen('main');
}, [importValidation]);

if (!settings || !hasLoadedClaudeStatus || !hasLoadedInstalledState) {
return <Text>Loading settings...</Text>;
}
Expand Down Expand Up @@ -871,6 +931,12 @@ export const App: React.FC = () => {
case 'configureStatusLine':
setScreen('refreshInterval');
break;
case 'exportConfig':
setScreen('exportConfig');
break;
case 'importConfig':
setScreen('importConfig');
break;
case 'starGithub':
setConfirmDialog({
message: `Open the ccstatusline GitHub repository in your browser?\n\n${GITHUB_REPO_URL}`,
Expand Down Expand Up @@ -1250,6 +1316,32 @@ export const App: React.FC = () => {
onClearMessage={() => { setFontInstallMessage(null); }}
/>
)}

{screen === 'exportConfig' && (
<ExportConfigDialog
onExport={(filePath) => { void handleExportConfig(filePath); }}
onCancel={() => { setScreen('main'); }}
/>
)}

{screen === 'importConfig' && (
<ImportConfigDialog
onFileChosen={(filePath) => { void handleImportFileChosen(filePath); }}
onCancel={() => { setScreen('main'); }}
/>
)}

{screen === 'importPreview' && importValidation?.status === 'valid' && (
<ImportPreviewDialog
validation={importValidation}
currentSettings={settings}
onApply={(mode) => { handleImportApply(mode); }}
onCancel={() => {
setImportValidation(null);
setScreen('main');
}}
/>
)}
</Box>
</Box>
);
Expand Down
19 changes: 14 additions & 5 deletions src/tui/__tests__/App.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ describe('Main menu structure', () => {
'globalOverrides',
'configureStatusLine',
'-',
'exportConfig',
'importConfig',
'-',
'install',
'-',
'exit',
Expand All @@ -187,6 +190,9 @@ describe('Main menu structure', () => {
'globalOverrides',
'configureStatusLine',
'-',
'exportConfig',
'importConfig',
'-',
'install',
'-',
'exit',
Expand All @@ -210,6 +216,9 @@ describe('Main menu structure', () => {
'globalOverrides',
'configureStatusLine',
'-',
'exportConfig',
'importConfig',
'-',
'manageInstallation',
'-',
'exit',
Expand Down Expand Up @@ -240,13 +249,13 @@ describe('Main menu structure', () => {
sublabel: '(install first)'
}));
expect(buildManageInstallationItems()[0]).toEqual(expect.objectContaining({ label: '🔄 Check for Updates' }));
expect(getMainMenuInstallSelectionIndex(false)).toBe(5);
expect(getMainMenuInstallSelectionIndex(true, autoInstallation)).toBe(6);
expect(getMainMenuInstallSelectionIndex(true, pinnedInstallation)).toBe(6);
expect(getMainMenuSelectionIndex(buildMainMenuItems(true, false, autoInstallation), 'install')).toBe(6);
expect(getMainMenuInstallSelectionIndex(false)).toBe(7);
expect(getMainMenuInstallSelectionIndex(true, autoInstallation)).toBe(8);
expect(getMainMenuInstallSelectionIndex(true, pinnedInstallation)).toBe(8);
expect(getMainMenuSelectionIndex(buildMainMenuItems(true, false, autoInstallation), 'install')).toBe(8);
expect(getMainMenuSelectionIndex(
buildMainMenuItems(true, false, pinnedInstallation),
'manageInstallation'
)).toBe(6);
)).toBe(8);
});
});
48 changes: 48 additions & 0 deletions src/tui/components/ExportConfigDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
Box,
Text,
useInput
} from 'ink';
import * as os from 'os';
import * as path from 'path';
import React, { useState } from 'react';

import { shouldInsertInput } from '../../utils/input-guards';

interface ExportConfigDialogProps {
onExport: (filePath: string) => void;
onCancel: () => void;
}

const DEFAULT_EXPORT_PATH = path.join(os.homedir(), 'ccstatusline-config.json');

export function ExportConfigDialog({ onExport, onCancel }: ExportConfigDialogProps): React.JSX.Element {
const [inputValue, setInputValue] = useState(DEFAULT_EXPORT_PATH);

useInput((input, key) => {
if (key.return) {
onExport(inputValue);
} else if (key.escape) {
onCancel();
} else if (key.backspace) {
setInputValue(inputValue.slice(0, -1));
} else if (shouldInsertInput(input, key)) {
setInputValue(inputValue + input);
}
});

return (
<Box flexDirection='column'>
<Text bold>Export Config</Text>
<Text dimColor>Enter the file path to export your configuration to:</Text>
<Box marginTop={1}>
<Text>Path: </Text>
<Text>{inputValue}</Text>
<Text inverse> </Text>
</Box>
<Box marginTop={1}>
<Text dimColor>Enter to confirm, Escape to cancel</Text>
</Box>
</Box>
);
}
44 changes: 44 additions & 0 deletions src/tui/components/ImportConfigDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
Box,
Text,
useInput
} from 'ink';
import React, { useState } from 'react';

import { shouldInsertInput } from '../../utils/input-guards';

interface ImportConfigDialogProps {
onFileChosen: (filePath: string) => void;
onCancel: () => void;
}

export function ImportConfigDialog({ onFileChosen, onCancel }: ImportConfigDialogProps): React.JSX.Element {
const [inputValue, setInputValue] = useState('');

useInput((input, key) => {
if (key.return) {
onFileChosen(inputValue);
} else if (key.escape) {
onCancel();
} else if (key.backspace) {
setInputValue(inputValue.slice(0, -1));
} else if (shouldInsertInput(input, key)) {
setInputValue(inputValue + input);
}
});

return (
<Box flexDirection='column'>
<Text bold>Import Config</Text>
<Text dimColor>Enter the file path to import configuration from:</Text>
<Box marginTop={1}>
<Text>Path: </Text>
<Text>{inputValue}</Text>
<Text inverse> </Text>
</Box>
<Box marginTop={1}>
<Text dimColor>Enter to confirm, Escape to cancel</Text>
</Box>
</Box>
);
}
Loading