Improve Warp custom theme discovery (#5287)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-12 18:09:06 -07:00 committed by GitHub
parent 71bfbbf840
commit 5dc05e2bd2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 758 additions and 145 deletions

View File

@ -0,0 +1,137 @@
import { realpath, stat } from 'fs/promises'
import path from 'path'
import type { WarpThemeImportSkippedFile } from '../../shared/terminal-custom-themes'
import { getWarpThemeDirectories, warpThemeSourceLabelForDirectory } from './discovery'
import type { PreviewOperationBudget } from './preview-operation-budget'
import { filesFromDirectory, type ThemeSourceSelection } from './theme-source-selection'
import { MAX_THEME_FILES, type ThemeFileCandidate } from './theme-file-scanner'
function themeFileCanonicalFallback(filePath: string): string {
return path.normalize(path.resolve(filePath))
}
async function themeFileDedupeKey(filePath: string): Promise<string> {
try {
return path.normalize(await realpath(filePath))
} catch {
return themeFileCanonicalFallback(filePath)
}
}
async function appendUniqueThemeFiles(
targetFiles: ThemeFileCandidate[],
seenFilePaths: Set<string>,
candidateFiles: ThemeFileCandidate[]
): Promise<boolean> {
let capped = false
for (const file of candidateFiles) {
const dedupeKey = await themeFileDedupeKey(file.path)
if (seenFilePaths.has(dedupeKey)) {
continue
}
seenFilePaths.add(dedupeKey)
if (targetFiles.length < MAX_THEME_FILES) {
targetFiles.push(file)
} else {
capped = true
break
}
}
return capped
}
async function isDirectoryPath(directoryPath: string): Promise<boolean> {
try {
const info = await stat(directoryPath)
return info.isDirectory()
} catch {
return false
}
}
async function directoryHasThemeFileCandidate(
directoryPath: string,
budget?: PreviewOperationBudget
): Promise<boolean> {
if (!(await isDirectoryPath(directoryPath))) {
return false
}
const selection = await filesFromDirectory(
directoryPath,
warpThemeSourceLabelForDirectory(directoryPath),
budget,
1,
false
)
return !selection.canceled && selection.files.length > 0
}
export async function filesFromAutoDirectories(
budget?: PreviewOperationBudget
): Promise<ThemeSourceSelection> {
const directories = getWarpThemeDirectories()
const mergedFiles: ThemeFileCandidate[] = []
const seenFilePaths = new Set<string>()
const skippedFiles: WarpThemeImportSkippedFile[] = []
let autoDiscoveryExpired = false
let globalThemeFileLimitHit = false
for (const directoryPath of directories) {
if (budget?.isExpired()) {
autoDiscoveryExpired = true
break
}
const remainingThemeFileSlots = MAX_THEME_FILES - mergedFiles.length
if (remainingThemeFileSlots <= 0) {
globalThemeFileLimitHit =
(await directoryHasThemeFileCandidate(directoryPath, budget)) || globalThemeFileLimitHit
if (budget?.isExpired()) {
autoDiscoveryExpired = true
}
if (globalThemeFileLimitHit || autoDiscoveryExpired) {
break
}
continue
}
if (!(await isDirectoryPath(directoryPath))) {
continue
}
const selection = await filesFromDirectory(
directoryPath,
warpThemeSourceLabelForDirectory(directoryPath),
budget,
MAX_THEME_FILES,
false
)
if (selection.canceled) {
continue
}
globalThemeFileLimitHit =
(await appendUniqueThemeFiles(mergedFiles, seenFilePaths, selection.files)) ||
selection.themeFileLimitHit ||
globalThemeFileLimitHit
skippedFiles.push(...selection.skippedFiles)
}
if (autoDiscoveryExpired) {
skippedFiles.push({
label: 'Warp themes',
reason: 'Preview budget expired before local Warp theme folders could be scanned.'
})
}
if (globalThemeFileLimitHit) {
skippedFiles.push({
label: 'Warp themes',
reason: `Only the first ${MAX_THEME_FILES} theme files were 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: mergedFiles,
skippedFiles
}
}

View File

@ -2,38 +2,185 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const platformMock = vi.hoisted(() => vi.fn())
const homedirMock = vi.hoisted(() => vi.fn(() => '/Users/alice'))
type MockDirectoryEntry = {
name: string
isDirectory: () => boolean
}
const readdirSyncMock = vi.hoisted(() => vi.fn<() => MockDirectoryEntry[]>(() => []))
vi.mock('fs', () => ({
readdirSync: readdirSyncMock
}))
vi.mock('os', () => ({
homedir: homedirMock,
platform: platformMock
}))
import { getWarpThemeDirectories } from './discovery'
import { getWarpThemeDirectories, warpThemeSourceLabelForDirectory } from './discovery'
function directoryEntry(name: string): MockDirectoryEntry {
return {
name,
isDirectory: () => true
}
}
function fileEntry(name: string): MockDirectoryEntry {
return {
name,
isDirectory: () => false
}
}
describe('getWarpThemeDirectories', () => {
beforeEach(() => {
vi.unstubAllEnvs()
platformMock.mockReset()
homedirMock.mockReturnValue('/Users/alice')
readdirSyncMock.mockReset()
readdirSyncMock.mockReturnValue([])
})
it('returns the macOS Warp theme directory', () => {
it('returns macOS Warp channel theme directories in stable-first order', () => {
platformMock.mockReturnValue('darwin')
expect(getWarpThemeDirectories()).toEqual(['/Users/alice/.warp/themes'])
expect(getWarpThemeDirectories()).toEqual([
'/Users/alice/.warp/themes',
'/Users/alice/.warp-preview/themes',
'/Users/alice/.warp-oss/themes',
'/Users/alice/.warp-dev/themes',
'/Users/alice/.warp-local/themes',
'/Users/alice/.warp-integration/themes'
])
})
it('returns the Linux XDG data theme directory', () => {
it('adds dynamic macOS .warp directories after known channels', () => {
platformMock.mockReturnValue('darwin')
readdirSyncMock.mockReturnValue([
directoryEntry('.warp-future'),
fileEntry('.warp-note'),
directoryEntry('.not-warp'),
directoryEntry('.warp-preview')
])
expect(getWarpThemeDirectories()).toEqual([
'/Users/alice/.warp/themes',
'/Users/alice/.warp-preview/themes',
'/Users/alice/.warp-oss/themes',
'/Users/alice/.warp-dev/themes',
'/Users/alice/.warp-local/themes',
'/Users/alice/.warp-integration/themes',
'/Users/alice/.warp-future/themes'
])
})
it('returns Linux XDG data channel directories in stable-first order', () => {
platformMock.mockReturnValue('linux')
vi.stubEnv('XDG_DATA_HOME', '/data/alice')
expect(getWarpThemeDirectories()).toEqual(['/data/alice/warp-terminal/themes'])
expect(getWarpThemeDirectories()).toEqual([
'/data/alice/warp-terminal/themes',
'/data/alice/warp-terminal-preview/themes',
'/data/alice/warp-oss/themes',
'/data/alice/warp-terminal-dev/themes',
'/data/alice/warp-terminal-local/themes',
'/data/alice/warp-terminal-integration/themes'
])
})
it('returns the Windows app data theme directory with Windows separators', () => {
it('adds dynamic Linux warp data directories', () => {
platformMock.mockReturnValue('linux')
vi.stubEnv('XDG_DATA_HOME', '/data/alice')
readdirSyncMock.mockReturnValue([
directoryEntry('warp-future'),
directoryEntry('warp-terminal'),
directoryEntry('not-warp'),
fileEntry('warp-note')
])
expect(getWarpThemeDirectories()).toEqual([
'/data/alice/warp-terminal/themes',
'/data/alice/warp-terminal-preview/themes',
'/data/alice/warp-oss/themes',
'/data/alice/warp-terminal-dev/themes',
'/data/alice/warp-terminal-local/themes',
'/data/alice/warp-terminal-integration/themes',
'/data/alice/warp-future/themes'
])
})
it('ignores relative Linux XDG data home values', () => {
platformMock.mockReturnValue('linux')
vi.stubEnv('XDG_DATA_HOME', 'relative-data-home')
expect(getWarpThemeDirectories()).toEqual([
'/Users/alice/.local/share/warp-terminal/themes',
'/Users/alice/.local/share/warp-terminal-preview/themes',
'/Users/alice/.local/share/warp-oss/themes',
'/Users/alice/.local/share/warp-terminal-dev/themes',
'/Users/alice/.local/share/warp-terminal-local/themes',
'/Users/alice/.local/share/warp-terminal-integration/themes'
])
expect(readdirSyncMock).toHaveBeenCalledWith('/Users/alice/.local/share', {
withFileTypes: true
})
})
it('returns Windows app data channel directories 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'
'C:\\Users\\alice\\AppData\\Roaming\\warp\\Warp\\data\\themes',
'C:\\Users\\alice\\AppData\\Roaming\\warp\\WarpPreview\\data\\themes',
'C:\\Users\\alice\\AppData\\Roaming\\warp\\WarpOss\\data\\themes',
'C:\\Users\\alice\\AppData\\Roaming\\warp\\WarpDev\\data\\themes',
'C:\\Users\\alice\\AppData\\Roaming\\warp\\WarpLocal\\data\\themes',
'C:\\Users\\alice\\AppData\\Roaming\\warp\\WarpIntegration\\data\\themes'
])
})
it('adds dynamic Windows Warp app data directories', () => {
platformMock.mockReturnValue('win32')
vi.stubEnv('APPDATA', 'C:\\Users\\alice\\AppData\\Roaming')
readdirSyncMock.mockReturnValue([
directoryEntry('WarpFuture'),
directoryEntry('WarpPreview'),
fileEntry('WarpNote')
])
expect(getWarpThemeDirectories()).toEqual([
'C:\\Users\\alice\\AppData\\Roaming\\warp\\Warp\\data\\themes',
'C:\\Users\\alice\\AppData\\Roaming\\warp\\WarpPreview\\data\\themes',
'C:\\Users\\alice\\AppData\\Roaming\\warp\\WarpOss\\data\\themes',
'C:\\Users\\alice\\AppData\\Roaming\\warp\\WarpDev\\data\\themes',
'C:\\Users\\alice\\AppData\\Roaming\\warp\\WarpLocal\\data\\themes',
'C:\\Users\\alice\\AppData\\Roaming\\warp\\WarpIntegration\\data\\themes',
'C:\\Users\\alice\\AppData\\Roaming\\warp\\WarpFuture\\data\\themes'
])
})
})
describe('warpThemeSourceLabelForDirectory', () => {
it('labels macOS and Linux theme directories by their Warp data home', () => {
expect(warpThemeSourceLabelForDirectory('/Users/alice/.warp-preview/themes')).toBe(
'.warp-preview'
)
expect(warpThemeSourceLabelForDirectory('/data/alice/warp-terminal-preview/themes')).toBe(
'warp-terminal-preview'
)
})
it('labels Windows theme directories by app folder instead of data', () => {
expect(
warpThemeSourceLabelForDirectory(
'C:\\Users\\alice\\AppData\\Roaming\\warp\\WarpPreview\\data\\themes'
)
).toBe('WarpPreview')
})
it('falls back to the nearest non-empty parent for unfamiliar shapes', () => {
expect(warpThemeSourceLabelForDirectory('/Users/alice/custom/themes')).toBe('custom')
expect(warpThemeSourceLabelForDirectory('/Users/alice/custom')).toBe('custom')
})
})

View File

@ -1,21 +1,115 @@
import { readdirSync } from 'fs'
import type { Dirent } from 'fs'
import { homedir, platform } from 'os'
import path from 'path'
const WARP_CHANNELS = [
{ macName: '.warp', linuxName: 'warp-terminal', windowsName: 'Warp' },
{ macName: '.warp-preview', linuxName: 'warp-terminal-preview', windowsName: 'WarpPreview' },
{ macName: '.warp-oss', linuxName: 'warp-oss', windowsName: 'WarpOss' },
{ macName: '.warp-dev', linuxName: 'warp-terminal-dev', windowsName: 'WarpDev' },
{ macName: '.warp-local', linuxName: 'warp-terminal-local', windowsName: 'WarpLocal' },
{
macName: '.warp-integration',
linuxName: 'warp-terminal-integration',
windowsName: 'WarpIntegration'
}
]
function readDirectoryEntries(directoryPath: string): Dirent[] {
try {
return readdirSync(directoryPath, { withFileTypes: true }).sort((left, right) =>
left.name.localeCompare(right.name, undefined, { sensitivity: 'base' })
)
} catch {
return []
}
}
function addDedupeDirectory(
directories: string[],
seenDirectories: Set<string>,
directoryPath: string
): void {
const normalizedPath = path.normalize(path.resolve(directoryPath))
if (seenDirectories.has(normalizedPath)) {
return
}
seenDirectories.add(normalizedPath)
directories.push(directoryPath)
}
function warpThemeDirectoriesFromDataHomes(dataHomes: string[]): string[] {
const directories: string[] = []
const seenDirectories = new Set<string>()
for (const dataHome of dataHomes) {
addDedupeDirectory(directories, seenDirectories, path.join(dataHome, 'themes'))
}
return directories
}
function getMacWarpThemeDirectories(home: string): string[] {
return warpThemeDirectoriesFromDataHomes([
...WARP_CHANNELS.map((channel) => path.join(home, channel.macName)),
...readDirectoryEntries(home)
.filter((entry) => entry.isDirectory() && entry.name.startsWith('.warp'))
.map((entry) => path.join(home, entry.name))
])
}
function getLinuxWarpThemeDirectories(home: string): string[] {
const xdgDataHome = process.env.XDG_DATA_HOME
// Why: XDG_DATA_HOME is only valid as an absolute path; relative values would
// make discovery depend on Orca's launch directory.
const dataHome =
xdgDataHome && path.isAbsolute(xdgDataHome) ? xdgDataHome : path.join(home, '.local', 'share')
return warpThemeDirectoriesFromDataHomes([
...WARP_CHANNELS.map((channel) => path.join(dataHome, channel.linuxName)),
...readDirectoryEntries(dataHome)
.filter(
(entry) =>
entry.isDirectory() && (entry.name === 'warp-terminal' || entry.name.startsWith('warp-'))
)
.map((entry) => path.join(dataHome, entry.name))
])
}
function getWindowsWarpThemeDirectories(home: string): string[] {
const appData = process.env.APPDATA || home
const warpAppData = path.win32.join(appData, 'warp')
const directories: string[] = []
const seenDirectories = new Set<string>()
for (const channel of WARP_CHANNELS) {
addDedupeDirectory(
directories,
seenDirectories,
path.win32.join(warpAppData, channel.windowsName, 'data', 'themes')
)
}
for (const entry of readDirectoryEntries(warpAppData)) {
if (!entry.isDirectory()) {
continue
}
addDedupeDirectory(
directories,
seenDirectories,
path.win32.join(warpAppData, entry.name, 'data', 'themes')
)
}
return directories
}
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')]
}
return getMacWarpThemeDirectories(home)
case 'linux':
return getLinuxWarpThemeDirectories(home)
case 'win32':
return getWindowsWarpThemeDirectories(home)
case 'aix':
case 'android':
case 'cygwin':
@ -27,3 +121,18 @@ export function getWarpThemeDirectories(): string[] {
return []
}
}
export function warpThemeSourceLabelForDirectory(directoryPath: string): string {
const parts = directoryPath.split(/[\\/]+/).filter(Boolean)
const themesIndex = parts.findLastIndex((part) => part.toLowerCase() === 'themes')
if (themesIndex < 0) {
return parts.at(-1) || 'Warp themes'
}
const previousPart = parts[themesIndex - 1]
const windowsAppPart = parts[themesIndex - 2]
if (previousPart?.toLowerCase() === 'data' && windowsAppPart) {
return windowsAppPart
}
return previousPart || 'Warp themes'
}

View File

@ -1,8 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import path from 'path'
import type * as WarpThemeDiscovery from './discovery'
const opendirMock = vi.hoisted(() => vi.fn())
const readFileMock = vi.hoisted(() => vi.fn())
const realpathMock = vi.hoisted(() => vi.fn((filePath: string) => Promise.resolve(filePath)))
const statMock = vi.hoisted(() => vi.fn())
const getWarpThemeDirectoriesMock = vi.hoisted(() => vi.fn(() => ['/Users/alice/.warp/themes']))
const parseWarpThemeYamlWithTimeoutMock = vi.hoisted(() => vi.fn())
@ -16,12 +18,17 @@ vi.mock('electron', () => ({
vi.mock('fs/promises', () => ({
opendir: opendirMock,
readFile: readFileMock,
realpath: realpathMock,
stat: statMock
}))
vi.mock('./discovery', () => ({
getWarpThemeDirectories: getWarpThemeDirectoriesMock
}))
vi.mock('./discovery', async (importOriginal) => {
const actual = await importOriginal<typeof WarpThemeDiscovery>()
return {
...actual,
getWarpThemeDirectories: getWarpThemeDirectoriesMock
}
})
vi.mock('./parser-runner', () => ({
parseWarpThemeYamlWithTimeout: parseWarpThemeYamlWithTimeoutMock
@ -44,7 +51,17 @@ function fileEntry(name: string) {
return {
name,
isFile: () => true,
isDirectory: () => false
isDirectory: () => false,
isSymbolicLink: () => false
}
}
function symlinkEntry(name: string) {
return {
name,
isFile: () => false,
isDirectory: () => false,
isSymbolicLink: () => true
}
}
@ -52,12 +69,18 @@ function directoryEntry(name: string) {
return {
name,
isFile: () => false,
isDirectory: () => true
isDirectory: () => true,
isSymbolicLink: () => false
}
}
function mockDirectory(
entries: { name: string; isFile: () => boolean; isDirectory: () => boolean }[]
entries: {
name: string
isFile: () => boolean
isDirectory: () => boolean
isSymbolicLink: () => boolean
}[]
) {
return {
async *[Symbol.asyncIterator]() {
@ -80,6 +103,7 @@ describe('previewWarpThemeImport', () => {
getWarpThemeDirectoriesMock.mockReturnValue(['/Users/alice/.warp/themes'])
statMock.mockImplementation(mockStat)
readFileMock.mockResolvedValue(VALID_THEME)
realpathMock.mockImplementation((filePath: string) => Promise.resolve(filePath))
opendirMock.mockResolvedValue(mockDirectory([fileEntry('z.yml'), fileEntry('a.yml')]))
parseWarpThemeYamlWithTimeoutMock.mockImplementation(parseWarpThemeYaml)
})
@ -129,6 +153,214 @@ describe('previewWarpThemeImport', () => {
expect(readFileMock).not.toHaveBeenCalled()
})
it('merges themes from multiple readable Warp directories', async () => {
getWarpThemeDirectoriesMock.mockReturnValue([
'/Users/alice/.warp/themes',
'/Users/alice/.warp-preview/themes'
])
opendirMock.mockImplementation((directoryPath: string) => {
if (directoryPath === '/Users/alice/.warp/themes') {
return Promise.resolve(mockDirectory([fileEntry('stable.yaml')]))
}
if (directoryPath === '/Users/alice/.warp-preview/themes') {
return Promise.resolve(mockDirectory([fileEntry('preview.yaml')]))
}
return Promise.resolve(mockDirectory([]))
})
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(readFileMock.mock.calls.map(([filePath]) => filePath)).toEqual([
path.join('/Users/alice/.warp/themes', 'stable.yaml'),
path.join('/Users/alice/.warp-preview/themes', 'preview.yaml')
])
expect(preview.themes.map((theme) => theme.sourceLabel)).toEqual(['.warp', '.warp-preview'])
})
it('continues scanning when an earlier Warp directory is empty', async () => {
getWarpThemeDirectoriesMock.mockReturnValue([
'/Users/alice/.warp/themes',
'/Users/alice/.warp-oss/themes'
])
opendirMock.mockImplementation((directoryPath: string) => {
if (directoryPath === '/Users/alice/.warp/themes') {
return Promise.resolve(mockDirectory([]))
}
if (directoryPath === '/Users/alice/.warp-oss/themes') {
return Promise.resolve(mockDirectory([fileEntry('oss.yaml')]))
}
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-oss/themes', 'oss.yaml')
])
expect(preview.themes.map((theme) => theme.sourceLabel)).toEqual(['.warp-oss'])
})
it('dedupes symlinked theme files by canonical path while preserving stable-first order', async () => {
getWarpThemeDirectoriesMock.mockReturnValue([
'/Users/alice/.warp/themes',
'/Users/alice/.warp-preview/themes'
])
opendirMock.mockImplementation((directoryPath: string) => {
if (directoryPath === '/Users/alice/.warp/themes') {
return Promise.resolve(mockDirectory([fileEntry('shared.yaml')]))
}
if (directoryPath === '/Users/alice/.warp-preview/themes') {
return Promise.resolve(mockDirectory([fileEntry('shared.yaml')]))
}
return Promise.resolve(mockDirectory([]))
})
realpathMock.mockResolvedValue('/Users/alice/.warp/themes/shared.yaml')
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.themes).toHaveLength(1)
expect(readFileMock).toHaveBeenCalledWith(
path.join('/Users/alice/.warp/themes', 'shared.yaml'),
'utf-8'
)
expect(preview.themes[0]?.sourceLabel).toBe('.warp')
expect(preview.skippedFiles).not.toContainEqual({
label: 'Warp themes',
reason: 'Only the first 200 theme files were scanned.'
})
})
it('discovers YAML files exposed as symlinked Warp theme entries', async () => {
opendirMock.mockResolvedValue(mockDirectory([symlinkEntry('linked.yaml')]))
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.found).toBe(true)
expect(readFileMock).toHaveBeenCalledWith(
path.join('/Users/alice/.warp/themes', 'linked.yaml'),
'utf-8'
)
})
it('dedupes theme files by normalized resolved path when canonical paths are unavailable', async () => {
getWarpThemeDirectoriesMock.mockReturnValue([
'/Users/alice/.warp/themes',
'/Users/alice/.warp/themes/../themes'
])
opendirMock.mockResolvedValue(mockDirectory([fileEntry('same.yaml')]))
realpathMock.mockRejectedValue(new Error('realpath unavailable'))
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.themes).toHaveLength(1)
expect(readFileMock).toHaveBeenCalledTimes(1)
})
it('applies the theme file cap globally across merged auto-discovery directories', async () => {
getWarpThemeDirectoriesMock.mockReturnValue([
'/Users/alice/.warp/themes',
'/Users/alice/.warp-preview/themes'
])
opendirMock.mockImplementation((directoryPath: string) => {
if (directoryPath === '/Users/alice/.warp/themes') {
return Promise.resolve(
mockDirectory(
Array.from({ length: 150 }, (_, index) => fileEntry(`stable-${index}.yaml`))
)
)
}
if (directoryPath === '/Users/alice/.warp-preview/themes') {
return Promise.resolve(
mockDirectory(
Array.from({ length: 150 }, (_, index) => fileEntry(`preview-${index}.yaml`))
)
)
}
return Promise.resolve(mockDirectory([]))
})
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.themes).toHaveLength(200)
expect(readFileMock).toHaveBeenCalledTimes(200)
expect(preview.skippedFiles).toContainEqual({
label: 'Warp themes',
reason: 'Only the first 200 theme files were scanned.'
})
})
it('reports the theme cap when later Warp directories contain themes after the cap is full', async () => {
getWarpThemeDirectoriesMock.mockReturnValue([
'/Users/alice/.warp/themes',
'/Users/alice/.warp-preview/themes'
])
opendirMock.mockImplementation((directoryPath: string) => {
if (directoryPath === '/Users/alice/.warp/themes') {
return Promise.resolve(
mockDirectory(
Array.from({ length: 200 }, (_, index) => fileEntry(`stable-${index}.yaml`))
)
)
}
if (directoryPath === '/Users/alice/.warp-preview/themes') {
return Promise.resolve(mockDirectory([fileEntry('preview.yaml')]))
}
return Promise.resolve(mockDirectory([]))
})
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.themes).toHaveLength(200)
expect(readFileMock).not.toHaveBeenCalledWith(
path.join('/Users/alice/.warp-preview/themes', 'preview.yaml'),
'utf-8'
)
expect(preview.skippedFiles).toContainEqual({
label: 'Warp themes',
reason: 'Only the first 200 theme files were scanned.'
})
})
it('keeps scanning later directories for unique themes after duplicate canonical files', async () => {
const stableDirectory = '/Users/alice/.warp/themes'
const previewDirectory = '/Users/alice/.warp-preview/themes'
getWarpThemeDirectoriesMock.mockReturnValue([stableDirectory, previewDirectory])
opendirMock.mockImplementation((directoryPath: string) => {
if (directoryPath === stableDirectory) {
return Promise.resolve(
mockDirectory(
Array.from({ length: 199 }, (_, index) => fileEntry(`stable-${index}.yaml`))
)
)
}
if (directoryPath === previewDirectory) {
return Promise.resolve(
mockDirectory([
fileEntry('duplicate-a.yaml'),
fileEntry('duplicate-b.yaml'),
fileEntry('unique.yaml')
])
)
}
return Promise.resolve(mockDirectory([]))
})
realpathMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('duplicate-a.yaml')) {
return Promise.resolve(path.join(stableDirectory, 'stable-0.yaml'))
}
if (filePath.endsWith('duplicate-b.yaml')) {
return Promise.resolve(path.join(stableDirectory, 'stable-1.yaml'))
}
return Promise.resolve(filePath)
})
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.themes).toHaveLength(200)
expect(readFileMock).toHaveBeenCalledWith(path.join(previewDirectory, 'unique.yaml'), 'utf-8')
})
it('reports bounded skips when local Warp folders are unreadable', async () => {
opendirMock.mockRejectedValue(
new Error("EACCES: permission denied, scandir '/Users/alice/.warp/themes'")
@ -138,19 +370,31 @@ describe('previewWarpThemeImport', () => {
expect(preview.found).toBe(false)
expect(preview.sourceLabel).toBe('Warp themes')
expect(preview.skippedFiles).toEqual([{ label: 'themes', reason: 'Could not read folder.' }])
expect(preview.skippedFiles).toEqual([{ label: '.warp', reason: 'Could not read folder.' }])
expect(preview.themes).toEqual([])
})
it('labels auto-discovered themes as local Warp themes', async () => {
it('labels root skipped entries by auto-discovered Warp data home', async () => {
getWarpThemeDirectoriesMock.mockReturnValue([
'/Users/alice/.warp/themes',
'/Users/alice/.warp-preview/themes'
])
opendirMock.mockRejectedValue(new Error('permission denied'))
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.skippedFiles).toEqual([
{ label: '.warp', reason: 'Could not read folder.' },
{ label: '.warp-preview', reason: 'Could not read folder.' }
])
})
it('labels auto-discovered themes by Warp data home', 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'
])
expect(preview.themes.map((theme) => theme.sourceLabel)).toEqual(['.warp', '.warp'])
})
it('returns a bounded preview error for invalid sources without auto discovery', async () => {
@ -320,10 +564,7 @@ describe('previewWarpThemeImport', () => {
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'
])
expect(preview.themes.map((theme) => theme.sourceLabel)).toEqual(['.warp', '.warp'])
})
it('caps broad folder scans before walking unbounded child directories', async () => {
@ -342,7 +583,7 @@ describe('previewWarpThemeImport', () => {
expect(preview.themes).toHaveLength(79)
expect(preview.skippedFiles).toContainEqual({
label: 'themes',
label: '.warp',
reason: 'Only the first 80 folders were scanned.'
})
})
@ -369,7 +610,7 @@ describe('previewWarpThemeImport', () => {
expect(preview.themes).toHaveLength(200)
expect(preview.skippedFiles).toContainEqual({
label: 'themes',
label: 'Warp themes',
reason: 'Only the first 200 theme files were scanned.'
})
expect(opendirMock).not.toHaveBeenCalledWith(
@ -389,11 +630,11 @@ describe('previewWarpThemeImport', () => {
expect(preview.skippedFiles).toEqual(
expect.arrayContaining([
{
label: 'themes',
label: '.warp',
reason: 'Only the first 500 folder entries were scanned.'
},
{
label: 'themes',
label: 'Warp themes',
reason: 'Only the first 200 theme files were scanned.'
}
])
@ -522,7 +763,7 @@ describe('previewWarpThemeImport', () => {
const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' })
expect(preview.skippedFiles).toEqual([{ label: 'themes', reason: 'Could not read folder.' }])
expect(preview.skippedFiles).toEqual([{ label: '.warp', reason: 'Could not read folder.' }])
})
it('does not copy absolute file paths into skipped reasons', async () => {

View File

@ -3,17 +3,11 @@ import type { WebContents } from 'electron'
import type { Store } from '../persistence'
import type {
WarpThemeImportPreview,
WarpThemeImportSource,
WarpThemeImportSkippedFile
WarpThemeImportSource
} 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 { sanitizeReadError } from './theme-file-scanner'
import {
createPreviewOperationBudget,
pushPreviewBudgetSkippedFile,
@ -21,6 +15,8 @@ import {
type WarpThemePreviewOptions
} from './preview-operation-budget'
import { validateWarpThemeImportSource } from './warp-theme-import-source-validation'
import { filesFromAutoDirectories } from './auto-discovered-theme-files'
import { filesFromDirectory, type ThemeSourceSelection } from './theme-source-selection'
import {
chooseManualWarpThemeFiles,
chooseManualWarpThemeFolderPath,
@ -29,101 +25,11 @@ import {
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,

View File

@ -22,12 +22,18 @@ export type WarpThemeScanBudget = {
isExpired: () => boolean
}
export type WarpThemeDirectoryScanOptions = {
themeFileLimit?: number
reportThemeFileLimit?: boolean
}
type DirectoryScanBudget = {
directoriesVisited: number
directoryLimitReported: boolean
entryLimitReported: boolean
themeFileLimitHit: boolean
previewBudgetReported: boolean
themeFileLimit: number
}
type DirectoryScanState = {
@ -49,8 +55,12 @@ function compareDirentNames(left: Dirent<string>, right: Dirent<string>): number
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' })
}
function isYamlFileEntry(entry: Dirent<string>): boolean {
return (entry.isFile() || entry.isSymbolicLink()) && isYamlFile(entry.name)
}
function couldContainThemeFile(entry: Dirent<string>): boolean {
return (entry.isFile() && isYamlFile(entry.name)) || entry.isDirectory()
return isYamlFileEntry(entry) || entry.isDirectory()
}
function reportPreviewBudgetExpired(
@ -88,7 +98,7 @@ async function collectYamlFilesFromDirectory(
reportPreviewBudgetExpired(sourceLabel, skippedFiles, budget)
return
}
if (files.length >= MAX_THEME_FILES) {
if (files.length >= budget.themeFileLimit) {
return
}
if (budget.directoriesVisited >= MAX_THEME_DIRECTORIES) {
@ -150,7 +160,7 @@ async function collectYamlFilesFromDirectory(
}
return
}
if (files.length >= MAX_THEME_FILES) {
if (files.length >= budget.themeFileLimit) {
if (sortedEntries.slice(index).some(couldContainThemeFile)) {
budget.themeFileLimitHit = true
}
@ -158,7 +168,7 @@ async function collectYamlFilesFromDirectory(
}
const relativeLabel = relativeDirectory ? path.join(relativeDirectory, entry.name) : entry.name
const entryPath = path.join(directoryPath, entry.name)
if (entry.isFile() && isYamlFile(entry.name)) {
if (isYamlFileEntry(entry)) {
files.push({ path: entryPath, label: relativeLabel })
continue
}
@ -182,7 +192,7 @@ async function collectYamlFilesFromDirectory(
scanBudget
)
if (
files.length >= MAX_THEME_FILES &&
files.length >= budget.themeFileLimit &&
sortedEntries.slice(index + 1).some(couldContainThemeFile)
) {
budget.themeFileLimitHit = true
@ -194,14 +204,17 @@ async function collectYamlFilesFromDirectory(
export async function scanWarpThemeDirectory(
directoryPath: string,
scanBudget?: WarpThemeScanBudget
scanBudget?: WarpThemeScanBudget,
options: WarpThemeDirectoryScanOptions = {}
): Promise<{
sourceLabel: string
rootReadable: boolean
files: ThemeFileCandidate[]
skippedFiles: WarpThemeImportSkippedFile[]
themeFileLimitHit: boolean
}> {
const sourceLabel = path.basename(directoryPath) || 'Warp themes'
const themeFileLimit = options.themeFileLimit ?? MAX_THEME_FILES
const files: ThemeFileCandidate[] = []
const skippedFiles: WarpThemeImportSkippedFile[] = []
const budget: DirectoryScanBudget = {
@ -209,7 +222,8 @@ export async function scanWarpThemeDirectory(
directoryLimitReported: false,
entryLimitReported: false,
themeFileLimitHit: false,
previewBudgetReported: false
previewBudgetReported: false,
themeFileLimit
}
const state: DirectoryScanState = { rootReadable: false }
await collectYamlFilesFromDirectory(
@ -223,11 +237,17 @@ export async function scanWarpThemeDirectory(
state,
scanBudget
)
if (budget.themeFileLimitHit) {
if (budget.themeFileLimitHit && options.reportThemeFileLimit !== false) {
skippedFiles.push({
label: sourceLabel,
reason: `Only the first ${MAX_THEME_FILES} theme files were scanned.`
reason: `Only the first ${themeFileLimit} theme files were scanned.`
})
}
return { sourceLabel, rootReadable: state.rootReadable, files, skippedFiles }
return {
sourceLabel,
rootReadable: state.rootReadable,
files,
skippedFiles,
themeFileLimitHit: budget.themeFileLimitHit
}
}

View File

@ -0,0 +1,40 @@
import type { WarpThemeImportSkippedFile } from '../../shared/terminal-custom-themes'
import type { PreviewOperationBudget } from './preview-operation-budget'
import {
MAX_THEME_FILES,
scanWarpThemeDirectory,
type ThemeFileCandidate
} from './theme-file-scanner'
export type ThemeSourceSelection =
| { canceled: true }
| {
canceled: false
sourceLabel: string
files: ThemeFileCandidate[]
skippedFiles: WarpThemeImportSkippedFile[]
rootReadable?: boolean
themeFileLimitHit?: boolean
}
export async function filesFromDirectory(
directoryPath: string,
sourceLabelOverride?: string,
budget?: PreviewOperationBudget,
themeFileLimit = MAX_THEME_FILES,
reportThemeFileLimit = true
): Promise<ThemeSourceSelection> {
const { sourceLabel, rootReadable, files, skippedFiles, themeFileLimitHit } =
await scanWarpThemeDirectory(directoryPath, budget, { themeFileLimit, reportThemeFileLimit })
const effectiveSourceLabel = sourceLabelOverride ?? sourceLabel
return {
canceled: false,
sourceLabel: effectiveSourceLabel,
files: files.map((file) => ({ ...file, sourceLabel: effectiveSourceLabel })),
skippedFiles: skippedFiles.map((file) =>
file.label === sourceLabel ? { ...file, label: effectiveSourceLabel } : file
),
rootReadable,
themeFileLimitHit
}
}

View File

@ -258,6 +258,14 @@ export function WarpThemeImportModal({
)}
</p>
) : null}
{!preview.error && mode !== 'yaml' ? (
<p>
{translate(
'auto.components.settings.WarpThemeImportModal.custom_theme_yaml_hint',
"Custom and community themes need to exist as YAML files in a Warp themes folder before auto-import can find them. If you cloned Warp's public themes repo, use Choose Folder to import that checkout."
)}
</p>
) : null}
{!desktopOnly ? (
<p>
{translate(

View File

@ -4682,6 +4682,7 @@
"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.",
"custom_theme_yaml_hint": "Custom and community themes need to exist as YAML files in a Warp themes folder before auto-import can find them. If you cloned Warp's public themes repo, use Choose Folder to import that checkout.",
"choose_manually": "Choose a theme YAML file or folder to import manually.",
"skipped_files": "Skipped files",
"more_skipped_files": "{{value0}} more skipped files.",

View File

@ -7512,6 +7512,7 @@
"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.",
"custom_theme_yaml_hint": "Los temas personalizados y de la comunidad deben existir como archivos YAML en una carpeta de temas de Warp para que la importación automática los encuentre. Si clonaste el repositorio público de temas de Warp, usa Choose Folder para importar esa copia.",
"choose_manually": "Choose a theme YAML file or folder to import manually.",
"skipped_files": "Skipped files",
"more_skipped_files": "{{value0}} more skipped files.",

View File

@ -4667,6 +4667,7 @@
"colors_only": "カラーのみ",
"no_themes_found": "カスタム Warp テーマが見つかりませんでした。",
"builtin_themes_hint": "Warp のプリロードテーマは Warp アプリ本体に含まれており、ディスクから読み取ることはできません。Dracula、Gruvbox、Solarized、Tokyo Night など、その多くは Orca の組み込みテーマとして既に利用できます。",
"custom_theme_yaml_hint": "カスタムテーマやコミュニティテーマは、Warp の themes フォルダー内に YAML ファイルとして存在している必要があります。Warp の公開 themes リポジトリをクローンした場合は、「フォルダーを選択」でそのチェックアウトをインポートしてください。",
"choose_manually": "テーマ YAML ファイルまたはフォルダーを選択して手動でインポートします。",
"skipped_files": "スキップされたファイル",
"more_skipped_files": "他 {{value0}} 件のファイルがスキップされました。",

View File

@ -7512,6 +7512,7 @@
"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.",
"custom_theme_yaml_hint": "사용자 지정 및 커뮤니티 테마는 자동 가져오기가 찾을 수 있도록 Warp themes 폴더에 YAML 파일로 있어야 합니다. Warp의 공개 테마 저장소를 클론했다면 Choose Folder를 사용해 해당 체크아웃을 가져오세요.",
"choose_manually": "Choose a theme YAML file or folder to import manually.",
"skipped_files": "Skipped files",
"more_skipped_files": "{{value0}} more skipped files.",

View File

@ -7512,6 +7512,7 @@
"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.",
"custom_theme_yaml_hint": "自定义和社区主题必须以 YAML 文件形式存在于 Warp themes 文件夹中,自动导入才能发现它们。如果你克隆了 Warp 的公开主题仓库,请使用 Choose Folder 导入该仓库副本。",
"choose_manually": "Choose a theme YAML file or folder to import manually.",
"skipped_files": "Skipped files",
"more_skipped_files": "{{value0}} more skipped files.",