Skip to content

Commit 8e4bab5

Browse files
authored
update plugin themes when plugin was updated (anomalyco#20052)
1 parent 3c32013 commit 8e4bab5

6 files changed

Lines changed: 237 additions & 38 deletions

File tree

packages/opencode/specs/tui-plugins.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,9 @@ Theme install behavior:
269269

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

packages/opencode/src/cli/cmd/tui/context/theme.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,18 @@ export function addTheme(name: string, theme: unknown) {
183183
return true
184184
}
185185

186+
export function upsertTheme(name: string, theme: unknown) {
187+
if (!name) return false
188+
if (!isTheme(theme)) return false
189+
if (customThemes[name] !== undefined) {
190+
customThemes[name] = theme
191+
} else {
192+
pluginThemes[name] = theme
193+
}
194+
syncThemes()
195+
return true
196+
}
197+
186198
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
187199
const defs = theme.defs ?? {}
188200
function resolveColor(c: ColorValue, chain: string[] = []): RGBA {

packages/opencode/src/cli/cmd/tui/plugin/runtime.ts

Lines changed: 81 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
} from "@/plugin/shared"
3232
import { PluginMeta } from "@/plugin/meta"
3333
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
34-
import { addTheme, hasTheme } from "../context/theme"
34+
import { hasTheme, upsertTheme } from "../context/theme"
3535
import { Global } from "@/global"
3636
import { Filesystem } from "@/util/filesystem"
3737
import { Process } from "@/util/process"
@@ -49,7 +49,8 @@ type PluginLoad = {
4949
source: PluginSource | "internal"
5050
id: string
5151
module: TuiPluginModule
52-
install_theme: TuiTheme["install"]
52+
theme_meta: TuiConfig.PluginMeta
53+
theme_root: string
5354
}
5455

5556
type Api = HostPluginApi
@@ -64,6 +65,7 @@ type PluginEntry = {
6465
id: string
6566
load: PluginLoad
6667
meta: TuiPluginMeta
68+
themes: Record<string, PluginMeta.Theme>
6769
plugin: TuiPlugin
6870
options: Config.PluginOptions | undefined
6971
enabled: boolean
@@ -143,12 +145,54 @@ function resolveRoot(root: string) {
143145
return path.resolve(process.cwd(), root)
144146
}
145147

146-
function createThemeInstaller(meta: TuiConfig.PluginMeta, root: string, spec: string): TuiTheme["install"] {
148+
function createThemeInstaller(
149+
meta: TuiConfig.PluginMeta,
150+
root: string,
151+
spec: string,
152+
plugin: PluginEntry,
153+
): TuiTheme["install"] {
147154
return async (file) => {
148155
const raw = file.startsWith("file://") ? fileURLToPath(file) : file
149156
const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw)
150-
const theme = path.basename(src, path.extname(src))
151-
if (hasTheme(theme)) return
157+
const name = path.basename(src, path.extname(src))
158+
const source_dir = path.dirname(meta.source)
159+
const local_dir =
160+
path.basename(source_dir) === ".opencode"
161+
? path.join(source_dir, "themes")
162+
: path.join(source_dir, ".opencode", "themes")
163+
const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
164+
const dest = path.join(dest_dir, `${name}.json`)
165+
const stat = await Filesystem.statAsync(src)
166+
const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined
167+
const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined
168+
const exists = hasTheme(name)
169+
const prev = plugin.themes[name]
170+
171+
if (exists) {
172+
if (plugin.meta.state !== "updated") return
173+
if (!prev) {
174+
if (await Filesystem.exists(dest)) {
175+
plugin.themes[name] = {
176+
src,
177+
dest,
178+
mtime,
179+
size,
180+
}
181+
await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
182+
log.warn("failed to track tui plugin theme", {
183+
path: spec,
184+
id: plugin.id,
185+
theme: src,
186+
dest,
187+
error,
188+
})
189+
})
190+
}
191+
return
192+
}
193+
if (prev.dest !== dest) return
194+
if (prev.mtime === mtime && prev.size === size) return
195+
}
152196

153197
const text = await Filesystem.readText(src).catch((error) => {
154198
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
170214
return
171215
}
172216

173-
const source_dir = path.dirname(meta.source)
174-
const local_dir =
175-
path.basename(source_dir) === ".opencode"
176-
? path.join(source_dir, "themes")
177-
: path.join(source_dir, ".opencode", "themes")
178-
const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
179-
const dest = path.join(dest_dir, `${theme}.json`)
180-
if (!(await Filesystem.exists(dest))) {
217+
if (exists || !(await Filesystem.exists(dest))) {
181218
await Filesystem.write(dest, text).catch((error) => {
182219
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
183220
})
184221
}
185222

186-
addTheme(theme, data)
223+
upsertTheme(name, data)
224+
plugin.themes[name] = {
225+
src,
226+
dest,
227+
mtime,
228+
size,
229+
}
230+
await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
231+
log.warn("failed to track tui plugin theme", {
232+
path: spec,
233+
id: plugin.id,
234+
theme: src,
235+
dest,
236+
error,
237+
})
238+
})
187239
}
188240
}
189241

@@ -222,7 +274,6 @@ async function loadExternalPlugin(
222274
}
223275

224276
const root = resolveRoot(source === "file" ? spec : target)
225-
const install_theme = createThemeInstaller(meta, root, spec)
226277
const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => {
227278
fail("failed to resolve tui plugin entry", { path: spec, target, retry, error })
228279
return
@@ -253,7 +304,8 @@ async function loadExternalPlugin(
253304
source,
254305
id,
255306
module: mod,
256-
install_theme,
307+
theme_meta: meta,
308+
theme_root: root,
257309
}
258310
}
259311

@@ -297,14 +349,11 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
297349
source: "internal",
298350
id: item.id,
299351
module: item,
300-
install_theme: createThemeInstaller(
301-
{
302-
scope: "global",
303-
source: target,
304-
},
305-
process.cwd(),
306-
spec,
307-
),
352+
theme_meta: {
353+
scope: "global",
354+
source: target,
355+
},
356+
theme_root: process.cwd(),
308357
}
309358
}
310359

@@ -436,7 +485,7 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
436485
if (plugin.scope) return true
437486

438487
const scope = createPluginScope(plugin.load, plugin.id)
439-
const api = pluginApi(state, plugin.load, scope, plugin.id)
488+
const api = pluginApi(state, plugin, scope, plugin.id)
440489
const ok = await Promise.resolve()
441490
.then(async () => {
442491
await plugin.plugin(api, plugin.options, plugin.meta)
@@ -479,9 +528,10 @@ async function deactivatePluginById(state: RuntimeState | undefined, id: string,
479528
return deactivatePluginEntry(state, plugin, persist)
480529
}
481530

482-
function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope, base: string): TuiPluginApi {
531+
function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScope, base: string): TuiPluginApi {
483532
const api = runtime.api
484533
const host = runtime.slots
534+
const load = plugin.load
485535
const command: TuiPluginApi["command"] = {
486536
register(cb) {
487537
return scope.track(api.command.register(cb))
@@ -504,7 +554,7 @@ function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope,
504554
}
505555

506556
const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
507-
install: load.install_theme,
557+
install: createThemeInstaller(load.theme_meta, load.theme_root, load.spec, plugin),
508558
})
509559

510560
const event: TuiPluginApi["event"] = {
@@ -563,13 +613,14 @@ function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope,
563613
}
564614
}
565615

566-
function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta) {
616+
function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta, themes: Record<string, PluginMeta.Theme> = {}) {
567617
const options = load.item ? Config.pluginOptions(load.item) : undefined
568618
return [
569619
{
570620
id: load.id,
571621
load,
572622
meta,
623+
themes,
573624
plugin: load.module.tui,
574625
options,
575626
enabled: true,
@@ -661,7 +712,8 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
661712
}
662713

663714
const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
664-
for (const plugin of collectPluginEntries(entry, row)) {
715+
const themes = hit?.entry.themes ? { ...hit.entry.themes } : {}
716+
for (const plugin of collectPluginEntries(entry, row, themes)) {
665717
if (!addPluginEntry(state, plugin)) {
666718
ok = false
667719
continue

packages/opencode/src/plugin/meta.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ import { parsePluginSpecifier, pluginSource } from "./shared"
1111
export namespace PluginMeta {
1212
type Source = "file" | "npm"
1313

14+
export type Theme = {
15+
src: string
16+
dest: string
17+
mtime?: number
18+
size?: number
19+
}
20+
1421
export type Entry = {
1522
id: string
1623
source: Source
@@ -24,6 +31,7 @@ export namespace PluginMeta {
2431
time_changed: number
2532
load_count: number
2633
fingerprint: string
34+
themes?: Record<string, Theme>
2735
}
2836

2937
export type State = "first" | "updated" | "same"
@@ -35,7 +43,7 @@ export namespace PluginMeta {
3543
}
3644

3745
type Store = Record<string, Entry>
38-
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint">
46+
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint" | "themes">
3947
type Row = Touch & { core: Core }
4048

4149
function storePath() {
@@ -52,11 +60,11 @@ export namespace PluginMeta {
5260
return
5361
}
5462

55-
function modifiedAt(file: string) {
56-
const stat = Filesystem.stat(file)
63+
async function modifiedAt(file: string) {
64+
const stat = await Filesystem.statAsync(file)
5765
if (!stat) return
58-
const value = stat.mtimeMs
59-
return Math.floor(typeof value === "bigint" ? Number(value) : value)
66+
const mtime = stat.mtimeMs
67+
return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime)
6068
}
6169

6270
function resolvedTarget(target: string) {
@@ -66,7 +74,7 @@ export namespace PluginMeta {
6674

6775
async function npmVersion(target: string) {
6876
const resolved = resolvedTarget(target)
69-
const stat = Filesystem.stat(resolved)
77+
const stat = await Filesystem.statAsync(resolved)
7078
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
7179
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
7280
.then((item) => item.version)
@@ -84,7 +92,7 @@ export namespace PluginMeta {
8492
source,
8593
spec,
8694
target,
87-
modified: file ? modifiedAt(file) : undefined,
95+
modified: file ? await modifiedAt(file) : undefined,
8896
}
8997
}
9098

@@ -122,6 +130,7 @@ export namespace PluginMeta {
122130
time_changed: prev?.time_changed ?? now,
123131
load_count: (prev?.load_count ?? 0) + 1,
124132
fingerprint: fingerprint(core),
133+
themes: prev?.themes,
125134
}
126135
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
127136
if (state === "updated") entry.time_changed = now
@@ -158,6 +167,20 @@ export namespace PluginMeta {
158167
})
159168
}
160169

170+
export async function setTheme(id: string, name: string, theme: Theme): Promise<void> {
171+
const file = storePath()
172+
await Flock.withLock(lock(file), async () => {
173+
const store = await read(file)
174+
const entry = store[id]
175+
if (!entry) return
176+
entry.themes = {
177+
...(entry.themes ?? {}),
178+
[name]: theme,
179+
}
180+
await Filesystem.writeJson(file, store)
181+
})
182+
}
183+
161184
export async function list(): Promise<Store> {
162185
const file = storePath()
163186
return Flock.withLock(lock(file), async () => read(file))

packages/opencode/src/util/filesystem.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { chmod, mkdir, readFile, writeFile } from "fs/promises"
1+
import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises"
22
import { createWriteStream, existsSync, statSync } from "fs"
33
import { lookup } from "mime-types"
44
import { realpathSync } from "fs"
@@ -25,6 +25,13 @@ export namespace Filesystem {
2525
return statSync(p, { throwIfNoEntry: false }) ?? undefined
2626
}
2727

28+
export async function statAsync(p: string): Promise<ReturnType<typeof statSync> | undefined> {
29+
return statFile(p).catch((e) => {
30+
if (isEnoent(e)) return undefined
31+
throw e
32+
})
33+
}
34+
2835
export async function size(p: string): Promise<number> {
2936
const s = stat(p)?.size ?? 0
3037
return typeof s === "bigint" ? Number(s) : s

0 commit comments

Comments
 (0)