update plugin themes when plugin was updated (#20052)

pull/14827/merge
Sebastian 2026-03-30 13:51:07 +02:00 committed by GitHub
parent 3c32013eb1
commit 8e4bab5181
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 237 additions and 38 deletions

View File

@ -269,7 +269,9 @@ Theme install behavior:
- Relative theme paths are resolved from the plugin root. - Relative theme paths are resolved from the plugin root.
- Theme name is the JSON basename. - Theme name is the JSON basename.
- Install is skipped if that theme name already exists. - First install writes only when the destination file is missing.
- If the theme name already exists, install is skipped unless plugin metadata state is `updated`.
- On `updated`, host only rewrites themes previously tracked for that plugin and only when source `mtime`/`size` changed.
- Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source. - Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source.
- Global plugins persist installed themes under the global `themes` dir. - Global plugins persist installed themes under the global `themes` dir.
- Invalid or unreadable theme files are ignored. - Invalid or unreadable theme files are ignored.

View File

@ -183,6 +183,18 @@ export function addTheme(name: string, theme: unknown) {
return true return true
} }
export function upsertTheme(name: string, theme: unknown) {
if (!name) return false
if (!isTheme(theme)) return false
if (customThemes[name] !== undefined) {
customThemes[name] = theme
} else {
pluginThemes[name] = theme
}
syncThemes()
return true
}
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
const defs = theme.defs ?? {} const defs = theme.defs ?? {}
function resolveColor(c: ColorValue, chain: string[] = []): RGBA { function resolveColor(c: ColorValue, chain: string[] = []): RGBA {

View File

@ -31,7 +31,7 @@ import {
} from "@/plugin/shared" } from "@/plugin/shared"
import { PluginMeta } from "@/plugin/meta" import { PluginMeta } from "@/plugin/meta"
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install" import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
import { addTheme, hasTheme } from "../context/theme" import { hasTheme, upsertTheme } from "../context/theme"
import { Global } from "@/global" import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process" import { Process } from "@/util/process"
@ -49,7 +49,8 @@ type PluginLoad = {
source: PluginSource | "internal" source: PluginSource | "internal"
id: string id: string
module: TuiPluginModule module: TuiPluginModule
install_theme: TuiTheme["install"] theme_meta: TuiConfig.PluginMeta
theme_root: string
} }
type Api = HostPluginApi type Api = HostPluginApi
@ -64,6 +65,7 @@ type PluginEntry = {
id: string id: string
load: PluginLoad load: PluginLoad
meta: TuiPluginMeta meta: TuiPluginMeta
themes: Record<string, PluginMeta.Theme>
plugin: TuiPlugin plugin: TuiPlugin
options: Config.PluginOptions | undefined options: Config.PluginOptions | undefined
enabled: boolean enabled: boolean
@ -143,12 +145,54 @@ function resolveRoot(root: string) {
return path.resolve(process.cwd(), root) return path.resolve(process.cwd(), root)
} }
function createThemeInstaller(meta: TuiConfig.PluginMeta, root: string, spec: string): TuiTheme["install"] { function createThemeInstaller(
meta: TuiConfig.PluginMeta,
root: string,
spec: string,
plugin: PluginEntry,
): TuiTheme["install"] {
return async (file) => { return async (file) => {
const raw = file.startsWith("file://") ? fileURLToPath(file) : file const raw = file.startsWith("file://") ? fileURLToPath(file) : file
const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw) const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw)
const theme = path.basename(src, path.extname(src)) const name = path.basename(src, path.extname(src))
if (hasTheme(theme)) return const source_dir = path.dirname(meta.source)
const local_dir =
path.basename(source_dir) === ".opencode"
? path.join(source_dir, "themes")
: path.join(source_dir, ".opencode", "themes")
const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
const dest = path.join(dest_dir, `${name}.json`)
const stat = await Filesystem.statAsync(src)
const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined
const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined
const exists = hasTheme(name)
const prev = plugin.themes[name]
if (exists) {
if (plugin.meta.state !== "updated") return
if (!prev) {
if (await Filesystem.exists(dest)) {
plugin.themes[name] = {
src,
dest,
mtime,
size,
}
await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
log.warn("failed to track tui plugin theme", {
path: spec,
id: plugin.id,
theme: src,
dest,
error,
})
})
}
return
}
if (prev.dest !== dest) return
if (prev.mtime === mtime && prev.size === size) return
}
const text = await Filesystem.readText(src).catch((error) => { const text = await Filesystem.readText(src).catch((error) => {
log.warn("failed to read tui plugin theme", { path: spec, theme: src, error }) log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
@ -170,20 +214,28 @@ function createThemeInstaller(meta: TuiConfig.PluginMeta, root: string, spec: st
return return
} }
const source_dir = path.dirname(meta.source) if (exists || !(await Filesystem.exists(dest))) {
const local_dir =
path.basename(source_dir) === ".opencode"
? path.join(source_dir, "themes")
: path.join(source_dir, ".opencode", "themes")
const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
const dest = path.join(dest_dir, `${theme}.json`)
if (!(await Filesystem.exists(dest))) {
await Filesystem.write(dest, text).catch((error) => { await Filesystem.write(dest, text).catch((error) => {
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error }) log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
}) })
} }
addTheme(theme, data) upsertTheme(name, data)
plugin.themes[name] = {
src,
dest,
mtime,
size,
}
await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
log.warn("failed to track tui plugin theme", {
path: spec,
id: plugin.id,
theme: src,
dest,
error,
})
})
} }
} }
@ -222,7 +274,6 @@ async function loadExternalPlugin(
} }
const root = resolveRoot(source === "file" ? spec : target) const root = resolveRoot(source === "file" ? spec : target)
const install_theme = createThemeInstaller(meta, root, spec)
const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => { const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => {
fail("failed to resolve tui plugin entry", { path: spec, target, retry, error }) fail("failed to resolve tui plugin entry", { path: spec, target, retry, error })
return return
@ -253,7 +304,8 @@ async function loadExternalPlugin(
source, source,
id, id,
module: mod, module: mod,
install_theme, theme_meta: meta,
theme_root: root,
} }
} }
@ -297,14 +349,11 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
source: "internal", source: "internal",
id: item.id, id: item.id,
module: item, module: item,
install_theme: createThemeInstaller( theme_meta: {
{ scope: "global",
scope: "global", source: target,
source: target, },
}, theme_root: process.cwd(),
process.cwd(),
spec,
),
} }
} }
@ -436,7 +485,7 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
if (plugin.scope) return true if (plugin.scope) return true
const scope = createPluginScope(plugin.load, plugin.id) const scope = createPluginScope(plugin.load, plugin.id)
const api = pluginApi(state, plugin.load, scope, plugin.id) const api = pluginApi(state, plugin, scope, plugin.id)
const ok = await Promise.resolve() const ok = await Promise.resolve()
.then(async () => { .then(async () => {
await plugin.plugin(api, plugin.options, plugin.meta) await plugin.plugin(api, plugin.options, plugin.meta)
@ -479,9 +528,10 @@ async function deactivatePluginById(state: RuntimeState | undefined, id: string,
return deactivatePluginEntry(state, plugin, persist) return deactivatePluginEntry(state, plugin, persist)
} }
function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope, base: string): TuiPluginApi { function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScope, base: string): TuiPluginApi {
const api = runtime.api const api = runtime.api
const host = runtime.slots const host = runtime.slots
const load = plugin.load
const command: TuiPluginApi["command"] = { const command: TuiPluginApi["command"] = {
register(cb) { register(cb) {
return scope.track(api.command.register(cb)) return scope.track(api.command.register(cb))
@ -504,7 +554,7 @@ function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope,
} }
const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), { const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
install: load.install_theme, install: createThemeInstaller(load.theme_meta, load.theme_root, load.spec, plugin),
}) })
const event: TuiPluginApi["event"] = { const event: TuiPluginApi["event"] = {
@ -563,13 +613,14 @@ function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope,
} }
} }
function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta) { function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta, themes: Record<string, PluginMeta.Theme> = {}) {
const options = load.item ? Config.pluginOptions(load.item) : undefined const options = load.item ? Config.pluginOptions(load.item) : undefined
return [ return [
{ {
id: load.id, id: load.id,
load, load,
meta, meta,
themes,
plugin: load.module.tui, plugin: load.module.tui,
options, options,
enabled: true, enabled: true,
@ -661,7 +712,8 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
} }
const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id) const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
for (const plugin of collectPluginEntries(entry, row)) { const themes = hit?.entry.themes ? { ...hit.entry.themes } : {}
for (const plugin of collectPluginEntries(entry, row, themes)) {
if (!addPluginEntry(state, plugin)) { if (!addPluginEntry(state, plugin)) {
ok = false ok = false
continue continue

View File

@ -11,6 +11,13 @@ import { parsePluginSpecifier, pluginSource } from "./shared"
export namespace PluginMeta { export namespace PluginMeta {
type Source = "file" | "npm" type Source = "file" | "npm"
export type Theme = {
src: string
dest: string
mtime?: number
size?: number
}
export type Entry = { export type Entry = {
id: string id: string
source: Source source: Source
@ -24,6 +31,7 @@ export namespace PluginMeta {
time_changed: number time_changed: number
load_count: number load_count: number
fingerprint: string fingerprint: string
themes?: Record<string, Theme>
} }
export type State = "first" | "updated" | "same" export type State = "first" | "updated" | "same"
@ -35,7 +43,7 @@ export namespace PluginMeta {
} }
type Store = Record<string, Entry> type Store = Record<string, Entry>
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint"> type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint" | "themes">
type Row = Touch & { core: Core } type Row = Touch & { core: Core }
function storePath() { function storePath() {
@ -52,11 +60,11 @@ export namespace PluginMeta {
return return
} }
function modifiedAt(file: string) { async function modifiedAt(file: string) {
const stat = Filesystem.stat(file) const stat = await Filesystem.statAsync(file)
if (!stat) return if (!stat) return
const value = stat.mtimeMs const mtime = stat.mtimeMs
return Math.floor(typeof value === "bigint" ? Number(value) : value) return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime)
} }
function resolvedTarget(target: string) { function resolvedTarget(target: string) {
@ -66,7 +74,7 @@ export namespace PluginMeta {
async function npmVersion(target: string) { async function npmVersion(target: string) {
const resolved = resolvedTarget(target) const resolved = resolvedTarget(target)
const stat = Filesystem.stat(resolved) const stat = await Filesystem.statAsync(resolved)
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved) const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json")) return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
.then((item) => item.version) .then((item) => item.version)
@ -84,7 +92,7 @@ export namespace PluginMeta {
source, source,
spec, spec,
target, target,
modified: file ? modifiedAt(file) : undefined, modified: file ? await modifiedAt(file) : undefined,
} }
} }
@ -122,6 +130,7 @@ export namespace PluginMeta {
time_changed: prev?.time_changed ?? now, time_changed: prev?.time_changed ?? now,
load_count: (prev?.load_count ?? 0) + 1, load_count: (prev?.load_count ?? 0) + 1,
fingerprint: fingerprint(core), fingerprint: fingerprint(core),
themes: prev?.themes,
} }
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated" const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
if (state === "updated") entry.time_changed = now if (state === "updated") entry.time_changed = now
@ -158,6 +167,20 @@ export namespace PluginMeta {
}) })
} }
export async function setTheme(id: string, name: string, theme: Theme): Promise<void> {
const file = storePath()
await Flock.withLock(lock(file), async () => {
const store = await read(file)
const entry = store[id]
if (!entry) return
entry.themes = {
...(entry.themes ?? {}),
[name]: theme,
}
await Filesystem.writeJson(file, store)
})
}
export async function list(): Promise<Store> { export async function list(): Promise<Store> {
const file = storePath() const file = storePath()
return Flock.withLock(lock(file), async () => read(file)) return Flock.withLock(lock(file), async () => read(file))

View File

@ -1,4 +1,4 @@
import { chmod, mkdir, readFile, writeFile } from "fs/promises" import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises"
import { createWriteStream, existsSync, statSync } from "fs" import { createWriteStream, existsSync, statSync } from "fs"
import { lookup } from "mime-types" import { lookup } from "mime-types"
import { realpathSync } from "fs" import { realpathSync } from "fs"
@ -25,6 +25,13 @@ export namespace Filesystem {
return statSync(p, { throwIfNoEntry: false }) ?? undefined return statSync(p, { throwIfNoEntry: false }) ?? undefined
} }
export async function statAsync(p: string): Promise<ReturnType<typeof statSync> | undefined> {
return statFile(p).catch((e) => {
if (isEnoent(e)) return undefined
throw e
})
}
export async function size(p: string): Promise<number> { export async function size(p: string): Promise<number> {
const s = stat(p)?.size ?? 0 const s = stat(p)?.size ?? 0
return typeof s === "bigint" ? Number(s) : s return typeof s === "bigint" ? Number(s) : s

View File

@ -561,3 +561,106 @@ describe("tui.plugin.loader", () => {
expect(data.leaked_global_to_local).toBe(false) expect(data.leaked_global_to_local).toBe(false)
}) })
}) })
test("updates installed theme when plugin metadata changes", async () => {
await using tmp = await tmpdir<{
spec: string
pluginPath: string
themePath: string
dest: string
themeName: string
}>({
init: async (dir) => {
const pluginPath = path.join(dir, "theme-update-plugin.ts")
const spec = pathToFileURL(pluginPath).href
const themeFile = "theme-update.json"
const themePath = path.join(dir, themeFile)
const dest = path.join(dir, ".opencode", "themes", themeFile)
const themeName = themeFile.replace(/\.json$/, "")
const configPath = path.join(dir, "tui.json")
await Bun.write(themePath, JSON.stringify({ theme: { primary: "#111111" } }, null, 2))
await Bun.write(
pluginPath,
`export default {
id: "demo.theme-update",
tui: async (api, options) => {
if (!options?.theme_path) return
await api.theme.install(options.theme_path)
},
}
`,
)
await Bun.write(
configPath,
JSON.stringify(
{
plugin: [[spec, { theme_path: `./${themeFile}` }]],
},
null,
2,
),
)
return {
spec,
pluginPath,
themePath,
dest,
themeName,
}
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const install = spyOn(Config, "installDependencies").mockResolvedValue()
const api = () =>
createTuiPluginApi({
theme: {
has(name) {
return allThemes()[name] !== undefined
},
},
})
try {
await TuiPluginRuntime.init(api())
await TuiPluginRuntime.dispose()
await expect(fs.readFile(tmp.extra.dest, "utf8")).resolves.toContain("#111111")
await Bun.write(tmp.extra.themePath, JSON.stringify({ theme: { primary: "#222222" } }, null, 2))
await Bun.write(
tmp.extra.pluginPath,
`export default {
id: "demo.theme-update",
tui: async (api, options) => {
if (!options?.theme_path) return
await api.theme.install(options.theme_path)
},
}
// v2
`,
)
const stamp = new Date(Date.now() + 10_000)
await fs.utimes(tmp.extra.pluginPath, stamp, stamp)
await fs.utimes(tmp.extra.themePath, stamp, stamp)
await TuiPluginRuntime.init(api())
const text = await fs.readFile(tmp.extra.dest, "utf8")
expect(text).toContain("#222222")
expect(text).not.toContain("#111111")
const list = await Filesystem.readJson<Record<string, { themes?: Record<string, { dest: string }> }>>(
process.env.OPENCODE_PLUGIN_META_FILE!,
)
expect(list["demo.theme-update"]?.themes?.[tmp.extra.themeName]?.dest).toBe(tmp.extra.dest)
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
install.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})