fix: import all default ghostty configs

This commit is contained in:
Neil
2026-05-31 08:37:38 -07:00
parent 36d5e7ef0d
commit 8acb2ffaba
8 changed files with 136 additions and 45 deletions
+19 -16
View File
@@ -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 () => {
+13 -6
View File
@@ -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<string | null> {
export async function findGhosttyConfigPaths(): Promise<string[]> {
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<string | null> {
const paths = await findGhosttyConfigPaths()
return paths[0] ?? null
}
+49 -1
View File
@@ -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<string, unknown> = {}): 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') {
+43 -19
View File
@@ -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<string, string | string[]>,
parsed: Record<string, string | string[]>
): 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<GhosttyImportPreview> {
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<string, string | string[]> = {}
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<GhosttyImportP
return {
found: true,
configPath,
configPath: configPaths[0],
configPaths,
diff: actualDiff,
unsupportedKeys
}
+6
View File
@@ -40,6 +40,12 @@ describe('mapGhosttyToOrca — font & cursor', () => {
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({})
+1 -1
View File
@@ -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.
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -54,9 +56,9 @@ export function GhosttyImportModal({
<p className="text-xs text-muted-foreground">Loading preview…</p>
) : preview == null ? null : preview.found ? (
<div className="space-y-3">
{preview.configPath && !applied && (
{configPaths.length > 0 && !applied && (
<p className="text-xs text-muted-foreground break-all">
Config: {preview.configPath}
{configPaths.length === 1 ? 'Config' : 'Configs'}: {configPaths.join(', ')}
</p>
)}
{applied ? (
+1
View File
@@ -2278,6 +2278,7 @@ export type CommitMessageAiSettings = {
export type GhosttyImportPreview = {
found: boolean
configPath?: string
configPaths?: string[]
diff: Partial<GlobalSettings>
unsupportedKeys: string[]
error?: string