diff --git a/src/main/ghostty/discovery.test.ts b/src/main/ghostty/discovery.test.ts index 53df7b28e15..30582fc32e9 100644 --- a/src/main/ghostty/discovery.test.ts +++ b/src/main/ghostty/discovery.test.ts @@ -16,7 +16,7 @@ vi.mock('fs/promises', () => ({ stat: statMock })) -import { findGhosttyConfigPath, getGhosttyConfigPaths } from './discovery' +import { findGhosttyConfigPath, findGhosttyConfigPaths, getGhosttyConfigPaths } from './discovery' function enoent(): Error { return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) @@ -48,10 +48,10 @@ describe('getGhosttyConfigPaths', () => { const paths = getGhosttyConfigPaths() expect(paths).toEqual([ - '/Users/alice/.config/ghostty/config', '/Users/alice/.config/ghostty/config.ghostty', - '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config', - '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config.ghostty' + '/Users/alice/.config/ghostty/config', + '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config.ghostty', + '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config' ]) }) @@ -62,10 +62,10 @@ describe('getGhosttyConfigPaths', () => { const paths = getGhosttyConfigPaths() expect(paths).toEqual([ - '/custom/xdg/ghostty/config', '/custom/xdg/ghostty/config.ghostty', - '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config', - '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config.ghostty' + '/custom/xdg/ghostty/config', + '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config.ghostty', + '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config' ]) }) @@ -76,8 +76,8 @@ describe('getGhosttyConfigPaths', () => { const paths = getGhosttyConfigPaths() expect(paths).toEqual([ - '/home/bob/.config/ghostty/config', - '/home/bob/.config/ghostty/config.ghostty' + '/home/bob/.config/ghostty/config.ghostty', + '/home/bob/.config/ghostty/config' ]) }) @@ -88,8 +88,8 @@ describe('getGhosttyConfigPaths', () => { const paths = getGhosttyConfigPaths() expect(paths).toEqual([ - '/custom/config/ghostty/config', - '/custom/config/ghostty/config.ghostty' + '/custom/config/ghostty/config.ghostty', + '/custom/config/ghostty/config' ]) }) @@ -100,8 +100,8 @@ describe('getGhosttyConfigPaths', () => { const paths = getGhosttyConfigPaths() expect(paths).toEqual([ - path.win32.join('C:\\Users\\Charlie\\AppData\\Roaming', 'ghostty', 'config'), - path.win32.join('C:\\Users\\Charlie\\AppData\\Roaming', 'ghostty', 'config.ghostty') + path.win32.join('C:\\Users\\Charlie\\AppData\\Roaming', 'ghostty', 'config.ghostty'), + path.win32.join('C:\\Users\\Charlie\\AppData\\Roaming', 'ghostty', 'config') ]) }) @@ -179,7 +179,7 @@ describe('findGhosttyConfigPath', () => { expect(result).toBe('/Users/alice/.config/ghostty/config.ghostty') }) - it('prefers config over config.ghostty when both exist in the same directory', async () => { + it('returns all existing config files in Ghostty load order', async () => { homedirMock.mockReturnValue('/Users/alice') platformMock.mockReturnValue('darwin') delete process.env.XDG_CONFIG_HOME @@ -194,8 +194,11 @@ describe('findGhosttyConfigPath', () => { throw enoent() }) - const result = await findGhosttyConfigPath() - expect(result).toBe('/Users/alice/.config/ghostty/config') + const result = await findGhosttyConfigPaths() + expect(result).toEqual([ + '/Users/alice/.config/ghostty/config.ghostty', + '/Users/alice/.config/ghostty/config' + ]) }) it('returns null when no config exists on macOS', async () => { diff --git a/src/main/ghostty/discovery.ts b/src/main/ghostty/discovery.ts index 958171438fd..27d7b08e83d 100644 --- a/src/main/ghostty/discovery.ts +++ b/src/main/ghostty/discovery.ts @@ -10,9 +10,10 @@ function xdgConfigDirs(home: string): string[] { return [path.join(home, '.config', 'ghostty')] } -// Why: Check legacy `config` first, then modern `config.ghostty` per Ghostty docs. +// Why: Ghostty loads the modern filename before the legacy `config` fallback, +// and later files in this order override earlier files. function withFilenames(dirs: string[]): string[] { - return dirs.flatMap((dir) => [path.join(dir, 'config'), path.join(dir, 'config.ghostty')]) + return dirs.flatMap((dir) => [path.join(dir, 'config.ghostty'), path.join(dir, 'config')]) } export function getGhosttyConfigPaths(): string[] { @@ -33,7 +34,7 @@ export function getGhosttyConfigPaths(): string[] { const appData = process.env.APPDATA || home const base = path.win32.join(appData, 'ghostty') // Why: path.win32.join preserves backslashes even when tests run on macOS/Linux. - return [path.win32.join(base, 'config'), path.win32.join(base, 'config.ghostty')] + return [path.win32.join(base, 'config.ghostty'), path.win32.join(base, 'config')] } case 'aix': case 'android': @@ -47,16 +48,22 @@ export function getGhosttyConfigPaths(): string[] { } } -export async function findGhosttyConfigPath(): Promise { +export async function findGhosttyConfigPaths(): Promise { + const found: string[] = [] for (const p of getGhosttyConfigPaths()) { try { const s = await stat(p) if (s.isFile()) { - return p + found.push(p) } } catch { // ENOENT or permission error — continue probing other paths. } } - return null + return found +} + +export async function findGhosttyConfigPath(): Promise { + const paths = await findGhosttyConfigPaths() + return paths[0] ?? null } diff --git a/src/main/ghostty/index.test.ts b/src/main/ghostty/index.test.ts index ce4e70df05a..37d059c91fd 100644 --- a/src/main/ghostty/index.test.ts +++ b/src/main/ghostty/index.test.ts @@ -1,6 +1,6 @@ import type { Store } from '../persistence' import type { GlobalSettings } from '../../shared/types' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' const { statMock, readFileMock } = vi.hoisted(() => ({ statMock: vi.fn(), @@ -19,6 +19,17 @@ vi.mock('os', () => ({ import { previewGhosttyImport } from './index' +const originalXdgConfigHome = process.env.XDG_CONFIG_HOME + +afterEach(() => { + vi.clearAllMocks() + if (originalXdgConfigHome !== undefined) { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome + } else { + delete process.env.XDG_CONFIG_HOME + } +}) + function createStore(settings: Record = {}): Store { return { getSettings: () => settings as GlobalSettings @@ -66,6 +77,43 @@ background = #1a1a1a expect(result.unsupportedKeys).toEqual([]) }) + it('imports every discovered config file in Ghostty load order', async () => { + delete process.env.XDG_CONFIG_HOME + statMock.mockImplementation(async (p: string) => { + if ( + p === '/Users/alice/.config/ghostty/config.ghostty' || + p === '/Users/alice/.config/ghostty/config' + ) { + return { isFile: () => true } + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + readFileMock.mockImplementation(async (p: string) => { + if (p === '/Users/alice/.config/ghostty/config.ghostty') { + return 'font-size = 22\nbackground = #1a1a1a\n' + } + if (p === '/Users/alice/.config/ghostty/config') { + return 'font-family = JetBrains Mono\nfont-size = 18\n' + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + + const result = await previewGhosttyImport(createStore()) + + expect(result.found).toBe(true) + expect(result.configPath).toBe('/Users/alice/.config/ghostty/config.ghostty') + expect(result.configPaths).toEqual([ + '/Users/alice/.config/ghostty/config.ghostty', + '/Users/alice/.config/ghostty/config' + ]) + expect(result.diff).toEqual({ + terminalFontFamily: 'JetBrains Mono', + terminalFontSize: 18, + terminalColorOverrides: { background: '#1a1a1a' } + }) + expect(result.unsupportedKeys).toEqual([]) + }) + it('omits values that match current settings', async () => { statMock.mockImplementation(async (p: string) => { if (p === '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config') { diff --git a/src/main/ghostty/index.ts b/src/main/ghostty/index.ts index 63e8d5d20d7..c7aa8f81835 100644 --- a/src/main/ghostty/index.ts +++ b/src/main/ghostty/index.ts @@ -2,7 +2,7 @@ import { readFile, stat } from 'fs/promises' import { platform } from 'os' import type { GlobalSettings, GhosttyImportPreview } from '../../shared/types' import type { Store } from '../persistence' -import { findGhosttyConfigPath } from './discovery' +import { findGhosttyConfigPaths } from './discovery' import { parseGhosttyConfig } from './parser' import { mapGhosttyToOrca } from './mapper' @@ -41,35 +41,58 @@ function valuesEqual(a: unknown, b: unknown): boolean { return false } +function asArray(value: string | string[] | undefined): string[] { + if (value === undefined) { + return [] + } + return Array.isArray(value) ? value : [value] +} + +function mergeParsedConfig( + target: Record, + parsed: Record +): void { + for (const [key, value] of Object.entries(parsed)) { + if (key === 'palette') { + target[key] = [...asArray(target[key]), ...asArray(value)] + continue + } + target[key] = value + } +} + export async function previewGhosttyImport(store: Store): Promise { - const configPath = await findGhosttyConfigPath() - if (!configPath) { + const configPaths = await findGhosttyConfigPaths() + if (configPaths.length === 0) { return { found: false, diff: {}, unsupportedKeys: [] } } - let content: string - try { - const info = await stat(configPath) - if (info.size > MAX_CONFIG_BYTES) { + const parsed: Record = {} + for (const configPath of configPaths) { + let content: string + try { + const info = await stat(configPath) + if (info.size > MAX_CONFIG_BYTES) { + return { + found: false, + diff: {}, + unsupportedKeys: [], + error: `Config file is too large to import (${info.size} bytes, limit ${MAX_CONFIG_BYTES}).` + } + } + content = await readFile(configPath, 'utf-8') + } catch (err) { + const message = err instanceof Error ? err.message : 'Could not read config file' return { found: false, diff: {}, unsupportedKeys: [], - error: `Config file is too large to import (${info.size} bytes, limit ${MAX_CONFIG_BYTES}).` + error: `Could not read config: ${message}` } } - content = await readFile(configPath, 'utf-8') - } catch (err) { - const message = err instanceof Error ? err.message : 'Could not read config file' - return { - found: false, - diff: {}, - unsupportedKeys: [], - error: `Could not read config: ${message}` - } + mergeParsedConfig(parsed, parseGhosttyConfig(content)) } - const parsed = parseGhosttyConfig(content) const { diff: rawDiff, unsupportedKeys } = mapGhosttyToOrca(parsed, platform() === 'darwin') const currentSettings = store.getSettings() @@ -85,7 +108,8 @@ export async function previewGhosttyImport(store: Store): Promise { expect(result.unsupportedKeys).toEqual([]) }) + it('uses the latest value when a scalar key is repeated', () => { + const result = mapGhosttyToOrca({ 'font-size': ['12', '14'] }) + expect(result.diff).toEqual({ terminalFontSize: 14 }) + expect(result.unsupportedKeys).toEqual([]) + }) + it('rejects out-of-range font-weight', () => { const result = mapGhosttyToOrca({ 'font-weight': '50' }) expect(result.diff).toEqual({}) diff --git a/src/main/ghostty/mapper.ts b/src/main/ghostty/mapper.ts index 496de3cadbf..69abcbff4d7 100644 --- a/src/main/ghostty/mapper.ts +++ b/src/main/ghostty/mapper.ts @@ -272,7 +272,7 @@ export function mapGhosttyToOrca( } for (const [key, rawValue] of Object.entries(parsed)) { - const value = Array.isArray(rawValue) ? rawValue[0] : rawValue + const value = Array.isArray(rawValue) ? (rawValue.at(-1) ?? '') : rawValue // Why: Ghostty's selection-word-chars defines characters that ARE part of a // word, while xterm.js wordSeparator defines characters that BREAK words. diff --git a/src/renderer/src/components/settings/GhosttyImportModal.tsx b/src/renderer/src/components/settings/GhosttyImportModal.tsx index 3d64e0c85e8..12d6a2ec909 100644 --- a/src/renderer/src/components/settings/GhosttyImportModal.tsx +++ b/src/renderer/src/components/settings/GhosttyImportModal.tsx @@ -39,6 +39,8 @@ export function GhosttyImportModal({ applyError = null }: GhosttyImportModalProps): React.JSX.Element { const hasChanges = preview?.found === true && Object.keys(preview.diff).length > 0 + const configPaths = + preview?.configPaths ?? (preview?.configPath !== undefined ? [preview.configPath] : []) return ( @@ -54,9 +56,9 @@ export function GhosttyImportModal({

Loading preview…

) : preview == null ? null : preview.found ? (
- {preview.configPath && !applied && ( + {configPaths.length > 0 && !applied && (

- Config: {preview.configPath} + {configPaths.length === 1 ? 'Config' : 'Configs'}: {configPaths.join(', ')}

)} {applied ? ( diff --git a/src/shared/types.ts b/src/shared/types.ts index 3ea73af6c95..a9d6cd14ec4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2278,6 +2278,7 @@ export type CommitMessageAiSettings = { export type GhosttyImportPreview = { found: boolean configPath?: string + configPaths?: string[] diff: Partial unsupportedKeys: string[] error?: string