diff --git a/src/tui/App.tsx b/src/tui/App.tsx index efaabf54..f81428cd 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -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, @@ -72,7 +76,10 @@ import { loadClaudeStatusLineState } from './claude-status'; import { ColorMenu, ConfirmDialog, + ExportConfigDialog, GlobalOverridesMenu, + ImportConfigDialog, + ImportPreviewDialog, InstallMenu, ItemsEditor, LineSelector, @@ -118,7 +125,10 @@ type AppScreen = 'main' | 'manageInstallation' | 'uninstallOptions' | 'updates' - | 'refreshInterval'; + | 'refreshInterval' + | 'exportConfig' + | 'importConfig' + | 'importPreview'; type PinnedVersionMismatchAction = 'update' | 'exit'; @@ -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(null); useEffect(() => { void loadClaudeStatusLineState() @@ -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 Loading settings...; } @@ -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}`, @@ -1250,6 +1316,32 @@ export const App: React.FC = () => { onClearMessage={() => { setFontInstallMessage(null); }} /> )} + + {screen === 'exportConfig' && ( + { void handleExportConfig(filePath); }} + onCancel={() => { setScreen('main'); }} + /> + )} + + {screen === 'importConfig' && ( + { void handleImportFileChosen(filePath); }} + onCancel={() => { setScreen('main'); }} + /> + )} + + {screen === 'importPreview' && importValidation?.status === 'valid' && ( + { handleImportApply(mode); }} + onCancel={() => { + setImportValidation(null); + setScreen('main'); + }} + /> + )} ); diff --git a/src/tui/__tests__/App.test.ts b/src/tui/__tests__/App.test.ts index fe34c653..a4dce02e 100644 --- a/src/tui/__tests__/App.test.ts +++ b/src/tui/__tests__/App.test.ts @@ -169,6 +169,9 @@ describe('Main menu structure', () => { 'globalOverrides', 'configureStatusLine', '-', + 'exportConfig', + 'importConfig', + '-', 'install', '-', 'exit', @@ -187,6 +190,9 @@ describe('Main menu structure', () => { 'globalOverrides', 'configureStatusLine', '-', + 'exportConfig', + 'importConfig', + '-', 'install', '-', 'exit', @@ -210,6 +216,9 @@ describe('Main menu structure', () => { 'globalOverrides', 'configureStatusLine', '-', + 'exportConfig', + 'importConfig', + '-', 'manageInstallation', '-', 'exit', @@ -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); }); }); diff --git a/src/tui/components/ExportConfigDialog.tsx b/src/tui/components/ExportConfigDialog.tsx new file mode 100644 index 00000000..26e424c6 --- /dev/null +++ b/src/tui/components/ExportConfigDialog.tsx @@ -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 ( + + Export Config + Enter the file path to export your configuration to: + + Path: + {inputValue} + + + + Enter to confirm, Escape to cancel + + + ); +} diff --git a/src/tui/components/ImportConfigDialog.tsx b/src/tui/components/ImportConfigDialog.tsx new file mode 100644 index 00000000..506563cb --- /dev/null +++ b/src/tui/components/ImportConfigDialog.tsx @@ -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 ( + + Import Config + Enter the file path to import configuration from: + + Path: + {inputValue} + + + + Enter to confirm, Escape to cancel + + + ); +} diff --git a/src/tui/components/ImportPreviewDialog.tsx b/src/tui/components/ImportPreviewDialog.tsx new file mode 100644 index 00000000..1c6435a4 --- /dev/null +++ b/src/tui/components/ImportPreviewDialog.tsx @@ -0,0 +1,217 @@ +import { + Box, + Static, + Text +} from 'ink'; +import React from 'react'; + +import type { Settings } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import type { ImportValidationResult } from '../../utils/config'; + +import { + List, + type ListEntry +} from './List'; + +type ValidImportResult = Extract; + +interface ImportPreviewDialogProps { + validation: ValidImportResult; + currentSettings: Settings; + onApply: (mode: 'replace' | 'merge') => void; + onCancel: () => void; +} + +type ImportMode = 'replace' | 'merge' | 'cancel'; + +const EXCLUDED_KEYS = new Set(['version', 'installation', 'updatemessage']); + +interface DiffEntry { + path: string; + current: unknown; + imported: unknown; +} + +function formatScalar(value: unknown): string { + if (value === null || value === undefined) { + return 'none'; + } + if (typeof value === 'boolean' || typeof value === 'number') { + return String(value); + } + if (typeof value === 'string') { + return value || '(empty)'; + } + return JSON.stringify(value); +} + +function diffObject(current: Record, imported: Record, prefix: string): DiffEntry[] { + const keys = new Set([...Object.keys(current), ...Object.keys(imported)]); + const entries: DiffEntry[] = []; + + for (const key of keys) { + const path = prefix ? `${prefix}.${key}` : key; + const a = current[key]; + const b = imported[key]; + if (JSON.stringify(a) === JSON.stringify(b)) { + continue; + } + + if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b)) { + entries.push(...diffObject(a as Record, b as Record, path)); + } else { + entries.push({ path, current: a, imported: b }); + } + } + + return entries; +} + +function diffLines(currentLines: WidgetItem[][], importedLines: WidgetItem[][]): DiffEntry[] { + const entries: DiffEntry[] = []; + const lineCount = Math.max(currentLines.length, importedLines.length); + + for (let li = 0; li < lineCount; li++) { + const curLine = currentLines[li] ?? []; + const impLine = importedLines[li] ?? []; + + const widgetCount = Math.max(curLine.length, impLine.length); + for (let wi = 0; wi < widgetCount; wi++) { + const curWidget = curLine[wi]; + const impWidget = impLine[wi]; + + if (JSON.stringify(curWidget) === JSON.stringify(impWidget)) { + continue; + } + + if (!curWidget) { + const addedType = impWidget?.type ?? 'unknown'; + entries.push({ path: `line ${li + 1} +${addedType}`, current: undefined, imported: '[added]' }); + continue; + } + + if (!impWidget) { + entries.push({ path: `line ${li + 1} -${curWidget.type}`, current: '[removed]', imported: undefined }); + continue; + } + + const label = `${curWidget.type} (line ${li + 1})`; + const widgetKeys = new Set([...Object.keys(curWidget), ...Object.keys(impWidget)]) as Set; + for (const key of widgetKeys) { + if (key === 'id') { + continue; + } + const a = curWidget[key]; + const b = impWidget[key]; + if (JSON.stringify(a) !== JSON.stringify(b)) { + entries.push({ path: `${label} ${key}`, current: a, imported: b }); + } + } + } + } + + return entries; +} + +export function ImportPreviewDialog({ + validation, + currentSettings, + onApply, + onCancel +}: ImportPreviewDialogProps): React.JSX.Element { + const topLevelKeys = (Object.keys(currentSettings) as (keyof Settings)[]) + .filter(k => !EXCLUDED_KEYS.has(k)); + + const items: ListEntry[] = [ + { label: 'Replace All', value: 'replace', description: 'Overwrite all settings with the imported config' }, + { label: 'Merge', value: 'merge', description: 'Overlay imported settings on top of current settings' }, + '-' as unknown as ListEntry, + { label: 'Cancel', value: 'cancel' } + ]; + + function handleSelect(value: ImportMode | 'back'): void { + if (value === 'cancel' || value === 'back') { + onCancel(); + } else { + onApply(value); + } + } + + const diffRows: React.JSX.Element[] = []; + + for (const key of topLevelKeys) { + const current = currentSettings[key]; + const imported = validation.data[key]; + const changed = JSON.stringify(current) !== JSON.stringify(imported); + + if (!changed) { + diffRows.push( + + {` ${key}: ${formatScalar(current)}`} + + ); + continue; + } + + if (key === 'lines') { + const entries = diffLines(current as WidgetItem[][], imported as WidgetItem[][]); + diffRows.push( + + {` ${key}:`} + {entries.map((e, i) => ( + + {`${e.path}: `} + {formatScalar(e.current)} + {' → '} + {formatScalar(e.imported)} + + ))} + + ); + continue; + } + + if (current && imported && typeof current === 'object' && typeof imported === 'object' && !Array.isArray(current)) { + const entries = diffObject( + current, + imported as Record, + key + ); + diffRows.push( + + {` ${key}:`} + {entries.map((e, i) => ( + + {`${e.path}: `} + {formatScalar(e.current)} + {' → '} + {formatScalar(e.imported)} + + ))} + + ); + continue; + } + + diffRows.push( + + {` ${key}: `} + {formatScalar(current)} + {' → '} + {formatScalar(imported)} + + ); + } + + return ( + + Import Preview + Changes that will be applied: + + {(row, i) => {row}} + + + + ); +} diff --git a/src/tui/components/MainMenu.tsx b/src/tui/components/MainMenu.tsx index cf906c4d..b3f9813a 100644 --- a/src/tui/components/MainMenu.tsx +++ b/src/tui/components/MainMenu.tsx @@ -21,6 +21,8 @@ export type MainMenuOption = 'lines' | 'manageInstallation' | 'checkUpdates' | 'configureStatusLine' + | 'exportConfig' + | 'importConfig' | 'starGithub' | 'save' | 'exit'; @@ -121,6 +123,17 @@ export function buildMainMenuItems( description: 'Configure Claude Code status line settings like refresh interval' }, '-', + { + label: '📤 Export Config', + value: 'exportConfig', + description: 'Save your current configuration to a JSON file for backup or sharing' + }, + { + label: '📥 Import Config', + value: 'importConfig', + description: 'Load configuration from a previously exported JSON file' + }, + '-', getInstallationMenuItem(isClaudeInstalled, installation) ]; diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts index fc8583e5..f26db2c2 100644 --- a/src/tui/components/index.ts +++ b/src/tui/components/index.ts @@ -1,5 +1,8 @@ // Barrel file - exports all components and their types export * from './ColorMenu'; +export * from './ExportConfigDialog'; +export * from './ImportConfigDialog'; +export * from './ImportPreviewDialog'; export * from './ConfirmDialog'; export * from './GlobalOverridesMenu'; export * from './InstallMenu'; diff --git a/src/utils/config.ts b/src/utils/config.ts index 8f2ccc0c..66c2255a 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -14,6 +14,7 @@ import { migrateConfig, needsMigration } from './migrations'; +import { getPackageVersion } from './terminal'; import { upgradeLegacyWidgetTypes } from './widgets'; // Use fs.promises directly (always available in modern Node.js) @@ -171,6 +172,67 @@ export async function saveSettings(settings: Settings): Promise { } catch { /* ignore hook sync failures */ } } +export type ImportValidationResult + = | { status: 'valid'; data: Settings } + | { status: 'invalid'; reason: string }; + +function expandPath(filePath: string): string { + if (filePath.startsWith('~/') || filePath === '~') { + return path.join(os.homedir(), filePath.slice(2)); + } + return filePath; +} + +export async function exportConfig(filePath: string): Promise { + const settings = await loadSettings(); + const expanded = expandPath(filePath); + const exportData = { ...settings, exportedBy: getPackageVersion() }; + await mkdir(path.dirname(expanded), { recursive: true }); + await writeFile(expanded, JSON.stringify(exportData, null, 2), 'utf-8'); +} + +export async function validateImportFile(filePath: string): Promise { + const expanded = expandPath(filePath); + let raw: string; + try { + raw = await readFile(expanded, 'utf-8'); + } catch { + return { status: 'invalid', reason: `Cannot read file: ${expanded}` }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { status: 'invalid', reason: 'File is not valid JSON' }; + } + + if (needsMigration(parsed, CURRENT_VERSION)) { + parsed = migrateConfig(parsed, CURRENT_VERSION); + } + + const result = SettingsSchema.safeParse(parsed); + if (!result.success) { + return { status: 'invalid', reason: `Invalid config format: ${result.error.issues[0]?.message ?? 'unknown error'}` }; + } + + return { status: 'valid', data: result.data }; +} + +const IMPORT_EXCLUDED_KEYS = ['installation', 'version', 'updatemessage', 'exportedBy'] as const; +type ImportExcludedKey = typeof IMPORT_EXCLUDED_KEYS[number]; + +export function applyImport(current: Settings, imported: Settings, mode: 'replace' | 'merge'): Settings { + const importedClean = Object.fromEntries( + Object.entries(imported).filter(([k]) => !IMPORT_EXCLUDED_KEYS.includes(k as ImportExcludedKey)) + ) as Partial; + + if (mode === 'replace') { + return SettingsSchema.parse({ ...importedClean }); + } + return { ...current, ...importedClean }; +} + export async function saveInstallationMetadata(metadata: InstallationMetadata | undefined): Promise { const paths = getSettingsPaths(); if (!metadata && !fs.existsSync(paths.settingsPath)) {