diff --git a/.claude/ccstatusline.json b/.claude/ccstatusline.json new file mode 100644 index 0000000..34c235d --- /dev/null +++ b/.claude/ccstatusline.json @@ -0,0 +1,56 @@ +{ + "version": 3, + "lines": [ + [ + { + "id": "1", + "type": "model", + "color": "cyan" + }, + { + "id": "2", + "type": "separator" + }, + { + "id": "3", + "type": "context-length", + "color": "brightBlack" + }, + { + "id": "4", + "type": "separator" + }, + { + "id": "5", + "type": "git-branch", + "color": "magenta" + }, + { + "id": "6", + "type": "separator" + }, + { + "id": "7", + "type": "git-changes", + "color": "yellow" + } + ] + ], + "flexMode": "full-minus-40", + "compactThreshold": 60, + "colorLevel": 2, + "inheritSeparatorColors": false, + "globalBold": false, + "powerline": { + "enabled": false, + "separators": [ + "" + ], + "separatorInvertBackground": [ + false + ], + "startCaps": [], + "endCaps": [], + "autoAlign": false + } +} \ No newline at end of file diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 3025624..dac6a6a 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -178,7 +178,8 @@ export const App: React.FC = () => { handleInstallUninstall(); break; case 'save': - await saveSettings(settings); + case 'saveLocally': + await saveSettings(settings, value === 'saveLocally' ? 'project' : 'global'); setOriginalSettings(JSON.parse(JSON.stringify(settings)) as Settings); // Update original after save setHasChanges(false); exit(); @@ -230,7 +231,7 @@ export const App: React.FC = () => { { // Only persist menu selection if not exiting - if (value !== 'save' && value !== 'exit') { + if (value !== 'save' && value !== 'exit' && value !== 'saveLocally') { const menuMap: Record = { lines: 0, colors: 1, diff --git a/src/tui/components/MainMenu.tsx b/src/tui/components/MainMenu.tsx index 5fe8cf0..16af184 100644 --- a/src/tui/components/MainMenu.tsx +++ b/src/tui/components/MainMenu.tsx @@ -6,6 +6,7 @@ import { import React, { useState } from 'react'; import type { Settings } from '../../types/Settings'; +import { getSettingsConfiguration } from '../../utils/config'; import { type PowerlineFontStatus } from '../../utils/powerline'; export interface MainMenuProps { @@ -21,6 +22,8 @@ export interface MainMenuProps { export const MainMenu: React.FC = ({ onSelect, isClaudeInstalled, hasChanges, initialSelection = 0, powerlineFontStatus, settings, previewIsTruncated }) => { const [selectedIndex, setSelectedIndex] = useState(initialSelection); + const settingsConfiguration = getSettingsConfiguration(); + // Build menu structure with visual gaps const menuItems = [ { label: '📝 Edit Lines', value: 'lines', selectable: true }, @@ -34,10 +37,13 @@ export const MainMenu: React.FC = ({ onSelect, isClaudeInstalled, ]; if (hasChanges) { - menuItems.push( - { label: '💾 Save & Exit', value: 'save', selectable: true }, - { label: '❌ Exit without saving', value: 'exit', selectable: true } - ); + menuItems.push({ label: '💾 Save & Exit', value: 'save', selectable: true }); + + if (settingsConfiguration.type === 'global') { + menuItems.push({ label: '📁 Save Locally & Exit', value: 'saveLocally', selectable: true }); + } + + menuItems.push({ label: '❌ Exit without saving', value: 'exit', selectable: true }); } else { menuItems.push({ label: '🚪 Exit', value: 'exit', selectable: true }); } @@ -70,6 +76,7 @@ export const MainMenu: React.FC = ({ onSelect, isClaudeInstalled, : 'Add ccstatusline to your Claude Code settings for automatic status line rendering', terminalConfig: 'Configure terminal-specific settings for optimal display', save: 'Save all changes and exit the configuration tool', + saveLocally: 'Save all changes to .claude/ccstatusline.json, which will be used by default for this directory going forwards', exit: hasChanges ? 'Exit without saving your changes' : 'Exit the configuration tool' @@ -90,7 +97,13 @@ export const MainMenu: React.FC = ({ onSelect, isClaudeInstalled, ⚠ Some lines are truncated, see Terminal Options → Terminal Width for info )} - Main Menu + + Main Menu + + {' '} + {settingsConfiguration.relativePath} + + {menuItems.map((item, idx) => { if (!item.selectable && item.value.startsWith('_gap')) { diff --git a/src/utils/__tests__/config.test.ts b/src/utils/__tests__/config.test.ts new file mode 100644 index 0000000..0e9fd14 --- /dev/null +++ b/src/utils/__tests__/config.test.ts @@ -0,0 +1,138 @@ +import * as fs from 'fs'; +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import { + CURRENT_VERSION, + SettingsSchema +} from '../../types/Settings'; +import { + getSettingsConfiguration, + loadSettings, + saveSettings +} from '../config'; + +vi.mock('os', () => ({ homedir: vi.fn().mockReturnValue('/some-home-dir') })); + +vi.mock('fs', () => ({ + existsSync: vi.fn(), + promises: { + mkdir: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn() + } +})); + +const globalConfig = '/some-home-dir/.config/ccstatusline/settings.json'; +const projectConfig = '/some-project-dir/.claude/ccstatusline.json'; + +function setGlobalConfig() { + vi.mocked(fs.existsSync).mockImplementation(path => path === globalConfig); +} + +function setProjectConfig() { + vi.mocked(fs.existsSync).mockImplementation(path => path === projectConfig); +} + +describe('config', () => { + beforeEach(() => { + setProjectConfig(); + + vi.clearAllMocks(); + vi.spyOn(process, 'cwd').mockReturnValue('/some-project-dir'); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.promises.readFile).mockResolvedValue('{}'); + vi.mocked(fs.promises.mkdir).mockResolvedValue(undefined); + }); + + it('should return project config', () => { + setProjectConfig(); + + const configuration = getSettingsConfiguration(); + expect(configuration.type).toBe('project'); + expect(configuration.relativePath).toBe('.claude/ccstatusline.json'); + expect(configuration.path).toBe(projectConfig); + }); + + it('should return global config', () => { + setGlobalConfig(); + + const configuration = getSettingsConfiguration(); + expect(configuration.type).toBe('global'); + expect(configuration.relativePath).toBe('~/.config/ccstatusline/settings.json'); + expect(configuration.path).toBe(globalConfig); + }); + + it('should write default settings', async () => { + // Results in global config + vi.mocked(fs.existsSync).mockReturnValue(false); + + const settings = await loadSettings(); + const defaultSettings = SettingsSchema.parse({}); + + expect(vi.mocked(fs.promises.writeFile)).toHaveBeenCalledWith(globalConfig, JSON.stringify(defaultSettings, null, 2), 'utf-8'); + + expect(settings.version).toBe(CURRENT_VERSION); + }); + + it('should backup bad settings', async () => { + vi.mocked(fs.promises.readFile).mockResolvedValue('invalid'); + + const backupPath = '/some-project-dir/.claude/ccstatusline.json.bak'; + + const settings = await loadSettings(); + + expect(vi.mocked(fs.promises.writeFile)).toHaveBeenCalledWith(backupPath, 'invalid', 'utf-8'); + expect(settings.version).toBe(CURRENT_VERSION); + }); + + it('should save settings to default location - global', async () => { + setGlobalConfig(); + + const settings = await loadSettings(); + await saveSettings(settings); + + expect(vi.mocked(fs.promises.writeFile)).toHaveBeenCalledWith(globalConfig, JSON.stringify(settings, null, 2), 'utf-8'); + }); + + it('should save settings to default location - project', async () => { + setProjectConfig(); + + const settings = await loadSettings(); + await saveSettings(settings); + + expect(vi.mocked(fs.promises.writeFile)).toHaveBeenCalledWith(projectConfig, JSON.stringify(settings, null, 2), 'utf-8'); + }); + + it('should save settings to specified location - global', async () => { + setProjectConfig(); + + const config = getSettingsConfiguration(); + expect(config.type).toBe('project'); + + const settings = await loadSettings(); + + await saveSettings(settings, 'global'); + + expect(vi.mocked(fs.promises.writeFile)).toHaveBeenCalledWith(globalConfig, JSON.stringify(settings, null, 2), 'utf-8'); + }); + + it('should save settings to specified location - project', async () => { + setGlobalConfig(); + + const config = getSettingsConfiguration(); + expect(config.type).toBe('global'); + + const settings = await loadSettings(); + + await saveSettings(settings, 'project'); + + expect(vi.mocked(fs.promises.writeFile)).toHaveBeenCalledWith(projectConfig, JSON.stringify(settings, null, 2), 'utf-8'); + }); +}); \ No newline at end of file diff --git a/src/utils/config.ts b/src/utils/config.ts index 5d60d79..c4a90cb 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -15,20 +15,43 @@ import { } from './migrations'; // Use fs.promises directly (always available in modern Node.js) -const readFile = fs.promises.readFile; -const writeFile = fs.promises.writeFile; -const mkdir = fs.promises.mkdir; +export const mkdir = async (path: string) => fs.promises.mkdir(path, { recursive: true }); +export const readFile = async (path: string) => fs.promises.readFile(path, 'utf-8'); +export const writeFile = async (path: string, content: string) => fs.promises.writeFile(path, content, 'utf-8'); + +export function getSettingsConfiguration(type?: 'global' | 'project') { + const projectConfig = path.join(process.cwd(), '.claude', 'ccstatusline.json'); + + if ((type === 'project') || (!type && fs.existsSync(projectConfig))) { + return { + configDir: path.dirname(projectConfig), + path: projectConfig, + relativePath: path.relative(process.cwd(), projectConfig), + type: 'project' + }; + } + + const userConfigDir = path.join(os.homedir(), '.config', 'ccstatusline'); + const userConfig = path.join(userConfigDir, 'settings.json'); -const CONFIG_DIR = path.join(os.homedir(), '.config', 'ccstatusline'); -const SETTINGS_PATH = path.join(CONFIG_DIR, 'settings.json'); -const SETTINGS_BACKUP_PATH = path.join(CONFIG_DIR, 'settings.bak'); + // Fallback to global config + return { + configDir: userConfigDir, + path: userConfig, + relativePath: '~/' + path.relative(os.homedir(), userConfig), + type: 'global' + }; +} async function backupBadSettings(): Promise { try { - if (fs.existsSync(SETTINGS_PATH)) { - const content = await readFile(SETTINGS_PATH, 'utf-8'); - await writeFile(SETTINGS_BACKUP_PATH, content, 'utf-8'); - console.error(`Bad settings backed up to ${SETTINGS_BACKUP_PATH}`); + const { path: settingsPath } = getSettingsConfiguration(); + const settingsBackupPath = settingsPath.replace('.json', '.json.bak'); + + if (fs.existsSync(settingsPath)) { + const content = await readFile(settingsPath); + await writeFile(settingsBackupPath, content); + console.error(`Bad settings backed up to ${settingsBackupPath}`); } } catch (error) { console.error('Failed to backup bad settings:', error); @@ -37,15 +60,10 @@ async function backupBadSettings(): Promise { async function writeDefaultSettings(): Promise { const defaults = SettingsSchema.parse({}); - const settingsWithVersion = { - ...defaults, - version: CURRENT_VERSION - }; try { - await mkdir(CONFIG_DIR, { recursive: true }); - await writeFile(SETTINGS_PATH, JSON.stringify(settingsWithVersion, null, 2), 'utf-8'); - console.error(`Default settings written to ${SETTINGS_PATH}`); + const { path: settingsPath } = await saveSettings(defaults); + console.error(`Default settings written to ${settingsPath}`); } catch (error) { console.error('Failed to write default settings:', error); } @@ -55,11 +73,13 @@ async function writeDefaultSettings(): Promise { export async function loadSettings(): Promise { try { + const { path: settingsPath } = getSettingsConfiguration(); + // Check if settings file exists - if (!fs.existsSync(SETTINGS_PATH)) + if (!fs.existsSync(settingsPath)) return await writeDefaultSettings(); - const content = await readFile(SETTINGS_PATH, 'utf-8'); + const content = await readFile(settingsPath); let rawData: unknown; try { @@ -84,16 +104,17 @@ export async function loadSettings(): Promise { // Migrate v1 to current version and save the migrated settings back to disk rawData = migrateConfig(rawData, CURRENT_VERSION); - await writeFile(SETTINGS_PATH, JSON.stringify(rawData, null, 2), 'utf-8'); + await writeFile(settingsPath, JSON.stringify(rawData, null, 2)); } else if (needsMigration(rawData, CURRENT_VERSION)) { // Handle migrations for versioned configs (v2+) and save the migrated settings back to disk rawData = migrateConfig(rawData, CURRENT_VERSION); - await writeFile(SETTINGS_PATH, JSON.stringify(rawData, null, 2), 'utf-8'); + await writeFile(settingsPath, JSON.stringify(rawData, null, 2)); } // At this point, data should be in current format with version field // Parse with main schema which will apply all defaults const result = SettingsSchema.safeParse(rawData); + if (!result.success) { console.error('Failed to parse settings:', result.error); await backupBadSettings(); @@ -109,9 +130,11 @@ export async function loadSettings(): Promise { } } -export async function saveSettings(settings: Settings): Promise { +export async function saveSettings(settings: Settings, type?: 'global' | 'project') { + const { path, configDir } = getSettingsConfiguration(type); + // Ensure config directory exists - await mkdir(CONFIG_DIR, { recursive: true }); + await mkdir(configDir); // Always include version when saving const settingsWithVersion = { @@ -120,5 +143,10 @@ export async function saveSettings(settings: Settings): Promise { }; // Write settings using Node.js-compatible API - await writeFile(SETTINGS_PATH, JSON.stringify(settingsWithVersion, null, 2), 'utf-8'); + await writeFile(path, JSON.stringify(settingsWithVersion, null, 2)); + + return { + settings: settingsWithVersion, + path + }; } \ No newline at end of file