refactor: implement phase 5 utility cleanup with tests

This commit is contained in:
Matthew Breedlove committed 2026-03-04 00:43:05 -05:00
1 parent 25b69228dd
commit a34a632b82
9 files changed
+600 -164

No files matched your search

+159
View File
@@ -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();
});
});
+87
View File
@@ -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);
});
});
+37
View File
@@ -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');
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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('; ')
};
}
+67
View File
@@ -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
View File
@@ -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);
}