Add Warp terminal theme import (#4714)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-06-11 12:36:57 -07:00
committed by Jinwoo-H
co-authored by Orca
parent de54c14b97
commit 94f37ef7fb
48 changed files with 4398 additions and 100 deletions
+1
View File
@@ -180,6 +180,7 @@ export default defineConfig({
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'),
// Why: electron-vite cleans out/main in dev. The dev CLI imports
// this path for `orca agent hooks ...`, so it must survive rebuilds.
'agent-hooks/managed-agent-hook-controls': resolve(
+97
View File
@@ -6,6 +6,7 @@ const {
browserWindowGetAllWindowsMock,
handleMock,
previewGhosttyImportMock,
previewWarpThemeImportMock,
rebuildAppMenuMock
} = vi.hoisted(() => ({
applyAppIconMock: vi.fn(),
@@ -13,6 +14,7 @@ const {
browserWindowGetAllWindowsMock: vi.fn(),
handleMock: vi.fn(),
previewGhosttyImportMock: vi.fn(),
previewWarpThemeImportMock: vi.fn(),
rebuildAppMenuMock: vi.fn()
}))
@@ -26,6 +28,10 @@ vi.mock('../ghostty/index', () => ({
previewGhosttyImport: previewGhosttyImportMock
}))
vi.mock('../warp-themes', () => ({
previewWarpThemeImport: previewWarpThemeImportMock
}))
vi.mock('../network/proxy-settings', () => ({
applyElectronProxySettings: applyElectronProxySettingsMock
}))
@@ -62,6 +68,7 @@ describe('registerSettingsHandlers', () => {
applyElectronProxySettingsMock.mockClear()
applyElectronProxySettingsMock.mockResolvedValue({ source: 'settings' })
previewGhosttyImportMock.mockClear()
previewWarpThemeImportMock.mockClear()
rebuildAppMenuMock.mockClear()
browserWindowGetAllWindowsMock.mockReset()
store.getSettings.mockReset()
@@ -75,6 +82,12 @@ describe('registerSettingsHandlers', () => {
expect(channels).toContain('settings:previewGhosttyImport')
})
it('registers settings:previewWarpThemeImport handler', () => {
registerSettingsHandlers(store as never)
const channels = handleMock.mock.calls.map((call) => call[0])
expect(channels).toContain('settings:previewWarpThemeImport')
})
it('settings:previewGhosttyImport returns preview result', async () => {
const expected = { found: false, diff: {}, unsupportedKeys: [] }
previewGhosttyImportMock.mockResolvedValue(expected)
@@ -89,6 +102,45 @@ describe('registerSettingsHandlers', () => {
expect(previewGhosttyImportMock).toHaveBeenCalledWith(store)
})
it('settings:previewWarpThemeImport returns preview result', async () => {
const expected = { found: false, themes: [], skippedFiles: [] }
previewWarpThemeImportMock.mockResolvedValue(expected)
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find(
(call) => call[0] === 'settings:previewWarpThemeImport'
)?.[1] as (event: { sender: unknown }, args: { kind: 'auto' }) => Promise<unknown>
const sender = { id: 3 }
const result = await handler!({ sender }, { kind: 'auto' })
expect(result).toEqual(expected)
expect(previewWarpThemeImportMock).toHaveBeenCalledWith(store, { kind: 'auto' }, sender)
})
it('settings:previewWarpThemeImport forwards malformed sources for main validation', async () => {
const expected = {
found: false,
themes: [],
skippedFiles: [],
error: 'Invalid Warp theme import source.'
}
previewWarpThemeImportMock.mockResolvedValue(expected)
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find(
(call) => call[0] === 'settings:previewWarpThemeImport'
)?.[1] as (event: { sender: unknown }, args: unknown) => Promise<unknown>
const invalidSource = { kind: 'unknown' }
const sender = { id: 3 }
const result = await handler!({ sender }, invalidSource)
expect(result).toEqual(expected)
expect(previewWarpThemeImportMock).toHaveBeenCalledWith(store, invalidSource, sender)
await handler!({ sender }, null)
expect(previewWarpThemeImportMock).toHaveBeenCalledWith(store, null, sender)
})
it('broadcasts store-level settings changes to open windows', () => {
const send = vi.fn()
browserWindowGetAllWindowsMock.mockReturnValue([
@@ -181,6 +233,51 @@ describe('registerSettingsHandlers', () => {
)
})
it('normalizes custom terminal themes from renderer settings IPC', async () => {
store.getSettings.mockReturnValue({ terminalCustomThemes: [] })
store.updateSettings.mockReturnValue({ terminalCustomThemes: [] })
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
_event: unknown,
args: unknown
) => Promise<unknown>
await handler(settingsInvokeEvent, {
terminalCustomThemes: [
{
id: 'warp:Test Theme',
name: 'Test Theme',
source: 'warp',
mode: 'dark',
terminal: {
background: '000',
foreground: 'fff',
black: '123',
red: 'nope'
},
sourcePath: '/Users/alice/.warp/themes/test.yaml'
}
]
})
expect(store.updateSettings).toHaveBeenCalledWith(
{
terminalCustomThemes: [
expect.objectContaining({
id: 'warp:test-theme',
terminal: {
background: '#000000',
foreground: '#ffffff',
black: '#112233'
}
})
]
},
{ notifyListeners: true, originWebContentsId: 1 }
)
})
it('sanitizes and applies proxy settings from renderer settings IPC', async () => {
store.getSettings.mockReturnValue({ httpProxyUrl: '' })
store.updateSettings.mockReturnValue({
+10
View File
@@ -3,6 +3,7 @@ import type { Store } from '../persistence'
import type { GlobalSettings, PersistedState } from '../../shared/types'
import { listSystemFontFamilies } from '../system-fonts'
import { previewGhosttyImport } from '../ghostty/index'
import { previewWarpThemeImport } from '../warp-themes'
import { setMainUiLanguage } from '../i18n/main-i18n'
import { rebuildAppMenu } from '../menu/register-app-menu'
import { track } from '../telemetry/client'
@@ -15,6 +16,7 @@ import { normalizeProxyBypassRules, normalizeProxyUrl } from '../../shared/netwo
import { normalizeAppIconId } from '../../shared/app-icon'
import { normalizeUiLanguage } from '../../shared/ui-language'
import { applyAppIcon } from '../app-icon'
import { normalizeTerminalCustomThemes } from '../../shared/terminal-custom-themes'
// Why: the whitelist is the source-of-truth for which keys we emit on. Casting
// to a Set once at module load lets the IPC handler's per-key membership
@@ -71,6 +73,9 @@ export function registerSettingsHandlers(
if ('appIcon' in args) {
sanitizedArgs.appIcon = normalizeAppIconId(args.appIcon)
}
if ('terminalCustomThemes' in args) {
sanitizedArgs.terminalCustomThemes = normalizeTerminalCustomThemes(args.terminalCustomThemes)
}
if ('uiLanguage' in args) {
sanitizedArgs.uiLanguage = normalizeUiLanguage(args.uiLanguage)
}
@@ -155,6 +160,11 @@ export function registerSettingsHandlers(
return previewGhosttyImport(store)
})
ipcMain.handle('settings:previewWarpThemeImport', (event, args?: unknown) => {
const source = args === undefined ? { kind: 'auto' } : args
return previewWarpThemeImport(store, source, event.sender)
})
ipcMain.handle('cache:getGitHub', () => {
return store.getGitHubCache()
})
+9
View File
@@ -109,6 +109,7 @@ import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../shared/auto-renam
import { normalizeOpenInApplications } from '../shared/open-in-applications'
import { normalizeTerminalShortcutPolicy } from '../shared/keybindings'
import { normalizeAppIconId } from '../shared/app-icon'
import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes'
import {
normalizeFeatureInteractions,
type FeatureInteractionId
@@ -2078,6 +2079,9 @@ export class Store {
terminalQuickCommands: normalizeTerminalQuickCommands(
parsed.settings?.terminalQuickCommands
),
terminalCustomThemes: normalizeTerminalCustomThemes(
parsed.settings?.terminalCustomThemes
),
appIcon: normalizeAppIconId(parsed.settings?.appIcon),
uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage),
defaultTaskSource: taskProviderSettings.defaultTaskSource,
@@ -3415,6 +3419,11 @@ export class Store {
updates.terminalQuickCommands
)
}
if ('terminalCustomThemes' in updates) {
sanitizedUpdates.terminalCustomThemes = normalizeTerminalCustomThemes(
updates.terminalCustomThemes
)
}
if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) {
const taskProviderSettings = normalizeTaskProviderSettings({
visibleTaskProviders:
+39
View File
@@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const platformMock = vi.hoisted(() => vi.fn())
const homedirMock = vi.hoisted(() => vi.fn(() => '/Users/alice'))
vi.mock('os', () => ({
homedir: homedirMock,
platform: platformMock
}))
import { getWarpThemeDirectories } from './discovery'
describe('getWarpThemeDirectories', () => {
beforeEach(() => {
vi.unstubAllEnvs()
platformMock.mockReset()
homedirMock.mockReturnValue('/Users/alice')
})
it('returns the macOS Warp theme directory', () => {
platformMock.mockReturnValue('darwin')
expect(getWarpThemeDirectories()).toEqual(['/Users/alice/.warp/themes'])
})
it('returns the Linux XDG data theme directory', () => {
platformMock.mockReturnValue('linux')
vi.stubEnv('XDG_DATA_HOME', '/data/alice')
expect(getWarpThemeDirectories()).toEqual(['/data/alice/warp-terminal/themes'])
})
it('returns the Windows app data theme directory with Windows separators', () => {
platformMock.mockReturnValue('win32')
homedirMock.mockReturnValue('C:\\Users\\alice')
vi.stubEnv('APPDATA', 'C:\\Users\\alice\\AppData\\Roaming')
expect(getWarpThemeDirectories()).toEqual([
'C:\\Users\\alice\\AppData\\Roaming\\warp\\Warp\\data\\themes'
])
})
})
+29
View File
@@ -0,0 +1,29 @@
import { homedir, platform } from 'os'
import path from 'path'
export function getWarpThemeDirectories(): string[] {
const home = homedir()
const plat = platform()
switch (plat) {
case 'darwin':
return [path.join(home, '.warp', 'themes')]
case 'linux': {
const dataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share')
return [path.join(dataHome, 'warp-terminal', 'themes')]
}
case 'win32': {
const appData = process.env.APPDATA || home
return [path.win32.join(appData, 'warp', 'Warp', 'data', 'themes')]
}
case 'aix':
case 'android':
case 'cygwin':
case 'freebsd':
case 'haiku':
case 'netbsd':
case 'openbsd':
case 'sunos':
return []
}
}
+541
View File
@@ -0,0 +1,541 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import path from 'path'
const opendirMock = vi.hoisted(() => vi.fn())
const readFileMock = vi.hoisted(() => vi.fn())
const statMock = vi.hoisted(() => vi.fn())
const getWarpThemeDirectoriesMock = vi.hoisted(() => vi.fn(() => ['/Users/alice/.warp/themes']))
const parseWarpThemeYamlWithTimeoutMock = vi.hoisted(() => vi.fn())
const showOpenDialogMock = vi.hoisted(() => vi.fn())
vi.mock('electron', () => ({
BrowserWindow: { fromWebContents: vi.fn() },
dialog: { showOpenDialog: showOpenDialogMock }
}))
vi.mock('fs/promises', () => ({
opendir: opendirMock,
readFile: readFileMock,
stat: statMock
}))
vi.mock('./discovery', () => ({
getWarpThemeDirectories: getWarpThemeDirectoriesMock
}))
vi.mock('./parser-runner', () => ({
parseWarpThemeYamlWithTimeout: parseWarpThemeYamlWithTimeoutMock
}))
import { previewWarpThemeImport } from './index'
import { parseWarpThemeYaml } from './parser'
import type { Store } from '../persistence'
const VALID_THEME = `
name: Duplicate
background: '#111111'
foreground: '#eeeeee'
terminal_colors:
normal:
black: '#000000'
`
function fileEntry(name: string) {
return {
name,
isFile: () => true,
isDirectory: () => false
}
}
function directoryEntry(name: string) {
return {
name,
isFile: () => false,
isDirectory: () => true
}
}
function mockDirectory(
entries: { name: string; isFile: () => boolean; isDirectory: () => boolean }[]
) {
return {
async *[Symbol.asyncIterator]() {
for (const entry of entries) {
yield entry
}
}
}
}
function mockStat(filePath: string) {
return filePath.endsWith('themes')
? { isDirectory: () => true }
: { isFile: () => true, size: VALID_THEME.length }
}
describe('previewWarpThemeImport', () => {
beforeEach(() => {
vi.clearAllMocks()
getWarpThemeDirectoriesMock.mockReturnValue(['/Users/alice/.warp/themes'])
statMock.mockImplementation(mockStat)
readFileMock.mockResolvedValue(VALID_THEME)
opendirMock.mockResolvedValue(mockDirectory([fileEntry('z.yml'), fileEntry('a.yml')]))
parseWarpThemeYamlWithTimeoutMock.mockImplementation(parseWarpThemeYaml)
})
it('sorts theme files before duplicate id suffixing', async () => {
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.themes.map((theme) => theme.id)).toEqual([
'warp:duplicate:a-yml',
'warp:duplicate:z-yml'
])
expect(readFileMock.mock.calls.map(([filePath]) => path.basename(filePath as string))).toEqual([
'a.yml',
'z.yml'
])
})
it('returns an empty errorless preview when no local Warp theme folder exists', async () => {
statMock.mockImplementation(() => {
throw new Error('missing local themes')
})
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview).toMatchObject({
found: false,
sourceLabel: 'Warp themes',
themes: [],
skippedFiles: []
})
expect(preview.error).toBeUndefined()
expect(readFileMock).not.toHaveBeenCalled()
})
it('returns an empty errorless preview for an empty readable local Warp theme folder', async () => {
opendirMock.mockResolvedValue(mockDirectory([]))
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview).toMatchObject({
found: false,
sourceLabel: 'Warp themes',
themes: [],
skippedFiles: []
})
expect(preview.error).toBeUndefined()
expect(readFileMock).not.toHaveBeenCalled()
})
it('reports bounded skips when local Warp folders are unreadable', async () => {
opendirMock.mockRejectedValue(
new Error("EACCES: permission denied, scandir '/Users/alice/.warp/themes'")
)
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.found).toBe(false)
expect(preview.sourceLabel).toBe('Warp themes')
expect(preview.skippedFiles).toEqual([{ label: 'themes', reason: 'Could not read folder.' }])
expect(preview.themes).toEqual([])
})
it('labels auto-discovered themes as local Warp themes', async () => {
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.sourceLabel).toBe('Warp themes')
expect(preview.themes.map((theme) => theme.name)).toEqual(['Duplicate', 'Duplicate'])
expect(preview.themes.map((theme) => theme.sourceLabel)).toEqual([
'Local Warp themes',
'Local Warp themes'
])
})
it('returns a bounded preview error for invalid sources without auto discovery', async () => {
const preview = await previewWarpThemeImport({} as Store, { kind: 'surprise' })
const nullPreview = await previewWarpThemeImport({} as Store, null)
const extraFieldPreview = await previewWarpThemeImport({} as Store, {
kind: 'auto',
path: '/Users/alice/.warp/themes'
})
expect(preview).toEqual({
found: false,
themes: [],
skippedFiles: [],
error: 'Invalid Warp theme import source.'
})
expect(nullPreview.error).toBe('Invalid Warp theme import source.')
expect(extraFieldPreview.error).toBe('Invalid Warp theme import source.')
expect(getWarpThemeDirectoriesMock).not.toHaveBeenCalled()
})
it('uses stable file labels in imported theme ids', async () => {
opendirMock.mockImplementation((directoryPath: string) => {
if (directoryPath.endsWith('themes')) {
return Promise.resolve(
mockDirectory([directoryEntry('standard'), directoryEntry('custom')])
)
}
if (directoryPath.endsWith('standard')) {
return Promise.resolve(mockDirectory([fileEntry('duplicate.yaml')]))
}
if (directoryPath.endsWith('custom')) {
return Promise.resolve(mockDirectory([fileEntry('duplicate.yaml')]))
}
return Promise.resolve(mockDirectory([]))
})
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.themes.map((theme) => theme.id)).toEqual([
'warp:duplicate:custom-duplicate-yaml',
'warp:duplicate:standard-duplicate-yaml'
])
})
it('stops scheduling parser work when the preview budget expires', async () => {
let currentTime = 0
parseWarpThemeYamlWithTimeoutMock.mockImplementation(
(...args: Parameters<typeof parseWarpThemeYaml>) => {
currentTime = 10
return parseWarpThemeYaml(...args)
}
)
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' }, undefined, {
operationBudgetMs: 5,
now: () => currentTime
})
expect(preview.themes).toHaveLength(1)
expect(parseWarpThemeYamlWithTimeoutMock).toHaveBeenCalledTimes(1)
expect(parseWarpThemeYamlWithTimeoutMock.mock.calls[0]?.[3]).toEqual({ timeoutMs: 5 })
expect(preview.skippedFiles).toContainEqual({
label: 'Warp themes',
reason: 'Preview budget expired before 1 theme file could be parsed.'
})
})
it('keeps same-basename manual file ids stable independent of dialog order', async () => {
const firstPath = path.join('/Users/alice/light', 'duplicate.yaml')
const secondPath = path.join('/Users/alice/dark', 'duplicate.yaml')
readFileMock.mockImplementation((filePath: string) =>
filePath === firstPath
? VALID_THEME.replace("background: '#111111'", "background: '#222222'")
: VALID_THEME.replace("background: '#111111'", "background: '#333333'")
)
showOpenDialogMock.mockResolvedValueOnce({
canceled: false,
filePaths: [firstPath, secondPath]
})
const firstPreview = await previewWarpThemeImport({} as Store, { kind: 'chooseFile' })
showOpenDialogMock.mockResolvedValueOnce({
canceled: false,
filePaths: [secondPath, firstPath]
})
const secondPreview = await previewWarpThemeImport({} as Store, { kind: 'chooseFile' })
expect(firstPreview.themes.map((theme) => theme.id)).toEqual(
secondPreview.themes.map((theme) => theme.id)
)
const ids = firstPreview.themes.map((theme) => theme.id)
expect(ids).toHaveLength(2)
expect(new Set(ids).size).toBe(2)
expect(ids.join(' ')).not.toContain('/Users/alice')
expect(ids.every((id) => id.startsWith('warp:duplicate:duplicate-yaml-'))).toBe(true)
})
it('marks dismissed file pickers as canceled', async () => {
showOpenDialogMock.mockResolvedValueOnce({ canceled: true, filePaths: [] })
const preview = await previewWarpThemeImport({} as Store, { kind: 'chooseFile' })
expect(preview).toEqual({ found: false, canceled: true, themes: [], skippedFiles: [] })
})
it('starts the preview budget after a manual file picker returns', async () => {
let currentTime = 0
showOpenDialogMock.mockImplementationOnce(() => {
currentTime = 10
return Promise.resolve({
canceled: false,
filePaths: [path.join('/Users/alice/themes', 'manual.yaml')]
})
})
const preview = await previewWarpThemeImport({} as Store, { kind: 'chooseFile' }, undefined, {
operationBudgetMs: 5,
now: () => currentTime
})
expect(preview.themes).toHaveLength(1)
expect(parseWarpThemeYamlWithTimeoutMock.mock.calls[0]?.[3]).toEqual({ timeoutMs: 5 })
expect(preview.skippedFiles).toEqual([])
})
it('starts the preview budget after a manual folder picker returns', async () => {
let currentTime = 0
showOpenDialogMock.mockImplementationOnce(() => {
currentTime = 10
return Promise.resolve({
canceled: false,
filePaths: ['/Users/alice/themes']
})
})
const preview = await previewWarpThemeImport({} as Store, { kind: 'chooseFolder' }, undefined, {
operationBudgetMs: 5,
now: () => currentTime
})
expect(preview.themes).toHaveLength(2)
expect(parseWarpThemeYamlWithTimeoutMock.mock.calls[0]?.[3]).toEqual({ timeoutMs: 5 })
expect(preview.skippedFiles).toEqual([])
})
it('finds themes in a cloned Warp themes repository layout', async () => {
opendirMock.mockImplementation((directoryPath: string) => {
if (directoryPath.endsWith('themes')) {
return Promise.resolve(
mockDirectory([directoryEntry('standard'), directoryEntry('warp_bundled')])
)
}
if (directoryPath.endsWith('standard')) {
return Promise.resolve(mockDirectory([fileEntry('tokyo-night.yaml')]))
}
if (directoryPath.endsWith('warp_bundled')) {
return Promise.resolve(mockDirectory([fileEntry('dracula.yml')]))
}
return Promise.resolve(mockDirectory([]))
})
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.found).toBe(true)
expect(readFileMock.mock.calls.map(([filePath]) => filePath)).toEqual([
path.join('/Users/alice/.warp/themes', 'standard', 'tokyo-night.yaml'),
path.join('/Users/alice/.warp/themes', 'warp_bundled', 'dracula.yml')
])
expect(preview.themes.map((theme) => theme.sourceLabel)).toEqual([
'Local Warp themes',
'Local Warp themes'
])
})
it('caps broad folder scans before walking unbounded child directories', async () => {
opendirMock.mockImplementation((directoryPath: string) => {
if (directoryPath.endsWith('themes')) {
return Promise.resolve(
mockDirectory(
Array.from({ length: 100 }, (_, index) => directoryEntry(`folder-${index}`))
)
)
}
return Promise.resolve(mockDirectory([fileEntry(`${path.basename(directoryPath)}.yaml`)]))
})
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.themes).toHaveLength(79)
expect(preview.skippedFiles).toContainEqual({
label: 'themes',
reason: 'Only the first 80 folders were scanned.'
})
})
it('reports the theme cap when a nested folder fills the cap before later folders', async () => {
opendirMock.mockImplementation((directoryPath: string) => {
if (directoryPath.endsWith('themes')) {
return Promise.resolve(
mockDirectory([directoryEntry('standard'), directoryEntry('warp_bundled')])
)
}
if (directoryPath.endsWith('standard')) {
return Promise.resolve(
mockDirectory(Array.from({ length: 200 }, (_, index) => fileEntry(`theme-${index}.yaml`)))
)
}
if (directoryPath.endsWith('warp_bundled')) {
return Promise.resolve(mockDirectory([fileEntry('extra.yaml')]))
}
return Promise.resolve(mockDirectory([]))
})
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.themes).toHaveLength(200)
expect(preview.skippedFiles).toContainEqual({
label: 'themes',
reason: 'Only the first 200 theme files were scanned.'
})
expect(opendirMock).not.toHaveBeenCalledWith(
path.join('/Users/alice/.warp/themes', 'warp_bundled'),
expect.anything()
)
})
it('caps entries processed from one large folder', async () => {
opendirMock.mockResolvedValue(
mockDirectory(Array.from({ length: 501 }, (_, index) => fileEntry(`theme-${index}.yaml`)))
)
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.themes).toHaveLength(200)
expect(preview.skippedFiles).toEqual(
expect.arrayContaining([
{
label: 'themes',
reason: 'Only the first 500 folder entries were scanned.'
},
{
label: 'themes',
reason: 'Only the first 200 theme files were scanned.'
}
])
)
})
it('does not report a skipped theme file warning for exactly the folder cap', async () => {
opendirMock.mockResolvedValue(
mockDirectory(Array.from({ length: 200 }, (_, index) => fileEntry(`theme-${index}.yaml`)))
)
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.themes).toHaveLength(200)
expect(preview.skippedFiles).not.toContainEqual({
label: 'themes',
reason: 'Only the first 200 theme files were scanned.'
})
})
it('does not report the theme cap for exactly the folder cap plus non-theme files', async () => {
opendirMock.mockResolvedValue(
mockDirectory([
...Array.from({ length: 200 }, (_, index) => fileEntry(`theme-${index}.yaml`)),
fileEntry('z-readme.md')
])
)
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.themes).toHaveLength(200)
expect(preview.skippedFiles).not.toContainEqual({
label: 'themes',
reason: 'Only the first 200 theme files were scanned.'
})
})
it('does not report the theme cap when a nested folder fills the cap before non-theme siblings', async () => {
opendirMock.mockImplementation((directoryPath: string) => {
if (directoryPath.endsWith('themes')) {
return Promise.resolve(
mockDirectory([directoryEntry('standard'), fileEntry('z-readme.md')])
)
}
if (directoryPath.endsWith('standard')) {
return Promise.resolve(
mockDirectory(Array.from({ length: 200 }, (_, index) => fileEntry(`theme-${index}.yaml`)))
)
}
return Promise.resolve(mockDirectory([]))
})
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.themes).toHaveLength(200)
expect(preview.skippedFiles).not.toContainEqual({
label: 'themes',
reason: 'Only the first 200 theme files were scanned.'
})
})
it('reports capped manually selected theme files after deterministic YAML sorting', async () => {
const themePaths = Array.from({ length: 201 }, (_, index) =>
path.join('/Users/alice/warp-themes', `theme-${String(index).padStart(3, '0')}.yaml`)
)
showOpenDialogMock.mockResolvedValue({
canceled: false,
filePaths: [
path.join('/Users/alice/warp-themes', 'aaa-not-theme.txt'),
...themePaths
].reverse()
})
const preview = await previewWarpThemeImport({} as Store, { kind: 'chooseFile' })
expect(preview.themes).toHaveLength(200)
expect(readFileMock.mock.calls[0]?.[0]).toBe(themePaths[0])
expect(readFileMock.mock.calls.at(-1)?.[0]).toBe(themePaths[199])
expect(preview.skippedFiles).toContainEqual({
label: 'Selected Warp themes',
reason: 'Only the first 200 theme files were scanned.'
})
})
it('does not report the manual theme cap for extra non-YAML selections', async () => {
const themePaths = Array.from({ length: 200 }, (_, index) =>
path.join('/Users/alice/warp-themes', `theme-${String(index).padStart(3, '0')}.yaml`)
)
showOpenDialogMock.mockResolvedValue({
canceled: false,
filePaths: [...themePaths, path.join('/Users/alice/warp-themes', 'readme.txt')]
})
const preview = await previewWarpThemeImport({} as Store, { kind: 'chooseFile' })
expect(preview.themes).toHaveLength(200)
expect(preview.skippedFiles).not.toContainEqual({
label: 'Selected Warp themes',
reason: 'Only the first 200 theme files were scanned.'
})
})
it('stops streaming a large folder after the entry budget', async () => {
const yieldedNames: string[] = []
opendirMock.mockResolvedValue({
async *[Symbol.asyncIterator]() {
for (let index = 0; index < 1000; index += 1) {
const name = `theme-${index}.yaml`
yieldedNames.push(name)
yield fileEntry(name)
}
}
})
await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(yieldedNames).toHaveLength(501)
expect(yieldedNames).not.toContain('theme-501.yaml')
expect(yieldedNames).not.toContain('theme-999.yaml')
})
it('does not copy absolute folder paths into skipped reasons', async () => {
opendirMock.mockRejectedValue(
new Error("ENOENT: no such file or directory, scandir '/Users/alice/.warp/themes'")
)
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.skippedFiles).toEqual([{ label: 'themes', reason: 'Could not read folder.' }])
})
it('does not copy absolute file paths into skipped reasons', async () => {
opendirMock.mockResolvedValue(mockDirectory([fileEntry('private.yml')]))
statMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('private.yml')) {
throw new Error("EACCES: permission denied, stat '/Users/alice/.warp/themes/private.yml'")
}
return mockStat(filePath)
})
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.skippedFiles).toEqual([{ label: 'private.yml', reason: 'Could not read file.' }])
})
})
+267
View File
@@ -0,0 +1,267 @@
import { readFile, stat } from 'fs/promises'
import type { WebContents } from 'electron'
import type { Store } from '../persistence'
import type {
WarpThemeImportPreview,
WarpThemeImportSource,
WarpThemeImportSkippedFile
} from '../../shared/terminal-custom-themes'
import { makeCustomTerminalThemeSelection } from '../../shared/terminal-custom-themes'
import { getWarpThemeDirectories } from './discovery'
import { parseWarpThemeYamlWithTimeout } from './parser-runner'
import {
sanitizeReadError,
scanWarpThemeDirectory,
type ThemeFileCandidate
} from './theme-file-scanner'
import {
createPreviewOperationBudget,
pushPreviewBudgetSkippedFile,
type PreviewOperationBudget,
type WarpThemePreviewOptions
} from './preview-operation-budget'
import { validateWarpThemeImportSource } from './warp-theme-import-source-validation'
import {
chooseManualWarpThemeFiles,
chooseManualWarpThemeFolderPath,
manualWarpThemeContentDiscriminator
} from './manual-warp-theme-files'
const MAX_THEME_FILE_BYTES = 1_000_000
type ThemeSourceSelection =
| { canceled: true }
| {
canceled: false
sourceLabel: string
files: ThemeFileCandidate[]
skippedFiles: WarpThemeImportSkippedFile[]
rootReadable?: boolean
}
type ThemeSourceResolution = {
selection: ThemeSourceSelection
budget: PreviewOperationBudget
}
async function filesFromDirectory(
directoryPath: string,
sourceLabelOverride?: string,
budget?: PreviewOperationBudget
): Promise<ThemeSourceSelection> {
const { sourceLabel, rootReadable, files, skippedFiles } = await scanWarpThemeDirectory(
directoryPath,
budget
)
const effectiveSourceLabel = sourceLabelOverride ?? sourceLabel
return {
canceled: false,
sourceLabel: effectiveSourceLabel,
files: files.map((file) => ({ ...file, sourceLabel: effectiveSourceLabel })),
skippedFiles,
rootReadable
}
}
async function filesFromAutoDirectories(
budget?: PreviewOperationBudget
): Promise<ThemeSourceSelection> {
const directories = getWarpThemeDirectories()
let localSelection: ThemeSourceSelection | null = null
const unreadableSkippedFiles: WarpThemeImportSkippedFile[] = []
let autoDiscoveryExpired = false
for (const directoryPath of directories) {
if (budget?.isExpired()) {
autoDiscoveryExpired = true
break
}
try {
const info = await stat(directoryPath)
if (!info.isDirectory()) {
continue
}
} catch {
continue
}
const selection = await filesFromDirectory(directoryPath, 'Local Warp themes', budget)
if (!selection.canceled && selection.rootReadable) {
localSelection = selection
break
}
if (!selection.canceled) {
unreadableSkippedFiles.push(...selection.skippedFiles)
}
}
if (localSelection) {
return {
canceled: false,
sourceLabel: 'Warp themes',
files: localSelection.files,
skippedFiles: localSelection.skippedFiles
}
}
if (autoDiscoveryExpired) {
return {
canceled: false,
sourceLabel: 'Warp themes',
files: [],
skippedFiles: [
{
label: 'Warp themes',
reason: 'Preview budget expired before local Warp theme folders could be scanned.'
}
]
}
}
// Why: Warp's preloaded themes live inside the Warp app binary, not on disk,
// so an absent or empty themes folder is a genuine empty result — the
// renderer explains this and points at Orca's built-in equivalents.
return {
canceled: false,
sourceLabel: 'Warp themes',
files: [],
skippedFiles: unreadableSkippedFiles
}
}
async function resolveThemeSource(
source: WarpThemeImportSource,
webContents?: WebContents,
options: WarpThemePreviewOptions = {}
): Promise<ThemeSourceResolution> {
switch (source.kind) {
case 'auto': {
const budget = createPreviewOperationBudget(options)
return { selection: await filesFromAutoDirectories(budget), budget }
}
case 'chooseFile': {
const selection = await chooseManualWarpThemeFiles(webContents)
return { selection, budget: createPreviewOperationBudget(options) }
}
case 'chooseFolder': {
const folderPath = await chooseManualWarpThemeFolderPath(webContents)
const budget = createPreviewOperationBudget(options)
return {
selection: folderPath
? await filesFromDirectory(folderPath, undefined, budget)
: { canceled: true },
budget
}
}
}
}
export async function previewWarpThemeImport(
_store: Store,
source: unknown = { kind: 'auto' },
webContents?: WebContents,
options: WarpThemePreviewOptions = {}
): Promise<WarpThemeImportPreview> {
const validatedSource = validateWarpThemeImportSource(source)
if (!validatedSource) {
return {
found: false,
themes: [],
skippedFiles: [],
error: 'Invalid Warp theme import source.'
}
}
const { selection, budget } = await resolveThemeSource(validatedSource, webContents, options)
if (selection.canceled) {
return { found: false, canceled: true, themes: [], skippedFiles: [] }
}
const skippedFiles = [...selection.skippedFiles]
const themes: WarpThemeImportPreview['themes'] = []
const idCounts = new Map<string, number>()
const importedAt = new Date().toISOString()
for (const [index, file] of selection.files.entries()) {
if (budget.isExpired()) {
pushPreviewBudgetSkippedFile(
skippedFiles,
selection.sourceLabel,
budget.remainingThemeFiles(index, selection.files.length)
)
break
}
let content: string
if (file.content !== undefined) {
content = file.content
} else {
try {
const info = await stat(file.path)
if (!info.isFile()) {
skippedFiles.push({ label: file.label, reason: 'Not a file.' })
continue
}
if (info.size > MAX_THEME_FILE_BYTES) {
skippedFiles.push({
label: file.label,
reason: `File is too large to import (${info.size} bytes, limit ${MAX_THEME_FILE_BYTES}).`
})
continue
}
content = await readFile(file.path, 'utf-8')
} catch {
skippedFiles.push({
label: file.label,
reason: sanitizeReadError('Could not read file.')
})
continue
}
}
if (budget.isExpired()) {
pushPreviewBudgetSkippedFile(
skippedFiles,
selection.sourceLabel,
budget.remainingThemeFiles(index, selection.files.length)
)
break
}
const parsed = await parseWarpThemeYamlWithTimeout(
content,
file.label,
{
idDiscriminator:
file.idDiscriminator ||
(file.contentHashDiscriminator
? manualWarpThemeContentDiscriminator(file.label, content)
: file.label || file.sourceLabel || selection.sourceLabel),
importedAt,
sourceLabel: file.sourceLabel ?? selection.sourceLabel
},
{
timeoutMs: budget.remainingMs()
}
)
if (!parsed.ok) {
skippedFiles.push({ label: file.label, reason: parsed.reason })
continue
}
const count = idCounts.get(parsed.theme.id) ?? 0
idCounts.set(parsed.theme.id, count + 1)
if (count > 0) {
const id = `${parsed.theme.id}-${count + 1}`
themes.push({
...parsed.theme,
id,
selectionValue: makeCustomTerminalThemeSelection(id)
})
continue
}
themes.push(parsed.theme)
}
// Why: an empty result carries no error — the renderer owns the localized
// empty-state copy; `error` is reserved for genuine failures.
return {
found: themes.length > 0,
sourceLabel: selection.sourceLabel,
themes,
skippedFiles
}
}
@@ -0,0 +1,89 @@
import { createHash } from 'crypto'
import path from 'path'
import { BrowserWindow, dialog, type OpenDialogOptions, type WebContents } from 'electron'
import type { WarpThemeImportSkippedFile } from '../../shared/terminal-custom-themes'
import {
compareThemeFileLabels,
isYamlFile,
MAX_THEME_FILES,
type ThemeFileCandidate
} from './theme-file-scanner'
export function createManualWarpThemeFileCandidates(filePaths: string[]): ThemeFileCandidate[] {
return filePaths
.map((filePath) => ({
path: filePath,
label: path.basename(filePath),
contentHashDiscriminator: true
}))
.sort((left, right) => {
const labelComparison = compareThemeFileLabels(left, right)
if (labelComparison !== 0) {
return labelComparison
}
// Why: manual dialogs can return selections in click order. Sort only in
// main so duplicate basenames get deterministic IDs without persisting paths.
return left.path.localeCompare(right.path, undefined, { sensitivity: 'base' })
})
}
export function manualWarpThemeContentDiscriminator(label: string, content: string): string {
return `${label}-${createHash('sha256').update(content).digest('hex').slice(0, 12)}`
}
export async function chooseManualWarpThemeFiles(webContents?: WebContents): Promise<
| { canceled: true }
| {
canceled: false
sourceLabel: string
files: ThemeFileCandidate[]
skippedFiles: WarpThemeImportSkippedFile[]
}
> {
const ownerWindow = webContents ? BrowserWindow.fromWebContents(webContents) : null
const options: OpenDialogOptions = {
title: 'Import Warp Theme',
properties: ['openFile', 'multiSelections'],
filters: [{ name: 'Warp theme YAML', extensions: ['yaml', 'yml'] }]
}
const result = ownerWindow
? await dialog.showOpenDialog(ownerWindow, options)
: await dialog.showOpenDialog(options)
if (result.canceled || result.filePaths.length === 0) {
return { canceled: true }
}
const selectedYamlFiles = result.filePaths.filter(isYamlFile)
const files = createManualWarpThemeFileCandidates(selectedYamlFiles).slice(0, MAX_THEME_FILES)
const skippedFiles: WarpThemeImportSkippedFile[] =
selectedYamlFiles.length > MAX_THEME_FILES
? [
{
label: 'Selected Warp themes',
reason: `Only the first ${MAX_THEME_FILES} theme files were scanned.`
}
]
: []
return {
canceled: false,
sourceLabel: files.length === 1 ? (files[0]?.label ?? 'Warp theme') : 'Selected Warp themes',
files,
skippedFiles
}
}
export async function chooseManualWarpThemeFolderPath(
webContents?: WebContents
): Promise<string | null> {
const ownerWindow = webContents ? BrowserWindow.fromWebContents(webContents) : null
const options: OpenDialogOptions = {
title: 'Import Warp Theme Folder',
properties: ['openDirectory']
}
const result = ownerWindow
? await dialog.showOpenDialog(ownerWindow, options)
: await dialog.showOpenDialog(options)
if (result.canceled || result.filePaths.length === 0) {
return null
}
return result.filePaths[0]!
}
+105
View File
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const workerState = vi.hoisted(() => ({
instances: [] as {
terminated: boolean
workerData: unknown
listeners: Map<string, (arg?: unknown) => void>
emit: (event: string, arg?: unknown) => void
terminate: () => Promise<number>
removeAllListeners: () => void
}[]
}))
vi.mock('electron', () => ({
app: { isPackaged: false }
}))
vi.mock('worker_threads', () => ({
Worker: class MockWorker {
terminated = false
workerData: unknown
listeners = new Map<string, (arg?: unknown) => void>()
constructor(_workerPath: string, options: { workerData?: unknown }) {
this.workerData = options.workerData
workerState.instances.push(this)
}
once(event: string, listener: (arg?: unknown) => void): this {
this.listeners.set(event, listener)
return this
}
removeAllListeners(): void {
this.listeners.clear()
}
async terminate(): Promise<number> {
this.terminated = true
return 0
}
emit(event: string, arg?: unknown): void {
this.listeners.get(event)?.(arg)
}
}
}))
import { parseWarpThemeYamlWithTimeout, WARP_THEME_PARSE_TIMEOUT_MS } from './parser-runner'
describe('parseWarpThemeYamlWithTimeout', () => {
beforeEach(() => {
vi.useFakeTimers()
workerState.instances.length = 0
})
afterEach(() => {
vi.useRealTimers()
})
it('returns worker parser results', async () => {
const resultPromise = parseWarpThemeYamlWithTimeout('name: Test', 'test.yaml')
const worker = workerState.instances[0]
worker?.emit('message', { ok: false, reason: 'Invalid YAML' })
await expect(resultPromise).resolves.toEqual({ ok: false, reason: 'Invalid YAML' })
expect(worker?.terminated).toBe(false)
})
it('terminates the worker when parsing exceeds the budget', async () => {
const resultPromise = parseWarpThemeYamlWithTimeout('name: Slow', 'slow.yaml')
const worker = workerState.instances[0]
await vi.advanceTimersByTimeAsync(WARP_THEME_PARSE_TIMEOUT_MS)
await expect(resultPromise).resolves.toEqual({
ok: false,
reason: 'Theme file took too long to parse.'
})
expect(worker?.terminated).toBe(true)
})
it('uses the shorter operation-budget timeout when provided', async () => {
const resultPromise = parseWarpThemeYamlWithTimeout(
'name: Slow',
'slow.yaml',
{},
{
timeoutMs: 25
}
)
const worker = workerState.instances[0]
await vi.advanceTimersByTimeAsync(24)
expect(worker?.terminated).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await expect(resultPromise).resolves.toEqual({
ok: false,
reason: 'Theme file took too long to parse.'
})
expect(worker?.terminated).toBe(true)
})
})
+76
View File
@@ -0,0 +1,76 @@
import { Worker } from 'worker_threads'
import { app } from 'electron'
import { join } from 'path'
import type { ParsedWarpThemeResult, ParseWarpThemeOptions } from './parser'
export const WARP_THEME_PARSE_TIMEOUT_MS = 1_000
type ParseWarpThemeTimeoutOptions = {
timeoutMs?: number
}
function getParserWorkerPath(): string {
if (app.isPackaged) {
return join(process.resourcesPath, 'app.asar', 'out', 'main', 'warp-theme-parser-worker.js')
}
return join(__dirname, 'warp-theme-parser-worker.js')
}
function isParsedWarpThemeResult(value: unknown): value is ParsedWarpThemeResult {
if (!value || typeof value !== 'object') {
return false
}
const record = value as Record<string, unknown>
return record.ok === true || record.ok === false
}
export function parseWarpThemeYamlWithTimeout(
content: string,
fileLabel: string,
options: ParseWarpThemeOptions = {},
timeoutOptions: ParseWarpThemeTimeoutOptions = {}
): Promise<ParsedWarpThemeResult> {
return new Promise((resolve) => {
const worker = new Worker(getParserWorkerPath(), {
workerData: { content, fileLabel, options }
})
let settled = false
// Why: callers may shorten the parse timeout (preview budget) but never
// extend it past the default cap, keeping untrusted-input parse time bounded.
const timeoutMs = Math.max(
0,
Math.min(WARP_THEME_PARSE_TIMEOUT_MS, timeoutOptions.timeoutMs ?? WARP_THEME_PARSE_TIMEOUT_MS)
)
const timeout = setTimeout(() => {
settle({ ok: false, reason: 'Theme file took too long to parse.' })
void worker.terminate()
}, timeoutMs)
timeout.unref?.()
function settle(result: ParsedWarpThemeResult): void {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
worker.removeAllListeners()
resolve(result)
}
worker.once('message', (message: unknown) => {
settle(
isParsedWarpThemeResult(message)
? message
: { ok: false, reason: 'Theme parser returned an invalid result.' }
)
})
worker.once('error', () => {
settle({ ok: false, reason: 'Invalid YAML' })
})
worker.once('exit', (code) => {
if (code !== 0) {
settle({ ok: false, reason: 'Theme parser exited before returning a result.' })
}
})
})
}
+137
View File
@@ -0,0 +1,137 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { parseWarpThemeYaml } from './parser'
const VALID_THEME = `
name: Tokyo Night
accent: '#7aa2f7'
background: '#1a1b26'
foreground: '#c0caf5'
cursor: '#c0caf5'
details: darker
terminal_colors:
normal:
black: '#15161e'
red: '#f7768e'
green: '#9ece6a'
yellow: '#e0af68'
blue: '#7aa2f7'
magenta: '#bb9af7'
cyan: '#7dcfff'
white: '#a9b1d6'
bright:
black: '#414868'
red: '#f7768e'
green: '#9ece6a'
yellow: '#e0af68'
blue: '#7aa2f7'
magenta: '#bb9af7'
cyan: '#7dcfff'
white: '#c0caf5'
`
describe('parseWarpThemeYaml', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('maps Warp normal and bright palettes to Orca terminal colors', () => {
const result = parseWarpThemeYaml(VALID_THEME, 'tokyo_night.yaml', {
importedAt: '2026-06-05T00:00:00.000Z',
sourceLabel: 'themes'
})
expect(result.ok).toBe(true)
if (!result.ok) {
return
}
expect(result.theme).toMatchObject({
id: 'warp:tokyo-night',
selectionValue: 'custom:warp:tokyo-night',
name: 'Tokyo Night',
source: 'warp',
mode: 'dark',
importedAt: '2026-06-05T00:00:00.000Z',
sourceLabel: 'themes'
})
expect(result.theme.terminal).toMatchObject({
background: '#1a1b26',
foreground: '#c0caf5',
cursor: '#c0caf5',
black: '#15161e',
red: '#f7768e',
brightBlack: '#414868',
brightWhite: '#c0caf5'
})
})
it('derives name from filename and light mode from background luminance', () => {
const result = parseWarpThemeYaml(
VALID_THEME.replace('name: Tokyo Night', '').replace(
"background: '#1a1b26'",
"background: '#ffffff'"
),
'bright-theme.yaml'
)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.theme.name).toBe('bright-theme')
expect(result.theme.mode).toBe('light')
}
})
it('reports unsupported background images and gradients', () => {
const result = parseWarpThemeYaml(
`${VALID_THEME}\nbackground_image:\n path: ./image.png\nbackground_gradient: []\n`,
'image.yaml'
)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.theme.unsupportedFeatures).toEqual([
'background image not supported',
'gradient not supported'
])
}
})
it('uses a gradient endpoint as the terminal background', () => {
const result = parseWarpThemeYaml(
VALID_THEME.replace(
"background: '#1a1b26'",
'background:\n top: "#002633"\n bottom: "#000000"'
).replace("accent: '#7aa2f7'", 'accent:\n left: "#007972"\n right: "#7b008f"'),
'cyber-wave.yaml'
)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.theme.terminal.background).toBe('#002633')
expect(result.theme.unsupportedFeatures).toEqual([
'background gradient not supported',
'accent gradient not supported'
])
}
})
it('rejects non-object or unusable themes', () => {
expect(parseWarpThemeYaml('- one\n- two', 'list.yaml')).toEqual({
ok: false,
reason: 'Theme file must contain a YAML object.'
})
const partial = parseWarpThemeYaml(
'background: "#000000"\nforeground: "#ffffff"',
'partial.yaml'
)
expect(partial.ok).toBe(false)
})
it('rejects themes that exceed the parse-time budget', () => {
vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(2_000)
expect(parseWarpThemeYaml(VALID_THEME, 'slow.yaml')).toEqual({
ok: false,
reason: 'Theme file took too long to parse.'
})
})
})
+228
View File
@@ -0,0 +1,228 @@
import path from 'path'
import { parseDocument } from 'yaml'
import type { TerminalColorOverrides } from '../../shared/types'
import {
hasUsableTerminalThemeColors,
makeCustomTerminalThemeSelection,
normalizeTerminalHexColor,
normalizeTerminalThemeId,
normalizeTerminalThemeName,
type TerminalCustomTheme,
type TerminalCustomThemeMode,
type WarpThemeImportPreviewTheme
} from '../../shared/terminal-custom-themes'
const WARP_COLOR_NAMES = [
'black',
'red',
'green',
'yellow',
'blue',
'magenta',
'cyan',
'white'
] as const
const MAX_PARSE_MS = 1_000
const NORMAL_COLOR_KEYS = {
black: 'black',
red: 'red',
green: 'green',
yellow: 'yellow',
blue: 'blue',
magenta: 'magenta',
cyan: 'cyan',
white: 'white'
} as const satisfies Record<(typeof WARP_COLOR_NAMES)[number], keyof TerminalColorOverrides>
const BRIGHT_COLOR_KEYS = {
black: 'brightBlack',
red: 'brightRed',
green: 'brightGreen',
yellow: 'brightYellow',
blue: 'brightBlue',
magenta: 'brightMagenta',
cyan: 'brightCyan',
white: 'brightWhite'
} as const satisfies Record<(typeof WARP_COLOR_NAMES)[number], keyof TerminalColorOverrides>
export type ParsedWarpThemeResult =
| { ok: true; theme: WarpThemeImportPreviewTheme }
| { ok: false; reason: string }
export type ParseWarpThemeOptions = {
idDiscriminator?: string
idSuffix?: string
importedAt?: string
sourceLabel?: string
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
function readColorValue(value: unknown): string | null {
const scalar = normalizeTerminalHexColor(value)
if (scalar) {
return scalar
}
if (!isRecord(value)) {
return null
}
return (
normalizeTerminalHexColor(value.top) ??
normalizeTerminalHexColor(value.bottom) ??
normalizeTerminalHexColor(value.left) ??
normalizeTerminalHexColor(value.right)
)
}
function readColor(input: Record<string, unknown>, key: string): string | null {
return readColorValue(input[key])
}
function addWarpPalette(
terminal: TerminalColorOverrides,
palette: unknown,
keys: Record<(typeof WARP_COLOR_NAMES)[number], keyof TerminalColorOverrides>
): void {
if (!isRecord(palette)) {
return
}
for (const name of WARP_COLOR_NAMES) {
const color = normalizeTerminalHexColor(palette[name])
if (color) {
terminal[keys[name]] = color
}
}
}
function luminance(hexColor: string): number {
const hex = hexColor.slice(1)
const red = parseInt(hex.slice(0, 2), 16) / 255
const green = parseInt(hex.slice(2, 4), 16) / 255
const blue = parseInt(hex.slice(4, 6), 16) / 255
return 0.2126 * red + 0.7152 * green + 0.0722 * blue
}
function inferMode(background: string | undefined, details: unknown): TerminalCustomThemeMode {
if (background) {
return luminance(background) >= 0.55 ? 'light' : 'dark'
}
if (details === 'lighter') {
return 'light'
}
if (details === 'darker') {
return 'dark'
}
return 'unknown'
}
function detectUnsupportedFeatures(input: Record<string, unknown>): string[] | undefined {
const unsupported = new Set<string>()
if ('background_image' in input) {
unsupported.add('background image not supported')
}
if (isRecord(input.background)) {
unsupported.add('background gradient not supported')
}
if (isRecord(input.accent)) {
unsupported.add('accent gradient not supported')
}
if ('background_gradient' in input || 'gradient' in input || 'gradients' in input) {
unsupported.add('gradient not supported')
}
return unsupported.size > 0 ? [...unsupported] : undefined
}
export function parseWarpThemeYaml(
content: string,
fileLabel: string,
options: ParseWarpThemeOptions = {}
): ParsedWarpThemeResult {
let value: unknown
const parseStartedAt = Date.now()
const parseTimedOut = (): boolean => Date.now() - parseStartedAt > MAX_PARSE_MS
try {
const document = parseDocument(content, {
keepSourceTokens: false,
logLevel: 'silent',
prettyErrors: false,
uniqueKeys: true
})
if (parseTimedOut()) {
return { ok: false, reason: 'Theme file took too long to parse.' }
}
if (document.errors.length > 0) {
return { ok: false, reason: document.errors[0]?.message ?? 'Invalid YAML' }
}
// Why: cap alias expansion so a malicious YAML alias bomb can't blow up memory.
value = document.toJS({ maxAliasCount: 20 })
if (parseTimedOut()) {
return { ok: false, reason: 'Theme file took too long to parse.' }
}
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : 'Invalid YAML'
}
}
if (!isRecord(value)) {
return { ok: false, reason: 'Theme file must contain a YAML object.' }
}
const fallbackName = path.basename(fileLabel, path.extname(fileLabel))
const name = normalizeTerminalThemeName(value.name, fallbackName)
const terminal: TerminalColorOverrides = {}
const background = readColor(value, 'background')
const foreground = readColor(value, 'foreground')
const cursor = readColor(value, 'cursor') ?? readColor(value, 'accent')
if (background) {
terminal.background = background
}
if (foreground) {
terminal.foreground = foreground
}
if (cursor) {
terminal.cursor = cursor
}
const terminalColors = isRecord(value.terminal_colors) ? value.terminal_colors : {}
addWarpPalette(terminal, terminalColors.normal, NORMAL_COLOR_KEYS)
addWarpPalette(terminal, terminalColors.bright, BRIGHT_COLOR_KEYS)
if (!hasUsableTerminalThemeColors(terminal)) {
return {
ok: false,
reason: 'Theme must include background, foreground, and at least one ANSI color.'
}
}
const safeDiscriminator = normalizeTerminalThemeId(options.idDiscriminator, '')
const idBase = normalizeTerminalThemeId(
safeDiscriminator ? `warp:${name}:${safeDiscriminator}` : `warp:${name}`
)
const id = options.idSuffix ? `${idBase}-${options.idSuffix}` : idBase
const unsupportedFeatures = detectUnsupportedFeatures(value)
const theme: TerminalCustomTheme = {
id,
name,
source: 'warp',
mode: inferMode(background ?? undefined, value.details),
terminal,
importedAt: options.importedAt ?? new Date().toISOString(),
sourceLabel: options.sourceLabel ?? fileLabel,
...(unsupportedFeatures ? { unsupportedFeatures } : {})
}
return {
ok: true,
theme: {
...theme,
selectionValue: makeCustomTerminalThemeSelection(theme.id)
}
}
}
@@ -0,0 +1,43 @@
import type { WarpThemeImportSkippedFile } from '../../shared/terminal-custom-themes'
import type { WarpThemeScanBudget } from './theme-file-scanner'
const DEFAULT_PREVIEW_BUDGET_MS = 5_000
export type WarpThemePreviewOptions = {
operationBudgetMs?: number
now?: () => number
}
export type PreviewOperationBudget = WarpThemeScanBudget & {
remainingMs: () => number
remainingThemeFiles: (scheduledCount: number, totalCount: number) => number
}
export function createPreviewOperationBudget(
options: WarpThemePreviewOptions = {}
): PreviewOperationBudget {
const now = options.now ?? Date.now
const budgetMs = options.operationBudgetMs ?? DEFAULT_PREVIEW_BUDGET_MS
const deadline = now() + Math.max(0, budgetMs)
return {
isExpired: () => now() >= deadline,
remainingMs: () => Math.max(0, deadline - now()),
remainingThemeFiles: (scheduledCount, totalCount) => Math.max(0, totalCount - scheduledCount)
}
}
export function pushPreviewBudgetSkippedFile(
skippedFiles: WarpThemeImportSkippedFile[],
sourceLabel: string,
remainingCount: number
): void {
skippedFiles.push({
label: sourceLabel,
reason:
remainingCount > 0
? `Preview budget expired before ${remainingCount} theme file${
remainingCount === 1 ? '' : 's'
} could be parsed.`
: 'Preview budget expired before all theme files could be parsed.'
})
}
+233
View File
@@ -0,0 +1,233 @@
import { opendir } from 'fs/promises'
import type { Dirent } from 'fs'
import path from 'path'
import type { WarpThemeImportSkippedFile } from '../../shared/terminal-custom-themes'
export const MAX_THEME_FILES = 200
const MAX_THEME_DIRECTORY_DEPTH = 3
const MAX_THEME_DIRECTORIES = 80
const MAX_THEME_ENTRIES_PER_DIRECTORY = 500
const YAML_EXTENSIONS = new Set(['.yaml', '.yml'])
export type ThemeFileCandidate = {
path: string
label: string
content?: string
contentHashDiscriminator?: boolean
idDiscriminator?: string
sourceLabel?: string
}
export type WarpThemeScanBudget = {
isExpired: () => boolean
}
type DirectoryScanBudget = {
directoriesVisited: number
directoryLimitReported: boolean
entryLimitReported: boolean
themeFileLimitHit: boolean
previewBudgetReported: boolean
}
type DirectoryScanState = {
rootReadable: boolean
}
export function isYamlFile(filePath: string): boolean {
return YAML_EXTENSIONS.has(path.extname(filePath).toLowerCase())
}
export function compareThemeFileLabels(
left: ThemeFileCandidate,
right: ThemeFileCandidate
): number {
return left.label.localeCompare(right.label, undefined, { sensitivity: 'base' })
}
function compareDirentNames(left: Dirent<string>, right: Dirent<string>): number {
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' })
}
function couldContainThemeFile(entry: Dirent<string>): boolean {
return (entry.isFile() && isYamlFile(entry.name)) || entry.isDirectory()
}
function reportPreviewBudgetExpired(
sourceLabel: string,
skippedFiles: WarpThemeImportSkippedFile[],
budget: DirectoryScanBudget
): void {
if (budget.previewBudgetReported) {
return
}
skippedFiles.push({
label: sourceLabel,
reason: 'Preview budget expired before all theme files were scanned.'
})
budget.previewBudgetReported = true
}
export function sanitizeReadError(fallback: string): string {
// Why: importer previews cross the IPC boundary, so filesystem paths must stay in main.
return fallback
}
async function collectYamlFilesFromDirectory(
directoryPath: string,
sourceLabel: string,
relativeDirectory: string,
depth: number,
files: ThemeFileCandidate[],
skippedFiles: WarpThemeImportSkippedFile[],
budget: DirectoryScanBudget,
state: DirectoryScanState,
scanBudget?: WarpThemeScanBudget
): Promise<void> {
if (scanBudget?.isExpired()) {
reportPreviewBudgetExpired(sourceLabel, skippedFiles, budget)
return
}
if (files.length >= MAX_THEME_FILES) {
return
}
if (budget.directoriesVisited >= MAX_THEME_DIRECTORIES) {
if (!budget.directoryLimitReported) {
skippedFiles.push({
label: sourceLabel,
reason: `Only the first ${MAX_THEME_DIRECTORIES} folders were scanned.`
})
budget.directoryLimitReported = true
}
return
}
budget.directoriesVisited += 1
const entries: Dirent<string>[] = []
let entryLimitHit = false
let previewBudgetExpiredWhileReading = false
try {
const directory = await opendir(directoryPath, { encoding: 'utf8' })
if (depth === 0) {
state.rootReadable = true
}
for await (const entry of directory) {
if (scanBudget?.isExpired()) {
previewBudgetExpiredWhileReading = true
reportPreviewBudgetExpired(sourceLabel, skippedFiles, budget)
break
}
if (entries.length >= MAX_THEME_ENTRIES_PER_DIRECTORY) {
entryLimitHit = true
break
}
entries.push(entry)
}
} catch {
skippedFiles.push({
label: relativeDirectory || sourceLabel,
reason: sanitizeReadError('Could not read folder.')
})
return
}
const sortedEntries = entries.sort(compareDirentNames)
if (previewBudgetExpiredWhileReading) {
return
}
if (entryLimitHit && !budget.entryLimitReported) {
skippedFiles.push({
label: relativeDirectory || sourceLabel,
reason: `Only the first ${MAX_THEME_ENTRIES_PER_DIRECTORY} folder entries were scanned.`
})
budget.entryLimitReported = true
}
for (const [index, entry] of sortedEntries.entries()) {
if (scanBudget?.isExpired()) {
if (sortedEntries.slice(index).some(couldContainThemeFile)) {
reportPreviewBudgetExpired(sourceLabel, skippedFiles, budget)
}
return
}
if (files.length >= MAX_THEME_FILES) {
if (sortedEntries.slice(index).some(couldContainThemeFile)) {
budget.themeFileLimitHit = true
}
return
}
const relativeLabel = relativeDirectory ? path.join(relativeDirectory, entry.name) : entry.name
const entryPath = path.join(directoryPath, entry.name)
if (entry.isFile() && isYamlFile(entry.name)) {
files.push({ path: entryPath, label: relativeLabel })
continue
}
if (entry.isDirectory()) {
if (depth >= MAX_THEME_DIRECTORY_DEPTH) {
skippedFiles.push({
label: relativeLabel,
reason: 'Nested folder depth limit reached.'
})
continue
}
await collectYamlFilesFromDirectory(
entryPath,
sourceLabel,
relativeLabel,
depth + 1,
files,
skippedFiles,
budget,
state,
scanBudget
)
if (
files.length >= MAX_THEME_FILES &&
sortedEntries.slice(index + 1).some(couldContainThemeFile)
) {
budget.themeFileLimitHit = true
return
}
}
}
}
export async function scanWarpThemeDirectory(
directoryPath: string,
scanBudget?: WarpThemeScanBudget
): Promise<{
sourceLabel: string
rootReadable: boolean
files: ThemeFileCandidate[]
skippedFiles: WarpThemeImportSkippedFile[]
}> {
const sourceLabel = path.basename(directoryPath) || 'Warp themes'
const files: ThemeFileCandidate[] = []
const skippedFiles: WarpThemeImportSkippedFile[] = []
const budget: DirectoryScanBudget = {
directoriesVisited: 0,
directoryLimitReported: false,
entryLimitReported: false,
themeFileLimitHit: false,
previewBudgetReported: false
}
const state: DirectoryScanState = { rootReadable: false }
await collectYamlFilesFromDirectory(
directoryPath,
sourceLabel,
'',
0,
files,
skippedFiles,
budget,
state,
scanBudget
)
if (budget.themeFileLimitHit) {
skippedFiles.push({
label: sourceLabel,
reason: `Only the first ${MAX_THEME_FILES} theme files were scanned.`
})
}
return { sourceLabel, rootReadable: state.rootReadable, files, skippedFiles }
}
@@ -0,0 +1,18 @@
import type { WarpThemeImportSource } from '../../shared/terminal-custom-themes'
const VALID_SOURCE_KINDS = new Set(['auto', 'chooseFile', 'chooseFolder'])
export function validateWarpThemeImportSource(source: unknown): WarpThemeImportSource | null {
if (!source || typeof source !== 'object' || Array.isArray(source)) {
return null
}
const entries = Object.entries(source)
if (entries.length !== 1 || entries[0]?.[0] !== 'kind') {
return null
}
const kind = entries[0][1]
if (typeof kind !== 'string' || !VALID_SOURCE_KINDS.has(kind)) {
return null
}
return { kind } as WarpThemeImportSource
}
@@ -0,0 +1,23 @@
import { parentPort, workerData } from 'worker_threads'
import { parseWarpThemeYaml } from './parser'
import type { ParseWarpThemeOptions } from './parser'
const data = workerData as {
content?: unknown
fileLabel?: unknown
options?: unknown
}
const options =
data.options && typeof data.options === 'object' ? (data.options as ParseWarpThemeOptions) : {}
if (!parentPort) {
throw new Error('Warp theme parser worker must run with a parent port.')
}
parentPort.postMessage(
parseWarpThemeYaml(
typeof data.content === 'string' ? data.content : '',
typeof data.fileLabel === 'string' ? data.fileLabel : 'theme.yaml',
options
)
)
+5
View File
@@ -155,6 +155,10 @@ import type {
WorkspaceSessionPatch,
WorkspaceSessionState
} from '../shared/types'
import type {
WarpThemeImportPreview,
WarpThemeImportSource
} from '../shared/terminal-custom-themes'
import type { SetupScriptImportCandidate } from '../shared/setup-script-imports'
import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history'
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
@@ -1644,6 +1648,7 @@ export type PreloadApi = {
set: (args: Partial<GlobalSettings>) => Promise<GlobalSettings>
listFonts: () => Promise<string[]>
previewGhosttyImport: () => Promise<GhosttyImportPreview>
previewWarpThemeImport: (source: WarpThemeImportSource) => Promise<WarpThemeImportPreview>
/** Subscribe to out-of-band settings updates (e.g. the View > Appearance
* menu toggles) so the renderer can stay in sync with main's persisted
* state without round-tripping through settings:get. */
+7
View File
@@ -44,6 +44,10 @@ import type {
WorktreeDefaultTabsLaunch,
WorktreeRemoteBranchConflictEvent
} from '../shared/types'
import type {
WarpThemeImportPreview,
WarpThemeImportSource
} from '../shared/terminal-custom-themes'
import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history'
import type { ShellOpenLocalPathResult } from '../shared/shell-open-types'
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills'
@@ -1516,6 +1520,9 @@ const api = {
previewGhosttyImport: (): Promise<GhosttyImportPreview> =>
ipcRenderer.invoke('settings:previewGhosttyImport'),
previewWarpThemeImport: (source: WarpThemeImportSource): Promise<WarpThemeImportPreview> =>
ipcRenderer.invoke('settings:previewWarpThemeImport', source),
onChanged: (callback: (updates: Record<string, unknown>) => void): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
@@ -0,0 +1,12 @@
import React from 'react'
// Why: the Warp brand mark labels the "Import themes from Warp" action. Keeping
// it as a currentColor glyph (like the other brand icons here) lets it inherit
// button text color and stay theme-aware. Path from simple-icons.
export function WarpIcon({ className }: { className?: string }): React.JSX.Element {
return (
<svg viewBox="0 0 24 24" aria-hidden className={className} fill="currentColor">
<path d="M12.035 2.723h9.253A2.712 2.712 0 0 1 24 5.435v10.529a2.712 2.712 0 0 1-2.712 2.713H8.047Zm-1.681 2.6L6.766 19.677h5.598l-.399 1.6H2.712A2.712 2.712 0 0 1 0 18.565V8.036a2.712 2.712 0 0 1 2.712-2.712Z" />
</svg>
)
}
@@ -100,6 +100,24 @@ function createGhosttyStub() {
}
}
function createWarpThemesStub() {
return {
open: false,
preview: null,
loading: false,
desktopOnly: false,
applyError: null,
importSignal: 0,
selectedThemeIds: new Set<string>(),
handleClick: vi.fn(),
handlePreviewSource: vi.fn(),
handleToggleTheme: vi.fn(),
handleToggleAll: vi.fn(),
handleApply: vi.fn(),
handleOpenChange: vi.fn()
}
}
async function renderAppearancePane(
settings: GlobalSettings,
updateSettings: (updates: Partial<GlobalSettings>) => void = vi.fn()
@@ -120,6 +138,7 @@ async function renderAppearancePane(
terminalFontSuggestions={[]}
systemPrefersDark={false}
ghostty={createGhosttyStub() as never}
warpThemes={createWarpThemesStub() as never}
/>
</I18nextProvider>
)
@@ -38,7 +38,9 @@ import {
import { getTerminalAppearanceSearchEntries } from './terminal-search'
import { TerminalAppearanceSection } from './TerminalAppearanceSection'
import type { UseGhosttyImportReturn } from './useGhosttyImport'
import type { UseWarpThemeImportReturn } from './useWarpThemeImport'
import { AppIconSelector } from './AppIconSelector'
import { isWebClientLocation } from '@/hooks/useSettingsNavigationMetadata'
import {
getUiLanguageChoiceLabel,
SHOW_UI_LANGUAGE_SETTING,
@@ -56,6 +58,7 @@ type AppearancePaneProps = {
terminalFontSuggestions: string[]
systemPrefersDark: boolean
ghostty: UseGhosttyImportReturn
warpThemes: UseWarpThemeImportReturn
}
function ShortcutHintList({ combos }: { combos: string[][] }): React.JSX.Element {
@@ -88,7 +91,8 @@ export function AppearancePane({
fontSuggestions,
terminalFontSuggestions,
systemPrefersDark,
ghostty
ghostty,
warpThemes
}: AppearancePaneProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const zoomInKeyCombos = useShortcutKeyCombos('zoom.in')
@@ -97,6 +101,9 @@ export function AppearancePane({
const toggleStatusBarItem = useAppStore((state) => state.toggleStatusBarItem)
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
const visibleStatusBarToggles = useAvailableStatusBarToggles(getStatusBarToggles())
const terminalAppearanceSearchEntries = getTerminalAppearanceSearchEntries({
showWarpImport: !isWebClientLocation()
})
const visibleSections = [
matchesSettingsSearch(searchQuery, getThemeEntries()) ||
(SHOW_UI_LANGUAGE_SETTING && matchesSettingsSearch(searchQuery, getLanguageEntries())) ||
@@ -256,7 +263,7 @@ export function AppearancePane({
) : null}
</section>
) : null,
matchesSettingsSearch(searchQuery, getTerminalAppearanceSearchEntries()) ? (
matchesSettingsSearch(searchQuery, terminalAppearanceSearchEntries) ? (
<TerminalAppearanceSection
key="terminal-appearance"
settings={settings}
@@ -264,6 +271,7 @@ export function AppearancePane({
systemPrefersDark={systemPrefersDark}
terminalFontSuggestions={terminalFontSuggestions}
ghostty={ghostty}
warpThemes={warpThemes}
/>
) : null,
matchesSettingsSearch(searchQuery, getLayoutEntries()) ? (
@@ -24,6 +24,7 @@ import { ShortcutsPane } from './ShortcutsPane'
import { TerminalPane } from './TerminalPane'
import { FloatingWorkspacePane } from './FloatingWorkspacePane'
import { useGhosttyImport } from './useGhosttyImport'
import { useWarpThemeImport } from './useWarpThemeImport'
import { RepositoryPane } from './RepositoryPane'
import { GitPane } from './GitPane'
import { CommitMessageAiPane } from './CommitMessageAiPane'
@@ -249,6 +250,7 @@ function Settings(): React.JSX.Element {
// Why: Appearance owns terminal visual controls, but the Ghostty import flow
// still needs Settings-level state so the modal survives section remounts.
const ghostty = useGhosttyImport(updateSettings, settings)
const warpThemes = useWarpThemeImport(updateSettings, settings)
const [fontSuggestions, setFontSuggestions] = useState<string[]>(
Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...getFallbackTerminalFonts()]))
)
@@ -1277,6 +1279,7 @@ function Settings(): React.JSX.Element {
)}
systemPrefersDark={systemPrefersDark}
ghostty={ghostty}
warpThemes={warpThemes}
/>
) : null}
</SettingsSection>
@@ -7,7 +7,7 @@ import { ScrollArea } from '../ui/scroll-area'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Check, ChevronsUpDown, CircleX } from 'lucide-react'
import { BUILTIN_TERMINAL_THEME_NAMES, normalizeColor } from '@/lib/terminal-theme'
import { normalizeColor, type TerminalThemeOption } from '@/lib/terminal-theme'
import { MAX_THEME_RESULTS } from './SettingsConstants'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@@ -236,9 +236,13 @@ type ThemePickerProps = {
label: string
description: string
selectedTheme: string
themeOptions: TerminalThemeOption[]
query: string
onQueryChange: (value: string) => void
onSelectTheme: (theme: string) => void
/** Bumps when themes are imported; scrolls the Imported group into view and
* briefly highlights it so freshly-imported themes are easy to find. */
importedHighlightSignal?: number
}
type ColorFieldProps = {
@@ -277,14 +281,49 @@ export function ThemePicker({
label,
description,
selectedTheme,
themeOptions,
query,
onQueryChange,
onSelectTheme
onSelectTheme,
importedHighlightSignal
}: ThemePickerProps): React.JSX.Element {
const importedGroupRef = useRef<HTMLDivElement | null>(null)
const [highlightImported, setHighlightImported] = useState(false)
// Why: imported themes render below the built-in list inside a fixed-height
// scroll area, so after an import they sit off-screen. On each import signal,
// scroll the Imported group into view and flash a highlight so it's easy to spot.
useEffect(() => {
if (!importedHighlightSignal) {
return
}
importedGroupRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
setHighlightImported(true)
const timer = setTimeout(() => setHighlightImported(false), 2000)
return () => clearTimeout(timer)
}, [importedHighlightSignal])
const normalizedQuery = query.trim().toLowerCase()
const filteredThemes = BUILTIN_TERMINAL_THEME_NAMES.filter((theme) =>
theme.toLowerCase().includes(normalizedQuery)
).slice(0, MAX_THEME_RESULTS)
const matchingThemes = themeOptions.filter((theme) =>
`${theme.label} ${theme.sourceLabel ?? ''}`.toLowerCase().includes(normalizedQuery)
)
const selectedThemeLabel =
themeOptions.find((option) => option.value === selectedTheme)?.label ?? selectedTheme
const groupedThemes = [
{
label: translate('auto.components.settings.SettingsFormControls.builtin_themes', 'Built-in'),
themes: matchingThemes
.filter((theme) => theme.group === 'built-in')
.slice(0, MAX_THEME_RESULTS)
},
{
label: translate('auto.components.settings.SettingsFormControls.imported_themes', 'Imported'),
themes: matchingThemes
.filter((theme) => theme.group === 'imported')
.slice(0, MAX_THEME_RESULTS)
}
].filter((group) => group.themes.length > 0)
const visibleThemeCount = groupedThemes.reduce((sum, group) => sum + group.themes.length, 0)
return (
<div className="space-y-3">
@@ -295,39 +334,122 @@ export function ThemePicker({
<Input
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder={translate("auto.components.settings.SettingsFormControls.fac59213fc", "Search builtin themes")}
placeholder={translate(
'auto.components.settings.SettingsFormControls.search_terminal_themes',
'Search terminal themes'
)}
/>
<div className="rounded-lg border border-border/50">
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
<span>{translate("auto.components.settings.SettingsFormControls.fbb428db98", "Selected:")} {selectedTheme}</span>
<span>
{translate("auto.components.settings.SettingsFormControls.4e11f87ca6", "Showing")} {filteredThemes.length}
{translate('auto.components.settings.SettingsFormControls.fbb428db98', 'Selected:')}{' '}
{selectedThemeLabel}
</span>
<span>
{translate('auto.components.settings.SettingsFormControls.4e11f87ca6', 'Showing')}{' '}
{visibleThemeCount}
{normalizedQuery
? translate("auto.components.settings.SettingsFormControls.c822571b2e", " matching \"{{value0}}\"", { value0: query.trim() })
: translate("auto.components.settings.SettingsFormControls.cb330ef7f8", " of {{value0}}", { value0: BUILTIN_TERMINAL_THEME_NAMES.length })}
? translate(
'auto.components.settings.SettingsFormControls.c822571b2e',
' matching "{{value0}}"',
{ value0: query.trim() }
)
: translate(
'auto.components.settings.SettingsFormControls.cb330ef7f8',
' of {{value0}}',
{ value0: themeOptions.length }
)}
</span>
</div>
<ScrollArea className="h-64">
<div className="space-y-1 p-2">
{filteredThemes.map((theme) => (
<button
key={theme}
onClick={() => onSelectTheme(theme)}
className={`flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors ${
selectedTheme === theme
? 'bg-accent font-medium text-accent-foreground'
: 'hover:bg-muted/60'
}`}
>
<span className="truncate">{theme}</span>
{selectedTheme === theme ? (
<span className="ml-3 shrink-0 text-[11px] uppercase tracking-[0.16em]">
{translate("auto.components.settings.SettingsFormControls.9119fb2268", "Current")}</span>
) : null}
</button>
))}
{filteredThemes.length === 0 ? (
<div className="px-3 py-6 text-sm text-muted-foreground">{translate("auto.components.settings.SettingsFormControls.ceefb9d7f1", "No themes found.")}</div>
{groupedThemes.map((group) => {
const isImported =
group.label ===
translate(
'auto.components.settings.SettingsFormControls.imported_themes',
'Imported'
)
return (
<div
key={group.label}
ref={isImported ? importedGroupRef : undefined}
className={cn(
'space-y-1 rounded-md transition-colors duration-500',
isImported && highlightImported && 'bg-accent/40 ring-1 ring-accent'
)}
>
<p className="px-3 pt-2 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{group.label}
</p>
{group.themes.map((theme) => (
<button
key={theme.value}
onClick={() => onSelectTheme(theme.value)}
className={cn(
'flex w-full items-center justify-between gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors',
selectedTheme === theme.value
? 'bg-accent font-medium text-accent-foreground'
: 'hover:bg-accent'
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate">{theme.label}</span>
{theme.sourceLabel ? (
<span className="block truncate text-[11px] font-normal text-muted-foreground">
{translate(
'auto.components.settings.SettingsFormControls.imported_from',
'Imported from {{value0}}',
{ value0: theme.sourceLabel }
)}
{theme.mode && theme.mode !== 'unknown' ? ` · ${theme.mode}` : ''}
</span>
) : null}
</span>
{/* Why: hide swatches on the current row so the color grid
doesn't shift left to make room for the "Current" label. */}
{theme.group === 'imported' &&
theme.previewTheme &&
selectedTheme !== theme.value ? (
<span className="flex shrink-0 overflow-hidden rounded-sm border border-border/60">
{[
theme.previewTheme.black,
theme.previewTheme.red,
theme.previewTheme.green,
theme.previewTheme.yellow,
theme.previewTheme.blue,
theme.previewTheme.magenta,
theme.previewTheme.cyan,
theme.previewTheme.white
].map((color, index) => (
<span
key={index}
className="h-3 w-2"
style={{ backgroundColor: color ?? 'transparent' }}
/>
))}
</span>
) : null}
{selectedTheme === theme.value ? (
<span className="ml-3 shrink-0 text-[11px] uppercase tracking-[0.16em]">
{translate(
'auto.components.settings.SettingsFormControls.9119fb2268',
'Current'
)}
</span>
) : null}
</button>
))}
</div>
)
})}
{visibleThemeCount === 0 ? (
<div className="px-3 py-6 text-sm text-muted-foreground">
{translate(
'auto.components.settings.SettingsFormControls.ceefb9d7f1',
'No themes found.'
)}
</div>
) : null}
</div>
</ScrollArea>
@@ -414,7 +536,10 @@ export function NumberField({
<>
{description}
{defaultValue !== undefined ? (
<span className="ml-1 text-muted-foreground/70">{translate("auto.components.settings.SettingsFormControls.b661b034ec", "· Default:")} {defaultValue}</span>
<span className="ml-1 text-muted-foreground/70">
{translate('auto.components.settings.SettingsFormControls.b661b034ec', '· Default:')}{' '}
{defaultValue}
</span>
) : null}
</>
}
@@ -620,8 +745,11 @@ export function FontAutocomplete({
focusInput()
}}
className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={translate("auto.components.settings.SettingsFormControls.a4ff6143f8", "Clear font selection")}
title={translate("auto.components.settings.SettingsFormControls.74bcecd5ec", "Clear")}
aria-label={translate(
'auto.components.settings.SettingsFormControls.a4ff6143f8',
'Clear font selection'
)}
title={translate('auto.components.settings.SettingsFormControls.74bcecd5ec', 'Clear')}
>
<CircleX className="size-3.5" />
</button>
@@ -637,8 +765,11 @@ export function FontAutocomplete({
}
}}
className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={translate("auto.components.settings.SettingsFormControls.c766f8ac75", "Toggle font suggestions")}
title={translate("auto.components.settings.SettingsFormControls.b55371ea18", "Fonts")}
aria-label={translate(
'auto.components.settings.SettingsFormControls.c766f8ac75',
'Toggle font suggestions'
)}
title={translate('auto.components.settings.SettingsFormControls.b55371ea18', 'Fonts')}
>
<ChevronsUpDown className="size-3.5" />
</button>
@@ -676,7 +807,12 @@ export function FontAutocomplete({
</button>
))
) : (
<div className="px-3 py-3 text-sm text-muted-foreground">{translate("auto.components.settings.SettingsFormControls.42a4d15a30", "No matching fonts.")}</div>
<div className="px-3 py-3 text-sm text-muted-foreground">
{translate(
'auto.components.settings.SettingsFormControls.42a4d15a30',
'No matching fonts.'
)}
</div>
)}
</div>
</ScrollArea>
@@ -110,6 +110,9 @@ vi.mock('./TerminalThemeSections', () => ({
},
LightTerminalThemeSection: function LightTerminalThemeSection() {
return null
},
TerminalThemeImportSection: function TerminalThemeImportSection() {
return null
}
}))
@@ -125,6 +128,12 @@ vi.mock('./GhosttyImportModal', () => ({
}
}))
vi.mock('./WarpThemeImportModal', () => ({
WarpThemeImportModal: function WarpThemeImportModal() {
return null
}
}))
vi.mock('@/lib/terminal-theme', () => ({
clampNumber: (v: number, min: number, max: number) => Math.max(min, Math.min(max, v)),
resolveEffectiveTerminalAppearance: () => ({
@@ -154,6 +163,29 @@ const ghosttyMock = {
handleOpenChange: vi.fn()
}
const warpThemesMock = {
open: true,
mode: 'warp' as const,
preview: {
found: true,
sourceLabel: 'themes',
themes: [],
skippedFiles: []
},
loading: false,
desktopOnly: false,
applyError: null,
importSignal: 0,
selectedThemeIds: new Set<string>(),
handleClick: vi.fn(),
handleImportYamlClick: vi.fn(),
handlePreviewSource: vi.fn(),
handleToggleTheme: vi.fn(),
handleToggleAll: vi.fn(),
handleApply: vi.fn(),
handleOpenChange: vi.fn()
}
import { TerminalAppearanceSection } from './TerminalAppearanceSection'
type ReactElementLike = {
@@ -197,6 +229,10 @@ function findButtons(node: unknown): { text: string; onClick: (() => void) | und
}
const el = n as ReactElementLike
const typeName = typeof el.type === 'function' ? el.type.name : String(el.type)
if (typeName === 'GhosttyImportButton' || typeName === 'WarpThemeImportButton') {
traverse((el.type as (props: Record<string, unknown>) => unknown)(el.props))
return
}
if (typeName === 'Button') {
const text = extractText(el.props.children)
buttons.push({ text, onClick: el.props.onClick as (() => void) | undefined })
@@ -219,6 +255,30 @@ function findButtons(node: unknown): { text: string; onClick: (() => void) | und
return buttons
}
function findDarkTerminalThemeSection(node: unknown): ReactElementLike | null {
if (node == null) {
return null
}
if (Array.isArray(node)) {
for (const child of node) {
const found = findDarkTerminalThemeSection(child)
if (found) {
return found
}
}
return null
}
const el = node as ReactElementLike
const typeName = typeof el.type === 'function' ? el.type.name : String(el.type)
if (typeName === 'DarkTerminalThemeSection') {
return el
}
if (el.props?.children) {
return findDarkTerminalThemeSection(el.props.children)
}
return null
}
function findGhosttyImportModal(node: unknown): ReactElementLike | null {
if (node == null) {
return null
@@ -243,10 +303,60 @@ function findGhosttyImportModal(node: unknown): ReactElementLike | null {
return null
}
function findTerminalThemeImportSection(node: unknown): ReactElementLike | null {
if (node == null) {
return null
}
if (Array.isArray(node)) {
for (const child of node) {
const found = findTerminalThemeImportSection(child)
if (found) {
return found
}
}
return null
}
const el = node as ReactElementLike
const typeName = typeof el.type === 'function' ? el.type.name : String(el.type)
if (typeName === 'TerminalThemeImportSection') {
return el
}
if (el.props?.children) {
return findTerminalThemeImportSection(el.props.children)
}
return null
}
function findWarpThemeImportModal(node: unknown): ReactElementLike | null {
if (node == null) {
return null
}
if (Array.isArray(node)) {
for (const child of node) {
const found = findWarpThemeImportModal(child)
if (found) {
return found
}
}
return null
}
const el = node as ReactElementLike
const typeName = typeof el.type === 'function' ? el.type.name : String(el.type)
if (typeName === 'WarpThemeImportModal') {
return el
}
if (el.props?.children) {
return findWarpThemeImportModal(el.props.children)
}
return null
}
describe('TerminalAppearanceSection ghostty import wiring', () => {
beforeEach(() => {
mockStateValues.length = 0
resetMockState()
vi.unstubAllGlobals()
vi.stubGlobal('window', { location: { pathname: '/index.html' } })
vi.clearAllMocks()
})
@@ -256,7 +366,8 @@ describe('TerminalAppearanceSection ghostty import wiring', () => {
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
ghostty: ghosttyMock
ghostty: ghosttyMock,
warpThemes: warpThemesMock
})
const buttons = findButtons(element)
@@ -267,13 +378,53 @@ describe('TerminalAppearanceSection ghostty import wiring', () => {
expect(ghosttyMock.handleClick).toHaveBeenCalled()
})
it('renders the shared theme import section above the theme pickers on desktop', () => {
const element = TerminalAppearanceSection({
settings: {} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
ghostty: ghosttyMock,
warpThemes: warpThemesMock
})
// Why: imports land in one pool shared by both pickers, so the buttons
// live in their own section rather than inside the Dark Theme section.
const buttons = findButtons(element)
expect(buttons.some((button) => button.text === 'Import themes from Warp')).toBe(false)
const importSection = findTerminalThemeImportSection(element)
expect(importSection?.props.warpThemes).toBe(warpThemesMock)
expect(findDarkTerminalThemeSection(element)).not.toBeNull()
})
it('hides the theme import affordance on paired web clients', () => {
vi.stubGlobal('window', {
__ORCA_WEB_CLIENT__: true,
location: { pathname: '/web-index.html' }
})
const element = TerminalAppearanceSection({
settings: {} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
ghostty: ghosttyMock,
warpThemes: warpThemesMock
})
expect(findTerminalThemeImportSection(element)).toBeNull()
expect(findWarpThemeImportModal(element)).toBeNull()
})
it('passes hook state to GhosttyImportModal', () => {
const element = TerminalAppearanceSection({
settings: {} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
ghostty: ghosttyMock
ghostty: ghosttyMock,
warpThemes: warpThemesMock
})
const modal = findGhosttyImportModal(element)
@@ -285,4 +436,24 @@ describe('TerminalAppearanceSection ghostty import wiring', () => {
expect(modal?.props.onApply).toBe(ghosttyMock.handleApply)
expect(modal?.props.onOpenChange).toBe(ghosttyMock.handleOpenChange)
})
it('passes hook state to WarpThemeImportModal', () => {
const element = TerminalAppearanceSection({
settings: {} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
ghostty: ghosttyMock,
warpThemes: warpThemesMock
})
const modal = findWarpThemeImportModal(element)
expect(modal).not.toBeNull()
expect(modal?.props.open).toBe(warpThemesMock.open)
expect(modal?.props.preview).toEqual(warpThemesMock.preview)
expect(modal?.props.loading).toBe(warpThemesMock.loading)
expect(modal?.props.desktopOnly).toBe(false)
expect(modal?.props.handleApply).toBe(warpThemesMock.handleApply)
expect(modal?.props.handleOpenChange).toBe(warpThemesMock.handleOpenChange)
})
})
@@ -9,15 +9,24 @@ import {
getTerminalLightThemeSearchEntries,
getTerminalPaneAppearanceSearchEntries,
getTerminalTypographySearchEntries,
getTerminalWindowSearchEntries
getTerminalWarpImportSearchEntries,
getTerminalWindowSearchEntries,
getTerminalYamlImportSearchEntries
} from './terminal-search'
import { DarkTerminalThemeSection, LightTerminalThemeSection } from './TerminalThemeSections'
import {
DarkTerminalThemeSection,
LightTerminalThemeSection,
TerminalThemeImportSection
} from './TerminalThemeSections'
import { TerminalWindowSection } from './TerminalWindowSection'
import { TerminalTypographyAppearanceSection } from './TerminalTypographyAppearanceSection'
import { TerminalCursorAppearanceSection } from './TerminalCursorAppearanceSection'
import { TerminalPaneAppearanceSection } from './TerminalPaneAppearanceSection'
import { GhosttyImportModal } from './GhosttyImportModal'
import type { UseGhosttyImportReturn } from './useGhosttyImport'
import { WarpThemeImportModal } from './WarpThemeImportModal'
import type { UseWarpThemeImportReturn } from './useWarpThemeImport'
import { isWebClientLocation } from '@/hooks/useSettingsNavigationMetadata'
type TerminalAppearanceSectionProps = {
settings: GlobalSettings
@@ -25,6 +34,7 @@ type TerminalAppearanceSectionProps = {
systemPrefersDark: boolean
terminalFontSuggestions: string[]
ghostty: UseGhosttyImportReturn
warpThemes: UseWarpThemeImportReturn
}
export function TerminalAppearanceSection({
@@ -32,12 +42,14 @@ export function TerminalAppearanceSection({
updateSettings,
systemPrefersDark,
terminalFontSuggestions,
ghostty
ghostty,
warpThemes
}: TerminalAppearanceSectionProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const [themeSearchDark, setThemeSearchDark] = useState('')
const [themeSearchLight, setThemeSearchLight] = useState('')
const [previewFontFamily, setPreviewFontFamily] = useState<string | null>(null)
const showWarpThemeImport = !isWebClientLocation()
const visibleSections = [
matchesSettingsSearch(searchQuery, getTerminalGhosttyImportSearchEntries()) ||
@@ -70,6 +82,11 @@ export function TerminalAppearanceSection({
matchesSettingsSearch(searchQuery, getTerminalWindowSearchEntries()) ? (
<TerminalWindowSection key="window" settings={settings} updateSettings={updateSettings} />
) : null,
showWarpThemeImport &&
(matchesSettingsSearch(searchQuery, getTerminalWarpImportSearchEntries()) ||
matchesSettingsSearch(searchQuery, getTerminalYamlImportSearchEntries())) ? (
<TerminalThemeImportSection key="theme-import" warpThemes={warpThemes} />
) : null,
matchesSettingsSearch(searchQuery, getTerminalDarkThemeSearchEntries()) ? (
<DarkTerminalThemeSection
key="dark-theme"
@@ -79,6 +96,7 @@ export function TerminalAppearanceSection({
setThemeSearchDark={setThemeSearchDark}
updateSettings={updateSettings}
previewFontFamily={previewFontFamily}
importedHighlightSignal={warpThemes.importSignal}
/>
) : null,
matchesSettingsSearch(searchQuery, getTerminalLightThemeSearchEntries()) ? (
@@ -110,6 +128,22 @@ export function TerminalAppearanceSection({
applied={ghostty.applied}
applyError={ghostty.applyError}
/>
{showWarpThemeImport ? (
<WarpThemeImportModal
open={warpThemes.open}
mode={warpThemes.mode}
preview={warpThemes.preview}
loading={warpThemes.loading}
desktopOnly={warpThemes.desktopOnly}
applyError={warpThemes.applyError}
selectedThemeIds={warpThemes.selectedThemeIds}
handlePreviewSource={warpThemes.handlePreviewSource}
handleToggleTheme={warpThemes.handleToggleTheme}
handleToggleAll={warpThemes.handleToggleAll}
handleApply={warpThemes.handleApply}
handleOpenChange={warpThemes.handleOpenChange}
/>
) : null}
</div>
)
}
@@ -111,6 +111,7 @@ export function TerminalSettingsPreview({
effectiveMode,
settings.terminalThemeDark,
settings.terminalThemeLight,
settings.terminalCustomThemes,
settings.terminalUseSeparateLightTheme,
settings.terminalDividerColorDark,
settings.terminalDividerColorLight,
@@ -282,18 +283,29 @@ export function TerminalSettingsPreview({
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
<div className="flex items-center gap-2 rounded-md border border-border/50 bg-background/40 px-2 py-1">
<span className="text-xs font-medium text-muted-foreground">{translate("auto.components.settings.TerminalSettingsPreview.50419052fe", "Pane divider")}</span>
<span className="text-xs font-medium text-muted-foreground">
{translate(
'auto.components.settings.TerminalSettingsPreview.50419052fe',
'Pane divider'
)}
</span>
<SettingsSwitch
checked={previewPaneDividerVisible}
onChange={() => setPreviewPaneDividerVisible((visible) => !visible)}
ariaLabel={translate("auto.components.settings.TerminalSettingsPreview.f8931d407d", "Show pane divider in preview")}
ariaLabel={translate(
'auto.components.settings.TerminalSettingsPreview.f8931d407d',
'Show pane divider in preview'
)}
/>
</div>
{showToggle ? (
<div
className="flex gap-0.5 rounded-md border border-border/50 p-0.5"
role="group"
aria-label={translate("auto.components.settings.TerminalSettingsPreview.2c248fcc27", "Preview theme")}
aria-label={translate(
'auto.components.settings.TerminalSettingsPreview.2c248fcc27',
'Preview theme'
)}
>
{(['dark', 'light'] as const).map((mode) => (
<button
@@ -301,15 +313,23 @@ export function TerminalSettingsPreview({
type="button"
onClick={() => setTogglePreviewMode(mode)}
aria-pressed={togglePreviewMode === mode}
aria-label={translate("auto.components.settings.TerminalSettingsPreview.a63953a48a", "Preview {{value0}} theme", { value0: mode })}
title={translate("auto.components.settings.TerminalSettingsPreview.a63953a48a", "Preview {{value0}} theme", { value0: mode })}
aria-label={translate(
'auto.components.settings.TerminalSettingsPreview.a63953a48a',
'Preview {{value0}} theme',
{ value0: mode }
)}
title={translate(
'auto.components.settings.TerminalSettingsPreview.a63953a48a',
'Preview {{value0}} theme',
{ value0: mode }
)}
className={`rounded-sm p-1 transition-colors ${
togglePreviewMode === mode
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{mode === "dark" ? <Moon className="size-3.5" /> : <Sun className="size-3.5" />}
{mode === 'dark' ? <Moon className="size-3.5" /> : <Sun className="size-3.5" />}
</button>
))}
</div>
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../../../shared/types'
import type { UseWarpThemeImportReturn } from './useWarpThemeImport'
vi.mock('./TerminalSettingsPreview', () => ({
TerminalSettingsPreview: function TerminalSettingsPreview() {
@@ -7,13 +8,68 @@ vi.mock('./TerminalSettingsPreview', () => ({
}
}))
import { LightTerminalThemeSection } from './TerminalThemeSections'
import {
DarkTerminalThemeSection,
LightTerminalThemeSection,
TerminalThemeImportSection
} from './TerminalThemeSections'
type ReactElementLike = {
type: unknown
props?: Record<string, unknown>
}
const warpThemesMock: UseWarpThemeImportReturn = {
open: false,
mode: 'warp',
preview: null,
loading: false,
desktopOnly: false,
applyError: null,
importSignal: 0,
selectedThemeIds: new Set<string>(),
handleClick: vi.fn(),
handleImportYamlClick: vi.fn(),
handlePreviewSource: vi.fn(),
handleToggleTheme: vi.fn(),
handleToggleAll: vi.fn(),
handleApply: vi.fn(),
handleOpenChange: vi.fn()
}
function findButtonTexts(node: unknown): string[] {
if (node == null || typeof node === 'string' || typeof node === 'number') {
return []
}
if (Array.isArray(node)) {
return node.flatMap(findButtonTexts)
}
const element = node as ReactElementLike
const typeName = typeof element.type === 'function' ? element.type.name : String(element.type)
if (typeName === 'WarpThemeImportButton') {
return ['Import themes from Warp']
}
if (typeName === 'YamlThemeImportButton') {
return ['Import from YAML']
}
return [...findButtonTexts(element.props?.children), ...findButtonTexts(element.props?.action)]
}
function renderDarkSection(): React.JSX.Element {
return DarkTerminalThemeSection({
settings: {
terminalThemeDark: 'Ghostty Default Style Dark',
terminalDividerColorDark: '#3f3f46'
} as GlobalSettings,
systemPrefersDark: true,
themeSearchDark: '',
setThemeSearchDark: () => {},
updateSettings: () => {},
previewFontFamily: null,
importedHighlightSignal: 0
})
}
function makeSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
return {
terminalUseSeparateLightTheme: false,
@@ -61,3 +117,16 @@ describe('LightTerminalThemeSection preview lifecycle', () => {
expect(countElementsByTypeName(element, 'TerminalSettingsPreview')).toBe(1)
})
})
describe('TerminalThemeImportSection', () => {
it('renders the Warp and YAML import buttons in the shared import section', () => {
const buttonTexts = findButtonTexts(TerminalThemeImportSection({ warpThemes: warpThemesMock }))
expect(buttonTexts).toContain('Import themes from Warp')
expect(buttonTexts).toContain('Import from YAML')
})
it('keeps the import buttons out of the mode-specific theme sections', () => {
expect(findButtonTexts(renderDarkSection())).toEqual([])
})
})
@@ -1,8 +1,12 @@
import type { Dispatch, SetStateAction } from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import { ColorField, ThemePicker } from './SettingsFormControls'
import { ColorField, SettingsSubsectionHeader, ThemePicker } from './SettingsFormControls'
import { SearchableSetting } from './SearchableSetting'
import { TerminalSettingsPreview } from './TerminalSettingsPreview'
import { WarpThemeImportButton } from './WarpThemeImportButton'
import { YamlThemeImportButton } from './YamlThemeImportButton'
import type { UseWarpThemeImportReturn } from './useWarpThemeImport'
import { getAvailableTerminalThemeOptions } from '@/lib/terminal-theme'
import { translate } from '@/i18n/i18n'
type DarkTerminalThemeSectionProps = {
@@ -12,6 +16,7 @@ type DarkTerminalThemeSectionProps = {
setThemeSearchDark: Dispatch<SetStateAction<string>>
updateSettings: (updates: Partial<GlobalSettings>) => void
previewFontFamily: string | null
importedHighlightSignal: number
}
type LightTerminalThemeSectionProps = {
@@ -22,46 +27,108 @@ type LightTerminalThemeSectionProps = {
previewFontFamily: string | null
}
/** Shared import affordance for terminal themes. Why: imported themes land in
* one pool used by both the dark and light pickers, so the buttons live above
* both sections rather than implying a mode-specific import. */
export function TerminalThemeImportSection({
warpThemes
}: {
warpThemes: UseWarpThemeImportReturn
}): React.JSX.Element {
return (
<section className="space-y-3">
<SettingsSubsectionHeader
title={translate(
'auto.components.settings.TerminalThemeSections.import_themes_title',
'Import Themes'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.import_themes_description',
'Imported themes are available in both the dark and light theme pickers.'
)}
/>
<div className="flex flex-wrap items-center gap-2">
<WarpThemeImportButton warpThemes={warpThemes} />
<YamlThemeImportButton warpThemes={warpThemes} />
</div>
</section>
)
}
export function DarkTerminalThemeSection({
settings,
systemPrefersDark,
themeSearchDark,
setThemeSearchDark,
updateSettings,
previewFontFamily
previewFontFamily,
importedHighlightSignal
}: DarkTerminalThemeSectionProps): React.JSX.Element {
const themeOptions = getAvailableTerminalThemeOptions(settings)
return (
<section className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-6">
<div className="space-y-1">
<h3 className="text-sm font-semibold">{translate("auto.components.settings.TerminalThemeSections.9499ad1dc4", "Dark Theme")}</h3>
<p className="text-xs text-muted-foreground">
{translate("auto.components.settings.TerminalThemeSections.f012172e21", "Choose the theme used for terminal panes in dark mode.")}</p>
</div>
<SettingsSubsectionHeader
title={translate(
'auto.components.settings.TerminalThemeSections.9499ad1dc4',
'Dark Theme'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.f012172e21',
'Choose the theme used for terminal panes in dark mode.'
)}
/>
<SearchableSetting
title={translate("auto.components.settings.TerminalThemeSections.9499ad1dc4", "Dark Theme")}
description={translate("auto.components.settings.TerminalThemeSections.7add204bd5", "Choose the terminal theme used in dark mode.")}
title={translate(
'auto.components.settings.TerminalThemeSections.9499ad1dc4',
'Dark Theme'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.7add204bd5',
'Choose the terminal theme used in dark mode.'
)}
keywords={['terminal', 'theme', 'dark', 'preview']}
>
<ThemePicker
label={translate("auto.components.settings.TerminalThemeSections.9499ad1dc4", "Dark Theme")}
description={translate("auto.components.settings.TerminalThemeSections.7add204bd5", "Choose the terminal theme used in dark mode.")}
label={translate(
'auto.components.settings.TerminalThemeSections.9499ad1dc4',
'Dark Theme'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.7add204bd5',
'Choose the terminal theme used in dark mode.'
)}
selectedTheme={settings.terminalThemeDark}
themeOptions={themeOptions}
query={themeSearchDark}
onQueryChange={setThemeSearchDark}
onSelectTheme={(theme) => updateSettings({ terminalThemeDark: theme })}
importedHighlightSignal={importedHighlightSignal}
/>
</SearchableSetting>
<SearchableSetting
title={translate("auto.components.settings.TerminalThemeSections.b739d2abfe", "Dark Divider Color")}
description={translate("auto.components.settings.TerminalThemeSections.cbe56a0f79", "Controls the split divider line between panes in dark mode.")}
title={translate(
'auto.components.settings.TerminalThemeSections.b739d2abfe',
'Dark Divider Color'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.cbe56a0f79',
'Controls the split divider line between panes in dark mode.'
)}
keywords={['terminal', 'divider', 'dark', 'color']}
>
<ColorField
label={translate("auto.components.settings.TerminalThemeSections.b739d2abfe", "Dark Divider Color")}
description={translate("auto.components.settings.TerminalThemeSections.cbe56a0f79", "Controls the split divider line between panes in dark mode.")}
label={translate(
'auto.components.settings.TerminalThemeSections.b739d2abfe',
'Dark Divider Color'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.cbe56a0f79',
'Controls the split divider line between panes in dark mode.'
)}
value={settings.terminalDividerColorDark}
fallback="#3f3f46"
onChange={(value) => updateSettings({ terminalDividerColorDark: value })}
@@ -70,7 +137,10 @@ export function DarkTerminalThemeSection({
</div>
<TerminalSettingsPreview
title={translate("auto.components.settings.TerminalThemeSections.bc8e8a251a", "Dark Mode Preview")}
title={translate(
'auto.components.settings.TerminalThemeSections.bc8e8a251a',
'Dark Mode Preview'
)}
settings={settings}
systemPrefersDark={systemPrefersDark}
previewFontFamily={previewFontFamily}
@@ -87,18 +157,35 @@ export function LightTerminalThemeSection({
updateSettings,
previewFontFamily
}: LightTerminalThemeSectionProps): React.JSX.Element {
const themeOptions = getAvailableTerminalThemeOptions(settings)
return (
<section className="space-y-4">
<SearchableSetting
title={translate("auto.components.settings.TerminalThemeSections.d76f60c9cc", "Use Separate Theme In Light Mode")}
description={translate("auto.components.settings.TerminalThemeSections.b584287e84", "When disabled, light mode reuses the dark terminal theme.")}
title={translate(
'auto.components.settings.TerminalThemeSections.d76f60c9cc',
'Use Separate Theme In Light Mode'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.b584287e84',
'When disabled, light mode reuses the dark terminal theme.'
)}
keywords={['terminal', 'light mode', 'theme']}
className="flex items-center justify-between gap-4 py-2"
>
<div className="space-y-0.5">
<p className="text-sm font-medium">{translate("auto.components.settings.TerminalThemeSections.d76f60c9cc", "Use Separate Theme In Light Mode")}</p>
<p className="text-sm font-medium">
{translate(
'auto.components.settings.TerminalThemeSections.d76f60c9cc',
'Use Separate Theme In Light Mode'
)}
</p>
<p className="text-xs text-muted-foreground">
{translate("auto.components.settings.TerminalThemeSections.b584287e84", "When disabled, light mode reuses the dark terminal theme.")}</p>
{translate(
'auto.components.settings.TerminalThemeSections.b584287e84',
'When disabled, light mode reuses the dark terminal theme.'
)}
</p>
</div>
<button
role="switch"
@@ -125,20 +212,42 @@ export function LightTerminalThemeSection({
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-6">
<div className="space-y-1">
<h3 className="text-sm font-semibold">{translate("auto.components.settings.TerminalThemeSections.8273bc75d7", "Light Theme")}</h3>
<h3 className="text-sm font-semibold">
{translate(
'auto.components.settings.TerminalThemeSections.8273bc75d7',
'Light Theme'
)}
</h3>
<p className="text-xs text-muted-foreground">
{translate("auto.components.settings.TerminalThemeSections.74b15574c8", "Configure the optional light-mode terminal appearance.")}</p>
{translate(
'auto.components.settings.TerminalThemeSections.74b15574c8',
'Configure the optional light-mode terminal appearance.'
)}
</p>
</div>
<SearchableSetting
title={translate("auto.components.settings.TerminalThemeSections.8273bc75d7", "Light Theme")}
description={translate("auto.components.settings.TerminalThemeSections.d56af60e6f", "Choose the theme used when Orca is in light mode.")}
title={translate(
'auto.components.settings.TerminalThemeSections.8273bc75d7',
'Light Theme'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.d56af60e6f',
'Choose the theme used when Orca is in light mode.'
)}
keywords={['terminal', 'theme', 'light', 'preview']}
>
<ThemePicker
label={translate("auto.components.settings.TerminalThemeSections.8273bc75d7", "Light Theme")}
description={translate("auto.components.settings.TerminalThemeSections.d56af60e6f", "Choose the theme used when Orca is in light mode.")}
label={translate(
'auto.components.settings.TerminalThemeSections.8273bc75d7',
'Light Theme'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.d56af60e6f',
'Choose the theme used when Orca is in light mode.'
)}
selectedTheme={settings.terminalThemeLight}
themeOptions={themeOptions}
query={themeSearchLight}
onQueryChange={setThemeSearchLight}
onSelectTheme={(theme) => updateSettings({ terminalThemeLight: theme })}
@@ -146,13 +255,25 @@ export function LightTerminalThemeSection({
</SearchableSetting>
<SearchableSetting
title={translate("auto.components.settings.TerminalThemeSections.ec2e33ad80", "Light Divider Color")}
description={translate("auto.components.settings.TerminalThemeSections.5e0c24b5c8", "Controls the split divider line between panes in light mode.")}
title={translate(
'auto.components.settings.TerminalThemeSections.ec2e33ad80',
'Light Divider Color'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.5e0c24b5c8',
'Controls the split divider line between panes in light mode.'
)}
keywords={['terminal', 'divider', 'light', 'color']}
>
<ColorField
label={translate("auto.components.settings.TerminalThemeSections.ec2e33ad80", "Light Divider Color")}
description={translate("auto.components.settings.TerminalThemeSections.5e0c24b5c8", "Controls the split divider line between panes in light mode.")}
label={translate(
'auto.components.settings.TerminalThemeSections.ec2e33ad80',
'Light Divider Color'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.5e0c24b5c8',
'Controls the split divider line between panes in light mode.'
)}
value={settings.terminalDividerColorLight}
fallback="#d4d4d8"
onChange={(value) => updateSettings({ terminalDividerColorLight: value })}
@@ -161,7 +282,10 @@ export function LightTerminalThemeSection({
</div>
<TerminalSettingsPreview
title={translate("auto.components.settings.TerminalThemeSections.db210115c5", "Light Mode Preview")}
title={translate(
'auto.components.settings.TerminalThemeSections.db210115c5',
'Light Mode Preview'
)}
settings={settings}
systemPrefersDark={false}
previewFontFamily={previewFontFamily}
@@ -0,0 +1,24 @@
import { Button } from '../ui/button'
import { WarpIcon } from '../icons/WarpIcon'
import type { UseWarpThemeImportReturn } from './useWarpThemeImport'
import { translate } from '@/i18n/i18n'
// Why: Warp import only produces terminal themes, so it sits with the theme
// pickers rather than in the Typography header.
export function WarpThemeImportButton({
warpThemes
}: {
warpThemes: UseWarpThemeImportReturn
}): React.JSX.Element {
return (
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => void warpThemes.handleClick()}
>
<WarpIcon className="size-4" />
{translate('auto.components.settings.WarpThemeImportModal.title', 'Import themes from Warp')}
</Button>
)
}
@@ -0,0 +1,331 @@
import { FileUp, FolderOpen, Loader2 } from 'lucide-react'
import type { WarpThemeImportPreviewTheme } from '../../../../shared/terminal-custom-themes'
import { Button } from '../ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui/dialog'
import { ScrollArea } from '../ui/scroll-area'
import { SettingsBadge } from './SettingsFormControls'
import type { UseWarpThemeImportReturn } from './useWarpThemeImport'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
type WarpThemeImportModalProps = Pick<
UseWarpThemeImportReturn,
| 'open'
| 'mode'
| 'preview'
| 'loading'
| 'desktopOnly'
| 'applyError'
| 'selectedThemeIds'
| 'handlePreviewSource'
| 'handleToggleTheme'
| 'handleToggleAll'
| 'handleApply'
| 'handleOpenChange'
>
function ThemeSwatches({ theme }: { theme: WarpThemeImportPreviewTheme }): React.JSX.Element {
const colors = [
theme.terminal.black,
theme.terminal.red,
theme.terminal.green,
theme.terminal.yellow,
theme.terminal.blue,
theme.terminal.magenta,
theme.terminal.cyan,
theme.terminal.white
]
return (
<span className="flex shrink-0 overflow-hidden rounded-sm border border-border/60">
{colors.map((color, index) => (
<span
key={index}
className="h-3 w-2.5"
style={{ backgroundColor: color ?? 'transparent' }}
/>
))}
</span>
)
}
export function WarpThemeImportModal({
open,
mode,
preview,
loading,
desktopOnly,
applyError,
selectedThemeIds,
handlePreviewSource,
handleToggleTheme,
handleToggleAll,
handleApply,
handleOpenChange
}: WarpThemeImportModalProps): React.JSX.Element {
const themes = preview?.themes ?? []
const allSelected = themes.length > 0 && themes.every((theme) => selectedThemeIds.has(theme.id))
const selectedCount = selectedThemeIds.size
const skippedCount = preview?.skippedFiles.length ?? 0
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-2xl sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-sm">
{mode === 'yaml'
? translate(
'auto.components.settings.WarpThemeImportModal.yaml_title',
'Import theme YAML'
)
: translate(
'auto.components.settings.WarpThemeImportModal.title',
'Import themes from Warp'
)}
</DialogTitle>
<DialogDescription className="text-xs">
{mode === 'yaml'
? translate(
'auto.components.settings.WarpThemeImportModal.yaml_description',
'Import theme YAML files (Warp format) as Orca terminal themes.'
)
: translate(
'auto.components.settings.WarpThemeImportModal.description',
'Import Warp themes as Orca terminal themes.'
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{!desktopOnly ? (
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
className="gap-1.5"
disabled={loading}
onClick={() => void handlePreviewSource({ kind: 'chooseFile' })}
>
<FileUp className="size-4" />
{translate(
'auto.components.settings.WarpThemeImportModal.choose_file',
'Choose File'
)}
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5"
disabled={loading}
onClick={() => void handlePreviewSource({ kind: 'chooseFolder' })}
>
<FolderOpen className="size-4" />
{translate(
'auto.components.settings.WarpThemeImportModal.choose_folder',
'Choose Folder'
)}
</Button>
</div>
) : null}
{loading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{translate(
'auto.components.settings.WarpThemeImportModal.loading',
'Loading Warp themes...'
)}
</div>
) : preview == null ? null : preview.found ? (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<span>
{preview.themes.length === 1
? translate(
'auto.components.settings.WarpThemeImportModal.found_theme_one',
'Found 1 theme'
)
: translate(
'auto.components.settings.WarpThemeImportModal.found_theme_other',
'Found {{value0}} themes',
{ value0: preview.themes.length }
)}
{preview.sourceLabel
? translate(
'auto.components.settings.WarpThemeImportModal.found_in_source',
' in {{value0}}',
{ value0: preview.sourceLabel }
)
: ''}
</span>
<button
type="button"
className="text-xs font-medium text-foreground hover:underline"
onClick={() => handleToggleAll(!allSelected)}
>
{allSelected
? translate(
'auto.components.settings.WarpThemeImportModal.clear_all',
'Clear all'
)
: translate(
'auto.components.settings.WarpThemeImportModal.select_all',
'Select all'
)}
</button>
</div>
<div className="rounded-lg border border-border/50">
<ScrollArea className="h-72">
<div className="space-y-1 p-2">
{themes.map((theme) => {
const selected = selectedThemeIds.has(theme.id)
return (
<button
type="button"
key={theme.id}
aria-pressed={selected}
onClick={() => handleToggleTheme(theme.id)}
className={cn(
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors',
selected ? 'bg-accent text-accent-foreground' : 'hover:bg-accent'
)}
>
<span
aria-hidden="true"
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-sm border text-[10px] leading-none',
selected
? 'border-accent-foreground bg-accent-foreground text-accent'
: 'border-border bg-background'
)}
>
{selected ? '✓' : null}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{theme.name}</span>
{theme.mode !== 'unknown' ? (
<SettingsBadge tone="muted">{theme.mode}</SettingsBadge>
) : null}
</div>
{theme.unsupportedFeatures?.length ? (
<p className="truncate text-xs text-muted-foreground">
{theme.unsupportedFeatures.join(', ')}
</p>
) : (
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.WarpThemeImportModal.colors_only',
'Colors only'
)}
</p>
)}
</div>
<ThemeSwatches theme={theme} />
</button>
)
})}
</div>
</ScrollArea>
</div>
</div>
) : (
<div className="space-y-2 text-xs text-muted-foreground">
<p>
{preview.error ??
(mode === 'yaml'
? translate(
'auto.components.settings.WarpThemeImportModal.yaml_no_themes_found',
'No themes found in the selected files.'
)
: translate(
'auto.components.settings.WarpThemeImportModal.no_themes_found',
'No custom Warp themes found.'
))}
</p>
{!preview.error && mode !== 'yaml' ? (
<p>
{translate(
'auto.components.settings.WarpThemeImportModal.builtin_themes_hint',
"Warp's preloaded themes are part of the Warp app and can't be read from disk. Orca already includes most of them, like Dracula, Gruvbox, Solarized, and Tokyo Night."
)}
</p>
) : null}
{!desktopOnly ? (
<p>
{translate(
'auto.components.settings.WarpThemeImportModal.choose_manually',
'Choose a theme YAML file or folder to import manually.'
)}
</p>
) : null}
</div>
)}
{!loading && preview && skippedCount > 0 ? (
<div className="rounded-lg border border-border/50 p-3">
<p className="mb-2 text-xs font-medium">
{translate(
'auto.components.settings.WarpThemeImportModal.skipped_files',
'Skipped files'
)}
</p>
<ul className="scrollbar-sleek max-h-24 space-y-1 overflow-auto text-xs text-muted-foreground">
{preview.skippedFiles.slice(0, 8).map((file) => (
<li key={`${file.label}:${file.reason}`} className="flex gap-2">
<span className="shrink-0 font-medium text-foreground/80">{file.label}</span>
<span>{file.reason}</span>
</li>
))}
{preview.skippedFiles.length > 8 ? (
<li>
{translate(
'auto.components.settings.WarpThemeImportModal.more_skipped_files',
'{{value0}} more skipped files.',
{ value0: preview.skippedFiles.length - 8 }
)}
</li>
) : null}
</ul>
</div>
) : null}
{applyError ? <p className="text-xs text-destructive">{applyError}</p> : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
{translate('auto.components.settings.WarpThemeImportModal.cancel', 'Cancel')}
</Button>
<Button
disabled={!preview?.found || selectedCount === 0 || loading}
onClick={() => void handleApply()}
>
{selectedCount === 1
? translate(
'auto.components.settings.WarpThemeImportModal.import_theme_one',
'Import 1 Theme'
)
: selectedCount > 0
? translate(
'auto.components.settings.WarpThemeImportModal.import_theme_other',
'Import {{value0}} Themes',
{ value0: selectedCount }
)
: translate(
'auto.components.settings.WarpThemeImportModal.import_themes',
'Import Themes'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,24 @@
import { FileUp } from 'lucide-react'
import { Button } from '../ui/button'
import type { UseWarpThemeImportReturn } from './useWarpThemeImport'
import { translate } from '@/i18n/i18n'
/** Imports theme YAML files (Warp format) straight from a native file picker,
* without routing through Warp auto-discovery. */
export function YamlThemeImportButton({
warpThemes
}: {
warpThemes: UseWarpThemeImportReturn
}): React.JSX.Element {
return (
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => void warpThemes.handleImportYamlClick()}
>
<FileUp className="size-4" />
{translate('auto.components.settings.YamlThemeImportButton.label', 'Import from YAML')}
</Button>
)
}
@@ -247,15 +247,39 @@ export const getAppIconEntries = createLocalizedCatalog((): SettingsSearchEntry[
}
])
export const getAppearancePaneSearchEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
...getThemeEntries(),
...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []),
...getTypographyEntries(),
...getZoomEntries(),
...getTerminalAppearanceSearchEntries(),
...getLayoutEntries(),
...getTitlebarEntries(),
...getStatusBarEntries(),
...getSidebarEntries(),
...getAppIconEntries()
])
type AppearancePaneSearchOptions = {
showWarpImport?: boolean
}
function buildAppearancePaneSearchEntries(
options: AppearancePaneSearchOptions
): SettingsSearchEntry[] {
return [
...getThemeEntries(),
...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []),
...getTypographyEntries(),
...getZoomEntries(),
...getTerminalAppearanceSearchEntries(options),
...getLayoutEntries(),
...getTitlebarEntries(),
...getStatusBarEntries(),
...getSidebarEntries(),
...getAppIconEntries()
]
}
const getAppearancePaneSearchEntriesWithWarp = createLocalizedCatalog(() =>
buildAppearancePaneSearchEntries({ showWarpImport: true })
)
const getAppearancePaneSearchEntriesWithoutWarp = createLocalizedCatalog(() =>
buildAppearancePaneSearchEntries({ showWarpImport: false })
)
export function getAppearancePaneSearchEntries(
options: AppearancePaneSearchOptions = {}
): SettingsSearchEntry[] {
return (options.showWarpImport ?? true)
? getAppearancePaneSearchEntriesWithWarp()
: getAppearancePaneSearchEntriesWithoutWarp()
}
@@ -84,6 +84,15 @@ describe('getTerminalPaneSearchEntries', () => {
)
})
it('omits the Warp import appearance entry when desktop-only controls are hidden', () => {
const desktopEntries = getAppearancePaneSearchEntries({ showWarpImport: true })
const webEntries = getAppearancePaneSearchEntries({ showWarpImport: false })
expect(desktopEntries.some((entry) => entry.title === 'Import themes from Warp')).toBe(true)
expect(webEntries.some((entry) => entry.title === 'Import themes from Warp')).toBe(false)
expect(webEntries.some((entry) => entry.title === 'Import from Ghostty')).toBe(true)
})
it('keeps sidebar shortcut restore settings in the Appearance search index', () => {
const automationsEntry = getSidebarEntries().find(
(entry) => entry.title === 'Show Automations Button'
@@ -11,7 +11,9 @@ import {
} from './terminal-pane-appearance-search'
import {
getTerminalDarkThemeSearchEntries,
getTerminalLightThemeSearchEntries
getTerminalLightThemeSearchEntries,
getTerminalWarpImportSearchEntries,
getTerminalYamlImportSearchEntries
} from './terminal-theme-search'
import {
getTerminalCursorSearchEntries,
@@ -37,7 +39,9 @@ export {
} from './terminal-pane-appearance-search'
export {
getTerminalDarkThemeSearchEntries,
getTerminalLightThemeSearchEntries
getTerminalLightThemeSearchEntries,
getTerminalWarpImportSearchEntries,
getTerminalYamlImportSearchEntries
} from './terminal-theme-search'
export {
getTerminalAdvancedSearchEntries,
@@ -51,7 +55,11 @@ export {
getTerminalSetupScriptSearchEntries
} from './terminal-window-setup-search'
export const getTerminalAppearanceSearchEntries = createLocalizedCatalog(
type TerminalAppearanceSearchOptions = {
showWarpImport?: boolean
}
const getTerminalAppearanceSearchEntriesWithoutWarp = createLocalizedCatalog(
(): SettingsSearchEntry[] => [
...getTerminalTypographySearchEntries(),
...getTerminalCursorSearchEntries(),
@@ -63,6 +71,24 @@ export const getTerminalAppearanceSearchEntries = createLocalizedCatalog(
]
)
// Why: compose rather than filter — entry titles are localized, so matching on
// an English title would leak the Warp entry back in under non-English locales.
const getTerminalAppearanceSearchEntriesWithWarp = createLocalizedCatalog(
(): SettingsSearchEntry[] => [
...getTerminalAppearanceSearchEntriesWithoutWarp(),
...getTerminalWarpImportSearchEntries(),
...getTerminalYamlImportSearchEntries()
]
)
export function getTerminalAppearanceSearchEntries(
options: TerminalAppearanceSearchOptions = {}
): SettingsSearchEntry[] {
return (options.showWarpImport ?? true)
? getTerminalAppearanceSearchEntriesWithWarp()
: getTerminalAppearanceSearchEntriesWithoutWarp()
}
export function getTerminalPaneSearchEntries(platform: {
isWindows: boolean
isMac: boolean
@@ -77,3 +77,45 @@ export const getTerminalLightThemeSearchEntries = createLocalizedCatalog(() => [
]
}
])
export const getTerminalYamlImportSearchEntries = createLocalizedCatalog(() => [
{
title: translate(
'auto.components.settings.terminal.search.yaml_import.title',
'Import from YAML'
),
description: translate(
'auto.components.settings.terminal.search.yaml_import.description',
'Import theme YAML files as Orca terminal themes.'
),
keywords: [
translate('auto.components.settings.terminal.search.yaml_import.keyword_yaml', 'yaml'),
translate('auto.components.settings.terminal.search.fd752b3cac', 'import'),
translate('auto.components.settings.terminal.search.f66a7cf715', 'terminal'),
translate('auto.components.settings.terminal.search.0ce176909a', 'theme'),
translate('auto.components.settings.terminal.search.warp_import.keyword_themes', 'themes'),
translate('auto.components.settings.terminal.search.yaml_import.keyword_custom', 'custom')
]
}
])
export const getTerminalWarpImportSearchEntries = createLocalizedCatalog(() => [
{
title: translate(
'auto.components.settings.terminal.search.warp_import.title',
'Import themes from Warp'
),
description: translate(
'auto.components.settings.terminal.search.warp_import.description',
'Import Warp themes as Orca terminal themes.'
),
keywords: [
translate('auto.components.settings.terminal.search.warp_import.keyword_warp', 'warp'),
translate('auto.components.settings.terminal.search.fd752b3cac', 'import'),
translate('auto.components.settings.terminal.search.f66a7cf715', 'terminal'),
translate('auto.components.settings.terminal.search.0ce176909a', 'theme'),
translate('auto.components.settings.terminal.search.warp_import.keyword_themes', 'themes'),
translate('auto.components.settings.terminal.search.warp_import.keyword_yaml', 'yaml')
]
}
])
@@ -0,0 +1,420 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../../../shared/types'
import {
MAX_TERMINAL_CUSTOM_THEMES,
type WarpThemeImportPreview
} from '../../../../shared/terminal-custom-themes'
const mockStateValues: unknown[] = []
let mockStateIndex = 0
const toastSuccess = vi.fn()
vi.mock('sonner', () => ({ toast: { success: (msg: string) => toastSuccess(msg) } }))
const baseSettings: GlobalSettings = {
terminalCustomThemes: [
{
id: 'warp:existing',
name: 'Existing',
source: 'warp',
mode: 'dark',
terminal: { background: '#000000', foreground: '#ffffff', black: '#111111' },
importedAt: '2026-06-01T00:00:00.000Z'
}
]
} as GlobalSettings
function resetMockState() {
mockStateIndex = 0
}
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return {
...actual,
useEffect: (effect: () => void | (() => void)) => {
void effect()
},
useRef: (initial: unknown) => ({ current: initial }),
useState: (initial: unknown) => {
const i = mockStateIndex++
if (mockStateValues[i] === undefined) {
mockStateValues[i] = typeof initial === 'function' ? initial() : initial
}
const setter = (v: unknown) => {
mockStateValues[i] = typeof v === 'function' ? v(mockStateValues[i]) : v
}
return [mockStateValues[i], setter]
}
}
})
import { useWarpThemeImport } from './useWarpThemeImport'
describe('useWarpThemeImport', () => {
beforeEach(() => {
mockStateValues.length = 0
resetMockState()
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('does not preview Warp themes on mount', () => {
const previewMock = vi.fn()
vi.stubGlobal('window', {
api: { settings: { previewWarpThemeImport: previewMock } }
})
useWarpThemeImport(vi.fn(), baseSettings)
expect(previewMock).not.toHaveBeenCalled()
})
it('previews on click and merges selected themes into settings', async () => {
const previewResponse: WarpThemeImportPreview = {
found: true,
sourceLabel: 'themes',
skippedFiles: [],
themes: [
{
id: 'warp:tokyo-night',
selectionValue: 'custom:warp:tokyo-night',
name: 'Tokyo Night',
source: 'warp',
mode: 'dark',
terminal: { background: '#1a1b26', foreground: '#c0caf5', black: '#15161e' },
importedAt: '2026-06-05T00:00:00.000Z',
sourceLabel: 'themes'
}
]
}
const previewMock = vi.fn().mockResolvedValue(previewResponse)
vi.stubGlobal('window', {
api: { settings: { previewWarpThemeImport: previewMock } }
})
const updateSettings = vi.fn()
let warp = useWarpThemeImport(updateSettings, baseSettings)
await warp.handleClick()
expect(previewMock).toHaveBeenCalledWith({ kind: 'auto' })
resetMockState()
warp = useWarpThemeImport(updateSettings, baseSettings)
expect(warp.open).toBe(true)
expect(warp.selectedThemeIds.has('warp:tokyo-night')).toBe(true)
await warp.handleApply()
expect(updateSettings).toHaveBeenCalledWith({
terminalCustomThemes: [
expect.objectContaining({ id: 'warp:existing' }),
expect.objectContaining({ id: 'warp:tokyo-night', name: 'Tokyo Night' })
]
})
// Success is reported via a toast, and the modal closes itself.
expect(toastSuccess).toHaveBeenCalledWith('Imported 1 theme')
resetMockState()
warp = useWarpThemeImport(updateSettings, baseSettings)
expect(warp.open).toBe(false)
})
it('does not apply when no themes are selected', async () => {
const previewResponse: WarpThemeImportPreview = {
found: true,
themes: [
{
id: 'warp:tokyo-night',
selectionValue: 'custom:warp:tokyo-night',
name: 'Tokyo Night',
source: 'warp',
mode: 'dark',
terminal: { background: '#1a1b26', foreground: '#c0caf5', black: '#15161e' },
importedAt: '2026-06-05T00:00:00.000Z'
}
],
skippedFiles: []
}
vi.stubGlobal('window', {
api: { settings: { previewWarpThemeImport: vi.fn().mockResolvedValue(previewResponse) } }
})
const updateSettings = vi.fn()
let warp = useWarpThemeImport(updateSettings, baseSettings)
await warp.handleClick()
resetMockState()
warp = useWarpThemeImport(updateSettings, baseSettings)
warp.handleToggleAll(false)
resetMockState()
warp = useWarpThemeImport(updateSettings, baseSettings)
await warp.handleApply()
expect(updateSettings).not.toHaveBeenCalled()
})
it('toggles every previewed theme id when selecting all', async () => {
const previewResponse: WarpThemeImportPreview = {
found: true,
themes: [
{
id: 'warp:one',
selectionValue: 'custom:warp:one',
name: 'One',
source: 'warp',
mode: 'dark',
terminal: { background: '#000000', foreground: '#ffffff', black: '#111111' },
importedAt: '2026-06-05T00:00:00.000Z'
},
{
id: 'warp:two',
selectionValue: 'custom:warp:two',
name: 'Two',
source: 'warp',
mode: 'dark',
terminal: { background: '#000000', foreground: '#ffffff', black: '#222222' },
importedAt: '2026-06-05T00:00:00.000Z'
}
],
skippedFiles: []
}
vi.stubGlobal('window', {
api: { settings: { previewWarpThemeImport: vi.fn().mockResolvedValue(previewResponse) } }
})
let warp = useWarpThemeImport(vi.fn(), baseSettings)
await warp.handleClick()
resetMockState()
warp = useWarpThemeImport(vi.fn(), baseSettings)
warp.handleToggleAll(false)
resetMockState()
warp = useWarpThemeImport(vi.fn(), baseSettings)
expect(warp.selectedThemeIds.size).toBe(0)
warp.handleToggleAll(true)
resetMockState()
warp = useWarpThemeImport(vi.fn(), baseSettings)
expect(warp.selectedThemeIds.has('warp:one')).toBe(true)
expect(warp.selectedThemeIds.has('warp:two')).toBe(true)
})
it('reports desktop-only preview responses', async () => {
const previewResponse: WarpThemeImportPreview = {
found: false,
desktopOnly: true,
themes: [],
skippedFiles: [],
error: 'Warp theme import is available in the desktop app.'
}
vi.stubGlobal('window', {
api: { settings: { previewWarpThemeImport: vi.fn().mockResolvedValue(previewResponse) } }
})
let warp = useWarpThemeImport(vi.fn(), baseSettings)
await warp.handleClick()
resetMockState()
warp = useWarpThemeImport(vi.fn(), baseSettings)
expect(warp.desktopOnly).toBe(true)
})
it('keeps an empty errorless preview unselected and not found', async () => {
const previewResponse: WarpThemeImportPreview = {
found: false,
sourceLabel: 'Warp themes',
skippedFiles: [],
themes: []
}
vi.stubGlobal('window', {
api: { settings: { previewWarpThemeImport: vi.fn().mockResolvedValue(previewResponse) } }
})
let warp = useWarpThemeImport(vi.fn(), baseSettings)
await warp.handleClick()
resetMockState()
warp = useWarpThemeImport(vi.fn(), baseSettings)
expect(warp.selectedThemeIds.size).toBe(0)
expect(warp.preview?.found).toBe(false)
expect(warp.preview?.error).toBeUndefined()
})
it('opens the modal in yaml mode once the picker returns a selection', async () => {
const previewResponse: WarpThemeImportPreview = {
found: true,
sourceLabel: 'My Custom Theme.yaml',
skippedFiles: [],
themes: [
{
id: 'warp:my-custom-theme',
selectionValue: 'custom:warp:my-custom-theme',
name: 'My Custom Theme',
source: 'warp',
mode: 'dark',
terminal: { background: '#10141c', foreground: '#d8dee9', black: '#1c2230' },
importedAt: '2026-06-09T00:00:00.000Z',
sourceLabel: 'My Custom Theme.yaml'
}
]
}
const previewMock = vi.fn().mockResolvedValue(previewResponse)
vi.stubGlobal('window', {
api: { settings: { previewWarpThemeImport: previewMock } }
})
let warp = useWarpThemeImport(vi.fn(), baseSettings)
await warp.handleImportYamlClick()
expect(previewMock).toHaveBeenCalledWith({ kind: 'chooseFile' })
resetMockState()
warp = useWarpThemeImport(vi.fn(), baseSettings)
expect(warp.open).toBe(true)
expect(warp.mode).toBe('yaml')
expect(warp.selectedThemeIds.has('warp:my-custom-theme')).toBe(true)
})
it('keeps the modal closed when the yaml picker is canceled', async () => {
const previewMock = vi
.fn()
.mockResolvedValue({ found: false, canceled: true, themes: [], skippedFiles: [] })
vi.stubGlobal('window', {
api: { settings: { previewWarpThemeImport: previewMock } }
})
let warp = useWarpThemeImport(vi.fn(), baseSettings)
await warp.handleImportYamlClick()
resetMockState()
warp = useWarpThemeImport(vi.fn(), baseSettings)
expect(warp.open).toBe(false)
expect(warp.preview).toBeNull()
})
it('keeps the current preview when an in-modal picker is canceled', async () => {
const autoResponse: WarpThemeImportPreview = {
found: true,
sourceLabel: 'Warp themes',
skippedFiles: [],
themes: [
{
id: 'warp:tokyo-night',
selectionValue: 'custom:warp:tokyo-night',
name: 'Tokyo Night',
source: 'warp',
mode: 'dark',
terminal: { background: '#1a1b26', foreground: '#c0caf5', black: '#15161e' },
importedAt: '2026-06-05T00:00:00.000Z',
sourceLabel: 'Warp themes'
}
]
}
const previewMock = vi
.fn()
.mockResolvedValueOnce(autoResponse)
.mockResolvedValueOnce({ found: false, canceled: true, themes: [], skippedFiles: [] })
vi.stubGlobal('window', {
api: { settings: { previewWarpThemeImport: previewMock } }
})
let warp = useWarpThemeImport(vi.fn(), baseSettings)
await warp.handleClick()
resetMockState()
warp = useWarpThemeImport(vi.fn(), baseSettings)
await warp.handlePreviewSource({ kind: 'chooseFile' })
resetMockState()
warp = useWarpThemeImport(vi.fn(), baseSettings)
expect(warp.preview?.found).toBe(true)
expect(warp.selectedThemeIds.has('warp:tokyo-night')).toBe(true)
})
it('blocks applying new distinct themes that exceed the custom theme cap', async () => {
const fullSettings = {
...baseSettings,
terminalCustomThemes: Array.from({ length: MAX_TERMINAL_CUSTOM_THEMES }, (_, index) => ({
id: `warp:existing-${index}`,
name: `Existing ${index}`,
source: 'warp' as const,
mode: 'dark' as const,
terminal: { background: '#000000', foreground: '#ffffff', black: '#111111' },
importedAt: '2026-06-01T00:00:00.000Z'
}))
} as GlobalSettings
const previewResponse: WarpThemeImportPreview = {
found: true,
skippedFiles: [],
themes: [
{
id: 'warp:new-theme:new-theme-yaml',
selectionValue: 'custom:warp:new-theme:new-theme-yaml',
name: 'New Theme',
source: 'warp',
mode: 'dark',
terminal: { background: '#000000', foreground: '#ffffff', black: '#222222' },
importedAt: '2026-06-05T00:00:00.000Z'
}
]
}
vi.stubGlobal('window', {
api: { settings: { previewWarpThemeImport: vi.fn().mockResolvedValue(previewResponse) } }
})
const updateSettings = vi.fn()
let warp = useWarpThemeImport(updateSettings, fullSettings)
await warp.handleClick()
resetMockState()
warp = useWarpThemeImport(updateSettings, fullSettings)
await warp.handleApply()
resetMockState()
warp = useWarpThemeImport(updateSettings, fullSettings)
expect(updateSettings).not.toHaveBeenCalled()
expect(warp.applyError).toContain('custom terminal theme limit')
})
it('allows replacements when the custom theme list is already at the cap', async () => {
const fullSettings = {
...baseSettings,
terminalCustomThemes: Array.from({ length: MAX_TERMINAL_CUSTOM_THEMES }, (_, index) => ({
id: index === 0 ? 'warp:replacement' : `warp:existing-${index}`,
name: `Existing ${index}`,
source: 'warp' as const,
mode: 'dark' as const,
terminal: { background: '#000000', foreground: '#ffffff', black: '#111111' },
importedAt: '2026-06-01T00:00:00.000Z'
}))
} as GlobalSettings
const previewResponse: WarpThemeImportPreview = {
found: true,
skippedFiles: [],
themes: [
{
id: 'warp:replacement',
selectionValue: 'custom:warp:replacement',
name: 'Replacement',
source: 'warp',
mode: 'dark',
terminal: { background: '#000000', foreground: '#ffffff', black: '#222222' },
importedAt: '2026-06-05T00:00:00.000Z'
}
]
}
vi.stubGlobal('window', {
api: { settings: { previewWarpThemeImport: vi.fn().mockResolvedValue(previewResponse) } }
})
const updateSettings = vi.fn()
let warp = useWarpThemeImport(updateSettings, fullSettings)
await warp.handleClick()
resetMockState()
warp = useWarpThemeImport(updateSettings, fullSettings)
await warp.handleApply()
expect(updateSettings).toHaveBeenCalledWith({
terminalCustomThemes: expect.arrayContaining([
expect.objectContaining({ id: 'warp:replacement', name: 'Replacement' })
])
})
})
})
@@ -0,0 +1,220 @@
import { useState } from 'react'
import { toast } from 'sonner'
import type { GlobalSettings } from '../../../../shared/types'
import {
MAX_TERMINAL_CUSTOM_THEMES,
normalizeTerminalCustomThemes,
type TerminalCustomTheme,
type WarpThemeImportPreview,
type WarpThemeImportSource
} from '../../../../shared/terminal-custom-themes'
import { useMountedRef } from '../../hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
/** Which entry point opened the import flow; only affects modal copy. */
export type ThemeImportMode = 'warp' | 'yaml'
export type UseWarpThemeImportReturn = {
open: boolean
mode: ThemeImportMode
preview: WarpThemeImportPreview | null
loading: boolean
desktopOnly: boolean
applyError: string | null
/** Bumps on each successful import so the theme picker can scroll to and
* highlight the freshly-imported themes. */
importSignal: number
selectedThemeIds: Set<string>
handleClick: () => Promise<void>
handleImportYamlClick: () => Promise<void>
handlePreviewSource: (source: WarpThemeImportSource) => Promise<void>
handleToggleTheme: (id: string) => void
handleToggleAll: (checked: boolean) => void
handleApply: () => Promise<void>
handleOpenChange: (open: boolean) => void
}
export function useWarpThemeImport(
updateSettings: (updates: Partial<GlobalSettings>) => void | Promise<void>,
settings: GlobalSettings | null
): UseWarpThemeImportReturn {
const [open, setOpen] = useState(false)
const [mode, setMode] = useState<ThemeImportMode>('warp')
const [preview, setPreview] = useState<WarpThemeImportPreview | null>(null)
const [loading, setLoading] = useState(false)
const [applyError, setApplyError] = useState<string | null>(null)
const [importSignal, setImportSignal] = useState(0)
const [selectedThemeIds, setSelectedThemeIds] = useState<Set<string>>(() => new Set())
const mountedRef = useMountedRef()
async function previewSource(source: WarpThemeImportSource): Promise<WarpThemeImportPreview> {
setLoading(true)
setApplyError(null)
try {
const result = await window.api.settings.previewWarpThemeImport(source)
// Why: a dismissed native picker keeps whatever preview was already
// showing instead of wiping it with an empty result.
if (mountedRef.current && !result.canceled) {
setPreview(result)
setSelectedThemeIds(new Set(result.themes.map((theme) => theme.id)))
}
return result
} catch (err) {
const message =
err instanceof Error
? err.message
: translate('auto.components.settings.useWarpThemeImport.unknown_error', 'Unknown error')
const failure: WarpThemeImportPreview = {
found: false,
themes: [],
skippedFiles: [],
error: message
}
if (mountedRef.current) {
setPreview(failure)
setSelectedThemeIds(new Set())
}
return failure
} finally {
if (mountedRef.current) {
setLoading(false)
}
}
}
async function handlePreviewSource(source: WarpThemeImportSource): Promise<void> {
await previewSource(source)
}
async function handleClick(): Promise<void> {
setMode('warp')
setOpen(true)
await previewSource({ kind: 'auto' })
}
async function handleImportYamlClick(): Promise<void> {
setMode('yaml')
// Why: go straight to the native picker and only surface the modal once
// there is a selection to preview — canceling leaves settings untouched.
const result = await previewSource({ kind: 'chooseFile' })
if (mountedRef.current && !result.canceled) {
setOpen(true)
}
}
function handleToggleTheme(id: string): void {
setSelectedThemeIds((current) => {
const next = new Set(current)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
function handleToggleAll(checked: boolean): void {
const targetIds = preview?.themes.map((theme) => theme.id) ?? []
setSelectedThemeIds(new Set(checked ? targetIds : []))
}
async function handleApply(): Promise<void> {
if (!preview?.found || !settings || selectedThemeIds.size === 0) {
return
}
const selectedThemes = preview.themes.filter((theme) => selectedThemeIds.has(theme.id))
const byId = new Map<string, TerminalCustomTheme>()
for (const theme of normalizeTerminalCustomThemes(settings.terminalCustomThemes)) {
byId.set(theme.id, theme)
}
const newThemeCount = selectedThemes.filter((theme) => !byId.has(theme.id)).length
const overflowCount = byId.size + newThemeCount - MAX_TERMINAL_CUSTOM_THEMES
if (overflowCount > 0) {
setApplyError(
overflowCount === 1
? translate(
'auto.components.settings.useWarpThemeImport.over_limit_one',
'Importing these themes would exceed the {{value0}} custom terminal theme limit. Deselect 1 new theme and try again.',
{ value0: MAX_TERMINAL_CUSTOM_THEMES }
)
: translate(
'auto.components.settings.useWarpThemeImport.over_limit_other',
'Importing these themes would exceed the {{value0}} custom terminal theme limit. Deselect {{value1}} new themes and try again.',
{ value0: MAX_TERMINAL_CUSTOM_THEMES, value1: overflowCount }
)
)
return
}
for (const theme of selectedThemes) {
const { selectionValue: _selectionValue, ...themeRecord } = theme
byId.set(themeRecord.id, themeRecord)
}
setApplyError(null)
try {
await updateSettings({
terminalCustomThemes: normalizeTerminalCustomThemes([...byId.values()])
})
const count = selectedThemes.length
// Why: report success via a toast and dismiss the modal rather than
// leaving an "imported" state inside the dialog.
toast.success(
count === 1
? translate(
'auto.components.settings.useWarpThemeImport.imported_one',
'Imported 1 theme'
)
: translate(
'auto.components.settings.useWarpThemeImport.imported_other',
'Imported {{value0}} themes',
{ value0: count }
)
)
// Bump the signal so the theme picker scrolls to / highlights the
// newly-imported themes, which otherwise sit off-screen below the
// built-in list.
setImportSignal((value) => value + 1)
handleOpenChange(false)
} catch (err) {
const message =
err instanceof Error
? err.message
: translate(
'auto.components.settings.useWarpThemeImport.import_failed',
'Failed to import themes'
)
if (mountedRef.current) {
setApplyError(message)
}
}
}
function handleOpenChange(newOpen: boolean): void {
setOpen(newOpen)
if (!newOpen) {
setPreview(null)
setLoading(false)
setApplyError(null)
setSelectedThemeIds(new Set())
}
}
return {
open,
mode,
preview,
loading,
desktopOnly: Boolean(preview?.desktopOnly),
applyError,
importSignal,
selectedThemeIds,
handleClick,
handleImportYamlClick,
handlePreviewSource,
handleToggleTheme,
handleToggleAll,
handleApply,
handleOpenChange
}
}
+55
View File
@@ -4494,6 +4494,42 @@
"273e7e81fe": "Configs",
"1f744a72f4": "Config"
},
"WarpThemeImportModal": {
"title": "Import themes from Warp",
"description": "Import Warp themes as Orca terminal themes.",
"yaml_title": "Import theme YAML",
"yaml_description": "Import theme YAML files (Warp format) as Orca terminal themes.",
"yaml_no_themes_found": "No themes found in the selected files.",
"choose_file": "Choose File",
"choose_folder": "Choose Folder",
"loading": "Loading Warp themes...",
"found_theme_one": "Found 1 theme",
"found_theme_other": "Found {{value0}} themes",
"found_in_source": " in {{value0}}",
"clear_all": "Clear all",
"select_all": "Select all",
"colors_only": "Colors only",
"no_themes_found": "No custom Warp themes found.",
"builtin_themes_hint": "Warp's preloaded themes are part of the Warp app and can't be read from disk. Orca already includes most of them, like Dracula, Gruvbox, Solarized, and Tokyo Night.",
"choose_manually": "Choose a theme YAML file or folder to import manually.",
"skipped_files": "Skipped files",
"more_skipped_files": "{{value0}} more skipped files.",
"cancel": "Cancel",
"import_theme_one": "Import 1 Theme",
"import_theme_other": "Import {{value0}} Themes",
"import_themes": "Import Themes"
},
"useWarpThemeImport": {
"unknown_error": "Unknown error",
"imported_one": "Imported 1 theme",
"imported_other": "Imported {{value0}} themes",
"import_failed": "Failed to import themes",
"over_limit_one": "Importing these themes would exceed the {{value0}} custom terminal theme limit. Deselect 1 new theme and try again.",
"over_limit_other": "Importing these themes would exceed the {{value0}} custom terminal theme limit. Deselect {{value1}} new themes and try again."
},
"YamlThemeImportButton": {
"label": "Import from YAML"
},
"GitPane": {
"d2eede4c54": "Add Orca attribution to commits, PRs, and issues.",
"e02ea23a32": "Orca Attribution",
@@ -5312,11 +5348,15 @@
"74bcecd5ec": "Clear",
"a4ff6143f8": "Clear font selection",
"b661b034ec": "· Default:",
"builtin_themes": "Built-in",
"ceefb9d7f1": "No themes found.",
"imported_from": "Imported from {{value0}}",
"imported_themes": "Imported",
"9119fb2268": "Current",
"4e11f87ca6": "Showing",
"fbb428db98": "Selected:",
"fac59213fc": "Search builtin themes",
"search_terminal_themes": "Search terminal themes",
"cb330ef7f8": " of {{value0}}",
"c822571b2e": " matching \"{{value0}}\"",
"3119c012a5": "string"
@@ -5683,6 +5723,8 @@
"d06664e889": "dark"
},
"TerminalThemeSections": {
"import_themes_title": "Import Themes",
"import_themes_description": "Imported themes are available in both the dark and light theme pickers.",
"db210115c5": "Light Mode Preview",
"5e0c24b5c8": "Controls the split divider line between panes in light mode.",
"ec2e33ad80": "Light Divider Color",
@@ -7137,6 +7179,19 @@
"82b63d07fe": "ghostty",
"73e9422f19": "One-time import of supported Ghostty terminal settings.",
"a979df0083": "Import from Ghostty",
"warp_import": {
"title": "Import themes from Warp",
"description": "Import Warp themes as Orca terminal themes.",
"keyword_warp": "warp",
"keyword_themes": "themes",
"keyword_yaml": "yaml"
},
"yaml_import": {
"title": "Import from YAML",
"description": "Import theme YAML files as Orca terminal themes.",
"keyword_yaml": "yaml",
"keyword_custom": "custom"
},
"4cec42dbf7": "intl",
"b495dc6a9f": "jis",
"d8d6f7a3c5": "macos",
+55
View File
@@ -4474,6 +4474,42 @@
"273e7e81fe": "構成",
"1f744a72f4": "構成"
},
"WarpThemeImportModal": {
"title": "Warp からテーマをインポート",
"description": "Warp テーマを Orca のターミナルテーマとしてインポートします。",
"yaml_title": "テーマ YAML をインポート",
"yaml_description": "テーマ YAML ファイル(Warp 形式)を Orca のターミナルテーマとしてインポートします。",
"yaml_no_themes_found": "選択したファイルにテーマが見つかりませんでした。",
"choose_file": "ファイルを選択",
"choose_folder": "フォルダーを選択",
"loading": "Warp テーマを読み込み中...",
"found_theme_one": "1 件のテーマが見つかりました",
"found_theme_other": "{{value0}} 件のテーマが見つかりました",
"found_in_source": "({{value0}})",
"clear_all": "すべてクリア",
"select_all": "すべて選択",
"colors_only": "カラーのみ",
"no_themes_found": "カスタム Warp テーマが見つかりませんでした。",
"builtin_themes_hint": "Warp のプリロードテーマは Warp アプリ本体に含まれており、ディスクから読み取ることはできません。Dracula、Gruvbox、Solarized、Tokyo Night など、その多くは Orca の組み込みテーマとして既に利用できます。",
"choose_manually": "テーマ YAML ファイルまたはフォルダーを選択して手動でインポートします。",
"skipped_files": "スキップされたファイル",
"more_skipped_files": "他 {{value0}} 件のファイルがスキップされました。",
"cancel": "キャンセル",
"import_theme_one": "1 件のテーマをインポート",
"import_theme_other": "{{value0}} 件のテーマをインポート",
"import_themes": "テーマをインポート"
},
"useWarpThemeImport": {
"unknown_error": "不明なエラー",
"imported_one": "1 件のテーマをインポートしました",
"imported_other": "{{value0}} 件のテーマをインポートしました",
"import_failed": "テーマのインポートに失敗しました",
"over_limit_one": "これらのテーマをインポートすると、カスタムターミナルテーマの上限({{value0}} 件)を超えます。新しいテーマを 1 件選択解除して、もう一度お試しください。",
"over_limit_other": "これらのテーマをインポートすると、カスタムターミナルテーマの上限({{value0}} 件)を超えます。新しいテーマを {{value1}} 件選択解除して、もう一度お試しください。"
},
"YamlThemeImportButton": {
"label": "YAML からインポート"
},
"GitPane": {
"d2eede4c54": "Orca の帰属をコミット、PR、イシューに追加します。",
"e02ea23a32": "Orcaの帰属",
@@ -5285,11 +5321,15 @@
"74bcecd5ec": "クリア",
"a4ff6143f8": "フォントの選択をクリア",
"b661b034ec": "・ デフォルト:",
"builtin_themes": "組み込み",
"ceefb9d7f1": "テーマが見つかりませんでした。",
"imported_from": "{{value0}} からインポート",
"imported_themes": "インポート済み",
"9119fb2268": "現在",
"4e11f87ca6": "表示中",
"fbb428db98": "選択済み:",
"fac59213fc": "組み込みテーマを検索する",
"search_terminal_themes": "ターミナルテーマを検索",
"cb330ef7f8": "{{value0}}の",
"c822571b2e": "「{{value0}}」に一致",
"3119c012a5": "文字列"
@@ -5656,6 +5696,8 @@
"d06664e889": "ダーク"
},
"TerminalThemeSections": {
"import_themes_title": "テーマをインポート",
"import_themes_description": "インポートしたテーマは、ダークとライト両方のテーマピッカーで利用できます。",
"db210115c5": "ライトモードのプレビュー",
"5e0c24b5c8": "ライト モードでペイン間の分割分割線を制御します。",
"ec2e33ad80": "ライトディバイダーの色",
@@ -7110,6 +7152,19 @@
"82b63d07fe": "幽霊っぽい",
"73e9422f19": "サポートされている Ghostty ターミナル設定の 1 回限りのインポート。",
"a979df0083": "Ghostty からインポート",
"warp_import": {
"title": "Warp からテーマをインポート",
"description": "Warp テーマを Orca のターミナルテーマとしてインポートします。",
"keyword_warp": "warp",
"keyword_themes": "テーマ",
"keyword_yaml": "yaml"
},
"yaml_import": {
"title": "YAML からインポート",
"description": "テーマ YAML ファイルを Orca のターミナルテーマとしてインポートします。",
"keyword_yaml": "yaml",
"keyword_custom": "カスタム"
},
"4cec42dbf7": "国際",
"b495dc6a9f": "JIS",
"d8d6f7a3c5": "マコス",
+98 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
DEFAULT_TERMINAL_THEME_DARK,
DEFAULT_TERMINAL_THEME_LIGHT,
getAvailableTerminalThemeOptions,
getTerminalThemePreview,
isTerminalBackgroundLight,
resolveEffectiveTerminalAppearance
@@ -75,7 +76,7 @@ describe('resolveEffectiveTerminalAppearance', () => {
expect(appearance.themeName).toBe(DEFAULT_TERMINAL_THEME_LIGHT)
})
it('keeps invalid terminalThemeLight names while preview falls back to dark', () => {
it('keeps invalid terminalThemeLight names while preview falls back to light', () => {
const appearance = resolveEffectiveTerminalAppearance(
{
theme: 'light',
@@ -89,8 +90,104 @@ describe('resolveEffectiveTerminalAppearance', () => {
)
expect(appearance.themeName).toBe('Invalid Theme Name')
expect(appearance.theme).toEqual(getTerminalThemePreview(DEFAULT_TERMINAL_THEME_LIGHT))
})
it('resolves custom theme selections by id', () => {
const appearance = resolveEffectiveTerminalAppearance(
{
theme: 'dark',
terminalThemeDark: 'custom:warp:tokyo-night',
terminalDividerColorDark: '#3f3f46',
terminalUseSeparateLightTheme: true,
terminalThemeLight: DEFAULT_TERMINAL_THEME_LIGHT,
terminalDividerColorLight: '#d4d4d8',
terminalCustomThemes: [
{
id: 'warp:tokyo-night',
name: 'Builtin Tango Light',
source: 'warp',
mode: 'dark',
terminal: {
background: '#1a1b26',
foreground: '#c0caf5',
black: '#15161e'
},
importedAt: '2026-06-05T00:00:00.000Z'
}
]
},
true
)
expect(appearance.themeName).toBe('custom:warp:tokyo-night')
expect(appearance.theme?.background).toBe('#1a1b26')
})
it('falls back visually when a custom selection is missing', () => {
const appearance = resolveEffectiveTerminalAppearance(
{
theme: 'dark',
terminalThemeDark: 'custom:warp:missing',
terminalDividerColorDark: '#3f3f46',
terminalUseSeparateLightTheme: true,
terminalThemeLight: DEFAULT_TERMINAL_THEME_LIGHT,
terminalDividerColorLight: '#d4d4d8',
terminalCustomThemes: []
},
true
)
expect(appearance.themeName).toBe('custom:warp:missing')
expect(appearance.theme).toEqual(getTerminalThemePreview(DEFAULT_TERMINAL_THEME_DARK))
})
it('falls back visually to the light default when a light custom selection is missing', () => {
const appearance = resolveEffectiveTerminalAppearance(
{
theme: 'light',
terminalThemeDark: DEFAULT_TERMINAL_THEME_DARK,
terminalDividerColorDark: '#3f3f46',
terminalUseSeparateLightTheme: true,
terminalThemeLight: 'custom:warp:missing',
terminalDividerColorLight: '#d4d4d8',
terminalCustomThemes: []
},
false
)
expect(appearance.themeName).toBe('custom:warp:missing')
expect(appearance.theme).toEqual(getTerminalThemePreview(DEFAULT_TERMINAL_THEME_LIGHT))
})
it('includes imported themes as grouped picker options', () => {
const options = getAvailableTerminalThemeOptions({
terminalCustomThemes: [
{
id: 'warp:tokyo-night',
name: 'Tokyo Night',
source: 'warp',
mode: 'dark',
terminal: {
background: '#1a1b26',
foreground: '#c0caf5',
black: '#15161e'
},
importedAt: '2026-06-05T00:00:00.000Z'
}
]
})
expect(options.some((option) => option.group === 'built-in')).toBe(true)
expect(options).toContainEqual(
expect.objectContaining({
value: 'custom:warp:tokyo-night',
label: 'Tokyo Night',
group: 'imported',
sourceLabel: 'Warp'
})
)
})
})
describe('isTerminalBackgroundLight', () => {
+76 -4
View File
@@ -1,6 +1,13 @@
import type { ITheme } from '@xterm/xterm'
import { getTheme, getThemeNames } from './terminal-themes-data'
import type { GlobalSettings } from '../../../shared/types'
import {
makeCustomTerminalThemeSelection,
normalizeTerminalCustomThemes,
parseCustomTerminalThemeSelection,
terminalCustomThemeToXtermTheme,
type TerminalCustomTheme
} from '../../../shared/terminal-custom-themes'
export const BUILTIN_TERMINAL_THEME_NAMES = getThemeNames()
@@ -18,6 +25,15 @@ export type EffectiveTerminalAppearance = {
systemPrefersDark: boolean
}
export type TerminalThemeOption = {
value: string
label: string
group: 'built-in' | 'imported'
sourceLabel?: string
mode?: TerminalCustomTheme['mode']
previewTheme: ITheme | null
}
export function getSystemPrefersDark(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return true
@@ -29,12 +45,67 @@ export function getBuiltinTheme(name: string): ITheme | null {
return getTheme(name)
}
export function getTerminalThemePreview(name: string): ITheme | null {
const theme = getTheme(name)
function findCustomTheme(
settings: Pick<GlobalSettings, 'terminalCustomThemes'> | undefined,
selection: string
): TerminalCustomTheme | null {
const customId = parseCustomTerminalThemeSelection(selection)
if (!customId || !settings) {
return null
}
return (
normalizeTerminalCustomThemes(settings.terminalCustomThemes).find(
(theme) => theme.id === customId
) ?? null
)
}
export function getTerminalTheme(
settings: Pick<GlobalSettings, 'terminalCustomThemes'> | undefined,
selection: string
): ITheme | null {
const customTheme = findCustomTheme(settings, selection)
if (customTheme) {
return terminalCustomThemeToXtermTheme(customTheme)
}
return getTheme(selection)
}
export function getTerminalThemePreview(
name: string,
settings?: Pick<GlobalSettings, 'terminalCustomThemes'>,
fallbackMode: 'dark' | 'light' = 'dark'
): ITheme | null {
const theme = getTerminalTheme(settings, name)
if (theme) {
return theme
}
return getTheme(DEFAULT_TERMINAL_THEME_DARK)
return getTheme(
fallbackMode === 'light' ? DEFAULT_TERMINAL_THEME_LIGHT : DEFAULT_TERMINAL_THEME_DARK
)
}
export function getAvailableTerminalThemeOptions(
settings: Pick<GlobalSettings, 'terminalCustomThemes'>
): TerminalThemeOption[] {
const builtinOptions = BUILTIN_TERMINAL_THEME_NAMES.map((name) => ({
value: name,
label: name,
group: 'built-in' as const,
previewTheme: getTheme(name)
}))
const customOptions = normalizeTerminalCustomThemes(settings.terminalCustomThemes).map(
(theme) => ({
value: makeCustomTerminalThemeSelection(theme.id),
label: theme.name,
group: 'imported' as const,
sourceLabel:
theme.source === 'warp' ? 'Warp' : theme.source === 'ghostty' ? 'Ghostty' : 'Manual',
mode: theme.mode,
previewTheme: terminalCustomThemeToXtermTheme(theme)
})
)
return [...builtinOptions, ...customOptions]
}
export function resolveEffectiveTerminalAppearance(
@@ -45,6 +116,7 @@ export function resolveEffectiveTerminalAppearance(
| 'terminalDividerColorDark'
| 'terminalUseSeparateLightTheme'
| 'terminalThemeLight'
| 'terminalCustomThemes'
| 'terminalDividerColorLight'
>,
systemPrefersDark = getSystemPrefersDark()
@@ -64,7 +136,7 @@ export function resolveEffectiveTerminalAppearance(
sourceTheme: settings.theme,
themeName,
dividerColor,
theme: getTerminalThemePreview(themeName),
theme: getTerminalThemePreview(themeName, settings, useLightVariant ? 'light' : 'dark'),
systemPrefersDark
}
}
@@ -10,6 +10,7 @@ import {
import { assertRuntimeStatusCompatible } from '@/runtime/runtime-protocol-compat'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import { normalizeTerminalQuickCommands } from '../../../../shared/terminal-quick-commands'
import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custom-themes'
import { normalizeTaskProviderSettings } from '../../../../shared/task-providers'
import { normalizeOpenInApplications } from '../../../../shared/open-in-applications'
import { createSettingsSearchState, type SettingsSearchState } from './settings-search-state'
@@ -76,6 +77,11 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
updates.terminalQuickCommands
)
}
if ('terminalCustomThemes' in updates) {
sanitizedUpdates.terminalCustomThemes = normalizeTerminalCustomThemes(
updates.terminalCustomThemes
)
}
if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) {
const taskProviderSettings = normalizeTaskProviderSettings({
visibleTaskProviders:
+6
View File
@@ -41,6 +41,7 @@ import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector'
import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection'
import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings'
import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings'
import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes'
import { normalizeUiLanguage } from '../../../shared/ui-language'
import type { RateLimitState } from '../../../shared/rate-limit-types'
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../../shared/runtime-types'
@@ -2511,6 +2512,7 @@ function getStoredSettings(): GlobalSettings {
...stored,
...normalizeAutoRenameBranchFromWorkDefaultOn(stored),
...normalizeTerminalCursorStyleDefault(stored),
terminalCustomThemes: normalizeTerminalCustomThemes(stored.terminalCustomThemes),
uiLanguage: normalizeUiLanguage(stored.uiLanguage)
}
if (
@@ -2521,6 +2523,7 @@ function getStoredSettings(): GlobalSettings {
stored.terminalCursorStyle !== migratedStored.terminalCursorStyle ||
stored.terminalCursorStyleDefaultedToBlock !==
migratedStored.terminalCursorStyleDefaultedToBlock ||
stored.terminalCustomThemes !== migratedStored.terminalCustomThemes ||
stored.uiLanguage !== migratedStored.uiLanguage)
) {
try {
@@ -2697,6 +2700,9 @@ function mergeSettings(
...updates.voice
} as NonNullable<GlobalSettings['voice']>,
activeRuntimeEnvironmentId: activeEnvironment?.id ?? updates.activeRuntimeEnvironmentId ?? null,
terminalCustomThemes: normalizeTerminalCustomThemes(
updates.terminalCustomThemes ?? base.terminalCustomThemes
),
uiLanguage: normalizeUiLanguage(updates.uiLanguage ?? base.uiLanguage)
}
return {
+1
View File
@@ -211,6 +211,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
terminalDividerColorDark: '#3f3f46',
terminalUseSeparateLightTheme: true,
terminalThemeLight: 'Builtin Tango Light',
terminalCustomThemes: [],
terminalDividerColorLight: '#d4d4d8',
terminalInactivePaneOpacity: 0.8,
terminalActivePaneOpacity: 1,
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import {
makeCustomTerminalThemeSelection,
normalizeTerminalCustomThemes,
normalizeTerminalHexColor,
parseCustomTerminalThemeSelection
} from './terminal-custom-themes'
describe('terminal custom themes', () => {
it('normalizes colors and drops invalid theme records', () => {
const themes = normalizeTerminalCustomThemes([
{
id: 'warp:Tokyo Night',
name: 'Tokyo/Night\\Dark',
source: 'warp',
mode: 'dark',
terminal: {
background: '1a1b26',
foreground: '#c0caf5',
black: '#15161e',
red: 'not-a-color'
},
importedAt: '2026-06-05T00:00:00.000Z',
sourcePath: '/Users/alice/.warp/themes/tokyo.yaml'
},
{
id: 'bad',
name: 'Bad',
source: 'warp',
terminal: { background: '#000000' }
}
])
expect(themes).toEqual([
{
id: 'warp:tokyo-night',
name: 'Tokyo Night Dark',
source: 'warp',
mode: 'dark',
terminal: {
background: '#1a1b26',
foreground: '#c0caf5',
black: '#15161e'
},
importedAt: '2026-06-05T00:00:00.000Z'
}
])
})
it('deduplicates normalized ids with last write winning', () => {
const themes = normalizeTerminalCustomThemes([
{
id: 'warp:dupe',
name: 'First',
source: 'warp',
terminal: { background: '#000000', foreground: '#ffffff', black: '#111111' }
},
{
id: 'warp:dupe',
name: 'Second',
source: 'warp',
terminal: { background: '#000000', foreground: '#ffffff', red: '#ff0000' }
}
])
expect(themes).toHaveLength(1)
expect(themes[0]?.name).toBe('Second')
expect(themes[0]?.terminal.red).toBe('#ff0000')
})
it('requires at least one ANSI palette color', () => {
expect(
normalizeTerminalCustomThemes([
{
id: 'warp:cursor-only',
name: 'Cursor Only',
source: 'warp',
terminal: { background: '#000000', foreground: '#ffffff', cursor: '#ffffff' }
}
])
).toEqual([])
})
it('round-trips custom selection values', () => {
expect(makeCustomTerminalThemeSelection('warp:tokyo-night')).toBe('custom:warp:tokyo-night')
expect(parseCustomTerminalThemeSelection('custom:warp:tokyo-night')).toBe('warp:tokyo-night')
expect(parseCustomTerminalThemeSelection('Builtin Tango Light')).toBeNull()
})
it('expands short hex colors', () => {
expect(normalizeTerminalHexColor('abc')).toBe('#aabbcc')
})
})
+238
View File
@@ -0,0 +1,238 @@
import type { ITheme } from '@xterm/xterm'
import type { TerminalColorOverrides } from './types'
import { HEX_COLOR_RE } from './color-validation'
export type TerminalCustomThemeSource = 'warp' | 'ghostty' | 'manual'
export type TerminalCustomThemeMode = 'dark' | 'light' | 'unknown'
export type TerminalCustomTheme = {
id: string
name: string
source: TerminalCustomThemeSource
mode: TerminalCustomThemeMode
terminal: TerminalColorOverrides
importedAt: string
sourceLabel?: string
unsupportedFeatures?: string[]
}
export type TerminalThemeSelection = string
export type WarpThemeImportSource =
| { kind: 'auto' }
| { kind: 'chooseFile' }
| { kind: 'chooseFolder' }
export type WarpThemeImportPreviewTheme = TerminalCustomTheme & {
selectionValue: string
}
export type WarpThemeImportSkippedFile = {
label: string
reason: string
}
export type WarpThemeImportPreview = {
found: boolean
/** True when the user dismissed the native picker without selecting anything. */
canceled?: boolean
desktopOnly?: boolean
sourceLabel?: string
themes: WarpThemeImportPreviewTheme[]
skippedFiles: WarpThemeImportSkippedFile[]
error?: string
}
export const MAX_TERMINAL_CUSTOM_THEMES = 200
export const CUSTOM_TERMINAL_THEME_PREFIX = 'custom:'
const TERMINAL_COLOR_KEYS = [
'foreground',
'background',
'cursor',
'cursorAccent',
'selectionBackground',
'selectionForeground',
'black',
'red',
'green',
'yellow',
'blue',
'magenta',
'cyan',
'white',
'brightBlack',
'brightRed',
'brightGreen',
'brightYellow',
'brightBlue',
'brightMagenta',
'brightCyan',
'brightWhite',
'bold'
] as const satisfies readonly (keyof TerminalColorOverrides)[]
const TERMINAL_ANSI_COLOR_KEYS = [
'black',
'red',
'green',
'yellow',
'blue',
'magenta',
'cyan',
'white',
'brightBlack',
'brightRed',
'brightGreen',
'brightYellow',
'brightBlue',
'brightMagenta',
'brightCyan',
'brightWhite'
] as const satisfies readonly (keyof TerminalColorOverrides)[]
export function makeCustomTerminalThemeSelection(id: string): string {
return `${CUSTOM_TERMINAL_THEME_PREFIX}${id}`
}
export function parseCustomTerminalThemeSelection(selection: string): string | null {
return selection.startsWith(CUSTOM_TERMINAL_THEME_PREFIX)
? selection.slice(CUSTOM_TERMINAL_THEME_PREFIX.length)
: null
}
function removeControlCharacters(value: string): string {
return [...value]
.filter((character) => {
const code = character.charCodeAt(0)
return code >= 32 && code !== 127
})
.join('')
}
export function normalizeTerminalThemeId(value: unknown, fallback = 'theme'): string {
const raw = typeof value === 'string' ? value : fallback
const normalized = removeControlCharacters(raw)
.trim()
.toLowerCase()
.replace(/['"]/g, '')
.replace(/[^a-z0-9:_-]+/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '')
return normalized || fallback
}
export function normalizeTerminalThemeName(value: unknown, fallback = 'Imported Theme'): string {
if (typeof value !== 'string') {
return fallback
}
const normalized = removeControlCharacters(value)
.replace(/[\\/]+/g, ' ')
.replace(/\s{2,}/g, ' ')
.trim()
return normalized || fallback
}
export function normalizeTerminalHexColor(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
if (!HEX_COLOR_RE.test(trimmed)) {
return null
}
const withoutHash = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
const expanded =
withoutHash.length === 3
? withoutHash
.split('')
.map((character) => `${character}${character}`)
.join('')
: withoutHash
return `#${expanded.toLowerCase()}`
}
export function normalizeTerminalColorOverrides(value: unknown): TerminalColorOverrides {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {}
}
const input = value as Record<string, unknown>
const output: TerminalColorOverrides = {}
for (const key of TERMINAL_COLOR_KEYS) {
const color = normalizeTerminalHexColor(input[key])
if (color) {
output[key] = color
}
}
return output
}
export function hasUsableTerminalThemeColors(terminal: TerminalColorOverrides): boolean {
const ansiCount = TERMINAL_ANSI_COLOR_KEYS.filter((key) => terminal[key]).length
return Boolean(terminal.background && terminal.foreground && ansiCount > 0)
}
function normalizeSource(value: unknown): TerminalCustomThemeSource {
return value === 'warp' || value === 'ghostty' || value === 'manual' ? value : 'manual'
}
function normalizeMode(value: unknown): TerminalCustomThemeMode {
return value === 'dark' || value === 'light' || value === 'unknown' ? value : 'unknown'
}
function normalizeStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined
}
const normalized = value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter(Boolean)
return normalized.length > 0 ? [...new Set(normalized)] : undefined
}
export function normalizeTerminalCustomThemes(value: unknown): TerminalCustomTheme[] {
if (!Array.isArray(value)) {
return []
}
const byId = new Map<string, TerminalCustomTheme>()
for (const entry of value.slice(-MAX_TERMINAL_CUSTOM_THEMES)) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
continue
}
const input = entry as Record<string, unknown>
const source = normalizeSource(input.source)
const name = normalizeTerminalThemeName(input.name)
const idBase = normalizeTerminalThemeId(input.id ?? `${source}:${name}`)
const id = idBase.includes(':') ? idBase : `${source}:${idBase}`
const terminal = normalizeTerminalColorOverrides(input.terminal)
if (!id || !name || !hasUsableTerminalThemeColors(terminal)) {
continue
}
const importedAt =
typeof input.importedAt === 'string' && input.importedAt.trim()
? input.importedAt
: new Date(0).toISOString()
byId.set(id, {
id,
name,
source,
mode: normalizeMode(input.mode),
terminal,
importedAt,
...(typeof input.sourceLabel === 'string' && input.sourceLabel.trim()
? { sourceLabel: input.sourceLabel.trim() }
: {}),
...(normalizeStringArray(input.unsupportedFeatures)
? { unsupportedFeatures: normalizeStringArray(input.unsupportedFeatures) }
: {})
})
}
return [...byId.values()].slice(-MAX_TERMINAL_CUSTOM_THEMES)
}
export function terminalCustomThemeToXtermTheme(theme: TerminalCustomTheme): ITheme {
return { ...theme.terminal }
}
+2
View File
@@ -27,6 +27,7 @@ import type {
import type { AgentKind, LaunchSource, RequestKind } from './telemetry-events'
import type { SleepingAgentSessionRecord } from './agent-session-resume'
import type { ClaudeAgentTeamsMode } from './claude-agent-teams-tmux-compat'
import type { TerminalCustomTheme } from './terminal-custom-themes'
import type { UiLanguage } from './ui-language'
// Re-exported for backward compat with renderer call sites that import
@@ -2198,6 +2199,7 @@ export type GlobalSettings = {
terminalCursorStyleDefaultedToBlock?: boolean
terminalCursorBlink: boolean
terminalThemeDark: string
terminalCustomThemes?: TerminalCustomTheme[]
terminalDividerColorDark: string
terminalUseSeparateLightTheme: boolean
terminalThemeLight: string