feat(plugins): add icon and terminal themes

This commit is contained in:
Neil
2026-07-13 03:33:43 -07:00
parent d8f27f9cd8
commit 3bc87fedcf
78 changed files with 2469 additions and 254 deletions
+6 -3
View File
@@ -1963,13 +1963,16 @@ app.whenReady().then(async () => {
.catch((error) => {
console.warn('[plugins] failed to initialize plugin service:', error)
})
pluginService.onChanged(() => {
if (setMainPluginLanguagePacks(pluginService?.contentPacks.languagePacks.list() ?? [])) {
pluginService.onChanged((event) => {
if (
event.contentPacksChanged &&
setMainPluginLanguagePacks(pluginService?.contentPacks.languagePacks.list() ?? [])
) {
void setMainUiLanguage(store!.getSettings().uiLanguage).then(() => rebuildAppMenu())
}
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
window.webContents.send('plugins:changed')
window.webContents.send('plugins:changed', event)
}
}
})
+14
View File
@@ -122,6 +122,20 @@ export function registerPluginHandlers(
await pluginService.whenReady()
return pluginService.contentPacks.languagePacks.list()
})
ipcMain.handle('plugins:listIconThemes', async () => {
await pluginService.whenReady()
return pluginService.contentPacks.iconThemes.list()
})
ipcMain.handle('plugins:loadIconTheme', async (_event, id: unknown) => {
await pluginService.whenReady()
return pluginService.contentPacks.iconThemes.load(
z.string().startsWith('plugin:').max(256).parse(id)
)
})
ipcMain.handle('plugins:listTerminalThemes', async () => {
await pluginService.whenReady()
return pluginService.contentPacks.terminalThemes.list()
})
ipcMain.handle('plugins:listSkillStore', async () => {
await pluginService.whenReady()
return pluginSkillStoreSnapshot(pluginService)
+52 -1
View File
@@ -2,6 +2,10 @@ import { createReadStream } from 'node:fs'
import { realpath, stat } from 'node:fs/promises'
import { isAbsolute, relative, resolve, sep } from 'node:path'
import type { PluginManifest } from '../../shared/plugins/plugin-manifest'
import {
parsePluginIconThemeArtifact,
sanitizePluginIconSvg
} from '../../shared/plugins/plugin-icon-theme-artifact'
export type PluginArtifactValidationResult = { ok: true } | { ok: false; error: string }
@@ -9,7 +13,10 @@ export const PLUGIN_PANEL_ENTRY_MAX_BYTES = 10 * 1024 * 1024
export const PLUGIN_WORKER_ENTRY_MAX_BYTES = 50 * 1024 * 1024
const PLUGIN_ICON_MAX_BYTES = 2 * 1024 * 1024
export const PLUGIN_THEME_MAX_BYTES = 256 * 1024
const PLUGIN_ICON_THEME_MAX_BYTES = 512 * 1024
export const PLUGIN_ICON_THEME_MAX_BYTES = 512 * 1024
export const PLUGIN_ICON_SVG_MAX_BYTES = 64 * 1024
export const PLUGIN_ICON_TOTAL_MAX_BYTES = 8 * 1024 * 1024
export const PLUGIN_TERMINAL_THEME_MAX_BYTES = 256 * 1024
export const PLUGIN_LANGUAGE_PACK_MAX_BYTES = 5 * 1024 * 1024
const PLUGIN_RECIPE_MAX_BYTES = 1024 * 1024
const PLUGIN_AGENT_PROFILE_MAX_BYTES = 1024 * 1024
@@ -58,6 +65,12 @@ function declaredArtifactPaths(manifest: PluginManifest): DeclaredArtifact[] {
kind: 'file' as const,
maxBytes: PLUGIN_ICON_THEME_MAX_BYTES
})),
...manifest.contributes.terminalThemes.map((theme) => ({
label: `terminal theme "${theme.id}"`,
path: theme.path,
kind: 'file' as const,
maxBytes: PLUGIN_TERMINAL_THEME_MAX_BYTES
})),
...manifest.contributes.languagePacks.map((languagePack) => ({
label: `language pack "${languagePack.locale}"`,
path: languagePack.path,
@@ -188,3 +201,41 @@ export async function validateDeclaredPluginArtifacts(
}
return { ok: true }
}
/** Parses and sanitizes icon-theme references at the immutable install boundary. */
export async function validatePluginInstallContent(
rootDir: string,
manifest: PluginManifest
): Promise<PluginArtifactValidationResult> {
let iconBytes = 0
for (const contribution of manifest.contributes.iconThemes) {
try {
const artifact = parsePluginIconThemeArtifact(
await readContainedPluginArtifactText(
rootDir,
contribution.path,
PLUGIN_ICON_THEME_MAX_BYTES
)
)
const paths = new Set([
...Object.values(artifact.icons),
...Object.values(artifact.fileNames),
...Object.values(artifact.fileExtensions)
])
for (const path of paths) {
const svg = await readContainedPluginArtifactText(rootDir, path, PLUGIN_ICON_SVG_MAX_BYTES)
iconBytes += Buffer.byteLength(svg, 'utf8')
if (iconBytes > PLUGIN_ICON_TOTAL_MAX_BYTES) {
throw new Error(`icon SVGs exceed ${PLUGIN_ICON_TOTAL_MAX_BYTES} bytes in total`)
}
sanitizePluginIconSvg(svg)
}
} catch (error) {
return {
ok: false,
error: `icon theme "${contribution.id}": ${error instanceof Error ? error.message : String(error)}`
}
}
}
return { ok: true }
}
@@ -0,0 +1,73 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest'
import { PluginContentVerifier } from './plugin-content-integrity'
import { PluginContentPackRegistry } from './plugin-content-pack-registry'
import type { ValidDiscoveredPlugin } from './plugin-discovery'
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('PluginContentPackRegistry', () => {
it('activates all contributions from a plugin atomically', async () => {
const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-content-pack-registry-'))
roots.push(rootDir)
await Promise.all([mkdir(join(rootDir, 'icons')), mkdir(join(rootDir, 'terminal'))])
await Promise.all([
writeFile(
join(rootDir, 'icons', 'invalid.json'),
JSON.stringify({ schemaVersion: 1, icons: { file: 42 } })
),
writeFile(
join(rootDir, 'terminal', 'valid.json'),
JSON.stringify({
schemaVersion: 1,
mode: 'dark',
terminal: {
background: '#101010',
foreground: '#f0f0f0',
black: '#000000'
}
})
)
])
const manifest = pluginManifestSchema.parse({
manifestVersion: 1,
id: 'mixed-content',
publisher: 'orca-samples',
name: 'Mixed Content',
version: '1.0.0',
engines: { orca: '>=1.0.0' },
pluginApi: 1,
contributes: {
iconThemes: [{ id: 'broken', label: 'Broken', path: 'icons/invalid.json' }],
terminalThemes: [{ id: 'valid', label: 'Valid', path: 'terminal/valid.json' }]
},
capabilities: []
})
const plugin: ValidDiscoveredPlugin = {
pluginKey: 'orca-samples.mixed-content',
rootDir,
manifest,
consentFingerprint: fingerprintPluginConsent(manifest),
contentHash: null,
isDev: true
}
const registry = new PluginContentPackRegistry(new PluginContentVerifier(), {
pluginsDataDir: join(rootDir, 'plugin-data'),
homeDirectory: join(rootDir, 'home')
})
await registry.reconcile([plugin], () => true)
expect(registry.error(plugin.pluginKey)).toContain('number')
expect(registry.iconThemes.list()).toEqual([])
expect(registry.terminalThemes.list()).toEqual([])
})
})
@@ -1,13 +1,22 @@
import type { PluginContentVerifier } from './plugin-content-integrity'
import type { DiscoveredPlugin, ValidDiscoveredPlugin } from './plugin-discovery'
import {
isInvalidDiscoveredPlugin,
type DiscoveredPlugin,
type ValidDiscoveredPlugin
} from './plugin-discovery'
import { PluginLanguagePackRegistry } from './plugin-language-pack-registry'
import { PluginIconThemeRegistry } from './plugin-icon-theme-registry'
import { PluginSkillRegistry } from './plugin-skill-registry'
import { PluginThemeRegistry } from './plugin-theme-registry'
import { PluginTerminalThemeRegistry } from './plugin-terminal-theme-registry'
export class PluginContentPackRegistry {
readonly themes: PluginThemeRegistry
readonly languagePacks: PluginLanguagePackRegistry
readonly iconThemes: PluginIconThemeRegistry
readonly terminalThemes: PluginTerminalThemeRegistry
readonly skills: PluginSkillRegistry
private readonly activationErrors = new Map<string, string>()
constructor(
contentVerifier: PluginContentVerifier,
@@ -15,6 +24,8 @@ export class PluginContentPackRegistry {
) {
this.themes = new PluginThemeRegistry(contentVerifier)
this.languagePacks = new PluginLanguagePackRegistry(contentVerifier)
this.iconThemes = new PluginIconThemeRegistry(contentVerifier)
this.terminalThemes = new PluginTerminalThemeRegistry(contentVerifier)
this.skills = new PluginSkillRegistry(
contentVerifier,
options.pluginsDataDir,
@@ -26,17 +37,51 @@ export class PluginContentPackRegistry {
discovered: readonly DiscoveredPlugin[],
isApproved: (plugin: ValidDiscoveredPlugin) => boolean
): Promise<void> {
await Promise.all([
this.themes.reconcile(discovered, isApproved),
this.languagePacks.reconcile(discovered, isApproved),
this.skills.reconcile(discovered, isApproved)
])
const approvedKeys = new Set(
discovered
.filter((plugin): plugin is ValidDiscoveredPlugin => !isInvalidDiscoveredPlugin(plugin))
.filter(isApproved)
.map((plugin) => plugin.pluginKey)
)
const excluded = new Set<string>()
this.activationErrors.clear()
while (true) {
const approveAtomically = (plugin: ValidDiscoveredPlugin): boolean =>
approvedKeys.has(plugin.pluginKey) && !excluded.has(plugin.pluginKey)
await Promise.all([
this.themes.reconcile(discovered, approveAtomically),
this.languagePacks.reconcile(discovered, approveAtomically),
this.iconThemes.reconcile(discovered, approveAtomically),
this.terminalThemes.reconcile(discovered, approveAtomically),
this.skills.reconcile(discovered, approveAtomically)
])
let foundNewError = false
for (const pluginKey of approvedKeys) {
const error = this.registryError(pluginKey)
if (error && !excluded.has(pluginKey)) {
excluded.add(pluginKey)
this.activationErrors.set(pluginKey, error)
foundNewError = true
}
}
if (!foundNewError) {
break
}
}
}
error(pluginKey: string): string | null {
return this.activationErrors.get(pluginKey) ?? this.registryError(pluginKey)
}
private registryError(pluginKey: string): string | null {
return (
this.themes.error(pluginKey) ??
this.languagePacks.error(pluginKey) ??
this.iconThemes.error(pluginKey) ??
this.terminalThemes.error(pluginKey) ??
this.skills.error(pluginKey)
)
}
+28 -1
View File
@@ -6,7 +6,8 @@ import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fi
import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest'
import {
PLUGIN_PANEL_ENTRY_MAX_BYTES,
validateDeclaredPluginArtifacts
validateDeclaredPluginArtifacts,
validatePluginInstallContent
} from './plugin-artifact-validation'
import { hashPluginTree } from './plugin-content-hash'
import { verifyHashAddressedPluginContent } from './plugin-content-integrity'
@@ -164,6 +165,32 @@ describe('declared plugin artifacts', () => {
error: expect.stringContaining('artifact limit')
})
})
it('sanitizes every icon-theme SVG at the install boundary', async () => {
const root = await tempRoot()
await mkdir(join(root, 'icons'))
await writeFile(
join(root, 'icons', 'theme.json'),
JSON.stringify({ schemaVersion: 1, icons: { file: 'icons/file.svg' } })
)
await writeFile(
join(root, 'icons', 'file.svg'),
'<svg><path fill="&#x75;rl(https://example.com/x)"/></svg>'
)
const pluginManifest = manifest({
contributes: {
iconThemes: [{ id: 'hostile', label: 'Hostile', path: 'icons/theme.json' }]
}
})
await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toEqual({
ok: true
})
await expect(validatePluginInstallContent(root, pluginManifest)).resolves.toMatchObject({
ok: false,
error: expect.stringContaining('active or external content')
})
})
})
describe('hash-addressed plugin content', () => {
@@ -0,0 +1,98 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest'
import { PluginContentVerifier } from './plugin-content-integrity'
import type { ValidDiscoveredPlugin } from './plugin-discovery'
import { PluginIconThemeRegistry } from './plugin-icon-theme-registry'
const roots: string[] = []
async function pluginWithIcons(svg: string): Promise<ValidDiscoveredPlugin> {
const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-icon-theme-'))
roots.push(rootDir)
await mkdir(join(rootDir, 'icons'))
await writeFile(
join(rootDir, 'icons', 'theme.json'),
JSON.stringify({
schemaVersion: 1,
icons: { file: 'icons/file.svg' },
fileExtensions: { ts: 'icons/typescript.svg' }
})
)
await Promise.all([
writeFile(join(rootDir, 'icons', 'file.svg'), svg),
writeFile(join(rootDir, 'icons', 'typescript.svg'), '<svg><path d="M0 0h16v16z"/></svg>')
])
const manifest = pluginManifestSchema.parse({
manifestVersion: 1,
id: 'icons',
publisher: 'orca-samples',
name: 'Icons',
version: '1.0.0',
engines: { orca: '>=1.0.0' },
pluginApi: 1,
contributes: { iconThemes: [{ id: 'minimal', label: 'Minimal', path: 'icons/theme.json' }] },
capabilities: []
})
return {
pluginKey: 'orca-samples.icons',
rootDir,
manifest,
consentFingerprint: fingerprintPluginConsent(manifest),
contentHash: null,
isDev: true
}
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('PluginIconThemeRegistry', () => {
it('loads intrinsic-color SVGs as images and currentColor SVGs as masks', async () => {
const plugin = await pluginWithIcons('<svg><path fill="currentColor" d="M0 0h16v16z"/></svg>')
const registry = new PluginIconThemeRegistry(new PluginContentVerifier())
await registry.reconcile([plugin], () => true)
expect(registry.list()).toEqual([
expect.objectContaining({
id: 'plugin:orca-samples.icons/minimal',
label: 'Minimal'
})
])
await expect(registry.load('plugin:orca-samples.icons/minimal')).resolves.toEqual(
expect.objectContaining({
id: 'plugin:orca-samples.icons/minimal',
icons: {
file: {
dataUrl: expect.stringMatching(/^data:image\/svg\+xml;base64,/),
rendering: 'mask'
}
},
fileExtensions: {
ts: {
dataUrl: expect.stringMatching(/^data:image\/svg\+xml;base64,/),
rendering: 'image'
}
}
})
)
})
it('fails closed when a selected SVG is active and clears metadata when disabled', async () => {
const plugin = await pluginWithIcons('<svg><foreignObject>bad</foreignObject></svg>')
const registry = new PluginIconThemeRegistry(new PluginContentVerifier())
await registry.reconcile([plugin], () => true)
expect(registry.list()).toHaveLength(1)
await expect(registry.load('plugin:orca-samples.icons/minimal')).rejects.toThrow('not allowed')
await registry.reconcile([plugin], () => false)
expect(registry.list()).toEqual([])
await expect(registry.load('plugin:orca-samples.icons/minimal')).resolves.toBeNull()
})
})
@@ -0,0 +1,160 @@
import {
parsePluginIconThemeArtifact,
sanitizePluginIconSvg,
type PluginIconThemeArtifact,
type PluginIconThemeImage,
type PluginIconThemeMetadata,
type PluginIconThemeRegistration
} from '../../shared/plugins/plugin-icon-theme-artifact'
import {
PLUGIN_ICON_SVG_MAX_BYTES,
PLUGIN_ICON_THEME_MAX_BYTES,
PLUGIN_ICON_TOTAL_MAX_BYTES,
readContainedPluginArtifactText
} from './plugin-artifact-validation'
import type { PluginContentVerifier } from './plugin-content-integrity'
import { mapPluginContentWithConcurrency } from './plugin-content-load-pool'
import {
isInvalidDiscoveredPlugin,
type DiscoveredPlugin,
type ValidDiscoveredPlugin
} from './plugin-discovery'
const ICON_THEME_LOAD_CONCURRENCY = 4
type IconThemeLoadResult =
| { pluginKey: string; themes: IconThemeDescriptor[] }
| { pluginKey: string; error: string }
type IconThemeDescriptor = PluginIconThemeMetadata & {
rootDir: string
artifact: PluginIconThemeArtifact
}
function svgThemeImage(svg: string): PluginIconThemeImage {
return {
dataUrl: `data:image/svg+xml;base64,${Buffer.from(svg, 'utf8').toString('base64')}`,
// External SVG image documents cannot inherit host currentColor. Render
// those monochrome assets as masks so status and dark-mode colors apply.
rendering: /\bcurrentColor\b/i.test(svg) ? 'mask' : 'image'
}
}
export class PluginIconThemeRegistry {
private themes: IconThemeDescriptor[] = []
private readonly loadedThemes = new Map<string, Promise<PluginIconThemeRegistration>>()
private readonly errors = new Map<string, string>()
constructor(private readonly contentVerifier: PluginContentVerifier) {}
list(): readonly PluginIconThemeMetadata[] {
return this.themes.map(({ id, pluginKey, label }) => ({ id, pluginKey, label }))
}
async load(id: string): Promise<PluginIconThemeRegistration | null> {
const descriptor = this.themes.find((theme) => theme.id === id)
if (!descriptor) {
return null
}
const existing = this.loadedThemes.get(id)
if (existing) {
return existing
}
const loading = this.materialize(descriptor)
this.loadedThemes.set(id, loading)
try {
return await loading
} catch (error) {
this.loadedThemes.delete(id)
throw error
}
}
error(pluginKey: string): string | null {
return this.errors.get(pluginKey) ?? null
}
async reconcile(
discovered: readonly DiscoveredPlugin[],
isApproved: (plugin: ValidDiscoveredPlugin) => boolean
): Promise<void> {
const candidates = discovered.filter(
(plugin): plugin is ValidDiscoveredPlugin =>
!isInvalidDiscoveredPlugin(plugin) &&
isApproved(plugin) &&
plugin.manifest.contributes.iconThemes.length > 0
)
const results = await mapPluginContentWithConcurrency(
candidates,
ICON_THEME_LOAD_CONCURRENCY,
async (plugin): Promise<IconThemeLoadResult> => {
try {
await this.contentVerifier.verify(plugin)
const themes: IconThemeDescriptor[] = []
for (const contribution of plugin.manifest.contributes.iconThemes) {
const artifact = parsePluginIconThemeArtifact(
await readContainedPluginArtifactText(
plugin.rootDir,
contribution.path,
PLUGIN_ICON_THEME_MAX_BYTES
)
)
themes.push({
id: `plugin:${plugin.pluginKey}/${contribution.id}`,
pluginKey: plugin.pluginKey,
label: contribution.label ?? contribution.id,
rootDir: plugin.rootDir,
artifact
})
}
return { pluginKey: plugin.pluginKey, themes }
} catch (error) {
return {
pluginKey: plugin.pluginKey,
error: error instanceof Error ? error.message : String(error)
}
}
}
)
this.themes = results.flatMap((result) => ('themes' in result ? result.themes : []))
this.loadedThemes.clear()
this.errors.clear()
for (const result of results) {
if ('error' in result) {
this.errors.set(result.pluginKey, result.error)
}
}
}
private async materialize(descriptor: IconThemeDescriptor): Promise<PluginIconThemeRegistration> {
const paths = new Set([
...Object.values(descriptor.artifact.icons),
...Object.values(descriptor.artifact.fileNames),
...Object.values(descriptor.artifact.fileExtensions)
])
const dataByPath = new Map<string, PluginIconThemeImage>()
let loadedIconBytes = 0
for (const path of paths) {
const svg = await readContainedPluginArtifactText(
descriptor.rootDir,
path,
PLUGIN_ICON_SVG_MAX_BYTES
)
loadedIconBytes += Buffer.byteLength(svg, 'utf8')
if (loadedIconBytes > PLUGIN_ICON_TOTAL_MAX_BYTES) {
throw new Error(`icon theme SVGs exceed ${PLUGIN_ICON_TOTAL_MAX_BYTES} bytes in total`)
}
dataByPath.set(path, svgThemeImage(sanitizePluginIconSvg(svg)))
}
const mapPaths = (entries: Record<string, string>): Record<string, PluginIconThemeImage> =>
Object.fromEntries(Object.entries(entries).map(([key, path]) => [key, dataByPath.get(path)!]))
return {
id: descriptor.id,
pluginKey: descriptor.pluginKey,
label: descriptor.label,
icons: mapPaths(descriptor.artifact.icons),
fileNames: mapPaths(descriptor.artifact.fileNames),
fileExtensions: mapPaths(descriptor.artifact.fileExtensions)
}
}
}
+164
View File
@@ -0,0 +1,164 @@
import { existsSync } from 'node:fs'
import { cp, mkdir, rename, rm } from 'node:fs/promises'
import { join, relative, resolve, sep } from 'node:path'
import {
PLUGIN_MANIFEST_FILENAME,
parsePluginManifest,
qualifiedPluginKey,
satisfiesOrcaEngineRange,
type PluginManifest
} from '../../shared/plugins/plugin-manifest'
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
import type {
PluginInstallSource,
PluginLockEntry
} from '../../shared/plugins/plugin-install-lockfile'
import {
validateDeclaredPluginArtifacts,
validatePluginInstallContent,
type PluginArtifactValidationResult
} from './plugin-artifact-validation'
import { hashPluginTree } from './plugin-content-hash'
import { publishPluginInstall } from './plugin-install-publication'
import { readPluginManifestText } from './plugin-manifest-file'
export type PluginInstallResult =
| {
ok: true
pluginKey: string
version: string
contentHash: string
consentFingerprint: string
resolvedCommit: string | null
}
| { ok: false; error: string }
async function validatePluginInstallTree(
rootDir: string,
manifest: PluginManifest
): Promise<PluginArtifactValidationResult> {
const declared = await validateDeclaredPluginArtifacts(rootDir, manifest)
return declared.ok ? validatePluginInstallContent(rootDir, manifest) : declared
}
/** Installs a validated staging tree into the hash-addressed layout. */
export async function installStagedPluginTree(input: {
pluginsDir: string
stagingDir: string
hostVersion: string
source: PluginInstallSource
resolvedCommit: string | null
}): Promise<PluginInstallResult> {
let manifestRaw: unknown
try {
manifestRaw = JSON.parse(await readPluginManifestText(input.stagingDir))
} catch (error) {
return {
ok: false,
error: `unreadable ${PLUGIN_MANIFEST_FILENAME}: ${error instanceof Error ? error.message : String(error)}`
}
}
const parsed = parsePluginManifest(manifestRaw)
if (!parsed.ok) {
return { ok: false, error: `invalid manifest: ${parsed.error}` }
}
const manifest = parsed.manifest
if (!satisfiesOrcaEngineRange(input.hostVersion, manifest.engines.orca)) {
return {
ok: false,
error: `plugin requires Orca ${manifest.engines.orca} (this is ${input.hostVersion})`
}
}
const declaredArtifacts = await validatePluginInstallTree(input.stagingDir, manifest)
if (!declaredArtifacts.ok) {
return { ok: false, error: `invalid declared artifact: ${declaredArtifacts.error}` }
}
// Hash before copy: also enforces the symlink/entry-count/size limits.
const treeHash = await hashPluginTree(input.stagingDir)
if (!treeHash.ok) {
return { ok: false, error: treeHash.error }
}
const pluginKey = qualifiedPluginKey(manifest)
const pluginDir = join(input.pluginsDir, pluginKey)
const versionDir = join(pluginDir, treeHash.hash)
if (!existsSync(versionDir)) {
const stagedVersionDir = `${versionDir}.staging`
try {
await rm(stagedVersionDir, { recursive: true, force: true })
await mkdir(pluginDir, { recursive: true })
// Source trees can change while copying. Hashing the destination closes
// that race before the immutable directory becomes current.
const stagingRoot = resolve(input.stagingDir)
await cp(stagingRoot, stagedVersionDir, {
recursive: true,
verbatimSymlinks: true,
// Source-control metadata is not plugin content. Skip it at the copy
// boundary so a large local repository cannot bypass install limits.
filter: (source) => {
const fromRoot = relative(stagingRoot, resolve(source))
return fromRoot !== '.git' && !fromRoot.startsWith(`.git${sep}`)
}
})
const copiedHash = await hashPluginTree(stagedVersionDir)
if (!copiedHash.ok || copiedHash.hash !== treeHash.hash) {
return {
ok: false,
error: copiedHash.ok
? 'plugin content changed while it was being copied'
: copiedHash.error
}
}
const copiedArtifacts = await validatePluginInstallTree(stagedVersionDir, manifest)
if (!copiedArtifacts.ok) {
return { ok: false, error: `copied artifact validation failed: ${copiedArtifacts.error}` }
}
await rename(stagedVersionDir, versionDir)
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
} finally {
await rm(stagedVersionDir, { recursive: true, force: true })
}
} else {
// Never repoint at an existing hash directory without proving its bytes;
// a previous partial/tampered install must not be revived by reinstall.
const existingHash = await hashPluginTree(versionDir)
if (!existingHash.ok || existingHash.hash !== treeHash.hash) {
return {
ok: false,
error: existingHash.ok
? 'existing plugin content failed integrity verification'
: existingHash.error
}
}
const existingArtifacts = await validatePluginInstallTree(versionDir, manifest)
if (!existingArtifacts.ok) {
return {
ok: false,
error: `installed artifact validation failed: ${existingArtifacts.error}`
}
}
}
const consentFingerprint = fingerprintPluginConsent(manifest)
const entry: PluginLockEntry = {
pluginKey,
version: manifest.version,
source: input.source,
resolvedCommit: input.resolvedCommit,
contentHash: treeHash.hash,
consentFingerprint,
installedAt: Date.now()
}
try {
await publishPluginInstall({ pluginsDir: input.pluginsDir, pluginDir, entry })
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
return {
ok: true,
pluginKey,
version: manifest.version,
contentHash: treeHash.hash,
consentFingerprint,
resolvedCommit: input.resolvedCommit
}
}
+8 -148
View File
@@ -1,28 +1,21 @@
import { execFile } from 'node:child_process'
import { cp, mkdir, mkdtemp, realpath, rename, rm } from 'node:fs/promises'
import { mkdtemp, realpath, rm } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
import { promisify } from 'node:util'
import {
PLUGIN_MANIFEST_FILENAME,
isQualifiedPluginKey,
parsePluginManifest,
qualifiedPluginKey,
satisfiesOrcaEngineRange
isQualifiedPluginKey
} from '../../shared/plugins/plugin-manifest'
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
import {
isAllowedPluginGitUrl,
removePluginLock,
type PluginInstallSource,
type PluginLockEntry
removePluginLock
} from '../../shared/plugins/plugin-install-lockfile'
import { hashPluginTree } from './plugin-content-hash'
import { validateDeclaredPluginArtifacts } from './plugin-artifact-validation'
import { readPluginManifestText } from './plugin-manifest-file'
import { readPluginLockfile, writePluginLockfile } from './plugin-install-lockfile-store'
import { publishPluginInstall } from './plugin-install-publication'
import { installStagedPluginTree, type PluginInstallResult } from './plugin-install-staging'
export type { PluginInstallResult } from './plugin-install-staging'
export {
PLUGIN_LOCKFILE_MAX_BYTES,
@@ -65,139 +58,6 @@ async function serializePluginMutation<T>(
}
}
export type PluginInstallResult =
| {
ok: true
pluginKey: string
version: string
contentHash: string
consentFingerprint: string
resolvedCommit: string | null
}
| { ok: false; error: string }
/** Installs a validated staging tree into the hash-addressed layout. */
async function installStagedTree(input: {
pluginsDir: string
stagingDir: string
hostVersion: string
source: PluginInstallSource
resolvedCommit: string | null
}): Promise<PluginInstallResult> {
let manifestRaw: unknown
try {
manifestRaw = JSON.parse(await readPluginManifestText(input.stagingDir))
} catch (error) {
return {
ok: false,
error: `unreadable ${PLUGIN_MANIFEST_FILENAME}: ${error instanceof Error ? error.message : String(error)}`
}
}
const parsed = parsePluginManifest(manifestRaw)
if (!parsed.ok) {
return { ok: false, error: `invalid manifest: ${parsed.error}` }
}
const manifest = parsed.manifest
if (!satisfiesOrcaEngineRange(input.hostVersion, manifest.engines.orca)) {
return {
ok: false,
error: `plugin requires Orca ${manifest.engines.orca} (this is ${input.hostVersion})`
}
}
const declaredArtifacts = await validateDeclaredPluginArtifacts(input.stagingDir, manifest)
if (!declaredArtifacts.ok) {
return { ok: false, error: `invalid declared artifact: ${declaredArtifacts.error}` }
}
// Hash before copy: also enforces the symlink/entry-count/size limits.
const treeHash = await hashPluginTree(input.stagingDir)
if (!treeHash.ok) {
return { ok: false, error: treeHash.error }
}
const pluginKey = qualifiedPluginKey(manifest)
const pluginDir = join(input.pluginsDir, pluginKey)
const versionDir = join(pluginDir, treeHash.hash)
if (!existsSync(versionDir)) {
const stagedVersionDir = `${versionDir}.staging`
try {
await rm(stagedVersionDir, { recursive: true, force: true })
await mkdir(pluginDir, { recursive: true })
// Source trees can change while copying. Hashing the destination closes
// that race before the immutable directory becomes current.
const stagingRoot = resolve(input.stagingDir)
await cp(stagingRoot, stagedVersionDir, {
recursive: true,
verbatimSymlinks: true,
// Source-control metadata is not plugin content. Skip it at the copy
// boundary so a large local repository cannot bypass install limits.
filter: (source) => {
const fromRoot = relative(stagingRoot, resolve(source))
return fromRoot !== '.git' && !fromRoot.startsWith(`.git${sep}`)
}
})
const copiedHash = await hashPluginTree(stagedVersionDir)
if (!copiedHash.ok || copiedHash.hash !== treeHash.hash) {
return {
ok: false,
error: copiedHash.ok
? 'plugin content changed while it was being copied'
: copiedHash.error
}
}
const copiedArtifacts = await validateDeclaredPluginArtifacts(stagedVersionDir, manifest)
if (!copiedArtifacts.ok) {
return { ok: false, error: `copied artifact validation failed: ${copiedArtifacts.error}` }
}
await rename(stagedVersionDir, versionDir)
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
} finally {
await rm(stagedVersionDir, { recursive: true, force: true })
}
} else {
// Never repoint at an existing hash directory without proving its bytes;
// a previous partial/tampered install must not be revived by reinstall.
const existingHash = await hashPluginTree(versionDir)
if (!existingHash.ok || existingHash.hash !== treeHash.hash) {
return {
ok: false,
error: existingHash.ok
? 'existing plugin content failed integrity verification'
: existingHash.error
}
}
const existingArtifacts = await validateDeclaredPluginArtifacts(versionDir, manifest)
if (!existingArtifacts.ok) {
return {
ok: false,
error: `installed artifact validation failed: ${existingArtifacts.error}`
}
}
}
const consentFingerprint = fingerprintPluginConsent(manifest)
const entry: PluginLockEntry = {
pluginKey,
version: manifest.version,
source: input.source,
resolvedCommit: input.resolvedCommit,
contentHash: treeHash.hash,
consentFingerprint,
installedAt: Date.now()
}
try {
await publishPluginInstall({ pluginsDir: input.pluginsDir, pluginDir, entry })
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
return {
ok: true,
pluginKey,
version: manifest.version,
contentHash: treeHash.hash,
consentFingerprint,
resolvedCommit: input.resolvedCommit
}
}
export async function installPluginFromLocalPath(input: {
pluginsDir: string
sourcePath: string
@@ -207,7 +67,7 @@ export async function installPluginFromLocalPath(input: {
if (!existsSync(join(input.sourcePath, PLUGIN_MANIFEST_FILENAME))) {
return { ok: false, error: `no ${PLUGIN_MANIFEST_FILENAME} found in ${input.sourcePath}` }
}
return installStagedTree({
return installStagedPluginTree({
pluginsDir: input.pluginsDir,
stagingDir: input.sourcePath,
hostVersion: input.hostVersion,
@@ -260,7 +120,7 @@ export async function installPluginFromGit(input: {
await runGit(args, tmpdir())
}
const resolvedCommit = await runGit(['rev-parse', 'HEAD'], stagingDir)
return await installStagedTree({
return await installStagedPluginTree({
pluginsDir: input.pluginsDir,
stagingDir,
hostVersion: input.hostVersion,
@@ -0,0 +1,11 @@
export async function waitForPluginRefreshSettlement(
getCurrent: () => Promise<void>
): Promise<void> {
while (true) {
const pending = getCurrent()
await pending.catch(() => undefined)
if (pending === getCurrent()) {
return
}
}
}
@@ -256,4 +256,67 @@ describe('PluginService worker reconciliation', () => {
expect(vi.getTimerCount()).toBe(1)
expect(harness.factory).not.toHaveBeenCalled()
})
it('serializes activation reconciliation so the latest disabled state wins', async () => {
const root = await pluginRoot()
const harness = createHarness(root)
await harness.service.initialize()
const originalReconcile = harness.service.contentPacks.reconcile.bind(
harness.service.contentPacks
)
let releaseFirst!: () => void
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve
})
let firstStarted!: () => void
const firstStartedPromise = new Promise<void>((resolve) => {
firstStarted = resolve
})
let activeReconciliations = 0
let maximumConcurrentReconciliations = 0
let callCount = 0
const reconcile = vi
.spyOn(harness.service.contentPacks, 'reconcile')
.mockImplementation(async (...args) => {
callCount += 1
activeReconciliations += 1
maximumConcurrentReconciliations = Math.max(
maximumConcurrentReconciliations,
activeReconciliations
)
try {
if (callCount === 1) {
firstStarted()
await firstGate
}
await originalReconcile(...args)
} finally {
activeReconciliations -= 1
}
})
const first = harness.service.reconcileActivationState()
await firstStartedPromise
harness.setDisabled([pluginKey])
const second = harness.service.reconcileActivationState()
let clientsReleased = false
const clientsReady = harness.service.whenReady().then(() => {
clientsReleased = true
})
await Promise.resolve()
expect(reconcile).toHaveBeenCalledTimes(1)
expect(clientsReleased).toBe(false)
releaseFirst()
await Promise.all([first, second, clientsReady])
expect(maximumConcurrentReconciliations).toBe(1)
expect(clientsReleased).toBe(true)
expect(reconcile).toHaveBeenCalledTimes(2)
expect(harness.service.activationState(harness.service.findValidPlugin(pluginKey)!)).toBe(
'disabled'
)
expect(harness.service.workerState(pluginKey).state).toBe('inactive')
})
})
+20 -9
View File
@@ -34,6 +34,8 @@ import type { PluginRunState } from './plugin-supervisor'
import { isPluginApproved, snapshotPluginConsentLists } from './plugin-activation-policy'
import { PluginContentPackRegistry } from './plugin-content-pack-registry'
import type { PluginServiceOptions } from './plugin-service-options'
import type { PluginChangeEvent } from '../../shared/plugins/plugin-change-event'
import { waitForPluginRefreshSettlement } from './plugin-refresh-settlement'
export type { PluginRuntimeDelegate } from './plugin-host-service-bindings'
export type { PluginLogLine } from './plugin-log-buffer'
@@ -49,7 +51,7 @@ export class PluginService {
private readonly contentVerifier = new PluginContentVerifier()
readonly contentPacks: PluginContentPackRegistry
readonly panels: PluginPanelController
private readonly changeListeners = new Set<() => void>()
private readonly changeListeners = new Set<(event: PluginChangeEvent) => void>()
private readonly housekeeping = new PluginServiceHousekeeping()
private discovered: DiscoveredPlugin[] = []
private runtimeDelegate: PluginRuntimeDelegate | null = null
@@ -89,7 +91,7 @@ export class PluginService {
executeHostCall: (pluginKey, method, params) =>
this.executeHostCall(pluginKey, method, params, { viaPanel: false }),
log: (pluginKey, level, line) => this.logBuffer.append(pluginKey, level, line),
onStateChanged: () => this.notifyChanged(),
onStateChanged: () => this.notifyChanged(false),
onWorkerGone: (pluginKey) => this.eventBus.clear(pluginKey)
})
}
@@ -98,14 +100,14 @@ export class PluginService {
this.runtimeDelegate = delegate
}
onChanged(listener: () => void): () => void {
onChanged(listener: (event: PluginChangeEvent) => void): () => void {
this.changeListeners.add(listener)
return () => this.changeListeners.delete(listener)
}
private notifyChanged(): void {
private notifyChanged(contentPacksChanged: boolean): void {
for (const listener of this.changeListeners) {
listener()
listener({ contentPacksChanged })
}
}
@@ -116,6 +118,9 @@ export class PluginService {
async whenReady(): Promise<void> {
await (this.initPromise ?? Promise.resolve()).catch(() => undefined)
// Client reads wait for the complete transaction so rollback-based content
// validation cannot expose a partially activated plugin between passes.
await waitForPluginRefreshSettlement(() => this.refreshChain)
}
refresh(): Promise<void> {
@@ -162,7 +167,7 @@ export class PluginService {
isPluginApproved(enabled, plugin, consentLists)
)
// Notify before slow shutdown so feature-off unmounts panels immediately.
this.notifyChanged()
this.notifyChanged(true)
await this.workerController.reconcile(nextSpecs)
if (this.disposed) {
return
@@ -173,7 +178,7 @@ export class PluginService {
reapIdle: () => this.workerController.reapIdle(),
refresh: () => void this.refresh()
})
this.notifyChanged()
this.notifyChanged(false)
}
getDiscovered(): readonly DiscoveredPlugin[] {
@@ -298,12 +303,18 @@ export class PluginService {
async deactivatePlugin(pluginKey: string): Promise<void> {
await this.workerController.deactivate(pluginKey)
this.notifyChanged()
this.notifyChanged(false)
}
/** Reconciles live workers and client projections after consent or
* enablement changes without re-reading plugin files or starting workers. */
async reconcileActivationState(): Promise<void> {
const reconcile = this.refreshChain.then(() => this.performActivationStateReconciliation())
this.refreshChain = reconcile.catch(() => undefined)
return reconcile
}
private async performActivationStateReconciliation(): Promise<void> {
await this.contentPacks.reconcile(this.discovered, (plugin) => {
return this.activationState(plugin) === 'approved'
})
@@ -312,7 +323,7 @@ export class PluginService {
(plugin) => this.activationState(plugin) === 'approved'
)
await this.workerController.reconcile(nextSpecs)
this.notifyChanged()
this.notifyChanged(true)
}
async dispose(): Promise<void> {
@@ -0,0 +1,75 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest'
import { PluginContentVerifier } from './plugin-content-integrity'
import type { ValidDiscoveredPlugin } from './plugin-discovery'
import { PluginTerminalThemeRegistry } from './plugin-terminal-theme-registry'
const roots: string[] = []
async function pluginWithTerminalTheme(): Promise<ValidDiscoveredPlugin> {
const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-terminal-theme-'))
roots.push(rootDir)
await mkdir(join(rootDir, 'terminal'))
await writeFile(
join(rootDir, 'terminal', 'nord.json'),
JSON.stringify({
schemaVersion: 1,
mode: 'dark',
terminal: {
background: '#101010',
foreground: '#f0f0f0',
black: '#000000',
red: '#ff0000'
}
})
)
const manifest = pluginManifestSchema.parse({
manifestVersion: 1,
id: 'terminal',
publisher: 'orca-samples',
name: 'Terminal',
version: '1.0.0',
engines: { orca: '>=1.0.0' },
pluginApi: 1,
contributes: {
terminalThemes: [{ id: 'nord', label: 'Nord Terminal', path: 'terminal/nord.json' }]
},
capabilities: []
})
return {
pluginKey: 'orca-samples.terminal',
rootDir,
manifest,
consentFingerprint: fingerprintPluginConsent(manifest),
contentHash: null,
isDev: true
}
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('PluginTerminalThemeRegistry', () => {
it('loads and removes approved terminal palettes', async () => {
const plugin = await pluginWithTerminalTheme()
const registry = new PluginTerminalThemeRegistry(new PluginContentVerifier())
await registry.reconcile([plugin], () => true)
expect(registry.list()).toEqual([
expect.objectContaining({
id: 'plugin:orca-samples.terminal/nord',
label: 'Nord Terminal',
mode: 'dark',
terminal: expect.objectContaining({ background: '#101010', red: '#ff0000' })
})
])
await registry.reconcile([plugin], () => false)
expect(registry.list()).toEqual([])
})
})
@@ -0,0 +1,84 @@
import {
parsePluginTerminalThemeArtifact,
type PluginTerminalThemeRegistration
} from '../../shared/plugins/plugin-terminal-theme-artifact'
import {
PLUGIN_TERMINAL_THEME_MAX_BYTES,
readContainedPluginArtifactText
} from './plugin-artifact-validation'
import type { PluginContentVerifier } from './plugin-content-integrity'
import { mapPluginContentWithConcurrency } from './plugin-content-load-pool'
import {
isInvalidDiscoveredPlugin,
type DiscoveredPlugin,
type ValidDiscoveredPlugin
} from './plugin-discovery'
const TERMINAL_THEME_LOAD_CONCURRENCY = 4
type TerminalThemeLoadResult =
| { pluginKey: string; themes: PluginTerminalThemeRegistration[] }
| { pluginKey: string; error: string }
export class PluginTerminalThemeRegistry {
private themes: PluginTerminalThemeRegistration[] = []
private readonly errors = new Map<string, string>()
constructor(private readonly contentVerifier: PluginContentVerifier) {}
list(): readonly PluginTerminalThemeRegistration[] {
return this.themes
}
error(pluginKey: string): string | null {
return this.errors.get(pluginKey) ?? null
}
async reconcile(
discovered: readonly DiscoveredPlugin[],
isApproved: (plugin: ValidDiscoveredPlugin) => boolean
): Promise<void> {
const candidates = discovered.filter(
(plugin): plugin is ValidDiscoveredPlugin =>
!isInvalidDiscoveredPlugin(plugin) &&
isApproved(plugin) &&
plugin.manifest.contributes.terminalThemes.length > 0
)
const results = await mapPluginContentWithConcurrency(
candidates,
TERMINAL_THEME_LOAD_CONCURRENCY,
async (plugin): Promise<TerminalThemeLoadResult> => {
try {
await this.contentVerifier.verify(plugin)
const themes = await Promise.all(
plugin.manifest.contributes.terminalThemes.map(async (contribution) => ({
id: `plugin:${plugin.pluginKey}/${contribution.id}` as const,
pluginKey: plugin.pluginKey,
label: contribution.label,
...parsePluginTerminalThemeArtifact(
await readContainedPluginArtifactText(
plugin.rootDir,
contribution.path,
PLUGIN_TERMINAL_THEME_MAX_BYTES
)
)
}))
)
return { pluginKey: plugin.pluginKey, themes }
} catch (error) {
return {
pluginKey: plugin.pluginKey,
error: error instanceof Error ? error.message : String(error)
}
}
}
)
this.themes = results.flatMap((result) => ('themes' in result ? result.themes : []))
this.errors.clear()
for (const result of results) {
if ('error' in result) {
this.errors.set(result.pluginKey, result.error)
}
}
}
}
+11 -2
View File
@@ -60,6 +60,12 @@ import type {
PluginSkillContributionMapping,
PluginSkillStoreSnapshot
} from '../shared/plugins/plugin-skill-store'
import type {
PluginIconThemeMetadata,
PluginIconThemeRegistration
} from '../shared/plugins/plugin-icon-theme-artifact'
import type { PluginTerminalThemeRegistration } from '../shared/plugins/plugin-terminal-theme-artifact'
import type { PluginChangeEvent } from '../shared/plugins/plugin-change-event'
import type {
LocalhostWorktreeLabelResult,
LocalhostWorktreeLabelRoute
@@ -3181,6 +3187,9 @@ export type PreloadApi = {
list: () => Promise<PluginHostListEntry[]>
listThemes: () => Promise<PluginThemeRegistration[]>
listLanguagePacks: () => Promise<PluginLanguagePackRegistration[]>
listIconThemes: () => Promise<PluginIconThemeMetadata[]>
loadIconTheme: (id: string) => Promise<PluginIconThemeRegistration | null>
listTerminalThemes: () => Promise<PluginTerminalThemeRegistration[]>
listSkillStore: () => Promise<PluginSkillStoreSnapshot>
setSkillMapping: (mapping: PluginSkillContributionMapping) => Promise<PluginSkillStoreSnapshot>
/** Records the consent-dialog answer; approval is keyed to the plugin's
@@ -3210,8 +3219,8 @@ export type PreloadApi = {
getLogs: (args: { pluginKey: string }) => Promise<PluginHostLogLine[]>
/** Re-discovers after settings edits (feature flag, dev paths). */
refresh: () => Promise<PluginHostListEntry[]>
/** Fires whenever installed plugins, worker states, or panels change. */
onChanged: (callback: () => void) => () => void
/** Fires whenever installed plugins, worker states, panels, or content packs change. */
onChanged: (callback: (event: PluginChangeEvent) => void) => () => void
}
agentStatus: {
/** Listen for agent status updates forwarded from native hook receivers. */
+7 -2
View File
@@ -17,6 +17,7 @@ import type {
PluginPanelEntry
} from '../shared/plugins/plugin-panel-bridge'
import type { PluginConsentRequest } from '../shared/plugins/plugin-consent-request'
import type { PluginChangeEvent } from '../shared/plugins/plugin-change-event'
import type {
BaseRefSearchResult,
BaseRefDefaultResult,
@@ -561,6 +562,9 @@ const api = {
list: (): Promise<PluginHostListEntry[]> => ipcRenderer.invoke('plugins:list'),
listThemes: () => ipcRenderer.invoke('plugins:listThemes'),
listLanguagePacks: () => ipcRenderer.invoke('plugins:listLanguagePacks'),
listIconThemes: () => ipcRenderer.invoke('plugins:listIconThemes'),
loadIconTheme: (id) => ipcRenderer.invoke('plugins:loadIconTheme', id),
listTerminalThemes: () => ipcRenderer.invoke('plugins:listTerminalThemes'),
listSkillStore: () => ipcRenderer.invoke('plugins:listSkillStore'),
setSkillMapping: (mapping) => ipcRenderer.invoke('plugins:setSkillMapping', mapping),
consent: (args: PluginConsentRequest): Promise<PluginHostListEntry[]> =>
@@ -588,8 +592,9 @@ const api = {
getLogs: (args: { pluginKey: string }): Promise<PluginHostLogLine[]> =>
ipcRenderer.invoke('plugins:getLogs', args),
refresh: (): Promise<PluginHostListEntry[]> => ipcRenderer.invoke('plugins:refresh'),
onChanged: (callback: () => void): (() => void) => {
const listener = (): void => callback()
onChanged: (callback): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, change: PluginChangeEvent): void =>
callback(change)
ipcRenderer.on('plugins:changed', listener)
return () => {
ipcRenderer.removeListener('plugins:changed', listener)
+18 -2
View File
@@ -131,6 +131,8 @@ import {
import { shouldRenderPetOverlay } from './components/pet/pet-overlay-visibility'
import { applyDocumentTheme, applyPluginAppTheme } from './lib/document-theme'
import { usePluginThemes } from './store/plugin-themes'
import { usePluginIconThemes, usePluginIconThemeStore } from './store/plugin-icon-themes'
import { usePluginTerminalThemes } from './store/plugin-terminal-themes'
import { getSystemPrefersDark } from './lib/terminal-theme'
import { publishTerminalViewAttributesAtAppStart } from './components/terminal-pane/terminal-appearance'
import { isEditableTarget } from './lib/editable-target'
@@ -655,14 +657,17 @@ function App(): React.JSX.Element {
const isFullScreen = useAppStore((s) => s.isFullScreen)
const settings = useAppStore((s) => s.settings)
const pluginThemes = usePluginThemes()
usePluginIconThemes()
const pluginTerminalThemes = usePluginTerminalThemes()
const setActivePluginIconTheme = usePluginIconThemeStore((state) => state.setActiveId)
const activePluginTheme = useMemo(
() => pluginThemes.find((theme) => theme.id === settings?.pluginAppTheme) ?? null,
[pluginThemes, settings?.pluginAppTheme]
)
const systemPrefersDark = useSystemPrefersDark()
const leftSidebarStyle = useMemo(
() => resolveLeftSidebarStyleVariables(settings, systemPrefersDark),
[settings, systemPrefersDark]
() => resolveLeftSidebarStyleVariables(settings, systemPrefersDark, pluginTerminalThemes),
[settings, systemPrefersDark, pluginTerminalThemes]
) as React.CSSProperties | undefined
const dictationState = useAppStore((s) => s.dictationState)
const hasSshCredentialRequest = useAppStore((s) => s.sshCredentialQueue.length > 0)
@@ -672,6 +677,17 @@ function App(): React.JSX.Element {
settings?.primarySelectionMiddleClickPaste
)
usePrimarySelectionPaste(primarySelectionMiddleClickPaste)
useEffect(() => {
setActivePluginIconTheme(settings?.pluginIconTheme ?? null)
}, [setActivePluginIconTheme, settings?.pluginIconTheme])
useEffect(() => {
// Why: plugin palette changes do not mutate settings, but paired clients
// and hidden terminals still need the newly effective terminal colors.
publishTerminalViewAttributesAtAppStart(useAppStore.getState().settings, getSystemPrefersDark())
scheduleRuntimeGraphSync()
}, [pluginTerminalThemes])
useAppMenuPaste()
useLargeTextControlPaste()
const petEnabled = useAppStore((s) => s.settings?.experimentalPet === true)
@@ -0,0 +1,123 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, describe, expect, it } from 'vitest'
import { usePluginIconThemeStore } from '@/store/plugin-icon-themes'
import { FileTypeIcon } from './FileTypeIcon'
import { PluginIconSlot } from './PluginIconSlot'
afterEach(() => {
usePluginIconThemeStore.setState({
themes: [],
activeId: null,
activeTheme: null,
loaded: true
})
document.body.innerHTML = ''
})
describe('FileTypeIcon', () => {
it('renders sanitized plugin SVG data as an image and reacts to theme removal', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const dataUrl = 'data:image/svg+xml;base64,PHN2Zy8+'
const activeTheme = {
id: 'plugin:acme.icons/main' as const,
pluginKey: 'acme.icons',
label: 'Acme',
icons: {},
fileNames: {},
fileExtensions: { ts: { dataUrl, rendering: 'image' as const } }
}
usePluginIconThemeStore.setState({
activeId: 'plugin:acme.icons/main',
activeTheme,
loaded: true,
themes: [activeTheme]
})
await act(async () => root.render(<FileTypeIcon filePath="src/index.ts" className="size-4" />))
expect(container.querySelector('img')?.getAttribute('src')).toBe(dataUrl)
expect(container.querySelector('svg')).toBeNull()
await act(async () => usePluginIconThemeStore.setState({ themes: [], activeTheme: null }))
expect(container.querySelector('img')).toBeNull()
expect(container.querySelector('svg')).not.toBeNull()
await act(async () => root.unmount())
})
it('renders host-defined plugin slots and falls back when the slot is absent', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const dataUrl = 'data:image/svg+xml;base64,PHN2Zy8+'
const activeTheme = {
id: 'plugin:acme.icons/main' as const,
pluginKey: 'acme.icons',
label: 'Acme',
icons: { 'sidebar.search': { dataUrl, rendering: 'image' as const } },
fileNames: {},
fileExtensions: {}
}
usePluginIconThemeStore.setState({
activeId: 'plugin:acme.icons/main',
activeTheme,
loaded: true,
themes: [activeTheme]
})
await act(async () =>
root.render(
<PluginIconSlot
slot="sidebar.search"
fallback={<span data-fallback="true" />}
className="size-4"
/>
)
)
expect(container.querySelector('img')?.getAttribute('src')).toBe(dataUrl)
await act(async () =>
root.render(
<PluginIconSlot slot="sidebar.plugins" fallback={<span data-fallback="true" />} />
)
)
expect(container.querySelector('[data-fallback="true"]')).not.toBeNull()
await act(async () => root.unmount())
})
it('renders currentColor SVGs as masks that inherit host color', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const dataUrl = 'data:image/svg+xml;base64,PHN2Zy8+'
const activeTheme = {
id: 'plugin:acme.icons/main' as const,
pluginKey: 'acme.icons',
label: 'Acme',
icons: {},
fileNames: {},
fileExtensions: { ts: { dataUrl, rendering: 'mask' as const } }
}
usePluginIconThemeStore.setState({
activeId: activeTheme.id,
activeTheme,
loaded: true,
themes: [activeTheme]
})
await act(async () =>
root.render(<FileTypeIcon filePath="src/index.ts" style={{ color: 'rgb(255, 0, 0)' }} />)
)
const mask = container.querySelector<HTMLElement>('span[aria-hidden="true"]')
expect(mask?.style.maskImage).toContain(dataUrl)
expect(mask?.style.backgroundColor).toBe('currentcolor')
expect(mask?.style.color).toBe('rgb(255, 0, 0)')
expect(container.querySelector('img')).toBeNull()
await act(async () => root.unmount())
})
})
@@ -0,0 +1,20 @@
import type { CSSProperties } from 'react'
import { getFileTypeIcon, getPluginFileTypeIconImage } from '@/lib/file-type-icons'
import { usePluginIconThemeStore } from '@/store/plugin-icon-themes'
import { PluginIconImage } from './PluginIconImage'
type FileTypeIconProps = {
filePath: string | null | undefined
className?: string
style?: CSSProperties
}
export function FileTypeIcon({ filePath, className, style }: FileTypeIconProps): React.JSX.Element {
const activeTheme = usePluginIconThemeStore((state) => state.activeTheme)
const pluginIcon = getPluginFileTypeIconImage(activeTheme, filePath)
if (pluginIcon) {
return <PluginIconImage image={pluginIcon} className={className} style={style} />
}
const BuiltInIcon = getFileTypeIcon(filePath)
return <BuiltInIcon aria-hidden="true" className={className} style={style} />
}
@@ -0,0 +1,52 @@
import type { CSSProperties } from 'react'
import type { PluginIconThemeImage } from '../../../shared/plugins/plugin-icon-theme-artifact'
type PluginIconImageProps = {
image: PluginIconThemeImage
className?: string
size?: number
style?: CSSProperties
}
export function PluginIconImage({
image,
className,
size,
style
}: PluginIconImageProps): React.JSX.Element {
if (image.rendering === 'mask') {
return (
<span
aria-hidden="true"
className={className}
style={{
display: 'inline-block',
flexShrink: 0,
width: size,
height: size,
backgroundColor: 'currentColor',
WebkitMaskImage: `url("${image.dataUrl}")`,
maskImage: `url("${image.dataUrl}")`,
WebkitMaskPosition: 'center',
maskPosition: 'center',
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat',
WebkitMaskSize: 'contain',
maskSize: 'contain',
...style
}}
/>
)
}
return (
<img
src={image.dataUrl}
alt=""
aria-hidden="true"
width={size}
height={size}
className={className}
style={style}
/>
)
}
@@ -0,0 +1,27 @@
import type { ReactNode } from 'react'
import type { PluginIconThemeSlot } from '@/lib/plugin-icon-theme'
import { getPluginIconSlotImage } from '@/lib/plugin-icon-theme'
import { usePluginIconThemeStore } from '@/store/plugin-icon-themes'
import { PluginIconImage } from './PluginIconImage'
type PluginIconSlotProps = {
slot: PluginIconThemeSlot
fallback: ReactNode
className?: string
size?: number
}
export function PluginIconSlot({
slot,
fallback,
className,
size
}: PluginIconSlotProps): React.JSX.Element {
const activeTheme = usePluginIconThemeStore((state) => state.activeTheme)
const pluginIcon = getPluginIconSlotImage(activeTheme, slot)
return pluginIcon ? (
<PluginIconImage image={pluginIcon} size={size} className={className} />
) : (
<>{fallback}</>
)
}
+5 -4
View File
@@ -3,7 +3,7 @@ import { useAppStore } from '@/store'
import { useActiveWorktree } from '@/store/selectors'
import { detectLanguage } from '@/lib/language-detect'
import { joinPath } from '@/lib/path'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import { FileTypeIcon } from '@/components/FileTypeIcon'
import {
CommandDialog,
CommandInput,
@@ -148,8 +148,6 @@ export default function QuickOpen(): React.JSX.Element | null {
const lastSlash = item.path.lastIndexOf('/')
const dir = lastSlash >= 0 ? item.path.slice(0, lastSlash) : ''
const filename = item.path.slice(lastSlash + 1)
const FileIcon = getFileTypeIcon(item.path)
return (
<CommandItem
key={item.path}
@@ -157,7 +155,10 @@ export default function QuickOpen(): React.JSX.Element | null {
onSelect={() => handleSelect(item.path)}
className="flex items-center gap-2 px-3 py-1.5"
>
<FileIcon className="size-3.5 text-muted-foreground flex-shrink-0" />
<FileTypeIcon
filePath={item.path}
className="size-3.5 text-muted-foreground flex-shrink-0"
/>
<span className="truncate text-foreground">{filename}</span>
{dir && <span className="truncate text-muted-foreground ml-1">{dir}</span>}
</CommandItem>
@@ -1,7 +1,7 @@
import React from 'react'
import { ChevronDown, Folder, FolderOpen, PanelLeftClose } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import { FileTypeIcon } from '@/components/FileTypeIcon'
import { cn } from '@/lib/utils'
import {
buildSourceControlTree,
@@ -156,7 +156,6 @@ function ConflictReviewFileTreeRow({
)
}
const FileIcon = getFileTypeIcon(node.entry.path)
const liveEntry = node.entry.liveEntry
const isStillUnresolved = liveEntry?.conflictStatus === 'unresolved'
@@ -178,7 +177,10 @@ function ConflictReviewFileTreeRow({
}
}}
>
<FileIcon className={cn('size-3.5 shrink-0', isStillUnresolved && 'text-destructive')} />
<FileTypeIcon
filePath={node.entry.path}
className={cn('size-3.5 shrink-0', isStillUnresolved && 'text-destructive')}
/>
<span className="min-w-0 flex-1 truncate">
<span className="text-foreground">{node.name}</span>
</span>
@@ -2,7 +2,7 @@ import type React from 'react'
import { ChevronDown, Folder, FolderOpen } from 'lucide-react'
import { STATUS_COLORS, STATUS_LABELS } from '@/components/right-sidebar/status-display'
import type { SourceControlTreeNode } from '@/components/right-sidebar/source-control-tree'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import { FileTypeIcon } from '@/components/FileTypeIcon'
import { basename, dirname, joinPath } from '@/lib/path'
import { cn } from '@/lib/utils'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
@@ -84,7 +84,6 @@ export function CombinedDiffFileTreeRow({
}
const sectionKey = getCombinedDiffFileTreeSectionKey(mode, node.entry)
const FileIcon = getFileTypeIcon(node.entry.path)
const fileName = basename(node.entry.path)
const parentDir = dirname(node.entry.path)
const dirPath = parentDir === '.' ? '' : parentDir
@@ -116,7 +115,11 @@ export function CombinedDiffFileTreeRow({
}}
onClick={() => onNavigate(node.entry)}
>
<FileIcon className="size-3.5 shrink-0" style={{ color: STATUS_COLORS[status] }} />
<FileTypeIcon
filePath={node.entry.path}
className="size-3.5 shrink-0"
style={{ color: STATUS_COLORS[status] }}
/>
<span className="min-w-0 flex-1 truncate">
<span className="text-foreground">{fileName}</span>
{dirPath && <span className="ml-1.5 text-[11px] text-muted-foreground">{dirPath}</span>}
@@ -36,7 +36,8 @@ import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { detectLanguage } from '@/lib/language-detect'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import { FileTypeIcon } from '@/components/FileTypeIcon'
import { PluginIconSlot } from '@/components/PluginIconSlot'
import { openFileInBrowserTab } from '@/lib/file-preview'
import {
encodeWorkspaceFilePaths,
@@ -450,7 +451,6 @@ export function FileExplorerRow({
const copyPathShortcutLabel = useShortcutLabel('fileExplorer.copyPath')
const copyRelativePathShortcutLabel = useShortcutLabel('fileExplorer.copyRelativePath')
const findInFolderShortcutLabel = useShortcutLabel('sidebar.search.toggle')
const FileIcon = getFileTypeIcon(node.relativePath || node.name)
const rowDropDir = node.isDirectory ? node.path : targetDir
const showRemoteDownloadAction = shouldShowRemoteDownloadAction(
node,
@@ -592,9 +592,17 @@ export function FileExplorerRow({
{isLoading ? (
<Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" />
) : isExpanded ? (
<FolderOpen className="size-3 shrink-0 text-muted-foreground" />
<PluginIconSlot
slot="folder-open"
className="size-3 shrink-0 text-muted-foreground"
fallback={<FolderOpen className="size-3 shrink-0 text-muted-foreground" />}
/>
) : (
<Folder className="size-3 shrink-0 text-muted-foreground" />
<PluginIconSlot
slot="folder"
className="size-3 shrink-0 text-muted-foreground"
fallback={<Folder className="size-3 shrink-0 text-muted-foreground" />}
/>
)}
</>
) : (
@@ -603,7 +611,10 @@ export function FileExplorerRow({
{node.isSymlink ? (
<Link className="size-3 shrink-0 text-muted-foreground" />
) : (
<FileIcon className="size-3 shrink-0 text-muted-foreground" />
<FileTypeIcon
filePath={node.relativePath || node.name}
className="size-3 shrink-0 text-muted-foreground"
/>
)}
</>
)}
@@ -6,7 +6,7 @@ import {
toSourceControlRowOpenEvent,
type SourceControlRowOpenEvent
} from './source-control-split-open'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import { FileTypeIcon } from '@/components/FileTypeIcon'
import { basename, dirname } from '@/lib/path'
import { translate } from '@/i18n/i18n'
import { formatGitHistoryTimestamp } from './git-history-format'
@@ -27,7 +27,6 @@ function CommitFileRow({
onOpen: (entry: GitBranchChangeEntry, event: SourceControlRowOpenEvent) => void
}): React.JSX.Element {
const status = entry.status as GitFileStatus
const FileIcon = getFileTypeIcon(entry.path)
const fileName = basename(entry.path)
const parentDir = dirname(entry.path)
const dirPath = parentDir === '.' ? '' : parentDir
@@ -41,7 +40,11 @@ function CommitFileRow({
onClick={(event) => onOpen(entry, toSourceControlRowOpenEvent(event))}
onDoubleClick={(event) => onOpen(entry, toPermanentSourceControlRowOpenEvent(event))}
>
<FileIcon className="size-3.5 shrink-0" style={{ color: STATUS_COLORS[status] }} />
<FileTypeIcon
filePath={entry.path}
className="size-3.5 shrink-0"
style={{ color: STATUS_COLORS[status] }}
/>
<span className="min-w-0 flex-1 truncate">
<span className="text-foreground">{fileName}</span>
{dirPath && <span className="ml-1.5 text-[11px] text-muted-foreground">{dirPath}</span>}
@@ -2,7 +2,7 @@ import React, { useMemo } from 'react'
import { ChevronRight, Copy } from 'lucide-react'
import { basename, dirname } from '@/lib/path'
import { cn } from '@/lib/utils'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import { FileTypeIcon } from '@/components/FileTypeIcon'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
import {
@@ -64,7 +64,6 @@ export function FileResultRow({
const fileName = basename(fileResult.relativePath)
const parentDir = dirname(fileResult.relativePath)
const dirPath = parentDir === '.' ? '' : parentDir
const FileIcon = getFileTypeIcon(fileResult.relativePath)
const matchCount = normalizeSearchFileMatchCount(fileResult)
return (
@@ -87,7 +86,10 @@ export function FileResultRow({
!collapsed && 'rotate-90'
)}
/>
<FileIcon className="size-3.5 flex-shrink-0 text-muted-foreground" />
<FileTypeIcon
filePath={fileResult.relativePath}
className="size-3.5 flex-shrink-0 text-muted-foreground"
/>
<div className="min-w-0 flex-1 text-xs">
<span className="min-w-0 block truncate">
<span className="text-foreground">{fileName}</span>
@@ -85,7 +85,7 @@ import {
canStageStatusEntry,
canUnstageStatusEntry
} from './source-control-entry-actions'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import { FileTypeIcon } from '@/components/FileTypeIcon'
import {
buildGitStatusSourceControlTree,
buildSourceControlTree,
@@ -7943,7 +7943,6 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
// its inner changes instead of opening a (uninformative) gitlink diff.
submoduleExpansion?: { isExpanded: boolean; onToggle: () => void }
}): React.JSX.Element {
const FileIcon = getFileTypeIcon(entry.path)
const fileName = basename(entry.path)
const parentDir = dirname(entry.path)
const dirPath = parentDir === '.' ? '' : parentDir
@@ -8040,7 +8039,11 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({
)}
/>
)}
<FileIcon className="size-3.5 shrink-0" style={{ color: STATUS_COLORS[entry.status] }} />
<FileTypeIcon
filePath={entry.path}
className="size-3.5 shrink-0"
style={{ color: STATUS_COLORS[entry.status] }}
/>
<div className="min-w-0 flex-1 text-xs">
<span className="min-w-0 block truncate">
<span className="text-foreground">{fileName}</span>
@@ -8220,7 +8223,6 @@ function BranchEntryRow({
commentCount: number
showPathHint?: boolean
}): React.JSX.Element {
const FileIcon = getFileTypeIcon(entry.path)
const fileName = basename(entry.path)
const parentDir = dirname(entry.path)
const dirPath = parentDir === '.' ? '' : parentDir
@@ -8247,7 +8249,11 @@ function BranchEntryRow({
onClick={(e) => onOpen(e)}
onDoubleClick={(e) => onOpen(toPermanentSourceControlRowOpenEvent(e))}
>
<FileIcon className="size-3.5 shrink-0" style={{ color: STATUS_COLORS[entry.status] }} />
<FileTypeIcon
filePath={entry.path}
className="size-3.5 shrink-0"
style={{ color: STATUS_COLORS[entry.status] }}
/>
<span className="min-w-0 flex-1 truncate text-xs">
<span className="text-foreground">{fileName}</span>
{showPathHint && dirPath && (
@@ -1,10 +1,11 @@
import React from 'react'
import { PluginIconSlot } from '@/components/PluginIconSlot'
export function AgentSessionHistoryIcon({
size = 16,
function AgentSessionHistoryFallback({
size,
className
}: {
size?: number
size: number
className?: string
}): React.JSX.Element {
return (
@@ -30,3 +31,20 @@ export function AgentSessionHistoryIcon({
</svg>
)
}
export function AgentSessionHistoryIcon({
size = 16,
className
}: {
size?: number
className?: string
}): React.JSX.Element {
return (
<PluginIconSlot
slot="agent.default"
size={size}
className={className}
fallback={<AgentSessionHistoryFallback size={size} className={className} />}
/>
)
}
@@ -55,9 +55,44 @@ import {
} from '@/lib/desktop-window-chrome'
import { getRendererAppPlatform } from '@/lib/renderer-app-platform'
import { useInstalledPluginRouteReconciliation } from './use-installed-plugin-route-reconciliation'
import { PluginIconSlot } from '@/components/PluginIconSlot'
const ACTIVITY_BAR_SIDE_WIDTH = 40
function ThemedWorkspaceIcon({
size,
className
}: {
size?: number
className?: string
}): React.JSX.Element {
return (
<PluginIconSlot
slot="sidebar.workspaces"
size={size}
className={className}
fallback={<Workflow size={size} className={className} />}
/>
)
}
function ThemedSourceControlIcon({
size,
className
}: {
size?: number
className?: string
}): React.JSX.Element {
return (
<PluginIconSlot
slot="sidebar.source-control"
size={size}
className={className}
fallback={<GitBranch size={size} className={className} />}
/>
)
}
function RightSidebarInner(): React.JSX.Element {
const hasDesktopWindowChrome = shouldRenderDesktopWindowChrome({
platform: getRendererAppPlatform(),
@@ -121,7 +156,7 @@ function RightSidebarInner(): React.JSX.Element {
},
{
id: 'workspaces',
icon: Workflow,
icon: ThemedWorkspaceIcon,
title: translate(
'auto.components.right.sidebar.index.folderWorkspaces',
'Attached worktrees'
@@ -138,7 +173,7 @@ function RightSidebarInner(): React.JSX.Element {
},
{
id: 'source-control',
icon: GitBranch,
icon: ThemedSourceControlIcon,
title: translate('auto.components.right.sidebar.index.0314901467', 'Source Control'),
shortcut: sourceControlShortcut === 'Unassigned' ? '' : sourceControlShortcut,
gitOnly: true
@@ -33,6 +33,7 @@ import type { UiLanguage } from '../../../../shared/ui-language'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
import { usePluginThemes } from '@/store/plugin-themes'
import { usePluginLanguagePacks } from '@/store/plugin-language-packs'
import { usePluginIconThemes } from '@/store/plugin-icon-themes'
type AppearanceInterfaceSectionProps = {
settings: GlobalSettings
@@ -55,6 +56,7 @@ export function AppearanceInterfaceSection({
}: AppearanceInterfaceSectionProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const pluginThemes = usePluginThemes()
const pluginIconThemes = usePluginIconThemes()
const pluginLanguagePacks = usePluginLanguagePacks()
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
const zoomInKeyCombos = useShortcutKeyComboDetails('zoom.in')
@@ -165,6 +167,61 @@ export function AppearanceInterfaceSection({
</SearchableSetting>
) : null}
{pluginIconThemes.length > 0 ? (
<SearchableSetting
title={translate(
'auto.components.settings.AppearanceInterfaceSection.pluginIconTheme.title',
'Icon theme'
)}
description={translate(
'auto.components.settings.AppearanceInterfaceSection.pluginIconTheme.description',
'Use file, folder, sidebar, and agent icons from an enabled Orca plugin.'
)}
keywords={['plugin', 'file', 'folder', 'sidebar', 'agent', 'icon', 'theme', 'appearance']}
forceVisible={forceVisiblePrimary}
>
<SettingsRow
label={translate(
'auto.components.settings.AppearanceInterfaceSection.pluginIconTheme.title',
'Icon theme'
)}
control={
<Select
value={settings.pluginIconTheme ?? 'built-in'}
onValueChange={(value) => {
updateSettings({
pluginIconTheme: value === 'built-in' ? null : (value as `plugin:${string}`)
})
}}
>
<SelectTrigger
className="w-48"
aria-label={translate(
'auto.components.settings.AppearanceInterfaceSection.pluginIconTheme.title',
'Icon theme'
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="built-in">
{translate(
'auto.components.settings.AppearanceInterfaceSection.pluginIconTheme.builtIn',
'Use built-in icons'
)}
</SelectItem>
{pluginIconThemes.map((theme) => (
<SelectItem key={theme.id} value={theme.id}>
{theme.label}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
</SearchableSetting>
) : null}
<SearchableSetting
title={translate('auto.components.settings.AppearancePane.5e6d7aba8d', 'UI Zoom')}
description={zoomEntry?.description}
@@ -98,6 +98,7 @@ vi.mock('../ui/select', async () => {
import { AppearancePane } from './AppearancePane'
import { TooltipProvider } from '../ui/tooltip'
import { usePluginIconThemeStore } from '../../store/plugin-icon-themes'
const mountedRoots: Root[] = []
@@ -181,6 +182,12 @@ describe('AppearancePane', () => {
mocks.state.availableStatusBarToggles = []
mocks.state.settingsSearchQuery = 'automations'
mocks.state.usagePercentageDisplay = 'used'
usePluginIconThemeStore.setState({
themes: [],
activeId: null,
activeTheme: null,
loaded: true
})
// UIZoomControl reads window.api.ui on mount; the inline-expansion pane can
// render the full Interface section, so provide a minimal renderer bridge
// without clobbering happy-dom's window.location.
@@ -229,6 +236,33 @@ describe('AppearancePane', () => {
expect(updateSettings).toHaveBeenCalledWith({ uiLanguage: 'zh' })
})
it('selects a qualified icon theme contributed by an enabled plugin', async () => {
mocks.state.settingsSearchQuery = 'icon theme'
const updateSettings = vi.fn()
usePluginIconThemeStore.setState({
loaded: true,
themes: [
{
id: 'plugin:acme.icons/main',
pluginKey: 'acme.icons',
label: 'Acme Icons'
}
]
})
const container = await renderAppearancePane(getDefaultSettings('/tmp'), updateSettings)
const option = container.querySelector<HTMLButtonElement>(
'[data-slot="select-item"][data-value="plugin:acme.icons/main"]'
)
expect(option?.textContent).toContain('Acme Icons')
await act(async () => {
option?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(updateSettings).toHaveBeenCalledWith({ pluginIconTheme: 'plugin:acme.icons/main' })
})
it('updates the left sidebar appearance from sidebar settings', async () => {
mocks.state.settingsSearchQuery = 'left sidebar'
const updateSettings = vi.fn()
@@ -20,6 +20,7 @@ import {
getStatusBarEntries,
getSystemTrayEntries,
getThemeEntries,
getPluginIconThemeEntries,
getTitlebarEntries,
getTypographyEntries,
getZoomEntries
@@ -130,6 +131,7 @@ export function AppearancePane({
const interfaceSearchEntries = [
{ title: interfaceTitle },
...getThemeEntries(),
...getPluginIconThemeEntries(),
...getZoomEntries(),
...getTypographyEntries(),
...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []),
@@ -339,6 +339,13 @@ export function ThemePicker({
themes: matchingThemes
.filter((theme) => theme.group === 'imported')
.slice(0, MAX_THEME_RESULTS)
},
{
label: translate(
'auto.components.settings.SettingsFormControls.plugin_themes',
'Plugin themes'
),
themes: matchingThemes.filter((theme) => theme.group === 'plugin').slice(0, MAX_THEME_RESULTS)
}
].filter((group) => group.themes.length > 0)
const visibleThemeCount = groupedThemes.reduce((sum, group) => sum + group.themes.length, 0)
@@ -415,18 +422,24 @@ export function ThemePicker({
<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.group === 'plugin'
? translate(
'auto.components.settings.SettingsFormControls.contributed_by',
'Contributed by {{value0}}',
{ value0: theme.sourceLabel }
)
: 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.group !== 'built-in' &&
theme.previewTheme &&
selectedTheme !== theme.value ? (
<span className="flex shrink-0 overflow-hidden rounded-sm border border-border/60">
@@ -1,11 +1,17 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { Bot, Mic, Network } from 'lucide-react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Bot, Mic, Network, Puzzle } from 'lucide-react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { SettingsSidebar } from './SettingsSidebar'
import { TooltipProvider } from '../ui/tooltip'
import type { SettingsSetupGuideProgress } from './settings-setup-guide-progress'
import type { GlobalSettings } from '../../../../shared/types'
import { usePluginIconThemeStore } from '@/store/plugin-icon-themes'
import { usePluginTerminalThemeStore } from '@/store/plugin-terminal-themes'
const mocks = vi.hoisted(() => ({
useSettingsSetupGuideProgress: vi.fn()
@@ -62,6 +68,11 @@ function renderSidebar(
title: 'Voice',
icon: Mic,
installStatus: 'installed'
},
{
id: 'plugins',
title: 'Plugins',
icon: Puzzle
}
]
},
@@ -93,6 +104,17 @@ describe('SettingsSidebar', () => {
beforeEach(() => {
mocks.useSettingsSetupGuideProgress.mockReset()
mocks.useSettingsSetupGuideProgress.mockReturnValue(makeSetupGuideProgress())
usePluginIconThemeStore.setState({
themes: [],
activeId: null,
activeTheme: null,
loaded: true
})
usePluginTerminalThemeStore.setState({ themes: [], loaded: true })
})
afterEach(() => {
document.body.innerHTML = ''
})
it('applies left sidebar appearance styles to the settings navigation', () => {
@@ -154,4 +176,99 @@ describe('SettingsSidebar', () => {
expect(markup).toContain('aria-current="page"')
expect(markup).toContain('Onboarding checklist')
})
it('uses the plugin-specific settings navigation icon slot', async () => {
const dataUrl = 'data:image/svg+xml;base64,cGx1Z2lucw=='
usePluginIconThemeStore.setState({
activeId: 'plugin:acme.icons/main',
activeTheme: {
id: 'plugin:acme.icons/main',
pluginKey: 'acme.icons',
label: 'Acme',
icons: { 'sidebar.plugins': { dataUrl, rendering: 'image' } },
fileNames: {},
fileExtensions: {}
}
})
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
await act(async () =>
root.render(
<TooltipProvider>
<SettingsSidebar
activeSectionId="plugins"
settings={getDefaultSettings('/tmp')}
generalGroups={[
{
id: 'plugins',
title: 'Extensions',
sections: [{ id: 'plugins', title: 'Plugins', icon: Puzzle }]
}
]}
repoSections={[]}
hasRepos={false}
searchQuery=""
onBack={vi.fn()}
onSearchChange={vi.fn()}
onSelectSection={vi.fn()}
/>
</TooltipProvider>
)
)
expect(container.querySelector('img')?.getAttribute('src')).toBe(dataUrl)
await act(async () => root.unmount())
})
it('reacts when a selected plugin terminal theme changes', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const settings = {
...getDefaultSettings('/tmp'),
theme: 'dark' as const,
leftSidebarAppearanceMode: 'match-terminal' as const,
terminalThemeDark: 'plugin:acme.terminal/night',
terminalThemeLight: 'plugin:acme.terminal/night'
}
await act(async () =>
root.render(
<TooltipProvider>
<SettingsSidebar
activeSectionId="plugins"
settings={settings}
generalGroups={[]}
repoSections={[]}
hasRepos={false}
searchQuery=""
onBack={vi.fn()}
onSearchChange={vi.fn()}
onSelectSection={vi.fn()}
/>
</TooltipProvider>
)
)
expect(container.querySelector('aside')?.getAttribute('style')).not.toContain('#123456')
await act(async () =>
usePluginTerminalThemeStore.setState({
themes: [
{
id: 'plugin:acme.terminal/night',
pluginKey: 'acme.terminal',
label: 'Night',
mode: 'dark',
terminal: { background: '#123456', foreground: '#abcdef', black: '#000000' }
}
],
loaded: true
})
)
expect(container.querySelector('aside')?.getAttribute('style')).toContain('#123456')
await act(async () => root.unmount())
})
})
@@ -17,6 +17,8 @@ import type { SettingsSetupGuideProgress } from './settings-setup-guide-progress
import { translate } from '@/i18n/i18n'
import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance'
import { useSystemPrefersDark } from '../terminal-pane/use-system-prefers-dark'
import { usePluginTerminalThemes } from '@/store/plugin-terminal-themes'
import { PluginIconSlot } from '@/components/PluginIconSlot'
type NavSection = {
id: string
@@ -129,9 +131,10 @@ export function SettingsSidebar({
}: SettingsSidebarProps): React.JSX.Element {
const setupGuideProgress = useSettingsSetupGuideProgress(true)
const systemPrefersDark = useSystemPrefersDark()
const pluginTerminalThemes = usePluginTerminalThemes()
const leftSidebarStyle = useMemo(
() => resolveLeftSidebarStyleVariables(settings, systemPrefersDark),
[settings, systemPrefersDark]
() => resolveLeftSidebarStyleVariables(settings, systemPrefersDark, pluginTerminalThemes),
[settings, systemPrefersDark, pluginTerminalThemes]
) as CSSProperties | undefined
const setupActive = activeSectionId === 'setup-guide'
// Why: "Hide from sidebar" only hides the top-left app sidebar prompt;
@@ -254,7 +257,15 @@ export function SettingsSidebar({
}
className={navItemClassName(isActive)}
>
<Icon className="size-4 shrink-0" />
{section.id === 'plugins' ? (
<PluginIconSlot
slot="sidebar.plugins"
className="size-4 shrink-0"
fallback={<Icon className="size-4 shrink-0" />}
/>
) : (
<Icon className="size-4 shrink-0" />
)}
<span className="truncate">{section.title}</span>
{section.installStatus ? (
<span className={installStatusClassName(section.installStatus)}>
@@ -15,6 +15,7 @@ import { PREVIEW_BUFFER } from './terminal-preview-content'
import { SettingsSwitch } from './SettingsFormControls'
import type { GlobalSettings } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import { usePluginTerminalThemeStore } from '@/store/plugin-terminal-themes'
// Why: pin cols/rows so PREVIEW_BUFFER never wraps. Sized so the longest
// line (the def total signature, 32 chars) plus a few cols of margin fits
@@ -90,6 +91,7 @@ export function TerminalSettingsPreview({
resolveAppMode(settings, systemPrefersDark)
)
const [previewPaneDividerVisible, setPreviewPaneDividerVisible] = useState(false)
const pluginTerminalThemes = usePluginTerminalThemeStore.getState().themes
// Why: precedence — modeOverride pins the mode; otherwise the in-header
// toggle drives it; otherwise follow whatever the app is set to right
@@ -117,6 +119,7 @@ export function TerminalSettingsPreview({
settings.terminalUseSeparateLightTheme,
settings.terminalDividerColorDark,
settings.terminalDividerColorLight,
pluginTerminalThemes,
systemPrefersDark
]
)
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../../../shared/types'
import type { UseWarpThemeImportReturn } from './useWarpThemeImport'
import { usePluginTerminalThemeStore } from '../../store/plugin-terminal-themes'
let themeTarget: 'dark' | 'light' | undefined = 'dark'
@@ -177,6 +178,7 @@ describe('TerminalThemeCatalogSection', () => {
themeTarget = 'dark'
resetTerminalThemeTargetMemoryForTests()
vi.clearAllMocks()
usePluginTerminalThemeStore.setState({ themes: [], loaded: true })
})
it('renders one theme picker and one preview for the active target', () => {
@@ -291,6 +293,34 @@ describe('TerminalThemeCatalogSection', () => {
expect(updateSettings).toHaveBeenCalledWith({ terminalThemeDark: 'Builtin Solarized Dark' })
})
it('offers and selects qualified terminal themes from enabled plugins', () => {
usePluginTerminalThemeStore.setState({
loaded: true,
themes: [
{
id: 'plugin:acme.terminal/nord',
pluginKey: 'acme.terminal',
label: 'Nord',
mode: 'dark',
terminal: { background: '#101010', foreground: '#eeeeee', black: '#000000' }
}
]
})
const updateSettings = vi.fn()
const element = renderCatalog(makeSettings(), updateSettings, 'dark')
const picker = findElementByTypeName(element, 'ThemePicker')
const options = picker?.props?.themeOptions as readonly { value: string; group: string }[]
const selectTheme = picker?.props?.onSelectTheme as (theme: string) => void
expect(options).toContainEqual(
expect.objectContaining({ value: 'plugin:acme.terminal/nord', group: 'plugin' })
)
selectTheme('plugin:acme.terminal/nord')
expect(updateSettings).toHaveBeenCalledWith({
terminalThemeDark: 'plugin:acme.terminal/nord'
})
})
it('updates the light theme from the catalog when the light target is active', () => {
const updateSettings = vi.fn()
const element = renderCatalog(
@@ -5,7 +5,7 @@ import { Button } from '../ui/button'
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '../ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
import { cn } from '@/lib/utils'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import { FileTypeIcon } from '@/components/FileTypeIcon'
import { SearchableSetting } from './SearchableSetting'
import {
getWorktreeSymlinkPathFilterState,
@@ -180,7 +180,6 @@ export function WorktreeSymlinksSection({
) : null}
{filtered.map((entry) => {
const alreadyAdded = paths.includes(entry.name)
const FileIcon = getFileTypeIcon(entry.name)
return (
<CommandItem
key={entry.name}
@@ -192,7 +191,10 @@ export function WorktreeSymlinksSection({
{entry.isDirectory ? (
<Folder className="size-3.5 text-muted-foreground" />
) : (
<FileIcon className="size-3.5 text-muted-foreground" />
<FileTypeIcon
filePath={entry.name}
className="size-3.5 text-muted-foreground"
/>
)}
<span className="truncate text-xs">{entry.name}</span>
{alreadyAdded ? (
@@ -27,6 +27,25 @@ export const getThemeEntries = createLocalizedCatalog((): SettingsSearchEntry[]
}
])
export const getPluginIconThemeEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
{
title: translate(
'auto.components.settings.AppearanceInterfaceSection.pluginIconTheme.title',
'Icon theme'
),
description: translate(
'auto.components.settings.AppearanceInterfaceSection.pluginIconTheme.description',
'Use file, folder, sidebar, and agent icons from an enabled Orca plugin.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.appearance.search.plugin', 'plugin'),
...translateSearchKeyword('auto.components.settings.appearance.search.file', 'file'),
...translateSearchKeyword('auto.components.settings.appearance.search.icon', 'icon'),
...translateSearchKeyword('auto.components.settings.appearance.search.0ce176909a', 'theme')
]
}
])
export const getLanguageEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
{
title: translate('settings.appearance.language.title', 'Language'),
@@ -3,7 +3,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronRight, Folder, ArrowUp, LoaderCircle, Home, Search } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import { FileTypeIcon } from '@/components/FileTypeIcon'
import { PluginIconSlot } from '@/components/PluginIconSlot'
import {
decideEnterAction,
decideEscAction,
@@ -737,7 +738,6 @@ export function RemoteFileBrowser({
</div>
) : (
displayEntries.map((entry) => {
const FileIcon = getFileTypeIcon(entry.name)
return (
<button
key={entry.name}
@@ -754,9 +754,16 @@ export function RemoteFileBrowser({
)}
>
{entry.isDirectory ? (
<Folder className="size-3.5 text-muted-foreground shrink-0" />
<PluginIconSlot
slot="folder"
className="size-3.5 text-muted-foreground shrink-0"
fallback={<Folder className="size-3.5 text-muted-foreground shrink-0" />}
/>
) : (
<FileIcon className="size-3.5 text-muted-foreground/60 shrink-0" />
<FileTypeIcon
filePath={entry.name}
className="size-3.5 text-muted-foreground/60 shrink-0"
/>
)}
<span className="truncate flex-1 min-w-0">{entry.name}</span>
{entry.isDirectory && (
@@ -1,5 +1,6 @@
import React from 'react'
import { Bell, CalendarClock, Search, Smartphone } from 'lucide-react'
import { PluginIconSlot } from '@/components/PluginIconSlot'
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
@@ -171,9 +172,15 @@ const SidebarNav = React.memo(function SidebarNav() {
)}
className="group relative flex h-7 w-full items-center rounded-md border border-worktree-sidebar-border/70 bg-worktree-sidebar-foreground/5 pl-7 pr-1.5 text-left text-[12px] font-medium tracking-tight text-worktree-sidebar-foreground/45 transition-colors hover:border-worktree-sidebar-border hover:bg-worktree-sidebar-foreground/8 hover:text-worktree-sidebar-foreground/60 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-worktree-sidebar-ring/50"
>
<Search
<PluginIconSlot
slot="sidebar.search"
className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-worktree-sidebar-foreground/30"
strokeWidth={1.75}
fallback={
<Search
className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-worktree-sidebar-foreground/30"
strokeWidth={1.75}
/>
}
/>
<span className="min-w-0 flex-1 truncate">
{translate('auto.components.sidebar.SidebarNav.80611a8b10', 'Search')}
@@ -16,6 +16,7 @@ import { useWorkspaceBoardPanel } from './useWorkspaceBoardPanel'
import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance'
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
import { lazyWithRetry } from '@/lib/lazy-with-retry'
import { usePluginTerminalThemes } from '@/store/plugin-terminal-themes'
const WorktreeMetaDialog = lazyWithRetry(() => import('./WorktreeMetaDialog'))
const RemoveFolderDialog = lazyWithRetry(() => import('./RemoveFolderDialog'))
@@ -50,9 +51,10 @@ function Sidebar({
const activeModal = useAppStore((s) => s.activeModal)
const statusBarVisible = useAppStore((s) => s.statusBarVisible)
const systemPrefersDark = useSystemPrefersDark()
const pluginTerminalThemes = usePluginTerminalThemes()
const leftSidebarStyle = useMemo(
() => resolveLeftSidebarStyleVariables(settings, systemPrefersDark),
[settings, systemPrefersDark]
() => resolveLeftSidebarStyleVariables(settings, systemPrefersDark, pluginTerminalThemes),
[settings, systemPrefersDark, pluginTerminalThemes]
) as React.CSSProperties | undefined
const { nativeDropTarget, dropHandlers, affordance } = useSidebarProjectDrop()
const {
@@ -8,7 +8,7 @@ import { getEditorDisplayLabel } from '@/components/editor/editor-labels'
import { renameFileOnDisk } from '@/lib/rename-file'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { detectLanguage } from '@/lib/language-detect'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import { FileTypeIcon } from '@/components/FileTypeIcon'
import { useRepoById, useWorktreeById } from '@/store/selectors'
import { useAppStore } from '@/store'
import { STATUS_COLORS, STATUS_LABELS } from '../right-sidebar/status-display'
@@ -65,7 +65,6 @@ export default function EditorFileTab({
}): React.JSX.Element {
const worktree = useWorktreeById(file.worktreeId)
const repo = useRepoById(worktree?.repoId ?? null)
const FileIcon = getFileTypeIcon(file.filePath)
// Why: no transform/transition/isDragging styling — the drag design is
// that tabs stay visually anchored; only the blue insertion bar moves.
const { attributes, listeners, setNodeRef } = useSortable({
@@ -270,7 +269,8 @@ export default function EditorFileTab({
className={`w-3.5 h-3.5 mr-1.5 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
/>
) : (
<FileIcon
<FileTypeIcon
filePath={file.filePath}
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
/>
)}
@@ -1,5 +1,5 @@
import { Globe, Terminal as TerminalIcon } from 'lucide-react'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import { FileTypeIcon } from '@/components/FileTypeIcon'
import { AgentIcon } from '@/lib/agent-catalog'
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
@@ -10,8 +10,7 @@ function LeadingIcon({ drag }: { drag: TabDragItemData }): React.JSX.Element {
return <Globe className="h-3.5 w-3.5 shrink-0" />
}
if (drag.tabType === 'editor') {
const FileIcon = getFileTypeIcon(drag.iconPath ?? drag.label)
return <FileIcon className="h-3.5 w-3.5 shrink-0" />
return <FileTypeIcon filePath={drag.iconPath ?? drag.label} className="h-3.5 w-3.5 shrink-0" />
}
if (drag.agent) {
return <AgentIcon agent={drag.agent} size={14} />
@@ -76,6 +76,7 @@ import {
import { useSystemPrefersDark } from './use-system-prefers-dark'
import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects'
import { useTerminalPaneLifecycle } from './use-terminal-pane-lifecycle'
import { usePluginTerminalThemeStore } from '@/store/plugin-terminal-themes'
import { useTerminalPaneContextMenu } from './use-terminal-pane-context-menu'
import {
detachTerminalPaneToTab,
@@ -958,6 +959,7 @@ export default function TerminalPane({
onPtyExitRef.current = onPtyExit
const systemPrefersDark = useSystemPrefersDark()
const pluginTerminalThemes = usePluginTerminalThemeStore((state) => state.themes)
const dispatchNotification = useNotificationDispatch(worktreeId)
const setCacheTimerStartedAt = useAppStore((store) => store.setCacheTimerStartedAt)
@@ -1450,6 +1452,7 @@ export default function TerminalPane({
isActive,
isVisible: isRendererVisible,
systemPrefersDark,
pluginTerminalThemes,
settings,
settingsRef,
requestOpenLinksInAppPreference,
@@ -45,6 +45,7 @@ import type {
TuiAgent
} from '../../../../shared/types'
import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry'
import type { PluginTerminalThemeRegistration } from '../../../../shared/plugins/plugin-terminal-theme-artifact'
import type { EventProps } from '../../../../shared/telemetry-events'
import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery'
import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume'
@@ -224,6 +225,7 @@ type UseTerminalPaneLifecycleDeps = {
isActive: boolean
isVisible: boolean
systemPrefersDark: boolean
pluginTerminalThemes: readonly PluginTerminalThemeRegistration[]
settings: GlobalSettings | null | undefined
settingsRef: React.RefObject<GlobalSettings | null | undefined>
requestOpenLinksInAppPreference: TerminalLinkRoutingPreferenceRequester
@@ -508,6 +510,7 @@ export function useTerminalPaneLifecycle({
isActive,
isVisible,
systemPrefersDark,
pluginTerminalThemes,
settings,
settingsRef,
requestOpenLinksInAppPreference,
@@ -1914,7 +1917,7 @@ export function useTerminalPaneLifecycle({
// macOptionIsMeta on every pane so the change takes effect
// immediately.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings, systemPrefersDark, effectiveMacOptionAsAlt])
}, [settings, systemPrefersDark, effectiveMacOptionAsAlt, pluginTerminalThemes])
useEffect(() => {
managerRef.current?.setTerminalGpuAcceleration(settings?.terminalGpuAcceleration ?? 'auto')
+8 -1
View File
@@ -6445,7 +6445,9 @@
"search_terminal_themes": "Search terminal themes",
"cb330ef7f8": " of {{value0}}",
"c822571b2e": " matching \"{{value0}}\"",
"3119c012a5": "string"
"3119c012a5": "string",
"plugin_themes": "Plugin themes",
"contributed_by": "Contributed by {{value0}}"
},
"SettingsSidebar": {
"e0900f83e7": "SSH",
@@ -9170,6 +9172,11 @@
"title": "Plugin theme",
"description": "Apply a theme from an enabled Orca plugin.",
"builtIn": "Use built-in theme"
},
"pluginIconTheme": {
"title": "Icon theme",
"description": "Use file, folder, sidebar, and agent icons from an enabled Orca plugin.",
"builtIn": "Use built-in icons"
}
},
"PluginSkillMappingDialog": {
+8 -1
View File
@@ -6408,7 +6408,9 @@
"builtin_themes": "Integrados",
"imported_from": "Importado desde {{value0}}",
"imported_themes": "Importados",
"search_terminal_themes": "Buscar temas de terminal"
"search_terminal_themes": "Buscar temas de terminal",
"plugin_themes": "Temas de plugins",
"contributed_by": "Aportado por {{value0}}"
},
"SettingsSidebar": {
"e0900f83e7": "SSH",
@@ -9170,6 +9172,11 @@
"title": "Tema de plugin",
"description": "Aplica un tema de un plugin de Orca habilitado.",
"builtIn": "Usar tema integrado"
},
"pluginIconTheme": {
"title": "Tema de iconos",
"description": "Usa iconos de archivos, carpetas, barra lateral y agentes de un plugin de Orca habilitado.",
"builtIn": "Usar iconos integrados"
}
},
"PluginSkillMappingDialog": {
+8 -1
View File
@@ -6430,7 +6430,9 @@
"search_terminal_themes": "terminal テーマを検索",
"cb330ef7f8": "{{value0}}の",
"c822571b2e": "「{{value0}}」に一致",
"3119c012a5": "文字列"
"3119c012a5": "文字列",
"plugin_themes": "プラグインテーマ",
"contributed_by": "{{value0}} による提供"
},
"SettingsSidebar": {
"e0900f83e7": "SSH",
@@ -9170,6 +9172,11 @@
"title": "プラグインテーマ",
"description": "有効な Orca プラグインのテーマを適用します。",
"builtIn": "組み込みテーマを使用"
},
"pluginIconTheme": {
"title": "アイコンテーマ",
"description": "有効な Orca プラグインのファイル、フォルダー、サイドバー、エージェント用アイコンを使用します。",
"builtIn": "組み込みアイコンを使用"
}
},
"PluginSkillMappingDialog": {
+8 -1
View File
@@ -6393,7 +6393,9 @@
"builtin_themes": "기본 제공",
"imported_from": "{{value0}}에서 가져옴",
"imported_themes": "가져옴",
"search_terminal_themes": "terminal 테마 검색"
"search_terminal_themes": "terminal 테마 검색",
"plugin_themes": "플러그인 테마",
"contributed_by": "{{value0}}에서 제공"
},
"SettingsSidebar": {
"e0900f83e7": "SSH",
@@ -9170,6 +9172,11 @@
"title": "플러그인 테마",
"description": "활성화된 Orca 플러그인의 테마를 적용합니다.",
"builtIn": "기본 제공 테마 사용"
},
"pluginIconTheme": {
"title": "아이콘 테마",
"description": "활성화된 Orca 플러그인의 파일, 폴더, 사이드바 및 에이전트 아이콘을 사용합니다.",
"builtIn": "기본 제공 아이콘 사용"
}
},
"PluginSkillMappingDialog": {
+8 -1
View File
@@ -6393,7 +6393,9 @@
"builtin_themes": "内置",
"imported_from": "从 {{value0}} 导入",
"imported_themes": "已导入",
"search_terminal_themes": "搜索终端主题"
"search_terminal_themes": "搜索终端主题",
"plugin_themes": "插件主题",
"contributed_by": "由 {{value0}} 提供"
},
"SettingsSidebar": {
"e0900f83e7": "SSH",
@@ -9170,6 +9172,11 @@
"title": "插件主题",
"description": "应用已启用的 Orca 插件所提供的主题。",
"builtIn": "使用内置主题"
},
"pluginIconTheme": {
"title": "图标主题",
"description": "使用已启用 Orca 插件提供的文件、文件夹、侧边栏和智能体图标。",
"builtIn": "使用内置图标"
}
},
"PluginSkillMappingDialog": {
@@ -0,0 +1,52 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, describe, expect, it } from 'vitest'
import { usePluginIconThemeStore } from '@/store/plugin-icon-themes'
import { AgentIcon } from './agent-catalog'
afterEach(() => {
usePluginIconThemeStore.setState({
themes: [],
activeId: null,
activeTheme: null,
loaded: true
})
document.body.innerHTML = ''
})
describe('AgentIcon plugin theme slots', () => {
it('uses agent-specific slots and falls back to the plugin default slot', async () => {
const codexIcon = 'data:image/svg+xml;base64,Y29kZXg='
const defaultIcon = 'data:image/svg+xml;base64,ZGVmYXVsdA=='
const activeTheme = {
id: 'plugin:acme.icons/main' as const,
pluginKey: 'acme.icons',
label: 'Acme',
icons: {
'agent.codex': { dataUrl: codexIcon, rendering: 'image' as const },
'agent.default': { dataUrl: defaultIcon, rendering: 'image' as const }
},
fileNames: {},
fileExtensions: {}
}
usePluginIconThemeStore.setState({
activeId: 'plugin:acme.icons/main',
activeTheme,
loaded: true,
themes: [activeTheme]
})
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
await act(async () => root.render(<AgentIcon agent="codex" />))
expect(container.querySelector('img')?.getAttribute('src')).toBe(codexIcon)
await act(async () => root.render(<AgentIcon agent="opencode" />))
expect(container.querySelector('img')?.getAttribute('src')).toBe(defaultIcon)
await act(async () => root.unmount())
})
})
+16
View File
@@ -15,6 +15,9 @@ import {
import { translate } from '@/i18n/i18n'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { AGENT_FAVICON_ASSETS } from './agent-favicon-assets'
import { usePluginIconThemeStore } from '@/store/plugin-icon-themes'
import { getPluginIconSlotImage, type PluginIconThemeSlot } from './plugin-icon-theme'
import { PluginIconImage } from '@/components/PluginIconImage'
export type AgentCatalogEntry = {
id: TuiAgent
@@ -307,6 +310,19 @@ export function AgentIcon({
agent: TuiAgent | null | undefined
size?: number
}): React.JSX.Element {
const activeTheme = usePluginIconThemeStore((state) => state.activeTheme)
const slot: PluginIconThemeSlot =
agent === 'codex'
? 'agent.codex'
: agent === 'claude' || agent === 'claude-agent-teams'
? 'agent.claude'
: 'agent.default'
const pluginIcon =
getPluginIconSlotImage(activeTheme, slot) ??
(slot === 'agent.default' ? null : getPluginIconSlotImage(activeTheme, 'agent.default'))
if (pluginIcon) {
return <PluginIconImage image={pluginIcon} size={size} />
}
// Why: render a neutral question-mark glyph when the agent identity is not
// yet known. Before, the caller coerced null → 'claude', which caused Codex
// panes to briefly show the Claude icon until the first hook callback
@@ -20,6 +20,7 @@ import {
} from 'lucide-react'
import { describe, expect, it } from 'vitest'
import { getFileTypeIcon } from './file-type-icons'
import { getPluginFileTypeIconImage } from './file-type-icons'
describe('getFileTypeIcon', () => {
it('prefers known filenames over generic extensions', () => {
@@ -58,4 +59,46 @@ describe('getFileTypeIcon', () => {
it('falls back to the generic file icon for unknown files', () => {
expect(getFileTypeIcon('unknown.customtype')).toBe(File)
})
it('resolves plugin file-name and extension mappings before the generic plugin icon', () => {
const theme = {
id: 'plugin:acme.icons/main' as const,
pluginKey: 'acme.icons',
label: 'Acme',
icons: {
file: { dataUrl: 'data:image/svg+xml;base64,ZmlsZQ==', rendering: 'image' as const }
},
fileNames: {
'readme.md': {
dataUrl: 'data:image/svg+xml;base64,cmVhZG1l',
rendering: 'image' as const
}
},
fileExtensions: {
ts: { dataUrl: 'data:image/svg+xml;base64,dHM=', rendering: 'image' as const }
}
}
expect(getPluginFileTypeIconImage(theme, '/repo/README.md')?.dataUrl).toContain('cmVhZG1l')
expect(getPluginFileTypeIconImage(theme, 'src/index.ts')?.dataUrl).toContain('dHM=')
expect(getPluginFileTypeIconImage(theme, 'unknown.bin')?.dataUrl).toContain('ZmlsZQ==')
})
it('rejects unexpected plugin icon URLs and preserves built-in fallback', () => {
expect(
getPluginFileTypeIconImage(
{
id: 'plugin:acme.icons/main',
pluginKey: 'acme.icons',
label: 'Acme',
icons: {
file: { dataUrl: 'https://example.com/icon.svg', rendering: 'image' }
},
fileNames: {},
fileExtensions: {}
},
'unknown.bin'
)
).toBeNull()
})
})
+21
View File
@@ -24,6 +24,11 @@ import {
Smartphone,
type LucideIcon
} from 'lucide-react'
import type {
PluginIconThemeImage,
PluginIconThemeRegistration
} from '../../../shared/plugins/plugin-icon-theme-artifact'
import { isPluginIconThemeImage } from './plugin-icon-theme'
const FILE_ICON_BY_NAME: Record<string, LucideIcon> = {
'.babelrc': FileSliders,
@@ -334,3 +339,19 @@ export function getFileTypeIcon(filePath: string | undefined | null): LucideIcon
// where OS-native file associations are not available.
return FILE_ICON_BY_EXTENSION[getExtension(filename)] ?? File
}
export function getPluginFileTypeIconImage(
theme: PluginIconThemeRegistration | null | undefined,
filePath: string | undefined | null
): PluginIconThemeImage | null {
if (!theme) {
return null
}
const filename = getFilename(filePath).toLowerCase()
if (!filename) {
return null
}
const image =
theme.fileNames[filename] ?? theme.fileExtensions[getExtension(filename)] ?? theme.icons.file
return isPluginIconThemeImage(image) ? image : null
}
@@ -5,6 +5,7 @@ import {
normalizeLeftSidebarTintOpacity
} from '../../../shared/left-sidebar-appearance'
import { resolveEffectiveTerminalAppearance } from './terminal-theme'
import type { PluginTerminalThemeRegistration } from '../../../shared/plugins/plugin-terminal-theme-artifact'
type LeftSidebarAppearanceSettings = Pick<
GlobalSettings,
@@ -91,9 +92,10 @@ function buildSurfaceVariables(args: {
function resolveTerminalSurfaceVariables(
settings: LeftSidebarAppearanceSettings,
systemPrefersDark: boolean
systemPrefersDark: boolean,
pluginThemes?: readonly PluginTerminalThemeRegistration[]
): LeftSidebarStyleVariables {
const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark, pluginThemes)
const background = applyAlpha(
settings.terminalColorOverrides?.background ?? appearance.theme?.background ?? '#000000',
settings.terminalBackgroundOpacity
@@ -115,7 +117,8 @@ function resolveTintedSurfaceVariables(
export function resolveLeftSidebarStyleVariables(
settings: LeftSidebarAppearanceSettings | null | undefined,
systemPrefersDark: boolean
systemPrefersDark: boolean,
pluginThemes?: readonly PluginTerminalThemeRegistration[]
): LeftSidebarStyleVariables | undefined {
if (!settings) {
return undefined
@@ -124,7 +127,7 @@ export function resolveLeftSidebarStyleVariables(
case 'default':
return undefined
case 'match-terminal':
return resolveTerminalSurfaceVariables(settings, systemPrefersDark)
return resolveTerminalSurfaceVariables(settings, systemPrefersDark, pluginThemes)
case 'tinted':
return resolveTintedSurfaceVariables(settings)
}
+29
View File
@@ -0,0 +1,29 @@
import type {
PluginIconThemeImage,
PluginIconThemeRegistration,
PLUGIN_ICON_THEME_SLOTS
} from '../../../shared/plugins/plugin-icon-theme-artifact'
export type PluginIconThemeSlot = (typeof PLUGIN_ICON_THEME_SLOTS)[number]
export function isPluginIconThemeImage(value: unknown): value is PluginIconThemeImage {
// Why: plugin SVGs are renderer data, never markup. Refuse unexpected IPC
// values so consumers only render the host-sanitized image representation.
return (
typeof value === 'object' &&
value !== null &&
'dataUrl' in value &&
typeof value.dataUrl === 'string' &&
value.dataUrl.startsWith('data:image/svg+xml;base64,') &&
'rendering' in value &&
(value.rendering === 'image' || value.rendering === 'mask')
)
}
export function getPluginIconSlotImage(
theme: PluginIconThemeRegistration | null | undefined,
slot: PluginIconThemeSlot
): PluginIconThemeImage | null {
const image = theme?.icons[slot]
return isPluginIconThemeImage(image) ? image : null
}
@@ -9,6 +9,7 @@ import {
resolveOpaqueTerminalBackground,
resolveEffectiveTerminalAppearance
} from './terminal-theme'
import { usePluginTerminalThemeStore } from '../store/plugin-terminal-themes'
// Mirrors Codex instruction block gray so the dark selection cannot disappear into it.
const INSTRUCTION_BLOCK_BACKGROUND = '#3e4451'
@@ -55,6 +56,7 @@ function contrastRatio(first: string, second: string): number {
afterEach(() => {
vi.restoreAllMocks()
usePluginTerminalThemeStore.setState({ themes: [], loaded: true })
})
describe('resolveEffectiveTerminalAppearance', () => {
@@ -237,6 +239,39 @@ describe('resolveEffectiveTerminalAppearance', () => {
})
)
})
it('resolves plugin palettes and lists qualified plugin picker options', () => {
usePluginTerminalThemeStore.setState({
loaded: true,
themes: [
{
id: 'plugin:acme.terminal/nord',
pluginKey: 'acme.terminal',
label: 'Nord',
mode: 'dark',
terminal: { background: '#101010', foreground: '#eeeeee', black: '#000000' }
}
]
})
expect(getTerminalThemePreview('plugin:acme.terminal/nord')?.background).toBe('#101010')
expect(getAvailableTerminalThemeOptions({ terminalCustomThemes: [] })).toContainEqual(
expect.objectContaining({
value: 'plugin:acme.terminal/nord',
group: 'plugin',
sourceLabel: 'acme.terminal'
})
)
})
it('falls back to the mode-appropriate built-in when a plugin theme disappears', () => {
const selection = 'plugin:acme.terminal/missing'
const dark = getTerminalThemePreview(selection, undefined, 'dark')
const light = getTerminalThemePreview(selection, undefined, 'light')
expect(dark).toEqual(getTerminalThemePreview(DEFAULT_TERMINAL_THEME_DARK))
expect(light).toEqual(getTerminalThemePreview(DEFAULT_TERMINAL_THEME_LIGHT))
})
})
describe('default dark terminal theme selection contrast', () => {
+30 -7
View File
@@ -1,6 +1,8 @@
import type { ITheme } from '@xterm/xterm'
import { getTheme, getThemeNames } from './terminal-themes-data'
import type { GlobalSettings } from '../../../shared/types'
import { usePluginTerminalThemeStore } from '@/store/plugin-terminal-themes'
import type { PluginTerminalThemeRegistration } from '../../../shared/plugins/plugin-terminal-theme-artifact'
import {
makeCustomTerminalThemeSelection,
normalizeTerminalCustomThemes,
@@ -28,7 +30,7 @@ export type EffectiveTerminalAppearance = {
export type TerminalThemeOption = {
value: string
label: string
group: 'built-in' | 'imported'
group: 'built-in' | 'imported' | 'plugin'
sourceLabel?: string
mode?: TerminalCustomTheme['mode']
previewTheme: ITheme | null
@@ -62,8 +64,14 @@ function findCustomTheme(
export function getTerminalTheme(
settings: Pick<GlobalSettings, 'terminalCustomThemes'> | undefined,
selection: string
selection: string,
pluginThemes: readonly PluginTerminalThemeRegistration[] = usePluginTerminalThemeStore.getState()
.themes
): ITheme | null {
const pluginTheme = pluginThemes.find((theme) => theme.id === selection)
if (pluginTheme) {
return pluginTheme.terminal
}
const customTheme = findCustomTheme(settings, selection)
if (customTheme) {
return terminalCustomThemeToXtermTheme(customTheme)
@@ -74,9 +82,10 @@ export function getTerminalTheme(
export function getTerminalThemePreview(
name: string,
settings?: Pick<GlobalSettings, 'terminalCustomThemes'>,
fallbackMode: 'dark' | 'light' = 'dark'
fallbackMode: 'dark' | 'light' = 'dark',
pluginThemes?: readonly PluginTerminalThemeRegistration[]
): ITheme | null {
const theme = getTerminalTheme(settings, name)
const theme = getTerminalTheme(settings, name, pluginThemes)
if (theme) {
return theme
}
@@ -105,7 +114,15 @@ export function getAvailableTerminalThemeOptions(
previewTheme: terminalCustomThemeToXtermTheme(theme)
})
)
return [...builtinOptions, ...customOptions]
const pluginOptions = usePluginTerminalThemeStore.getState().themes.map((theme) => ({
value: theme.id,
label: theme.label,
group: 'plugin' as const,
sourceLabel: theme.pluginKey,
mode: theme.mode,
previewTheme: theme.terminal
}))
return [...builtinOptions, ...customOptions, ...pluginOptions]
}
export function resolveEffectiveTerminalAppearance(
@@ -119,7 +136,8 @@ export function resolveEffectiveTerminalAppearance(
| 'terminalCustomThemes'
| 'terminalDividerColorLight'
>,
systemPrefersDark = getSystemPrefersDark()
systemPrefersDark = getSystemPrefersDark(),
pluginThemes?: readonly PluginTerminalThemeRegistration[]
): EffectiveTerminalAppearance {
const sourceTheme =
settings.theme === 'system' ? (systemPrefersDark ? 'dark' : 'light') : settings.theme
@@ -136,7 +154,12 @@ export function resolveEffectiveTerminalAppearance(
sourceTheme: settings.theme,
themeName,
dividerColor,
theme: getTerminalThemePreview(themeName, settings, useLightVariant ? 'light' : 'dark'),
theme: getTerminalThemePreview(
themeName,
settings,
useLightVariant ? 'light' : 'dark',
pluginThemes
),
systemPrefersDark
}
}
@@ -0,0 +1,77 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { PluginChangeEvent } from '../../../shared/plugins/plugin-change-event'
import { ensurePluginIconThemesLoaded, usePluginIconThemeStore } from './plugin-icon-themes'
import {
ensurePluginTerminalThemesLoaded,
usePluginTerminalThemeStore
} from './plugin-terminal-themes'
afterEach(() => {
delete (window as unknown as { api?: unknown }).api
})
describe('plugin content-pack refreshes', () => {
it('loads only the selected icon theme and ignores worker-only notifications', async () => {
const listeners: ((event: PluginChangeEvent) => void)[] = []
const metadata = {
id: 'plugin:acme.icons/main' as const,
pluginKey: 'acme.icons',
label: 'Acme'
}
const registration = {
...metadata,
icons: {},
fileNames: {},
fileExtensions: {
ts: { dataUrl: 'data:image/svg+xml;base64,PHN2Zy8+', rendering: 'image' as const }
}
}
const listIconThemes = vi.fn(async () => [metadata])
const loadIconTheme = vi.fn(async () => registration)
const listTerminalThemes = vi.fn(async () => [])
;(window as unknown as { api: unknown }).api = {
plugins: {
listIconThemes,
loadIconTheme,
listTerminalThemes,
onChanged: (listener: (event: PluginChangeEvent) => void) => {
listeners.push(listener)
return () => undefined
}
}
}
usePluginIconThemeStore.setState({
themes: [],
activeId: null,
activeTheme: null,
loaded: false
})
usePluginTerminalThemeStore.setState({ themes: [], loaded: false })
ensurePluginIconThemesLoaded()
ensurePluginTerminalThemesLoaded()
await vi.waitFor(() => expect(listIconThemes).toHaveBeenCalledOnce())
expect(loadIconTheme).not.toHaveBeenCalled()
usePluginIconThemeStore.getState().setActiveId(metadata.id)
await vi.waitFor(() =>
expect(usePluginIconThemeStore.getState().activeTheme).toEqual(registration)
)
expect(loadIconTheme).toHaveBeenCalledOnce()
for (const listener of listeners) {
listener({ contentPacksChanged: false })
}
await Promise.resolve()
expect(listIconThemes).toHaveBeenCalledOnce()
expect(listTerminalThemes).toHaveBeenCalledOnce()
for (const listener of listeners) {
listener({ contentPacksChanged: true })
}
await vi.waitFor(() => expect(listIconThemes).toHaveBeenCalledTimes(2))
expect(listTerminalThemes).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,85 @@
import { useEffect } from 'react'
import { create } from 'zustand'
import type {
PluginIconThemeMetadata,
PluginIconThemeRegistration
} from '../../../shared/plugins/plugin-icon-theme-artifact'
type PluginIconThemeState = {
themes: PluginIconThemeMetadata[]
activeId: `plugin:${string}` | null
activeTheme: PluginIconThemeRegistration | null
loaded: boolean
fetchThemes: () => Promise<void>
loadActiveTheme: () => Promise<void>
setActiveId: (id: `plugin:${string}` | null) => void
}
let requestGeneration = 0
let activeRequestGeneration = 0
let changeSubscriptionStarted = false
export const usePluginIconThemeStore = create<PluginIconThemeState>()((set, get) => ({
themes: [],
activeId: null,
activeTheme: null,
loaded: false,
fetchThemes: async () => {
const generation = ++requestGeneration
try {
const themes = (await window.api?.plugins?.listIconThemes?.()) ?? []
if (generation === requestGeneration) {
set({ themes, loaded: true })
await get().loadActiveTheme()
}
} catch {
if (generation === requestGeneration) {
set({ themes: [], loaded: true })
await get().loadActiveTheme()
}
}
},
loadActiveTheme: async () => {
const generation = ++activeRequestGeneration
const { activeId, themes } = get()
if (!activeId || !themes.some((theme) => theme.id === activeId)) {
set({ activeTheme: null })
return
}
try {
const activeTheme = (await window.api?.plugins?.loadIconTheme?.(activeId)) ?? null
if (generation === activeRequestGeneration && get().activeId === activeId) {
set({ activeTheme })
}
} catch {
if (generation === activeRequestGeneration && get().activeId === activeId) {
set({ activeTheme: null })
}
}
},
setActiveId: (activeId) => {
set({ activeId })
void get().loadActiveTheme()
}
}))
export function ensurePluginIconThemesLoaded(): void {
const state = usePluginIconThemeStore.getState()
if (!state.loaded) {
void state.fetchThemes()
}
if (!changeSubscriptionStarted && window.api?.plugins?.onChanged) {
changeSubscriptionStarted = true
window.api.plugins.onChanged((event) => {
if (event?.contentPacksChanged ?? true) {
void usePluginIconThemeStore.getState().fetchThemes()
}
})
}
}
export function usePluginIconThemes(): PluginIconThemeMetadata[] {
const themes = usePluginIconThemeStore((state) => state.themes)
useEffect(() => ensurePluginIconThemesLoaded(), [])
return themes
}
@@ -43,7 +43,11 @@ export function ensurePluginLanguagePacksLoaded(): void {
}
if (!changeSubscriptionStarted && window.api?.plugins?.onChanged) {
changeSubscriptionStarted = true
window.api.plugins.onChanged(() => void usePluginLanguagePackStore.getState().fetchPacks())
window.api.plugins.onChanged((event) => {
if (event?.contentPacksChanged ?? true) {
void usePluginLanguagePackStore.getState().fetchPacks()
}
})
}
}
@@ -0,0 +1,51 @@
import { useEffect } from 'react'
import { create } from 'zustand'
import type { PluginTerminalThemeRegistration } from '../../../shared/plugins/plugin-terminal-theme-artifact'
type PluginTerminalThemeState = {
themes: PluginTerminalThemeRegistration[]
loaded: boolean
fetchThemes: () => Promise<void>
}
let requestGeneration = 0
let changeSubscriptionStarted = false
export const usePluginTerminalThemeStore = create<PluginTerminalThemeState>()((set) => ({
themes: [],
loaded: false,
fetchThemes: async () => {
const generation = ++requestGeneration
try {
const themes = (await window.api?.plugins?.listTerminalThemes?.()) ?? []
if (generation === requestGeneration) {
set({ themes, loaded: true })
}
} catch {
if (generation === requestGeneration) {
set({ themes: [], loaded: true })
}
}
}
}))
export function ensurePluginTerminalThemesLoaded(): void {
const state = usePluginTerminalThemeStore.getState()
if (!state.loaded) {
void state.fetchThemes()
}
if (!changeSubscriptionStarted && window.api?.plugins?.onChanged) {
changeSubscriptionStarted = true
window.api.plugins.onChanged((event) => {
if (event?.contentPacksChanged ?? true) {
void usePluginTerminalThemeStore.getState().fetchThemes()
}
})
}
}
export function usePluginTerminalThemes(): PluginTerminalThemeRegistration[] {
const themes = usePluginTerminalThemeStore((state) => state.themes)
useEffect(() => ensurePluginTerminalThemesLoaded(), [])
return themes
}
+5 -1
View File
@@ -43,7 +43,11 @@ export function ensurePluginThemesLoaded(): void {
}
if (!changeSubscriptionStarted && window.api?.plugins?.onChanged) {
changeSubscriptionStarted = true
window.api.plugins.onChanged(() => void usePluginThemeStore.getState().fetchThemes())
window.api.plugins.onChanged((event) => {
if (event?.contentPacksChanged ?? true) {
void usePluginThemeStore.getState().fetchThemes()
}
})
}
}
+1
View File
@@ -200,6 +200,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
enableGitHubAttribution: false,
theme: 'system',
pluginAppTheme: null,
pluginIconTheme: null,
leftSidebarAppearanceMode: 'default',
leftSidebarTintColor: DEFAULT_LEFT_SIDEBAR_TINT_COLOR,
leftSidebarTintOpacity: DEFAULT_LEFT_SIDEBAR_TINT_OPACITY,
@@ -0,0 +1,4 @@
export type PluginChangeEvent = {
/** True only when declarative contribution catalogs were reconciled. */
contentPacksChanged: boolean
}
@@ -21,6 +21,9 @@ describe('content-pack manifest contributions', () => {
manifest({
themes: [{ id: 'nord', label: 'Nord', path: 'themes/nord.json' }],
iconThemes: [{ id: 'minimal', path: 'icons/minimal.json' }],
terminalThemes: [
{ id: 'nord-terminal', label: 'Nord Terminal', path: 'terminal/nord.json' }
],
languagePacks: [{ locale: 'pt-BR', path: 'locales/pt-BR.json' }],
skills: [{ path: 'skills/', providers: ['codex', 'claude'] }],
commands: [
@@ -51,6 +54,7 @@ describe('content-pack manifest contributions', () => {
events: [],
themes: [],
iconThemes: [],
terminalThemes: [],
languagePacks: [],
skills: [],
keybindings: [],
@@ -8,6 +8,7 @@ import {
export const PLUGIN_THEME_LIMIT = 64
export const PLUGIN_ICON_THEME_LIMIT = 32
export const PLUGIN_TERMINAL_THEME_LIMIT = 64
export const PLUGIN_LANGUAGE_PACK_LIMIT = 16
export const PLUGIN_SKILL_LIMIT = 128
export const PLUGIN_KEYBINDING_LIMIT = 256
@@ -30,6 +31,14 @@ export const pluginIconThemeContributionSchema = z
})
.strict()
export const pluginTerminalThemeContributionSchema = z
.object({
id: pluginIdSchema,
label: z.string().min(1).max(128),
path: pluginRelativePathSchema
})
.strict()
// Why: locale ids become i18next bundle keys and filenames. This bounded BCP
// 47 subset covers current community packs without accepting path syntax.
export const pluginLocaleIdSchema = z
@@ -76,6 +85,7 @@ export const pluginAgentProfileContributionSchema = z
export type PluginThemeContribution = z.infer<typeof pluginThemeContributionSchema>
export type PluginIconThemeContribution = z.infer<typeof pluginIconThemeContributionSchema>
export type PluginTerminalThemeContribution = z.infer<typeof pluginTerminalThemeContributionSchema>
export type PluginLanguagePackContribution = z.infer<typeof pluginLanguagePackContributionSchema>
export type PluginSkillContribution = z.infer<typeof pluginSkillContributionSchema>
export type PluginKeybindingContribution = z.infer<typeof pluginKeybindingContributionSchema>
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { parsePluginIconThemeArtifact, sanitizePluginIconSvg } from './plugin-icon-theme-artifact'
describe('plugin icon theme artifacts', () => {
it('canonicalizes bounded host slots, file names, and extensions', () => {
expect(
parsePluginIconThemeArtifact(
JSON.stringify({
schemaVersion: 1,
icons: { file: 'icons/file.svg', 'sidebar.search': 'icons/search.svg' },
fileNames: { 'README.md': 'icons/readme.svg' },
fileExtensions: { TSX: 'icons/react.svg' }
})
)
).toEqual({
icons: { file: 'icons/file.svg', 'sidebar.search': 'icons/search.svg' },
fileNames: { 'readme.md': 'icons/readme.svg' },
fileExtensions: { tsx: 'icons/react.svg' }
})
})
it('rejects unknown slots, unsafe paths, and case-insensitive collisions', () => {
expect(() =>
parsePluginIconThemeArtifact(
JSON.stringify({ schemaVersion: 1, icons: { arbitrary: 'icon.svg' } })
)
).toThrow()
expect(() =>
parsePluginIconThemeArtifact(
JSON.stringify({ schemaVersion: 1, fileNames: { README: '../outside.svg' } })
)
).toThrow()
expect(() =>
parsePluginIconThemeArtifact(
JSON.stringify({
schemaVersion: 1,
fileExtensions: { TS: 'one.svg', ts: 'two.svg' }
})
)
).toThrow(/duplicate case-insensitive/)
})
})
describe('plugin SVG icon sanitizer', () => {
it('keeps a passive SVG subset and strips comments', () => {
expect(
sanitizePluginIconSvg(
'<!-- source --><svg viewBox="0 0 16 16"><path fill="currentColor" d="M0 0h16v16z"/></svg>'
)
).toBe('<svg viewBox="0 0 16 16"><path fill="currentColor" d="M0 0h16v16z"/></svg>')
})
it.each([
'<svg><script>alert(1)</script></svg>',
'<svg><foreignObject><iframe src="https://example.com"/></foreignObject></svg>',
'<svg onload="alert(1)"><path/></svg>',
'<svg><image href="https://example.com/pixel"/></svg>',
'<svg><path style="fill:url(https://example.com/x)"/></svg>',
'<svg><path style="fill:red"/></svg>',
'<svg><path fill="&#x75;rl(https://example.com/x)"/></svg>',
'<svg><path fill="u\\72l(https://example.com/x)"/></svg>',
'<svg/onload="alert(1)">',
'<svg><path/></svg><svg/>',
'<!DOCTYPE svg><svg><path/></svg>'
])('rejects active or externally-referencing SVG: %s', (svg) => {
expect(() => sanitizePluginIconSvg(svg)).toThrow()
})
})
@@ -0,0 +1,162 @@
import { z } from 'zod'
import { pluginRelativePathSchema } from './plugin-manifest-fields'
export const PLUGIN_ICON_THEME_SLOTS = [
'file',
'folder',
'folder-open',
'sidebar.workspaces',
'sidebar.source-control',
'sidebar.search',
'sidebar.plugins',
'agent.default',
'agent.codex',
'agent.claude'
] as const
const iconPathMapSchema = z.record(z.string(), pluginRelativePathSchema)
const pluginIconThemeArtifactSchema = z
.object({
schemaVersion: z.literal(1),
icons: z.partialRecord(z.enum(PLUGIN_ICON_THEME_SLOTS), pluginRelativePathSchema).default({}),
fileNames: iconPathMapSchema.default({}),
fileExtensions: iconPathMapSchema.default({})
})
.strict()
.superRefine((artifact, ctx) => {
if (
Object.keys(artifact.icons).length +
Object.keys(artifact.fileNames).length +
Object.keys(artifact.fileExtensions).length >
512
) {
ctx.addIssue({ code: 'custom', message: 'icon theme exceeds 512 mappings' })
}
for (const name of Object.keys(artifact.fileNames)) {
if (
name.length === 0 ||
name.length > 128 ||
/[\\/]/.test(name) ||
[...name].some((character) => character.charCodeAt(0) <= 31)
) {
ctx.addIssue({ code: 'custom', path: ['fileNames', name], message: 'unsafe file name' })
}
}
for (const extension of Object.keys(artifact.fileExtensions)) {
if (!/^[A-Za-z0-9][A-Za-z0-9+._-]{0,31}$/.test(extension)) {
ctx.addIssue({
code: 'custom',
path: ['fileExtensions', extension],
message: 'unsafe file extension'
})
}
}
})
export type PluginIconThemeArtifact = {
icons: Partial<Record<(typeof PLUGIN_ICON_THEME_SLOTS)[number], string>>
fileNames: Record<string, string>
fileExtensions: Record<string, string>
}
export type PluginIconThemeMetadata = {
id: `plugin:${string}`
pluginKey: string
label: string
}
export type PluginIconThemeImage = {
dataUrl: string
rendering: 'image' | 'mask'
}
export type PluginIconThemeRegistration = PluginIconThemeMetadata & {
icons: Partial<Record<(typeof PLUGIN_ICON_THEME_SLOTS)[number], PluginIconThemeImage>>
fileNames: Record<string, PluginIconThemeImage>
fileExtensions: Record<string, PluginIconThemeImage>
}
export function parsePluginIconThemeArtifact(raw: string): PluginIconThemeArtifact {
const parsed = pluginIconThemeArtifactSchema.parse(JSON.parse(raw))
const canonicalNames: Record<string, string> = {}
const canonicalExtensions: Record<string, string> = {}
for (const [name, path] of Object.entries(parsed.fileNames)) {
const canonical = name.toLowerCase()
if (canonicalNames[canonical]) {
throw new Error(`duplicate case-insensitive file name mapping: ${name}`)
}
canonicalNames[canonical] = path
}
for (const [extension, path] of Object.entries(parsed.fileExtensions)) {
const canonical = extension.toLowerCase()
if (canonicalExtensions[canonical]) {
throw new Error(`duplicate case-insensitive file extension mapping: ${extension}`)
}
canonicalExtensions[canonical] = path
}
return { icons: parsed.icons, fileNames: canonicalNames, fileExtensions: canonicalExtensions }
}
const ALLOWED_SVG_ELEMENTS = new Set([
'svg',
'g',
'path',
'circle',
'ellipse',
'rect',
'line',
'polyline',
'polygon',
'title',
'desc',
'defs',
'clippath',
'mask',
'lineargradient',
'radialgradient',
'stop'
])
export function sanitizePluginIconSvg(raw: string): string {
const withoutComments = raw.replace(/<!--[\s\S]*?-->/g, '')
if (withoutComments.length === 0 || withoutComments.length > 64 * 1024) {
throw new Error('SVG icon is empty or exceeds 65536 bytes')
}
if (
/<\?|<!/i.test(withoutComments) ||
/[\s/]on[a-z][a-z0-9:-]*\s*=/i.test(withoutComments) ||
/[\s/](?:href|xlink:href)\s*=/i.test(withoutComments) ||
/[\s/]style\s*=/i.test(withoutComments) ||
/url\s*\(/i.test(withoutComments) ||
/[&\\]/.test(withoutComments) ||
[...withoutComments].some((character) => {
const code = character.charCodeAt(0)
return code <= 31 && code !== 9 && code !== 10 && code !== 13
})
) {
throw new Error('SVG icon contains active or external content')
}
const trimmed = withoutComments.trim()
if (
!/^<svg(?:\s[^<>]*)?\/>$/is.test(trimmed) &&
!/^<svg(?:\s[^<>]*)?>[\s\S]*<\/svg>$/i.test(trimmed)
) {
throw new Error('icon must contain exactly one SVG root')
}
const tags = [...trimmed.matchAll(/<\/?\s*([A-Za-z][A-Za-z0-9:-]*)\b/g)]
if (
tags.length === 0 ||
tags[0]?.[1]?.toLowerCase() !== 'svg' ||
[...trimmed.matchAll(/<\s*svg\b/gi)].length !== 1
) {
throw new Error('icon must have an SVG root element')
}
for (const tag of tags) {
const name = tag[1]!.toLowerCase()
if (!ALLOWED_SVG_ELEMENTS.has(name)) {
throw new Error(`SVG element is not allowed: ${name}`)
}
}
return trimmed
}
@@ -11,6 +11,7 @@ type ContributionValidationManifest = {
events: { on: string }[]
themes: IdentifiedContribution[]
iconThemes: IdentifiedContribution[]
terminalThemes: IdentifiedContribution[]
languagePacks: { locale: string }[]
skills: PathContribution[]
keybindings: { command: string; key: string }[]
@@ -45,7 +46,7 @@ export function validatePluginManifestContributions(
manifest: ContributionValidationManifest,
ctx: RefinementCtx
): void {
for (const path of ['panels', 'commands', 'themes', 'iconThemes'] as const) {
for (const path of ['panels', 'commands', 'themes', 'iconThemes', 'terminalThemes'] as const) {
rejectDuplicateValues(
manifest.contributes[path],
(entry) => (entry as IdentifiedContribution).id,
+7
View File
@@ -7,6 +7,7 @@ import {
PLUGIN_LANGUAGE_PACK_LIMIT,
PLUGIN_SKILL_LIMIT,
PLUGIN_THEME_LIMIT,
PLUGIN_TERMINAL_THEME_LIMIT,
PLUGIN_VM_RECIPE_LIMIT,
pluginAgentProfileContributionSchema,
pluginIconThemeContributionSchema,
@@ -14,6 +15,7 @@ import {
pluginLanguagePackContributionSchema,
pluginSkillContributionSchema,
pluginThemeContributionSchema,
pluginTerminalThemeContributionSchema,
pluginVmRecipeContributionSchema
} from './plugin-content-pack-contributions'
import {
@@ -112,6 +114,10 @@ export const pluginManifestSchema = z
.array(pluginIconThemeContributionSchema)
.max(PLUGIN_ICON_THEME_LIMIT)
.default([]),
terminalThemes: z
.array(pluginTerminalThemeContributionSchema)
.max(PLUGIN_TERMINAL_THEME_LIMIT)
.default([]),
languagePacks: z
.array(pluginLanguagePackContributionSchema)
.max(PLUGIN_LANGUAGE_PACK_LIMIT)
@@ -136,6 +142,7 @@ export const pluginManifestSchema = z
events: [],
themes: [],
iconThemes: [],
terminalThemes: [],
languagePacks: [],
skills: [],
keybindings: [],
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { parsePluginTerminalThemeArtifact } from './plugin-terminal-theme-artifact'
describe('plugin terminal theme artifacts', () => {
it('accepts a bounded terminal palette', () => {
expect(
parsePluginTerminalThemeArtifact(
JSON.stringify({
schemaVersion: 1,
mode: 'dark',
terminal: {
background: '#101010',
foreground: '#f0f0f0',
black: '#000000',
red: '#ff0000'
}
})
)
).toEqual({
mode: 'dark',
terminal: {
background: '#101010',
foreground: '#f0f0f0',
black: '#000000',
red: '#ff0000'
}
})
})
it('rejects invalid colors, unknown slots, and unusable palettes', () => {
expect(() =>
parsePluginTerminalThemeArtifact(
JSON.stringify({
schemaVersion: 1,
terminal: { background: '#000', foreground: '#fff', black: 'expression(alert(1))' }
})
)
).toThrow(/invalid color/)
expect(() =>
parsePluginTerminalThemeArtifact(
JSON.stringify({
schemaVersion: 1,
terminal: { background: '#000', foreground: '#fff', arbitrary: '#123456' }
})
)
).toThrow(/unknown terminal color slot/)
expect(() =>
parsePluginTerminalThemeArtifact(
JSON.stringify({ schemaVersion: 1, terminal: { background: '#000', foreground: '#fff' } })
)
).toThrow(/ANSI color/)
})
})
@@ -0,0 +1,44 @@
import { z } from 'zod'
import {
TERMINAL_COLOR_KEYS,
hasUsableTerminalThemeColors,
normalizeTerminalColorOverrides
} from '../terminal-custom-themes'
import type { TerminalColorOverrides } from '../types'
const pluginTerminalThemeArtifactSchema = z
.object({
schemaVersion: z.literal(1),
mode: z.enum(['dark', 'light', 'unknown']).default('unknown'),
terminal: z.record(z.string(), z.string()).superRefine((colors, ctx) => {
for (const key of Object.keys(colors)) {
if (!(TERMINAL_COLOR_KEYS as readonly string[]).includes(key)) {
ctx.addIssue({ code: 'custom', path: [key], message: 'unknown terminal color slot' })
}
}
})
})
.strict()
export type PluginTerminalThemeRegistration = {
id: `plugin:${string}`
pluginKey: string
label: string
mode: 'dark' | 'light' | 'unknown'
terminal: TerminalColorOverrides
}
export function parsePluginTerminalThemeArtifact(raw: string): {
mode: PluginTerminalThemeRegistration['mode']
terminal: TerminalColorOverrides
} {
const parsed = pluginTerminalThemeArtifactSchema.parse(JSON.parse(raw))
const terminal = normalizeTerminalColorOverrides(parsed.terminal)
if (Object.keys(terminal).length !== Object.keys(parsed.terminal).length) {
throw new Error('terminal theme contains an invalid color')
}
if (!hasUsableTerminalThemeColors(terminal)) {
throw new Error('terminal theme requires background, foreground, and at least one ANSI color')
}
return { mode: parsed.mode, terminal }
}
+1 -1
View File
@@ -45,7 +45,7 @@ export type WarpThemeImportPreview = {
export const MAX_TERMINAL_CUSTOM_THEMES = 200
export const CUSTOM_TERMINAL_THEME_PREFIX = 'custom:'
const TERMINAL_COLOR_KEYS = [
export const TERMINAL_COLOR_KEYS = [
'foreground',
'background',
'cursor',
+2
View File
@@ -2515,6 +2515,8 @@ export type GlobalSettings = {
theme: 'system' | 'dark' | 'light'
/** Qualified plugin theme id layered over the built-in light/dark preference. */
pluginAppTheme?: `plugin:${string}` | null
/** Qualified plugin icon-theme id; missing contributions fall back to built-in icons. */
pluginIconTheme?: `plugin:${string}` | null
/** Controls the left sidebar surface without changing terminal brightness. */
leftSidebarAppearanceMode: LeftSidebarAppearanceMode
leftSidebarTintColor?: string