mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-10-14 16:17:01 +00:00
refactor: implement phase 5 utility cleanup with tests
This commit is contained in:
1 parent
25b69228dd
commit
a34a632b82
9 files changed
+600
-164
No files matched your search
@@ -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<Settings>;
|
||||
let saveSettings: (settings: Settings) => Promise<void>;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<string, unknown>[][];
|
||||
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<string, unknown>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+45
-29
@@ -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<void> {
|
||||
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<void> {
|
||||
await mkdir(paths.configDir, { recursive: true });
|
||||
await writeFile(paths.settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
async function backupBadSettings(paths: SettingsPaths): Promise<void> {
|
||||
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<Settings> {
|
||||
async function writeDefaultSettings(paths: SettingsPaths): Promise<Settings> {
|
||||
const defaults = SettingsSchema.parse({});
|
||||
const settingsWithVersion = {
|
||||
...defaults,
|
||||
@@ -43,9 +59,8 @@ async function writeDefaultSettings(): Promise<Settings> {
|
||||
};
|
||||
|
||||
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<Settings> {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
async function recoverWithDefaults(paths: SettingsPaths): Promise<Settings> {
|
||||
await backupBadSettings(paths);
|
||||
return await writeDefaultSettings(paths);
|
||||
}
|
||||
|
||||
export async function loadSettings(): Promise<Settings> {
|
||||
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<Settings> {
|
||||
} 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<Settings> {
|
||||
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<Settings> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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);
|
||||
}
|
||||
+108
-49
@@ -10,11 +10,116 @@ interface Migration {
|
||||
migrate: (data: Record<string, unknown>) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
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<string, unknown>): 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<string, unknown>, target: Record<string, unknown>): 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<string, unknown> = {};
|
||||
|
||||
// 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;
|
||||
|
||||
+51
-24
@@ -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<string, OpenCommandPlan[]> = {
|
||||
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('; ')
|
||||
};
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
+24
-62
@@ -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<WidgetItemType, Widget>([
|
||||
['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<WidgetItemType, Widget>(
|
||||
WIDGET_MANIFEST.map((entry): [WidgetItemType, Widget] => [entry.type, entry.create()])
|
||||
);
|
||||
const layoutWidgetTypes = new Set<WidgetItemType>(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<string, Omit<WidgetCatalogEntry, 'type' | 'searchText'>> = {
|
||||
'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<WidgetItemType, WidgetCatalogEntry>(
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user