mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
feat(plugins): add icon and terminal themes
This commit is contained in:
+6
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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="url(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}</>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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="url(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,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 }
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user