diff --git a/docs/USAGE.md b/docs/USAGE.md index 2496063ec..52a526fb8 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -122,6 +122,7 @@ Configure global formatting preferences that apply to all widgets: - **Default Padding** - Add consistent padding around each widget - **Padding Side** - Choose whether default padding applies to **Both** sides (default), **Left only**, or **Right only** +- Turning Powerline on sets Default Padding to one space. Turning Powerline off puts back the padding from before, unless you changed it while Powerline was on. - **Default Separator** - Automatically insert a separator between all widgets - Press **(p)** to edit padding - Press **(d)** to cycle padding side diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 2a143d115..b550575f1 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -77,6 +77,7 @@ import { checkPowerlineFonts, checkPowerlineFontsAsync, installPowerlineFonts, + type FontInstallResult, type PowerlineFontStatus } from '../utils/powerline'; import { getPackageVersion } from '../utils/terminal'; @@ -565,7 +566,7 @@ export const App: React.FC = () => { const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80); const [powerlineFontStatus, setPowerlineFontStatus] = useState({ installed: false }); const [installingFonts, setInstallingFonts] = useState(false); - const [fontInstallMessage, setFontInstallMessage] = useState(null); + const [fontInstallMessage, setFontInstallMessage] = useState(null); const [existingStatusLine, setExistingStatusLine] = useState(null); const [flashMessage, setFlashMessage] = useState(null); const [currentRefreshInterval, setCurrentRefreshInterval] = useState(null); @@ -1646,7 +1647,7 @@ export const App: React.FC = () => { setTimeout(() => { void installPowerlineFonts().then((result) => { setInstallingFonts(false); - setFontInstallMessage(result.message); + setFontInstallMessage(result); // Refresh font status void checkPowerlineFontsAsync().then((asyncStatus) => { setPowerlineFontStatus(asyncStatus); diff --git a/src/tui/components/PowerlineSetup.tsx b/src/tui/components/PowerlineSetup.tsx index 2ef1f9dbe..4778a6ea1 100644 --- a/src/tui/components/PowerlineSetup.tsx +++ b/src/tui/components/PowerlineSetup.tsx @@ -9,8 +9,14 @@ import React, { useState } from 'react'; import type { PowerlineConfig } from '../../types/PowerlineConfig'; import type { Settings } from '../../types/Settings'; import { getPlainInput } from '../../utils/input-guards'; -import { type PowerlineFontStatus } from '../../utils/powerline'; -import { buildEnabledPowerlineSettings } from '../../utils/powerline-settings'; +import { + type FontInstallResult, + type PowerlineFontStatus +} from '../../utils/powerline'; +import { + buildDisabledPowerlineSettings, + buildEnabledPowerlineSettings +} from '../../utils/powerline-settings'; import { ConfirmDialog } from './ConfirmDialog'; import { @@ -103,6 +109,12 @@ export function getThemeDisplay(powerlineConfig: PowerlineConfig): string { return theme.charAt(0).toUpperCase() + theme.slice(1); } +// From the install's own result, not its wording: the Windows success message +// doesn't say "success" +export function getFontInstallMessageColor(result: FontInstallResult): 'green' | 'red' { + return result.success ? 'green' : 'red'; +} + export function buildPowerlineSetupMenuItems( powerlineConfig: PowerlineConfig ): ListEntry[] { @@ -147,7 +159,7 @@ export interface PowerlineSetupProps { onBack: () => void; onInstallFonts: () => void; installingFonts: boolean; - fontInstallMessage: string | null; + fontInstallMessage: FontInstallResult | null; onClearMessage: () => void; } @@ -197,13 +209,7 @@ export const PowerlineSetup: React.FC = ({ onUpdate(buildEnabledPowerlineSettings(settings)); } } else { - onUpdate({ - ...settings, - powerline: { - ...powerlineConfig, - enabled: false - } - }); + onUpdate(buildDisabledPowerlineSettings(settings)); } } else if (shortcut === 'i' || shortcut === 'I') { setConfirmingFontInstall(true); @@ -381,8 +387,8 @@ export const PowerlineSetup: React.FC = ({ ) : fontInstallMessage ? ( - - {fontInstallMessage} + + {fontInstallMessage.message} Press any key to continue... diff --git a/src/tui/components/__tests__/PowerlineSetup.test.ts b/src/tui/components/__tests__/PowerlineSetup.test.ts index 493c5e9ce..c31a1b0a3 100644 --- a/src/tui/components/__tests__/PowerlineSetup.test.ts +++ b/src/tui/components/__tests__/PowerlineSetup.test.ts @@ -22,6 +22,7 @@ import { PowerlineSetup, buildPowerlineSetupMenuItems, getCapDisplay, + getFontInstallMessageColor, getSeparatorDisplay, getThemeDisplay, type PowerlineSetupProps @@ -87,6 +88,22 @@ describe('PowerlineSetup helpers', () => { expect(getThemeDisplay(config)).toBe('Gruvbox'); }); + // The Windows success message doesn't say "success" + it('colors a font install message by whether the install worked', () => { + expect(getFontInstallMessageColor({ + success: true, + message: 'Installed 3 Powerline fonts. Please restart your terminal and select a Powerline font from your terminal settings.' + })).toBe('green'); + expect(getFontInstallMessageColor({ + success: true, + message: 'Powerline fonts installed successfully! Please restart your terminal and select a Powerline font' + })).toBe('green'); + expect(getFontInstallMessageColor({ + success: false, + message: 'Failed to install Powerline fonts: No fonts were installed. You can manually install fonts from: https://github.com/powerline/fonts' + })).toBe('red'); + }); + it('builds powerline setup items with disabled states and sublabels', () => { const disabledItems = buildPowerlineSetupMenuItems({ ...DEFAULT_SETTINGS.powerline, @@ -181,6 +198,318 @@ describe('PowerlineSetup helpers', () => { } }); + it('keeps a chosen Custom theme when (t) turns Powerline off and on again', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + // Each update is drawn again with the new settings, as the app does + const onUpdate = vi.fn((settings) => { + instance.rerender(React.createElement(PowerlineSetup, { ...props, settings })); + }); + const props: PowerlineSetupProps = { + settings: { + ...DEFAULT_SETTINGS, + lines: [[{ id: '1', type: 'model', color: 'red', backgroundColor: 'bgYellow' }]], + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: 'custom' + } + }, + powerlineFontStatus: { installed: true }, + onUpdate, + onBack: vi.fn(), + onInstallFonts: vi.fn(), + installingFonts: false, + fontInstallMessage: null, + onClearMessage: vi.fn() + }; + const instance = render( + React.createElement(PowerlineSetup, props), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await waitFor(() => { + expect(stdout.getOutput()).toContain('✓ Enabled'); + }); + + stdin.write('t'); + await waitFor(() => { + expect(stdout.getOutput()).toContain('✗ Disabled'); + }); + + stdin.write('t'); + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(2); + }); + + const updatedSettings = onUpdate.mock.calls[1]?.[0]; + expect(updatedSettings?.powerline.enabled).toBe(true); + expect(updatedSettings?.powerline.theme).toBe('custom'); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + it.each([ + ['unset', undefined, ''], + ['none', '', ''], + ['two spaces', ' ', ' '] + ])('restores the default padding (%s) when (t) turns Powerline on and off again', async (_name, padding, restored) => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + // Each update is drawn again with the new settings, as the app does + const onUpdate = vi.fn((settings) => { + instance.rerender(React.createElement(PowerlineSetup, { ...props, settings })); + }); + const props: PowerlineSetupProps = { + settings: { + ...DEFAULT_SETTINGS, + lines: [[{ id: '1', type: 'model' }, { id: '2', type: 'context-length' }]], + defaultSeparator: '|', + defaultPadding: padding + }, + powerlineFontStatus: { installed: true }, + onUpdate, + onBack: vi.fn(), + onInstallFonts: vi.fn(), + installingFonts: false, + fontInstallMessage: null, + onClearMessage: vi.fn() + }; + const instance = render( + React.createElement(PowerlineSetup, props), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await waitFor(() => { + expect(stdout.getOutput()).toContain('✗ Disabled'); + }); + + stdin.write('t'); + await waitFor(() => { + expect(stdout.getOutput()).toContain('✓ Enabled'); + }); + expect(onUpdate.mock.calls[0]?.[0].defaultPadding).toBe(' '); + + stdin.write('t'); + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(2); + }); + + const updatedSettings = onUpdate.mock.calls[1]?.[0]; + expect(updatedSettings?.powerline.enabled).toBe(false); + expect(updatedSettings?.defaultPadding).toBe(restored); + expect(updatedSettings?.powerline.paddingToRestore).toBeUndefined(); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + // A padding set while Powerline was on is the user's own choice + it('keeps a default padding changed since Powerline was turned on when (t) turns it off', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const onUpdate = vi.fn(); + const instance = render( + React.createElement(PowerlineSetup, { + settings: { + ...DEFAULT_SETTINGS, + defaultPadding: ' ', + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + theme: 'nord-aurora', + paddingToRestore: '' + } + }, + powerlineFontStatus: { installed: true }, + onUpdate, + onBack: vi.fn(), + onInstallFonts: vi.fn(), + installingFonts: false, + fontInstallMessage: null, + onClearMessage: vi.fn() + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await waitFor(() => { + expect(stdout.getOutput()).toContain('✓ Enabled'); + }); + + stdin.write('t'); + await waitFor(() => { + expect(onUpdate).toHaveBeenCalled(); + }); + + const updatedSettings = onUpdate.mock.calls[0]?.[0]; + expect(updatedSettings?.powerline.enabled).toBe(false); + expect(updatedSettings?.defaultPadding).toBe(' '); + expect(updatedSettings?.powerline.paddingToRestore).toBeUndefined(); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + // Turning Powerline on with manual separators asks first; the separators, + // a chosen Custom theme and the padding from before all come back with it off + it('keeps manual separators, a Custom theme and the default padding when (t) turns Powerline on and off again', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + // Each update is drawn again with the new settings, as the app does + const onUpdate = vi.fn((settings) => { + instance.rerender(React.createElement(PowerlineSetup, { ...props, settings })); + }); + const props: PowerlineSetupProps = { + settings: { + ...DEFAULT_SETTINGS, + lines: [[{ id: '1', type: 'model' }, { id: '2', type: 'separator' }, { id: '3', type: 'context-length' }]], + defaultPadding: ' ', + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: false, + theme: 'custom' + } + }, + powerlineFontStatus: { installed: true }, + onUpdate, + onBack: vi.fn(), + onInstallFonts: vi.fn(), + installingFonts: false, + fontInstallMessage: null, + onClearMessage: vi.fn() + }; + const instance = render( + React.createElement(PowerlineSetup, props), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await waitFor(() => { + expect(stdout.getOutput()).toContain('✗ Disabled'); + }); + + stdin.write('t'); + await waitFor(() => { + expect(stdout.getOutput()).toContain('Do you want to continue?'); + }); + + stdin.write('\r'); + await waitFor(() => { + expect(stdout.getOutput()).toContain('✓ Enabled'); + }); + const enabledSettings = onUpdate.mock.calls[0]?.[0]; + expect(enabledSettings?.lines[0]?.map(item => item.type)).toEqual(['model', 'separator', 'context-length']); + expect(enabledSettings?.powerline.theme).toBe('custom'); + + stdin.write('t'); + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(2); + }); + + const disabledSettings = onUpdate.mock.calls[1]?.[0]; + expect(disabledSettings?.powerline.enabled).toBe(false); + expect(disabledSettings?.lines[0]?.map(item => item.type)).toEqual(['model', 'separator', 'context-length']); + expect(disabledSettings?.powerline.theme).toBe('custom'); + expect(disabledSettings?.defaultPadding).toBe(' '); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + it('shows the font install result it is given', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const instance = render( + React.createElement(PowerlineSetup, { + settings: DEFAULT_SETTINGS, + powerlineFontStatus: { installed: true }, + onUpdate: vi.fn(), + onBack: vi.fn(), + onInstallFonts: vi.fn(), + installingFonts: false, + fontInstallMessage: { + success: true, + message: 'Installed 3 Powerline fonts. Please restart your terminal and select a Powerline font from your terminal settings.' + }, + onClearMessage: vi.fn() + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await waitFor(() => { + expect(stdout.getOutput()).toContain('Installed 3 Powerline fonts.'); + expect(stdout.getOutput()).toContain('Press any key to continue...'); + }); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + it('warns when a global foreground override is active', async () => { const stdin = createMockStdin(); const stdout = createMockStdout(); diff --git a/src/types/PowerlineConfig.ts b/src/types/PowerlineConfig.ts index 8657a0464..903ee0c64 100644 --- a/src/types/PowerlineConfig.ts +++ b/src/types/PowerlineConfig.ts @@ -8,6 +8,8 @@ export const PowerlineConfigSchema = z.object({ startCaps: z.array(z.string()).default([]), endCaps: z.array(z.string()).default([]), theme: z.string().optional(), + // The Default Padding that turning Powerline on replaced, put back when it's turned off + paddingToRestore: z.string().optional(), autoAlign: z.boolean().default(false), continueThemeAcrossLines: z.boolean().default(false) }); diff --git a/src/utils/__tests__/powerline-install.test.ts b/src/utils/__tests__/powerline-install.test.ts index ba041dad3..994bc0100 100644 --- a/src/utils/__tests__/powerline-install.test.ts +++ b/src/utils/__tests__/powerline-install.test.ts @@ -75,7 +75,8 @@ describe('installPowerlineFonts', () => { const cloneDirs: string[] = []; mockExecFileSync.mockImplementation((file, args = []) => { cloneDirs.push(args[args.length - 1] ?? ''); - throw new Error(`${file} failed`); + // What execFileSync throws when git isn't installed + throw Object.assign(new Error(`spawnSync ${file} ENOENT`), { code: 'ENOENT', syscall: `spawnSync ${file}`, path: file }); }); const result = await installPowerlineFonts(); @@ -87,4 +88,117 @@ describe('installPowerlineFonts', () => { expect(cloneDirs).toHaveLength(1); expect(fs.existsSync(cloneDirs[0] ?? '')).toBe(false); }); + + // A clone's error always starts "Command failed: git clone ...", whatever went wrong + it.each([ + ['offline', 'Could not resolve host: github.com'], + ['behind an unreachable proxy', 'Failed to connect to 127.0.0.1 port 9 after 0 ms: Couldn\'t connect to server'] + ])('reports why the clone failed and links the manual install when %s', async (_name, reason) => { + const fatal = `fatal: unable to access '${FONTS_REPO}/': ${reason}`; + mockExecFileSync.mockImplementation((file, args = []) => { + const dir = args[args.length - 1] ?? ''; + const stderr = `Cloning into '${dir}'...\n${fatal}\n`; + // What execFileSync throws when the command exits with an error + throw Object.assign(new Error(`Command failed: ${file} ${args.join(' ')}\n${stderr}`), { status: 128, stderr }); + }); + + const result = await installPowerlineFonts(); + + expect(result).toEqual({ + success: false, + message: `Failed to install Powerline fonts: ${fatal}. You can manually install fonts from: https://github.com/powerline/fonts` + }); + }); + + // The error repeats all of stderr, and the line that says why isn't always the last or in English + it.each([ + [ + 'git is the macOS stub for missing Command Line Tools', + (_dir: string) => [ + 'xcode-select: note: No developer tools were found, requesting install.', + 'If developer tools are located at a non-default location on disk, use `sudo xcode-select --switch path/to/Xcode.app` to specify the Xcode that you wish to use for command line developer tools, and cancel the installation dialog.', + 'See `man xcode-select` for more details.' + ], + { status: 1, signal: null }, + 'xcode-select: note: No developer tools were found, requesting install.' + ], + [ + 'git is killed after it starts the clone', + (dir: string) => [`Cloning into '${dir}'...`], + { status: null, signal: 'SIGKILL' }, + 'git was stopped by SIGKILL.' + ], + [ + 'git gives up after a dropped connection', + (dir: string) => [ + `Cloning into '${dir}'...`, + 'error: RPC failed; curl 92 HTTP/2 stream 5 was not closed cleanly: CANCEL (err 8)', + 'error: 2771 bytes of body are still expected', + 'fetch-pack: unexpected disconnect while reading sideband packet', + 'fatal: early EOF', + 'fatal: fetch-pack: invalid index-pack output' + ], + { status: 128, signal: null }, + 'error: RPC failed; curl 92 HTTP/2 stream 5 was not closed cleanly: CANCEL (err 8).' + ], + [ + 'git speaks Chinese', + (dir: string) => [ + `正克隆到 '${dir}'...`, + `致命错误:无法访问 '${FONTS_REPO}/':Could not resolve host: github.com` + ], + { status: 128, signal: null }, + `致命错误:无法访问 '${FONTS_REPO}/':Could not resolve host: github.com.` + ], + [ + 'git speaks French', + (dir: string) => [ + `Clonage dans '${dir}'...`, + `fatal : impossible d'accéder à '${FONTS_REPO}/' : Could not resolve host: github.com` + ], + { status: 128, signal: null }, + `fatal : impossible d'accéder à '${FONTS_REPO}/' : Could not resolve host: github.com.` + ], + [ + 'the server says why before git does', + (dir: string) => [ + `Clonage dans '${dir}'...`, + 'remote: Repository not found.', + `fatal : dépôt '${FONTS_REPO}/' non trouvé` + ], + { status: 128, signal: null }, + 'remote: Repository not found.' + ] + ])('reports why the clone failed when %s', async (_name, stderrLines, exit, reason) => { + mockExecFileSync.mockImplementation((file, args = []) => { + const stderr = `${stderrLines(args[args.length - 1] ?? '').join('\n')}\n`; + // What execFileSync throws when the command exits with an error or is killed + throw Object.assign(new Error(`Command failed: ${file} ${args.join(' ')}\n${stderr}`), { ...exit, stderr }); + }); + + const result = await installPowerlineFonts(); + + expect(result).toEqual({ + success: false, + message: `Failed to install Powerline fonts: ${reason} You can manually install fonts from: https://github.com/powerline/fonts` + }); + }); + + it.skipIf(process.platform === 'win32')('names install.sh when it is killed', async () => { + mockExecFileSync.mockImplementation((file, args = []) => { + if (file === 'git') { + fs.writeFileSync(path.join(args[args.length - 1] ?? '', 'install.sh'), '#!/bin/sh\n'); + return ''; + } + // What execFileSync throws when the command is killed before writing to stderr + throw Object.assign(new Error(`Command failed: ${file}`), { status: null, signal: 'SIGTERM', stderr: '' }); + }); + + const result = await installPowerlineFonts(); + + expect(result).toEqual({ + success: false, + message: 'Failed to install Powerline fonts: install.sh was stopped by SIGTERM. You can manually install fonts from: https://github.com/powerline/fonts' + }); + }); }); diff --git a/src/utils/__tests__/powerline-settings.test.ts b/src/utils/__tests__/powerline-settings.test.ts index 8dc5736ef..05a257a50 100644 --- a/src/utils/__tests__/powerline-settings.test.ts +++ b/src/utils/__tests__/powerline-settings.test.ts @@ -6,7 +6,10 @@ import { import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; -import { buildEnabledPowerlineSettings } from '../powerline-settings'; +import { + buildDisabledPowerlineSettings, + buildEnabledPowerlineSettings +} from '../powerline-settings'; describe('powerline settings helpers', () => { it('enables powerline with default theme and default padding', () => { @@ -54,6 +57,21 @@ describe('powerline settings helpers', () => { expect(updated.powerline.theme).toBe('custom'); }); + it.each([ + ['unset', undefined, ''], + ['none', '', ''], + ['two spaces', ' ', ' '] + ])('remembers the default padding it replaces (%s) when enabling powerline', (_name, padding, remembered) => { + const settings = { + ...DEFAULT_SETTINGS, + defaultPadding: padding + }; + + const updated = buildEnabledPowerlineSettings(settings); + expect(updated.defaultPadding).toBe(' '); + expect(updated.powerline.paddingToRestore).toBe(remembered); + }); + it('keeps manual separators alongside flex separators when enabling', () => { const line: WidgetItem[] = [ { id: '1', type: 'model' }, @@ -96,4 +114,32 @@ describe('powerline settings helpers', () => { expect(disabled.lines[0]?.map(item => item.type)).toEqual(['model', 'separator', 'context-length']); }); + + it('keeps manual separators, a Custom theme and the default padding when turned on, off and on again', () => { + const line: WidgetItem[] = [ + { id: '1', type: 'model' }, + { id: '2', type: 'separator' }, + { id: '3', type: 'context-length' } + ]; + const settings = { + ...DEFAULT_SETTINGS, + defaultPadding: ' ', + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: false, + theme: 'custom' + }, + lines: [line] + }; + + const enabled = buildEnabledPowerlineSettings(settings); + const disabled = buildDisabledPowerlineSettings(enabled); + const enabledAgain = buildEnabledPowerlineSettings(disabled); + + expect(disabled.lines[0]?.map(item => item.type)).toEqual(['model', 'separator', 'context-length']); + expect(disabled.defaultPadding).toBe(' '); + expect(disabled.powerline.theme).toBe('custom'); + expect(enabledAgain.lines[0]?.map(item => item.type)).toEqual(['model', 'separator', 'context-length']); + expect(enabledAgain.powerline.theme).toBe('custom'); + }); }); diff --git a/src/utils/powerline-settings.ts b/src/utils/powerline-settings.ts index 955f13f40..b1927d248 100644 --- a/src/utils/powerline-settings.ts +++ b/src/utils/powerline-settings.ts @@ -2,6 +2,9 @@ import type { Settings } from '../types/Settings'; import { getDefaultPowerlineTheme } from './colors'; +// The Default Padding turning Powerline on sets, so segment text doesn't touch the arrows +const POWERLINE_PADDING = ' '; + // Only a first enable has no theme yet. 'custom' is chosen in the Themes menu, // which is open only while Powerline is enabled, so it is kept. function resolveEnabledPowerlineTheme(theme: string | undefined): string { @@ -23,8 +26,28 @@ export function buildEnabledPowerlineSettings(settings: Settings): Settings { theme: resolveEnabledPowerlineTheme(powerlineConfig.theme), // Separators are initialized by schema defaults, preserve existing values. separators: powerlineConfig.separators, - separatorInvertBackground: powerlineConfig.separatorInvertBackground + separatorInvertBackground: powerlineConfig.separatorInvertBackground, + // Unset and empty padding render the same + paddingToRestore: settings.defaultPadding ?? '' + }, + defaultPadding: POWERLINE_PADDING + }; +} + +/** + * Turns Powerline off. The Default Padding that turning it on replaced comes + * back, unless the padding was changed since: that change is the user's own. + */ +export function buildDisabledPowerlineSettings(settings: Settings): Settings { + const { paddingToRestore, ...powerlineConfig } = settings.powerline; + const restoresPadding = paddingToRestore !== undefined && settings.defaultPadding === POWERLINE_PADDING; + + return { + ...settings, + powerline: { + ...powerlineConfig, + enabled: false }, - defaultPadding: ' ' + defaultPadding: restoresPadding ? paddingToRestore : settings.defaultPadding }; } diff --git a/src/utils/powerline.ts b/src/utils/powerline.ts index 58f77f9be..721f9b123 100644 --- a/src/utils/powerline.ts +++ b/src/utils/powerline.ts @@ -156,13 +156,63 @@ export async function checkPowerlineFontsAsync(): Promise { } } +export interface FontInstallResult { + success: boolean; + message: string; +} + +// Only git failing to start means it isn't installed. A clone that fails +// offline or behind a proxy names git in its error too +function isGitMissing(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false; + } + const { code, path: command } = error as NodeJS.ErrnoException; + return code === 'ENOENT' && command === 'git'; +} + +// git's own error lines in English, such as "fatal: unable to access ...: +// Could not resolve host" +const GIT_ERROR_LINE = /^(?:fatal|error):/; + +// Why the install failed, as a sentence. A failed command's error repeats its +// whole command line and output, so this is the signal that stopped it, or +// one line of its stderr: git's first error line, or else its first line +// that doesn't name the temp folder, such as the macOS git stub's "No +// developer tools were found" when the Command Line Tools aren't installed. +// That skips git's progress line, "Cloning into ''...", which +// git translates, as it does its error prefixes +function describeInstallError(error: unknown, command: string, tempDir: string | undefined): string { + const failure = typeof error === 'object' && error !== null + ? error as { signal?: unknown; stderr?: unknown } + : undefined; + let reason = error instanceof Error ? error.message : String(error); + if (typeof failure?.signal === 'string') { + reason = `${command} was stopped by ${failure.signal}`; + } else if (typeof failure?.stderr === 'string') { + const lines = failure.stderr.split('\n') + .map(line => line.trim()) + .filter(line => line !== ''); + reason = lines.find(line => GIT_ERROR_LINE.test(line)) + ?? lines.find(line => tempDir === undefined || !line.includes(tempDir)) + ?? reason; + } + + return /[.!?]$/.test(reason) ? reason : `${reason}.`; +} + /** * Install Powerline fonts on the system */ -export async function installPowerlineFonts(): Promise<{ success: boolean; message: string }> { +export async function installPowerlineFonts(): Promise { // Ensure this is always async await Promise.resolve(); + // The command running now, named if a signal stops it + let command = 'git'; + // The folder the fonts are cloned into, named by git's progress line + let tempDir: string | undefined; + try { const platform = os.platform(); let fontDir: string; @@ -196,7 +246,7 @@ export async function installPowerlineFonts(): Promise<{ success: boolean; messa // A new folder only this account can use for the download: os.tmpdir() // is shared on Linux, and a folder another account created first would // let it swap install.sh between the clone and running it - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-powerline-fonts-')); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-powerline-fonts-')); try { // Clone Powerline fonts repository @@ -219,6 +269,7 @@ export async function installPowerlineFonts(): Promise<{ success: boolean; messa fs.chmodSync(installScript, 0o755); // Run install script + command = 'install.sh'; execFileSync(installScript, [], { cwd: tempDir, stdio: 'pipe', @@ -309,10 +360,7 @@ export async function installPowerlineFonts(): Promise<{ success: boolean; messa } } } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - - // Provide helpful error messages - if (errorMessage.includes('git')) { + if (isGitMissing(error)) { return { success: false, message: 'Git is required to install Powerline fonts. Please install Git and try again.' @@ -321,7 +369,7 @@ export async function installPowerlineFonts(): Promise<{ success: boolean; messa return { success: false, - message: `Failed to install Powerline fonts: ${errorMessage}. You can manually install fonts from: https://github.com/powerline/fonts` + message: `Failed to install Powerline fonts: ${describeInstallError(error, command, tempDir)} You can manually install fonts from: https://github.com/powerline/fonts` }; } }