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.
- 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.
- Global plugins persist installed themes under the global `themes` dir.
- Invalid or unreadable theme files are ignored.

View File

@ -183,6 +183,18 @@ export function addTheme(name: string, theme: unknown) {
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") {
const defs = theme.defs ?? {}
function resolveColor(c: ColorValue, chain: string[] = []): RGBA {

View File

@ -31,7 +31,7 @@ import {
} from "@/plugin/shared"
import { PluginMeta } from "@/plugin/meta"
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 { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
@ -49,7 +49,8 @@ type PluginLoad = {
source: PluginSource | "internal"
id: string
module: TuiPluginModule
install_theme: TuiTheme["install"]
theme_meta: TuiConfig.PluginMeta
theme_root: string
}
type Api = HostPluginApi
@ -64,6 +65,7 @@ type PluginEntry = {
id: string
load: PluginLoad
meta: TuiPluginMeta
themes: Record<string, PluginMeta.Theme>
plugin: TuiPlugin
options: Config.PluginOptions | undefined
enabled: boolean
@ -143,12 +145,54 @@ function resolveRoot(root: string) {
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) => {
const raw = file.startsWith("file://") ? fileURLToPath(file) : file
const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw)
const theme = path.basename(src, path.extname(src))
if (hasTheme(theme)) return
const name = path.basename(src, path.extname(src))
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) => {
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
}
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, `${theme}.json`)
if (!(await Filesystem.exists(dest))) {
if (exists || !(await Filesystem.exists(dest))) {
await Filesystem.write(dest, text).catch((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 install_theme = createThemeInstaller(meta, root, spec)
const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => {
fail("failed to resolve tui plugin entry", { path: spec, target, retry, error })
return
@ -253,7 +304,8 @@ async function loadExternalPlugin(
source,
id,
module: mod,
install_theme,
theme_meta: meta,
theme_root: root,
}
}
@ -297,14 +349,11 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
source: "internal",
id: item.id,
module: item,
install_theme: createThemeInstaller(
{
scope: "global",
source: target,
},
process.cwd(),
spec,
),
theme_meta: {
scope: "global",
source: target,
},
theme_root: process.cwd(),
}
}
@ -436,7 +485,7 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
if (plugin.scope) return true
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()
.then(async () => {
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)
}
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 host = runtime.slots
const load = plugin.load
const command: TuiPluginApi["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), {
install: load.install_theme,
install: createThemeInstaller(load.theme_meta, load.theme_root, load.spec, plugin),
})
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
return [
{
id: load.id,
load,
meta,
themes,
plugin: load.module.tui,
options,
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)
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)) {
ok = false
continue

View File

@ -11,6 +11,13 @@ import { parsePluginSpecifier, pluginSource } from "./shared"
export namespace PluginMeta {
type Source = "file" | "npm"
export type Theme = {
src: string
dest: string
mtime?: number
size?: number
}
export type Entry = {
id: string
source: Source
@ -24,6 +31,7 @@ export namespace PluginMeta {
time_changed: number
load_count: number
fingerprint: string
themes?: Record<string, Theme>
}
export type State = "first" | "updated" | "same"
@ -35,7 +43,7 @@ export namespace PluginMeta {
}
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 }
function storePath() {
@ -52,11 +60,11 @@ export namespace PluginMeta {
return
}
function modifiedAt(file: string) {
const stat = Filesystem.stat(file)
async function modifiedAt(file: string) {
const stat = await Filesystem.statAsync(file)
if (!stat) return
const value = stat.mtimeMs
return Math.floor(typeof value === "bigint" ? Number(value) : value)
const mtime = stat.mtimeMs
return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime)
}
function resolvedTarget(target: string) {
@ -66,7 +74,7 @@ export namespace PluginMeta {
async function npmVersion(target: string) {
const resolved = resolvedTarget(target)
const stat = Filesystem.stat(resolved)
const stat = await Filesystem.statAsync(resolved)
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
.then((item) => item.version)
@ -84,7 +92,7 @@ export namespace PluginMeta {
source,
spec,
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,
load_count: (prev?.load_count ?? 0) + 1,
fingerprint: fingerprint(core),
themes: prev?.themes,
}
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
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> {
const file = storePath()
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 { lookup } from "mime-types"
import { realpathSync } from "fs"
@ -25,6 +25,13 @@ export namespace Filesystem {
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> {
const s = stat(p)?.size ?? 0
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)
})
})
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
}
})