mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 08:03:08 +00:00
fix: import all default ghostty configs
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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({})
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -2278,6 +2278,7 @@ export type CommitMessageAiSettings = {
|
||||
export type GhosttyImportPreview = {
|
||||
found: boolean
|
||||
configPath?: string
|
||||
configPaths?: string[]
|
||||
diff: Partial<GlobalSettings>
|
||||
unsupportedKeys: string[]
|
||||
error?: string
|
||||
|
||||
Reference in New Issue
Block a user