From a34a632b825a7eed88aac6934afb8e38736fdc69 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Wed, 4 Mar 2026 00:43:05 -0500 Subject: [PATCH] refactor: implement phase 5 utility cleanup with tests --- src/utils/__tests__/config.test.ts | 159 +++++++++++++++++++++++++ src/utils/__tests__/migrations.test.ts | 87 ++++++++++++++ src/utils/__tests__/open-url.test.ts | 37 ++++++ src/utils/__tests__/widgets.test.ts | 22 ++++ src/utils/config.ts | 74 +++++++----- src/utils/migrations.ts | 157 ++++++++++++++++-------- src/utils/open-url.ts | 75 ++++++++---- src/utils/widget-manifest.ts | 67 +++++++++++ src/utils/widgets.ts | 86 ++++--------- 9 files changed, 600 insertions(+), 164 deletions(-) create mode 100644 src/utils/__tests__/config.test.ts create mode 100644 src/utils/__tests__/migrations.test.ts create mode 100644 src/utils/widget-manifest.ts diff --git a/src/utils/__tests__/config.test.ts b/src/utils/__tests__/config.test.ts new file mode 100644 index 0000000..d95f6a2 --- /dev/null +++ b/src/utils/__tests__/config.test.ts @@ -0,0 +1,159 @@ +import * as fs from 'fs'; +import path from 'path'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import { + CURRENT_VERSION, + DEFAULT_SETTINGS, + type Settings +} from '../../types/Settings'; + +const MOCK_HOME_DIR = '/tmp/ccstatusline-config-test-home'; + +vi.mock('os', () => ({ homedir: () => MOCK_HOME_DIR })); + +let loadSettings: () => Promise; +let saveSettings: (settings: Settings) => Promise; +let consoleErrorSpy: ReturnType; + +function getSettingsPaths(): { configDir: string; settingsPath: string; backupPath: string } { + const configDir = path.join(MOCK_HOME_DIR, '.config', 'ccstatusline'); + return { + configDir, + settingsPath: path.join(configDir, 'settings.json'), + backupPath: path.join(configDir, 'settings.bak') + }; +} + +describe('config utilities', () => { + beforeAll(async () => { + const configModule = await import('../config'); + loadSettings = configModule.loadSettings; + saveSettings = configModule.saveSettings; + }); + + beforeEach(() => { + fs.rmSync(MOCK_HOME_DIR, { recursive: true, force: true }); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + afterAll(() => { + fs.rmSync(MOCK_HOME_DIR, { recursive: true, force: true }); + }); + + it('writes defaults when settings file does not exist', async () => { + const { settingsPath } = getSettingsPaths(); + + const settings = await loadSettings(); + + expect(settings.version).toBe(CURRENT_VERSION); + expect(fs.existsSync(settingsPath)).toBe(true); + + const onDisk = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + version?: number; + lines?: unknown[]; + }; + expect(onDisk.version).toBe(CURRENT_VERSION); + expect(Array.isArray(onDisk.lines)).toBe(true); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Default settings written to') + ); + }); + + it('backs up invalid JSON and recovers with defaults', async () => { + const { settingsPath, backupPath, configDir } = getSettingsPaths(); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(settingsPath, '{ invalid json', 'utf-8'); + + const settings = await loadSettings(); + + expect(settings.version).toBe(CURRENT_VERSION); + expect(fs.existsSync(backupPath)).toBe(true); + expect(fs.readFileSync(backupPath, 'utf-8')).toBe('{ invalid json'); + + const recovered = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { version?: number }; + expect(recovered.version).toBe(CURRENT_VERSION); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to parse settings.json, backing up and using defaults' + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Bad settings backed up to') + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Default settings written to') + ); + }); + + it('backs up invalid v1 payloads and recovers with defaults', async () => { + const { settingsPath, backupPath, configDir } = getSettingsPaths(); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify({ flexMode: 123 }), 'utf-8'); + + const settings = await loadSettings(); + + expect(settings.version).toBe(CURRENT_VERSION); + expect(fs.existsSync(backupPath)).toBe(true); + const recovered = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { version?: number }; + expect(recovered.version).toBe(CURRENT_VERSION); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Invalid v1 settings format:', + expect.anything() + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Bad settings backed up to') + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Default settings written to') + ); + }); + + it('migrates older versioned settings and persists migrated result', async () => { + const { settingsPath, configDir } = getSettingsPaths(); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + version: 2, + lines: [[{ id: 'widget-1', type: 'model' }]] + }), + 'utf-8' + ); + + const settings = await loadSettings(); + + expect(settings.version).toBe(CURRENT_VERSION); + const migrated = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + version?: number; + updatemessage?: { message?: string }; + }; + expect(migrated.version).toBe(CURRENT_VERSION); + expect(migrated.updatemessage?.message).toContain('v2.0.2'); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('always saves current version in saveSettings', async () => { + const { settingsPath } = getSettingsPaths(); + + await saveSettings({ + ...DEFAULT_SETTINGS, + version: 1 + }); + + const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { version?: number }; + expect(saved.version).toBe(CURRENT_VERSION); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/migrations.test.ts b/src/utils/__tests__/migrations.test.ts new file mode 100644 index 0000000..aa0bdac --- /dev/null +++ b/src/utils/__tests__/migrations.test.ts @@ -0,0 +1,87 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { + detectVersion, + migrateConfig, + needsMigration +} from '../migrations'; + +describe('migrations', () => { + it('detects version for unknown data and versioned objects', () => { + expect(detectVersion(null)).toBe(1); + expect(detectVersion('invalid')).toBe(1); + expect(detectVersion({})).toBe(1); + expect(detectVersion({ version: 2 })).toBe(2); + }); + + it('reports whether migration is needed', () => { + expect(needsMigration({ version: 2 }, 3)).toBe(true); + expect(needsMigration({ version: 3 }, 3)).toBe(false); + expect(needsMigration({}, 3)).toBe(true); + }); + + it('returns original value for non-record migration input', () => { + expect(migrateConfig('invalid', 3)).toBe('invalid'); + expect(migrateConfig(123, 3)).toBe(123); + }); + + it('migrates v1 to v2 by copying known fields and assigning ids', () => { + const migrated = migrateConfig({ + lines: [[ + { type: 'model', color: 'cyan' }, + { type: 'separator' }, + { type: 'git-branch' } + ]], + flexMode: 'full', + compactThreshold: 70, + colorLevel: 3, + defaultSeparator: '|', + defaultPadding: ' ', + inheritSeparatorColors: true, + overrideBackgroundColor: 'black', + overrideForegroundColor: 'white', + globalBold: true, + unknownField: 'ignored' + }, 2) as Record; + + expect(migrated.version).toBe(2); + expect(migrated.flexMode).toBe('full'); + expect(migrated.compactThreshold).toBe(70); + expect(migrated.colorLevel).toBe(3); + expect(migrated.defaultSeparator).toBe('|'); + expect(migrated.defaultPadding).toBe(' '); + expect(migrated.inheritSeparatorColors).toBe(true); + expect(migrated.overrideBackgroundColor).toBe('black'); + expect(migrated.overrideForegroundColor).toBe('white'); + expect(migrated.globalBold).toBe(true); + expect(migrated.unknownField).toBeUndefined(); + + const lines = migrated.lines as Record[][]; + const firstLine = lines[0]; + expect(Array.isArray(firstLine)).toBe(true); + expect(firstLine?.map(item => item.type)).toEqual(['model', 'git-branch']); + expect(typeof firstLine?.[0]?.id).toBe('string'); + expect(typeof firstLine?.[1]?.id).toBe('string'); + + const updateMessage = migrated.updatemessage as { message?: string; remaining?: number }; + expect(updateMessage.message).toContain('v2.0.0'); + expect(updateMessage.remaining).toBe(12); + }); + + it('applies sequential migrations to reach target version', () => { + const migrated = migrateConfig({ + lines: [[ + { type: 'model' } + ]] + }, 3) as Record; + + expect(migrated.version).toBe(3); + const updateMessage = migrated.updatemessage as { message?: string; remaining?: number }; + expect(updateMessage.message).toContain('v2.0.2'); + expect(updateMessage.remaining).toBe(12); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/open-url.test.ts b/src/utils/__tests__/open-url.test.ts index a06c5e0..455c293 100644 --- a/src/utils/__tests__/open-url.test.ts +++ b/src/utils/__tests__/open-url.test.ts @@ -93,6 +93,43 @@ describe('openExternalUrl', () => { expect(mockSpawnSync.mock.calls.length).toBe(0); }); + it('rejects malformed URLs', () => { + const result = openExternalUrl('not-a-valid-url'); + + expect(result).toEqual({ + success: false, + error: 'Invalid URL' + }); + expect(mockSpawnSync.mock.calls.length).toBe(0); + }); + + it('returns command spawn error details', () => { + vi.spyOn(os, 'platform').mockReturnValue('darwin'); + mockSpawnSync.mockReturnValue({ error: new Error('spawn failed') }); + + const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline'); + + expect(result).toEqual({ + success: false, + error: 'spawn failed' + }); + }); + + it('preserves status-based error formatting when signal is present', () => { + vi.spyOn(os, 'platform').mockReturnValue('darwin'); + mockSpawnSync.mockReturnValue({ + status: null, + signal: 'SIGTERM' + }); + + const result = openExternalUrl('https://github.com/sirmalloc/ccstatusline'); + + expect(result).toEqual({ + success: false, + error: 'Command exited with status null' + }); + }); + it('returns unsupported platform error', () => { vi.spyOn(os, 'platform').mockReturnValue('freebsd'); diff --git a/src/utils/__tests__/widgets.test.ts b/src/utils/__tests__/widgets.test.ts index eb809b9..8cd965e 100644 --- a/src/utils/__tests__/widgets.test.ts +++ b/src/utils/__tests__/widgets.test.ts @@ -11,8 +11,11 @@ import { import type { WidgetItemType } from '../../types/Widget'; import { filterWidgetCatalog, + getAllWidgetTypes, + getWidget, getWidgetCatalog, getWidgetCatalogCategories, + isKnownWidgetType, type WidgetCatalogEntry } from '../widgets'; @@ -87,6 +90,25 @@ describe('widget catalog', () => { expect(categories).toContain('Custom'); expect(categories).toContain('Layout'); }); + + it('returns runtime widget instances for non-layout widget types', () => { + const runtimeTypes = getAllWidgetTypes(baseSettings).filter( + type => type !== 'separator' && type !== 'flex-separator' + ); + + for (const type of runtimeTypes) { + const widget = getWidget(type); + expect(widget).not.toBeNull(); + expect(widget?.getDisplayName().length).toBeGreaterThan(0); + } + }); + + it('recognizes known widget and layout types', () => { + expect(isKnownWidgetType('model')).toBe(true); + expect(isKnownWidgetType('separator')).toBe(true); + expect(isKnownWidgetType('flex-separator')).toBe(true); + expect(isKnownWidgetType('unknown-widget-type')).toBe(false); + }); }); describe('widget catalog filtering', () => { diff --git a/src/utils/config.ts b/src/utils/config.ts index 5d60d79..1c450af 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -19,23 +19,39 @@ const readFile = fs.promises.readFile; const writeFile = fs.promises.writeFile; const mkdir = fs.promises.mkdir; -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'); +interface SettingsPaths { + configDir: string; + settingsPath: string; + settingsBackupPath: string; +} -async function backupBadSettings(): Promise { +function getSettingsPaths(): SettingsPaths { + const configDir = path.join(os.homedir(), '.config', 'ccstatusline'); + return { + configDir, + settingsPath: path.join(configDir, 'settings.json'), + settingsBackupPath: path.join(configDir, 'settings.bak') + }; +} + +async function writeSettingsJson(settings: unknown, paths: SettingsPaths): Promise { + await mkdir(paths.configDir, { recursive: true }); + await writeFile(paths.settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); +} + +async function backupBadSettings(paths: SettingsPaths): 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}`); + if (fs.existsSync(paths.settingsPath)) { + const content = await readFile(paths.settingsPath, 'utf-8'); + await writeFile(paths.settingsBackupPath, content, 'utf-8'); + console.error(`Bad settings backed up to ${paths.settingsBackupPath}`); } } catch (error) { console.error('Failed to backup bad settings:', error); } } -async function writeDefaultSettings(): Promise { +async function writeDefaultSettings(paths: SettingsPaths): Promise { const defaults = SettingsSchema.parse({}); const settingsWithVersion = { ...defaults, @@ -43,9 +59,8 @@ async function writeDefaultSettings(): Promise { }; 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}`); + await writeSettingsJson(settingsWithVersion, paths); + console.error(`Default settings written to ${paths.settingsPath}`); } catch (error) { console.error('Failed to write default settings:', error); } @@ -53,13 +68,20 @@ async function writeDefaultSettings(): Promise { return defaults; } +async function recoverWithDefaults(paths: SettingsPaths): Promise { + await backupBadSettings(paths); + return await writeDefaultSettings(paths); +} + export async function loadSettings(): Promise { + const paths = getSettingsPaths(); + try { // Check if settings file exists - if (!fs.existsSync(SETTINGS_PATH)) - return await writeDefaultSettings(); + if (!fs.existsSync(paths.settingsPath)) + return await writeDefaultSettings(paths); - const content = await readFile(SETTINGS_PATH, 'utf-8'); + const content = await readFile(paths.settingsPath, 'utf-8'); let rawData: unknown; try { @@ -67,8 +89,7 @@ export async function loadSettings(): Promise { } catch { // If we can't parse the JSON, backup and write defaults console.error('Failed to parse settings.json, backing up and using defaults'); - await backupBadSettings(); - return await writeDefaultSettings(); + return await recoverWithDefaults(paths); } // Check if this is a v1 config (no version field) @@ -78,17 +99,16 @@ export async function loadSettings(): Promise { const v1Result = SettingsSchema_v1.safeParse(rawData); if (!v1Result.success) { console.error('Invalid v1 settings format:', v1Result.error); - await backupBadSettings(); - return await writeDefaultSettings(); + return await recoverWithDefaults(paths); } // 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 writeSettingsJson(rawData, paths); } 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 writeSettingsJson(rawData, paths); } // At this point, data should be in current format with version field @@ -96,22 +116,19 @@ export async function loadSettings(): Promise { const result = SettingsSchema.safeParse(rawData); if (!result.success) { console.error('Failed to parse settings:', result.error); - await backupBadSettings(); - return await writeDefaultSettings(); + return await recoverWithDefaults(paths); } return result.data; } catch (error) { // Any other error, backup and write defaults console.error('Error loading settings:', error); - await backupBadSettings(); - return await writeDefaultSettings(); + return await recoverWithDefaults(paths); } } export async function saveSettings(settings: Settings): Promise { - // Ensure config directory exists - await mkdir(CONFIG_DIR, { recursive: true }); + const paths = getSettingsPaths(); // Always include version when saving const settingsWithVersion = { @@ -119,6 +136,5 @@ export async function saveSettings(settings: Settings): Promise { version: CURRENT_VERSION }; - // Write settings using Node.js-compatible API - await writeFile(SETTINGS_PATH, JSON.stringify(settingsWithVersion, null, 2), 'utf-8'); + await writeSettingsJson(settingsWithVersion, paths); } \ No newline at end of file diff --git a/src/utils/migrations.ts b/src/utils/migrations.ts index 35dbc5b..0ebacf7 100644 --- a/src/utils/migrations.ts +++ b/src/utils/migrations.ts @@ -10,11 +10,116 @@ interface Migration { migrate: (data: Record) => Record; } +type V1MigratedField + = | 'flexMode' + | 'compactThreshold' + | 'colorLevel' + | 'defaultSeparator' + | 'defaultPadding' + | 'inheritSeparatorColors' + | 'overrideBackgroundColor' + | 'overrideForegroundColor' + | 'globalBold'; + +interface V1FieldRule { + key: V1MigratedField; + isValid: (value: unknown) => boolean; +} + // Type guards for checking data structure function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +const V1_FIELD_RULES: V1FieldRule[] = [ + { + key: 'flexMode', + isValid: value => typeof value === 'string' + }, + { + key: 'compactThreshold', + isValid: value => typeof value === 'number' + }, + { + key: 'colorLevel', + isValid: value => typeof value === 'number' + }, + { + key: 'defaultSeparator', + isValid: value => typeof value === 'string' + }, + { + key: 'defaultPadding', + isValid: value => typeof value === 'string' + }, + { + key: 'inheritSeparatorColors', + isValid: value => typeof value === 'boolean' + }, + { + key: 'overrideBackgroundColor', + isValid: value => typeof value === 'string' + }, + { + key: 'overrideForegroundColor', + isValid: value => typeof value === 'string' + }, + { + key: 'globalBold', + isValid: value => typeof value === 'boolean' + } +]; + +function toWidgetLine(line: unknown[], stripSeparators: boolean): WidgetItem[] { + const lineToProcess = stripSeparators + ? line.filter((item) => { + if (isRecord(item)) { + return item.type !== 'separator'; + } + return true; + }) + : line; + + const typedLine: WidgetItem[] = []; + for (const item of lineToProcess) { + if (isRecord(item) && typeof item.type === 'string') { + typedLine.push({ + ...item, + id: generateGuid(), + type: item.type + } as WidgetItem); + } + } + + return typedLine; +} + +function migrateV1Lines(data: Record): WidgetItem[][] | undefined { + if (!Array.isArray(data.lines)) { + return undefined; + } + + const stripSeparators = Boolean(data.defaultSeparator); + const processedLines: WidgetItem[][] = []; + + for (const line of data.lines) { + if (Array.isArray(line)) { + processedLines.push(toWidgetLine(line, stripSeparators)); + } + } + + return processedLines; +} + +function copyV1Fields(data: Record, target: Record): void { + for (const rule of V1_FIELD_RULES) { + const value = data[rule.key]; + if (rule.isValid(value)) { + target[rule.key] = value; + } + } +} + // Define all migrations here export const migrations: Migration[] = [ { @@ -26,59 +131,13 @@ export const migrations: Migration[] = [ const migrated: Record = {}; // Process lines: strip separators if needed and assign GUIDs - if (data.lines && Array.isArray(data.lines)) { - const processedLines: WidgetItem[][] = []; - - for (const line of data.lines) { - if (Array.isArray(line)) { - // Filter out separators if defaultSeparator is enabled - let processedLine = line; - if (data.defaultSeparator) { - processedLine = line.filter((item: unknown) => { - if (isRecord(item)) { - return item.type !== 'separator'; - } - return true; - }); - } - - // Assign GUIDs to all items and build typed array - const typedLine: WidgetItem[] = []; - for (const item of processedLine) { - if (isRecord(item) && typeof item.type === 'string') { - typedLine.push({ - ...item, - id: generateGuid(), - type: item.type - } as WidgetItem); - } - } - processedLines.push(typedLine); - } - } - + const processedLines = migrateV1Lines(data); + if (processedLines) { migrated.lines = processedLines; } // Copy all v1 fields that exist - if (typeof data.flexMode === 'string') - migrated.flexMode = data.flexMode; - if (typeof data.compactThreshold === 'number') - migrated.compactThreshold = data.compactThreshold; - if (typeof data.colorLevel === 'number') - migrated.colorLevel = data.colorLevel; - if (typeof data.defaultSeparator === 'string') - migrated.defaultSeparator = data.defaultSeparator; - if (typeof data.defaultPadding === 'string') - migrated.defaultPadding = data.defaultPadding; - if (typeof data.inheritSeparatorColors === 'boolean') - migrated.inheritSeparatorColors = data.inheritSeparatorColors; - if (typeof data.overrideBackgroundColor === 'string') - migrated.overrideBackgroundColor = data.overrideBackgroundColor; - if (typeof data.overrideForegroundColor === 'string') - migrated.overrideForegroundColor = data.overrideForegroundColor; - if (typeof data.globalBold === 'boolean') - migrated.globalBold = data.globalBold; + copyV1Fields(data, migrated); // Add version field for v2 migrated.version = 2; diff --git a/src/utils/open-url.ts b/src/utils/open-url.ts index 54a9234..c20409f 100644 --- a/src/utils/open-url.ts +++ b/src/utils/open-url.ts @@ -6,6 +6,12 @@ export interface OpenExternalUrlResult { error?: string; } +interface OpenCommandPlan { + command: string; + args: (url: string) => string[]; + errorPrefix?: string; +} + function runOpenCommand(command: string, args: string[]): string | null { const result = spawnSync(command, args, { stdio: 'ignore', @@ -27,6 +33,33 @@ function runOpenCommand(command: string, args: string[]): string | null { return null; } +const PLATFORM_OPEN_PLANS: Record = { + darwin: [ + { + command: 'open', + args: url => [url] + } + ], + win32: [ + { + command: 'cmd', + args: url => ['/c', 'start', '', url] + } + ], + linux: [ + { + command: 'xdg-open', + args: url => [url], + errorPrefix: 'xdg-open failed: ' + }, + { + command: 'gio', + args: url => ['open', url], + errorPrefix: 'gio open failed: ' + } + ] +}; + export function openExternalUrl(url: string): OpenExternalUrlResult { let parsedUrl: URL; @@ -47,36 +80,30 @@ export function openExternalUrl(url: string): OpenExternalUrlResult { } const platform = os.platform(); - - if (platform === 'darwin') { - const commandError = runOpenCommand('open', [url]); - return commandError ? { success: false, error: commandError } : { success: true }; - } - - if (platform === 'win32') { - const commandError = runOpenCommand('cmd', ['/c', 'start', '', url]); - return commandError ? { success: false, error: commandError } : { success: true }; - } - - if (platform === 'linux') { - const xdgError = runOpenCommand('xdg-open', [url]); - if (!xdgError) { - return { success: true }; - } - - const gioError = runOpenCommand('gio', ['open', url]); - if (!gioError) { - return { success: true }; - } - + const plans = PLATFORM_OPEN_PLANS[platform]; + if (!plans) { return { success: false, - error: `xdg-open failed: ${xdgError}; gio open failed: ${gioError}` + error: `Unsupported platform: ${platform}` }; } + const errors: string[] = []; + for (const plan of plans) { + const commandError = runOpenCommand(plan.command, plan.args(url)); + if (!commandError) { + return { success: true }; + } + + if (plan.errorPrefix) { + errors.push(`${plan.errorPrefix}${commandError}`); + } else { + errors.push(commandError); + } + } + return { success: false, - error: `Unsupported platform: ${platform}` + error: errors.join('; ') }; } \ No newline at end of file diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts new file mode 100644 index 0000000..96062d8 --- /dev/null +++ b/src/utils/widget-manifest.ts @@ -0,0 +1,67 @@ +import type { + Widget, + WidgetItemType +} from '../types/Widget'; +import * as widgets from '../widgets'; + +export interface WidgetManifestEntry { + type: WidgetItemType; + create: () => Widget; +} + +export interface LayoutWidgetManifestEntry { + type: WidgetItemType; + displayName: string; + description: string; + category: string; +} + +export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ + { type: 'model', create: () => new widgets.ModelWidget() }, + { type: 'output-style', create: () => new widgets.OutputStyleWidget() }, + { type: 'git-branch', create: () => new widgets.GitBranchWidget() }, + { type: 'git-changes', create: () => new widgets.GitChangesWidget() }, + { type: 'git-insertions', create: () => new widgets.GitInsertionsWidget() }, + { type: 'git-deletions', create: () => new widgets.GitDeletionsWidget() }, + { type: 'git-root-dir', create: () => new widgets.GitRootDirWidget() }, + { type: 'git-worktree', create: () => new widgets.GitWorktreeWidget() }, + { type: 'current-working-dir', create: () => new widgets.CurrentWorkingDirWidget() }, + { type: 'tokens-input', create: () => new widgets.TokensInputWidget() }, + { type: 'tokens-output', create: () => new widgets.TokensOutputWidget() }, + { type: 'tokens-cached', create: () => new widgets.TokensCachedWidget() }, + { type: 'tokens-total', create: () => new widgets.TokensTotalWidget() }, + { type: 'context-length', create: () => new widgets.ContextLengthWidget() }, + { type: 'context-percentage', create: () => new widgets.ContextPercentageWidget() }, + { type: 'context-percentage-usable', create: () => new widgets.ContextPercentageUsableWidget() }, + { type: 'session-clock', create: () => new widgets.SessionClockWidget() }, + { type: 'session-cost', create: () => new widgets.SessionCostWidget() }, + { type: 'block-timer', create: () => new widgets.BlockTimerWidget() }, + { type: 'terminal-width', create: () => new widgets.TerminalWidthWidget() }, + { type: 'version', create: () => new widgets.VersionWidget() }, + { type: 'custom-text', create: () => new widgets.CustomTextWidget() }, + { type: 'custom-command', create: () => new widgets.CustomCommandWidget() }, + { type: 'link', create: () => new widgets.LinkWidget() }, + { type: 'claude-session-id', create: () => new widgets.ClaudeSessionIdWidget() }, + { type: 'session-name', create: () => new widgets.SessionNameWidget() }, + { type: 'free-memory', create: () => new widgets.FreeMemoryWidget() }, + { type: 'session-usage', create: () => new widgets.SessionUsageWidget() }, + { type: 'weekly-usage', create: () => new widgets.WeeklyUsageWidget() }, + { type: 'reset-timer', create: () => new widgets.BlockResetTimerWidget() }, + { type: 'weekly-reset-timer', create: () => new widgets.WeeklyResetTimerWidget() }, + { type: 'context-bar', create: () => new widgets.ContextBarWidget() } +]; + +export const LAYOUT_WIDGET_MANIFEST: LayoutWidgetManifestEntry[] = [ + { + type: 'separator', + displayName: 'Separator', + description: 'A separator character between status line widgets', + category: 'Layout' + }, + { + type: 'flex-separator', + displayName: 'Flex Separator', + description: 'Expands to fill available terminal width', + category: 'Layout' + } +]; \ No newline at end of file diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 05d5a7c..1631cca 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -3,50 +3,24 @@ import type { Widget, WidgetItemType } from '../types/Widget'; -import * as widgets from '../widgets'; + +import { + LAYOUT_WIDGET_MANIFEST, + WIDGET_MANIFEST +} from './widget-manifest'; // Create widget registry -const widgetRegistry = new Map([ - ['model', new widgets.ModelWidget()], - ['output-style', new widgets.OutputStyleWidget()], - ['git-branch', new widgets.GitBranchWidget()], - ['git-changes', new widgets.GitChangesWidget()], - ['git-insertions', new widgets.GitInsertionsWidget()], - ['git-deletions', new widgets.GitDeletionsWidget()], - ['git-root-dir', new widgets.GitRootDirWidget()], - ['git-worktree', new widgets.GitWorktreeWidget()], - ['current-working-dir', new widgets.CurrentWorkingDirWidget()], - ['tokens-input', new widgets.TokensInputWidget()], - ['tokens-output', new widgets.TokensOutputWidget()], - ['tokens-cached', new widgets.TokensCachedWidget()], - ['tokens-total', new widgets.TokensTotalWidget()], - ['context-length', new widgets.ContextLengthWidget()], - ['context-percentage', new widgets.ContextPercentageWidget()], - ['context-percentage-usable', new widgets.ContextPercentageUsableWidget()], - ['session-clock', new widgets.SessionClockWidget()], - ['session-cost', new widgets.SessionCostWidget()], - ['block-timer', new widgets.BlockTimerWidget()], - ['terminal-width', new widgets.TerminalWidthWidget()], - ['version', new widgets.VersionWidget()], - ['custom-text', new widgets.CustomTextWidget()], - ['custom-command', new widgets.CustomCommandWidget()], - ['link', new widgets.LinkWidget()], - ['claude-session-id', new widgets.ClaudeSessionIdWidget()], - ['session-name', new widgets.SessionNameWidget()], - ['free-memory', new widgets.FreeMemoryWidget()], - ['session-usage', new widgets.SessionUsageWidget()], - ['weekly-usage', new widgets.WeeklyUsageWidget()], - ['reset-timer', new widgets.BlockResetTimerWidget()], - ['weekly-reset-timer', new widgets.WeeklyResetTimerWidget()], - ['context-bar', new widgets.ContextBarWidget()] -]); +const widgetRegistry = new Map( + WIDGET_MANIFEST.map((entry): [WidgetItemType, Widget] => [entry.type, entry.create()]) +); +const layoutWidgetTypes = new Set(LAYOUT_WIDGET_MANIFEST.map(entry => entry.type)); export function getWidget(type: WidgetItemType): Widget | null { return widgetRegistry.get(type) ?? null; } export function getAllWidgetTypes(settings: Settings): WidgetItemType[] { - const allTypes = Array.from(widgetRegistry.keys()); + const allTypes = WIDGET_MANIFEST.map(entry => entry.type); // Add separator types based on settings if (!settings.powerline.enabled) { @@ -67,32 +41,21 @@ export interface WidgetCatalogEntry { searchText: string; } -const LAYOUT_WIDGETS: Record> = { - 'separator': { - displayName: 'Separator', - description: 'A separator character between status line widgets', - category: 'Layout' - }, - 'flex-separator': { - displayName: 'Flex Separator', - description: 'Expands to fill available terminal width', - category: 'Layout' - } -}; +const layoutCatalogEntries = new Map( + LAYOUT_WIDGET_MANIFEST.map((entry): [WidgetItemType, WidgetCatalogEntry] => [ + entry.type, + { + type: entry.type, + displayName: entry.displayName, + description: entry.description, + category: entry.category, + searchText: `${entry.displayName} ${entry.description} ${entry.type}`.toLowerCase() + } + ]) +); function getLayoutCatalogEntry(type: WidgetItemType): WidgetCatalogEntry | null { - const layout = LAYOUT_WIDGETS[type]; - if (!layout) { - return null; - } - - return { - type, - displayName: layout.displayName, - description: layout.description, - category: layout.category, - searchText: `${layout.displayName} ${layout.description} ${type}`.toLowerCase() - }; + return layoutCatalogEntries.get(type) ?? null; } export function getWidgetCatalog(settings: Settings): WidgetCatalogEntry[] { @@ -185,6 +148,5 @@ export function filterWidgetCatalog(catalog: WidgetCatalogEntry[], category: str export function isKnownWidgetType(type: string): boolean { return widgetRegistry.has(type) - || type === 'separator' - || type === 'flex-separator'; + || layoutWidgetTypes.has(type); } \ No newline at end of file