diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 5865e64366..7f1b4c5828 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -1,6 +1,6 @@ import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core" import path from "path" -import { createEffect, createMemo, onCleanup, onMount } from "solid-js" +import { createEffect, createMemo, onMount } from "solid-js" import { createSimpleContext } from "./helper" import { Glob } from "../../../../util/glob" import aura from "./theme/aura.json" with { type: "json" } @@ -138,44 +138,6 @@ type ThemeJson = { } } -type ThemeRegistry = { - themes: Record - listeners: Set<(themes: Record) => void> -} - -const registry: ThemeRegistry = { - themes: {}, - listeners: new Set(), -} - -export function registerThemes(themes: Record) { - const entries = Object.entries(themes).filter((entry): entry is [string, ThemeJson] => { - const theme = entry[1] - if (!theme || typeof theme !== "object") return false - if (!("theme" in theme)) return false - return true - }) - if (entries.length === 0) return - - for (const [name, theme] of entries) { - registry.themes[name] = theme - } - - const payload = Object.fromEntries(entries) - for (const handler of registry.listeners) { - handler(payload) - } -} - -function registeredThemes() { - return registry.themes -} - -function onThemes(handler: (themes: Record) => void) { - registry.listeners.add(handler) - return () => registry.listeners.delete(handler) -} - export const DEFAULT_THEMES: Record = { aura, ayu, @@ -212,6 +174,46 @@ export const DEFAULT_THEMES: Record = { carbonfox, } +type State = { + themes: Record + mode: "dark" | "light" + active: string + ready: boolean +} + +const [store, setStore] = createStore({ + themes: DEFAULT_THEMES, + mode: "dark", + active: "opencode", + ready: false, +}) + +function mergeThemes(themes: Record) { + setStore( + produce((draft) => { + for (const [name, theme] of Object.entries(themes)) { + if (draft.themes[name]) continue + draft.themes[name] = theme + } + }), + ) +} + +export function allThemes() { + return store.themes +} + +export function registerThemes(themes: Record) { + const list = Object.entries(themes).filter((entry): entry is [string, ThemeJson] => { + const theme = entry[1] + if (!theme || typeof theme !== "object") return false + if (!("theme" in theme)) return false + return true + }) + if (!list.length) return + mergeThemes(Object.fromEntries(list)) +} + function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { const defs = theme.defs ?? {} function resolveColor(c: ColorValue): RGBA { @@ -320,12 +322,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ init: (props: { mode: "dark" | "light" }) => { const config = useTuiConfig() const kv = useKV() - const [store, setStore] = createStore({ - themes: DEFAULT_THEMES, - mode: kv.get("theme_mode", props.mode), - active: (config.theme ?? kv.get("theme", "opencode")) as string, - ready: false, - }) + + setStore( + produce((draft) => { + draft.mode = kv.get("theme_mode", props.mode) + draft.active = (config.theme ?? kv.get("theme", "opencode")) as string + draft.ready = false + }), + ) createEffect(() => { const theme = config.theme @@ -334,7 +338,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ function init() { resolveSystemTheme() - mergeThemes(registeredThemes()) getCustomThemes() .then((custom) => { setStore( @@ -354,22 +357,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ } onMount(init) - onCleanup( - onThemes((themes) => { - mergeThemes(themes) - }), - ) - - function mergeThemes(themes: Record) { - setStore( - produce((draft) => { - for (const [name, theme] of Object.entries(themes)) { - if (draft.themes[name]) continue - draft.themes[name] = theme - } - }), - ) - } function resolveSystemTheme() { console.log("resolveSystemTheme") @@ -425,7 +412,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ return store.active }, all() { - return store.themes + return allThemes() }, syntax, subtleSyntax, diff --git a/packages/opencode/test/cli/tui/theme-store.test.ts b/packages/opencode/test/cli/tui/theme-store.test.ts new file mode 100644 index 0000000000..abcbe738ad --- /dev/null +++ b/packages/opencode/test/cli/tui/theme-store.test.ts @@ -0,0 +1,39 @@ +import { expect, mock, test } from "bun:test" + +mock.module("@opentui/solid/jsx-runtime", () => ({ + Fragment: Symbol.for("Fragment"), + jsx: () => null, + jsxs: () => null, + jsxDEV: () => null, +})) + +const { DEFAULT_THEMES, allThemes, registerThemes } = await import("../../../src/cli/cmd/tui/context/theme") + +test("registerThemes writes into module theme store", () => { + const name = `plugin-theme-${Date.now()}` + registerThemes({ + [name]: DEFAULT_THEMES.opencode, + }) + + expect(allThemes()[name]).toBeDefined() +}) + +test("registerThemes keeps first theme for duplicate names", () => { + const name = `plugin-theme-keep-${Date.now()}` + const one = structuredClone(DEFAULT_THEMES.opencode) + const two = structuredClone(DEFAULT_THEMES.opencode) + ;(one.theme as Record).primary = "#101010" + ;(two.theme as Record).primary = "#fefefe" + + registerThemes({ [name]: one }) + registerThemes({ [name]: two }) + + expect(allThemes()[name]).toBeDefined() + expect(allThemes()[name]!.theme.primary).toBe("#101010") +}) + +test("registerThemes ignores entries without a theme object", () => { + const name = `plugin-theme-invalid-${Date.now()}` + registerThemes({ [name]: { defs: { a: "#ffffff" } } }) + expect(allThemes()[name]).toBeUndefined() +})