Explorar el Código

update plugin themes when plugin was updated (#20052)

Sebastian hace 4 meses
padre
commit
8e4bab5181

+ 3 - 1
packages/opencode/specs/tui-plugins.md

@@ -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.

+ 12 - 0
packages/opencode/src/cli/cmd/tui/context/theme.tsx

@@ -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 {

+ 81 - 29
packages/opencode/src/cli/cmd/tui/plugin/runtime.ts

@@ -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

+ 30 - 7
packages/opencode/src/plugin/meta.ts

@@ -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))

+ 8 - 1
packages/opencode/src/util/filesystem.ts

@@ -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

+ 103 - 0
packages/opencode/test/cli/tui/plugin-loader.test.ts

@@ -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
+  }
+})