From 94f37ef7fb14021d50ffae1566e2beb0a0cdd201 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:16:36 -0700 Subject: [PATCH] Add Warp terminal theme import (#4714) Co-authored-by: Orca --- electron.vite.config.ts | 1 + src/main/ipc/settings.test.ts | 97 ++++ src/main/ipc/settings.ts | 10 + src/main/persistence.ts | 9 + src/main/warp-themes/discovery.test.ts | 39 ++ src/main/warp-themes/discovery.ts | 29 + src/main/warp-themes/index.test.ts | 541 ++++++++++++++++++ src/main/warp-themes/index.ts | 267 +++++++++ .../warp-themes/manual-warp-theme-files.ts | 89 +++ src/main/warp-themes/parser-runner.test.ts | 105 ++++ src/main/warp-themes/parser-runner.ts | 76 +++ src/main/warp-themes/parser.test.ts | 137 +++++ src/main/warp-themes/parser.ts | 228 ++++++++ .../warp-themes/preview-operation-budget.ts | 43 ++ src/main/warp-themes/theme-file-scanner.ts | 233 ++++++++ .../warp-theme-import-source-validation.ts | 18 + .../warp-themes/warp-theme-parser-worker.ts | 23 + src/preload/api-types.ts | 5 + src/preload/index.ts | 7 + .../src/components/icons/WarpIcon.tsx | 12 + .../settings/AppearancePane.test.tsx | 19 + .../components/settings/AppearancePane.tsx | 12 +- .../src/components/settings/Settings.tsx | 3 + .../settings/SettingsFormControls.tsx | 206 +++++-- .../TerminalAppearanceSection.ghostty.test.ts | 175 +++++- .../settings/TerminalAppearanceSection.tsx | 40 +- .../settings/TerminalSettingsPreview.tsx | 32 +- .../TerminalThemeSections.lifecycle.test.ts | 71 ++- .../settings/TerminalThemeSections.tsx | 186 +++++- .../settings/WarpThemeImportButton.tsx | 24 + .../settings/WarpThemeImportModal.tsx | 331 +++++++++++ .../settings/YamlThemeImportButton.tsx | 24 + .../components/settings/appearance-search.ts | 48 +- .../settings/terminal-search.test.ts | 9 + .../components/settings/terminal-search.ts | 32 +- .../settings/terminal-theme-search.ts | 42 ++ .../settings/useWarpThemeImport.test.ts | 420 ++++++++++++++ .../components/settings/useWarpThemeImport.ts | 220 +++++++ src/renderer/src/i18n/locales/en.json | 55 ++ src/renderer/src/i18n/locales/ja.json | 55 ++ src/renderer/src/lib/terminal-theme.test.ts | 99 +++- src/renderer/src/lib/terminal-theme.ts | 80 ++- src/renderer/src/store/slices/settings.ts | 6 + src/renderer/src/web/web-preload-api.ts | 6 + src/shared/constants.ts | 1 + src/shared/terminal-custom-themes.test.ts | 93 +++ src/shared/terminal-custom-themes.ts | 238 ++++++++ src/shared/types.ts | 2 + 48 files changed, 4398 insertions(+), 100 deletions(-) create mode 100644 src/main/warp-themes/discovery.test.ts create mode 100644 src/main/warp-themes/discovery.ts create mode 100644 src/main/warp-themes/index.test.ts create mode 100644 src/main/warp-themes/index.ts create mode 100644 src/main/warp-themes/manual-warp-theme-files.ts create mode 100644 src/main/warp-themes/parser-runner.test.ts create mode 100644 src/main/warp-themes/parser-runner.ts create mode 100644 src/main/warp-themes/parser.test.ts create mode 100644 src/main/warp-themes/parser.ts create mode 100644 src/main/warp-themes/preview-operation-budget.ts create mode 100644 src/main/warp-themes/theme-file-scanner.ts create mode 100644 src/main/warp-themes/warp-theme-import-source-validation.ts create mode 100644 src/main/warp-themes/warp-theme-parser-worker.ts create mode 100644 src/renderer/src/components/icons/WarpIcon.tsx create mode 100644 src/renderer/src/components/settings/WarpThemeImportButton.tsx create mode 100644 src/renderer/src/components/settings/WarpThemeImportModal.tsx create mode 100644 src/renderer/src/components/settings/YamlThemeImportButton.tsx create mode 100644 src/renderer/src/components/settings/useWarpThemeImport.test.ts create mode 100644 src/renderer/src/components/settings/useWarpThemeImport.ts create mode 100644 src/shared/terminal-custom-themes.test.ts create mode 100644 src/shared/terminal-custom-themes.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index b8a66ca2f5e..04b20738a3d 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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( diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 278bf41bec1..ef58ec34f72 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -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 + + 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 + + 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 + + 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({ diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 5272fbf2902..8d593ca3c75 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -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() }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 68c4b115fe2..374d1ded203 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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: diff --git a/src/main/warp-themes/discovery.test.ts b/src/main/warp-themes/discovery.test.ts new file mode 100644 index 00000000000..48a2daf0a2e --- /dev/null +++ b/src/main/warp-themes/discovery.test.ts @@ -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' + ]) + }) +}) diff --git a/src/main/warp-themes/discovery.ts b/src/main/warp-themes/discovery.ts new file mode 100644 index 00000000000..8403051d630 --- /dev/null +++ b/src/main/warp-themes/discovery.ts @@ -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 [] + } +} diff --git a/src/main/warp-themes/index.test.ts b/src/main/warp-themes/index.test.ts new file mode 100644 index 00000000000..f8c5e21383a --- /dev/null +++ b/src/main/warp-themes/index.test.ts @@ -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) => { + 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.' }]) + }) +}) diff --git a/src/main/warp-themes/index.ts b/src/main/warp-themes/index.ts new file mode 100644 index 00000000000..93411aa3b8b --- /dev/null +++ b/src/main/warp-themes/index.ts @@ -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 { + 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 { + 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 { + 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 { + 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() + 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 + } +} diff --git a/src/main/warp-themes/manual-warp-theme-files.ts b/src/main/warp-themes/manual-warp-theme-files.ts new file mode 100644 index 00000000000..801eaadc547 --- /dev/null +++ b/src/main/warp-themes/manual-warp-theme-files.ts @@ -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 { + 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]! +} diff --git a/src/main/warp-themes/parser-runner.test.ts b/src/main/warp-themes/parser-runner.test.ts new file mode 100644 index 00000000000..c6362b8ce7d --- /dev/null +++ b/src/main/warp-themes/parser-runner.test.ts @@ -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 void> + emit: (event: string, arg?: unknown) => void + terminate: () => Promise + removeAllListeners: () => void + }[] +})) + +vi.mock('electron', () => ({ + app: { isPackaged: false } +})) + +vi.mock('worker_threads', () => ({ + Worker: class MockWorker { + terminated = false + workerData: unknown + listeners = new Map 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 { + 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) + }) +}) diff --git a/src/main/warp-themes/parser-runner.ts b/src/main/warp-themes/parser-runner.ts new file mode 100644 index 00000000000..4d12007e929 --- /dev/null +++ b/src/main/warp-themes/parser-runner.ts @@ -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 + return record.ok === true || record.ok === false +} + +export function parseWarpThemeYamlWithTimeout( + content: string, + fileLabel: string, + options: ParseWarpThemeOptions = {}, + timeoutOptions: ParseWarpThemeTimeoutOptions = {} +): Promise { + 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.' }) + } + }) + }) +} diff --git a/src/main/warp-themes/parser.test.ts b/src/main/warp-themes/parser.test.ts new file mode 100644 index 00000000000..5295be639ca --- /dev/null +++ b/src/main/warp-themes/parser.test.ts @@ -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.' + }) + }) +}) diff --git a/src/main/warp-themes/parser.ts b/src/main/warp-themes/parser.ts new file mode 100644 index 00000000000..40647355917 --- /dev/null +++ b/src/main/warp-themes/parser.ts @@ -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 { + 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, 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[] | undefined { + const unsupported = new Set() + 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) + } + } +} diff --git a/src/main/warp-themes/preview-operation-budget.ts b/src/main/warp-themes/preview-operation-budget.ts new file mode 100644 index 00000000000..36985475d95 --- /dev/null +++ b/src/main/warp-themes/preview-operation-budget.ts @@ -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.' + }) +} diff --git a/src/main/warp-themes/theme-file-scanner.ts b/src/main/warp-themes/theme-file-scanner.ts new file mode 100644 index 00000000000..1b6951f103a --- /dev/null +++ b/src/main/warp-themes/theme-file-scanner.ts @@ -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, right: Dirent): number { + return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }) +} + +function couldContainThemeFile(entry: Dirent): 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 { + 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[] = [] + 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 } +} diff --git a/src/main/warp-themes/warp-theme-import-source-validation.ts b/src/main/warp-themes/warp-theme-import-source-validation.ts new file mode 100644 index 00000000000..df78a531a33 --- /dev/null +++ b/src/main/warp-themes/warp-theme-import-source-validation.ts @@ -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 +} diff --git a/src/main/warp-themes/warp-theme-parser-worker.ts b/src/main/warp-themes/warp-theme-parser-worker.ts new file mode 100644 index 00000000000..e5cc55d2722 --- /dev/null +++ b/src/main/warp-themes/warp-theme-parser-worker.ts @@ -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 + ) +) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index abe041bfc3a..c92b87952d6 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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) => Promise listFonts: () => Promise previewGhosttyImport: () => Promise + previewWarpThemeImport: (source: WarpThemeImportSource) => Promise /** 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. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 81e81745259..04287247c81 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('settings:previewGhosttyImport'), + previewWarpThemeImport: (source: WarpThemeImportSource): Promise => + ipcRenderer.invoke('settings:previewWarpThemeImport', source), + onChanged: (callback: (updates: Record) => void): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, diff --git a/src/renderer/src/components/icons/WarpIcon.tsx b/src/renderer/src/components/icons/WarpIcon.tsx new file mode 100644 index 00000000000..7a9626a4eeb --- /dev/null +++ b/src/renderer/src/components/icons/WarpIcon.tsx @@ -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 ( + + + + ) +} diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index 553984bb0c3..6a54b0c63e9 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -100,6 +100,24 @@ function createGhosttyStub() { } } +function createWarpThemesStub() { + return { + open: false, + preview: null, + loading: false, + desktopOnly: false, + applyError: null, + importSignal: 0, + selectedThemeIds: new Set(), + 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) => void = vi.fn() @@ -120,6 +138,7 @@ async function renderAppearancePane( terminalFontSuggestions={[]} systemPrefersDark={false} ghostty={createGhosttyStub() as never} + warpThemes={createWarpThemesStub() as never} /> ) diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index b3ec84bdca2..18ea6b1762f 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -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} ) : null, - matchesSettingsSearch(searchQuery, getTerminalAppearanceSearchEntries()) ? ( + matchesSettingsSearch(searchQuery, terminalAppearanceSearchEntries) ? ( ) : null, matchesSettingsSearch(searchQuery, getLayoutEntries()) ? ( diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 1fa6703d4b1..7b5ef155881 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -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( 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} diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index 1c6e5588bb3..40b8b144bea 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -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(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 (
@@ -295,39 +334,122 @@ export function ThemePicker({ 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' + )} />
- {translate("auto.components.settings.SettingsFormControls.fbb428db98", "Selected:")} {selectedTheme} - {translate("auto.components.settings.SettingsFormControls.4e11f87ca6", "Showing")} {filteredThemes.length} + {translate('auto.components.settings.SettingsFormControls.fbb428db98', 'Selected:')}{' '} + {selectedThemeLabel} + + + {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 } + )}
- {filteredThemes.map((theme) => ( - - ))} - {filteredThemes.length === 0 ? ( -
{translate("auto.components.settings.SettingsFormControls.ceefb9d7f1", "No themes found.")}
+ {groupedThemes.map((group) => { + const isImported = + group.label === + translate( + 'auto.components.settings.SettingsFormControls.imported_themes', + 'Imported' + ) + return ( +
+

+ {group.label} +

+ {group.themes.map((theme) => ( + + ))} +
+ ) + })} + {visibleThemeCount === 0 ? ( +
+ {translate( + 'auto.components.settings.SettingsFormControls.ceefb9d7f1', + 'No themes found.' + )} +
) : null}
@@ -414,7 +536,10 @@ export function NumberField({ <> {description} {defaultValue !== undefined ? ( - {translate("auto.components.settings.SettingsFormControls.b661b034ec", "· Default:")} {defaultValue} + + {translate('auto.components.settings.SettingsFormControls.b661b034ec', '· Default:')}{' '} + {defaultValue} + ) : 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')} > @@ -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')} > @@ -676,7 +807,12 @@ export function FontAutocomplete({ )) ) : ( -
{translate("auto.components.settings.SettingsFormControls.42a4d15a30", "No matching fonts.")}
+
+ {translate( + 'auto.components.settings.SettingsFormControls.42a4d15a30', + 'No matching fonts.' + )} +
)}
diff --git a/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts b/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts index ae7f0145ac5..36e8eb5bae9 100644 --- a/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts +++ b/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts @@ -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(), + 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) => 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) + }) }) diff --git a/src/renderer/src/components/settings/TerminalAppearanceSection.tsx b/src/renderer/src/components/settings/TerminalAppearanceSection.tsx index bff16a7915c..0ffac9efa37 100644 --- a/src/renderer/src/components/settings/TerminalAppearanceSection.tsx +++ b/src/renderer/src/components/settings/TerminalAppearanceSection.tsx @@ -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(null) + const showWarpThemeImport = !isWebClientLocation() const visibleSections = [ matchesSettingsSearch(searchQuery, getTerminalGhosttyImportSearchEntries()) || @@ -70,6 +82,11 @@ export function TerminalAppearanceSection({ matchesSettingsSearch(searchQuery, getTerminalWindowSearchEntries()) ? ( ) : null, + showWarpThemeImport && + (matchesSettingsSearch(searchQuery, getTerminalWarpImportSearchEntries()) || + matchesSettingsSearch(searchQuery, getTerminalYamlImportSearchEntries())) ? ( + + ) : null, matchesSettingsSearch(searchQuery, getTerminalDarkThemeSearchEntries()) ? ( ) : null, matchesSettingsSearch(searchQuery, getTerminalLightThemeSearchEntries()) ? ( @@ -110,6 +128,22 @@ export function TerminalAppearanceSection({ applied={ghostty.applied} applyError={ghostty.applyError} /> + {showWarpThemeImport ? ( + + ) : null}
) } diff --git a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx index caad67ee097..4294e497a93 100644 --- a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx +++ b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx @@ -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({
- {translate("auto.components.settings.TerminalSettingsPreview.50419052fe", "Pane divider")} + + {translate( + 'auto.components.settings.TerminalSettingsPreview.50419052fe', + 'Pane divider' + )} + 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' + )} />
{showToggle ? (
{(['dark', 'light'] as const).map((mode) => ( ))}
diff --git a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts index 388c8060b19..bde83da6774 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts +++ b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts @@ -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 } +const warpThemesMock: UseWarpThemeImportReturn = { + open: false, + mode: 'warp', + preview: null, + loading: false, + desktopOnly: false, + applyError: null, + importSignal: 0, + selectedThemeIds: new Set(), + 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 { 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([]) + }) +}) diff --git a/src/renderer/src/components/settings/TerminalThemeSections.tsx b/src/renderer/src/components/settings/TerminalThemeSections.tsx index 8a998ebe602..01105472cf9 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.tsx +++ b/src/renderer/src/components/settings/TerminalThemeSections.tsx @@ -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> updateSettings: (updates: Partial) => 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 ( +
+ +
+ + +
+
+ ) +} + export function DarkTerminalThemeSection({ settings, systemPrefersDark, themeSearchDark, setThemeSearchDark, updateSettings, - previewFontFamily + previewFontFamily, + importedHighlightSignal }: DarkTerminalThemeSectionProps): React.JSX.Element { + const themeOptions = getAvailableTerminalThemeOptions(settings) + return (
-
-

{translate("auto.components.settings.TerminalThemeSections.9499ad1dc4", "Dark Theme")}

-

- {translate("auto.components.settings.TerminalThemeSections.f012172e21", "Choose the theme used for terminal panes in dark mode.")}

-
+ updateSettings({ terminalThemeDark: theme })} + importedHighlightSignal={importedHighlightSignal} /> updateSettings({ terminalDividerColorDark: value })} @@ -70,7 +137,10 @@ export function DarkTerminalThemeSection({
-

{translate("auto.components.settings.TerminalThemeSections.d76f60c9cc", "Use Separate Theme In Light Mode")}

+

+ {translate( + 'auto.components.settings.TerminalThemeSections.d76f60c9cc', + 'Use Separate Theme In Light Mode' + )} +

- {translate("auto.components.settings.TerminalThemeSections.b584287e84", "When disabled, light mode reuses the dark terminal theme.")}

+ {translate( + 'auto.components.settings.TerminalThemeSections.b584287e84', + 'When disabled, light mode reuses the dark terminal theme.' + )} +

+ ) +} diff --git a/src/renderer/src/components/settings/WarpThemeImportModal.tsx b/src/renderer/src/components/settings/WarpThemeImportModal.tsx new file mode 100644 index 00000000000..6ba507113fd --- /dev/null +++ b/src/renderer/src/components/settings/WarpThemeImportModal.tsx @@ -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 ( + + {colors.map((color, index) => ( + + ))} + + ) +} + +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 ( + + + + + {mode === 'yaml' + ? translate( + 'auto.components.settings.WarpThemeImportModal.yaml_title', + 'Import theme YAML' + ) + : translate( + 'auto.components.settings.WarpThemeImportModal.title', + 'Import themes from Warp' + )} + + + {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.' + )} + + + +
+ {!desktopOnly ? ( +
+ + +
+ ) : null} + + {loading ? ( +
+ + {translate( + 'auto.components.settings.WarpThemeImportModal.loading', + 'Loading Warp themes...' + )} +
+ ) : preview == null ? null : preview.found ? ( +
+
+ + {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 } + ) + : ''} + + +
+ +
+ +
+ {themes.map((theme) => { + const selected = selectedThemeIds.has(theme.id) + return ( + + ) + })} +
+
+
+
+ ) : ( +
+

+ {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.' + ))} +

+ {!preview.error && mode !== 'yaml' ? ( +

+ {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." + )} +

+ ) : null} + {!desktopOnly ? ( +

+ {translate( + 'auto.components.settings.WarpThemeImportModal.choose_manually', + 'Choose a theme YAML file or folder to import manually.' + )} +

+ ) : null} +
+ )} + + {!loading && preview && skippedCount > 0 ? ( +
+

+ {translate( + 'auto.components.settings.WarpThemeImportModal.skipped_files', + 'Skipped files' + )} +

+
    + {preview.skippedFiles.slice(0, 8).map((file) => ( +
  • + {file.label} + {file.reason} +
  • + ))} + {preview.skippedFiles.length > 8 ? ( +
  • + {translate( + 'auto.components.settings.WarpThemeImportModal.more_skipped_files', + '{{value0}} more skipped files.', + { value0: preview.skippedFiles.length - 8 } + )} +
  • + ) : null} +
+
+ ) : null} + + {applyError ?

{applyError}

: null} +
+ + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/YamlThemeImportButton.tsx b/src/renderer/src/components/settings/YamlThemeImportButton.tsx new file mode 100644 index 00000000000..ab069669d8d --- /dev/null +++ b/src/renderer/src/components/settings/YamlThemeImportButton.tsx @@ -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 ( + + ) +} diff --git a/src/renderer/src/components/settings/appearance-search.ts b/src/renderer/src/components/settings/appearance-search.ts index 7e08e412ac5..8dd4857f142 100644 --- a/src/renderer/src/components/settings/appearance-search.ts +++ b/src/renderer/src/components/settings/appearance-search.ts @@ -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() +} diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts index 8672852c18b..5f46fca1037 100644 --- a/src/renderer/src/components/settings/terminal-search.test.ts +++ b/src/renderer/src/components/settings/terminal-search.test.ts @@ -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' diff --git a/src/renderer/src/components/settings/terminal-search.ts b/src/renderer/src/components/settings/terminal-search.ts index e841d7d56ee..42c49695198 100644 --- a/src/renderer/src/components/settings/terminal-search.ts +++ b/src/renderer/src/components/settings/terminal-search.ts @@ -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 diff --git a/src/renderer/src/components/settings/terminal-theme-search.ts b/src/renderer/src/components/settings/terminal-theme-search.ts index fa2395005f8..3433b9b0a16 100644 --- a/src/renderer/src/components/settings/terminal-theme-search.ts +++ b/src/renderer/src/components/settings/terminal-theme-search.ts @@ -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') + ] + } +]) diff --git a/src/renderer/src/components/settings/useWarpThemeImport.test.ts b/src/renderer/src/components/settings/useWarpThemeImport.test.ts new file mode 100644 index 00000000000..cc0a31288dd --- /dev/null +++ b/src/renderer/src/components/settings/useWarpThemeImport.test.ts @@ -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('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' }) + ]) + }) + }) +}) diff --git a/src/renderer/src/components/settings/useWarpThemeImport.ts b/src/renderer/src/components/settings/useWarpThemeImport.ts new file mode 100644 index 00000000000..615df1a0f54 --- /dev/null +++ b/src/renderer/src/components/settings/useWarpThemeImport.ts @@ -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 + handleClick: () => Promise + handleImportYamlClick: () => Promise + handlePreviewSource: (source: WarpThemeImportSource) => Promise + handleToggleTheme: (id: string) => void + handleToggleAll: (checked: boolean) => void + handleApply: () => Promise + handleOpenChange: (open: boolean) => void +} + +export function useWarpThemeImport( + updateSettings: (updates: Partial) => void | Promise, + settings: GlobalSettings | null +): UseWarpThemeImportReturn { + const [open, setOpen] = useState(false) + const [mode, setMode] = useState('warp') + const [preview, setPreview] = useState(null) + const [loading, setLoading] = useState(false) + const [applyError, setApplyError] = useState(null) + const [importSignal, setImportSignal] = useState(0) + const [selectedThemeIds, setSelectedThemeIds] = useState>(() => new Set()) + const mountedRef = useMountedRef() + + async function previewSource(source: WarpThemeImportSource): Promise { + 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 { + await previewSource(source) + } + + async function handleClick(): Promise { + setMode('warp') + setOpen(true) + await previewSource({ kind: 'auto' }) + } + + async function handleImportYamlClick(): Promise { + 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 { + if (!preview?.found || !settings || selectedThemeIds.size === 0) { + return + } + const selectedThemes = preview.themes.filter((theme) => selectedThemeIds.has(theme.id)) + const byId = new Map() + 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 + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 88fc1852fae..13eabd36e02 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index de75b00a505..2eff318e980 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": "マコス", diff --git a/src/renderer/src/lib/terminal-theme.test.ts b/src/renderer/src/lib/terminal-theme.test.ts index 51c7a10c9a1..457d2562582 100644 --- a/src/renderer/src/lib/terminal-theme.test.ts +++ b/src/renderer/src/lib/terminal-theme.test.ts @@ -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', () => { diff --git a/src/renderer/src/lib/terminal-theme.ts b/src/renderer/src/lib/terminal-theme.ts index 6e5dba409c5..057f26aaa20 100644 --- a/src/renderer/src/lib/terminal-theme.ts +++ b/src/renderer/src/lib/terminal-theme.ts @@ -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 | 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 | 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, + 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 +): 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 } } diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 78e0a3a4f60..eb7b33cb9e6 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -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 updates.terminalQuickCommands ) } + if ('terminalCustomThemes' in updates) { + sanitizedUpdates.terminalCustomThemes = normalizeTerminalCustomThemes( + updates.terminalCustomThemes + ) + } if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) { const taskProviderSettings = normalizeTaskProviderSettings({ visibleTaskProviders: diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 5e87164143e..63a10433bd4 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -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, activeRuntimeEnvironmentId: activeEnvironment?.id ?? updates.activeRuntimeEnvironmentId ?? null, + terminalCustomThemes: normalizeTerminalCustomThemes( + updates.terminalCustomThemes ?? base.terminalCustomThemes + ), uiLanguage: normalizeUiLanguage(updates.uiLanguage ?? base.uiLanguage) } return { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index eac779c43d4..d0b94b121ad 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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, diff --git a/src/shared/terminal-custom-themes.test.ts b/src/shared/terminal-custom-themes.test.ts new file mode 100644 index 00000000000..46dc08d3189 --- /dev/null +++ b/src/shared/terminal-custom-themes.test.ts @@ -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') + }) +}) diff --git a/src/shared/terminal-custom-themes.ts b/src/shared/terminal-custom-themes.ts new file mode 100644 index 00000000000..af4fcdd218d --- /dev/null +++ b/src/shared/terminal-custom-themes.ts @@ -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 + 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() + for (const entry of value.slice(-MAX_TERMINAL_CUSTOM_THEMES)) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + continue + } + const input = entry as Record + 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 } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index e12004211db..61d4a6ed95b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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