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