|
|
@@ -6,6 +6,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
|
|
import type { CliRenderer } from "@opentui/core"
|
|
|
import { tmpdir } from "../../fixture/fixture"
|
|
|
import { Log } from "../../../src/util/log"
|
|
|
+import { Global } from "../../../src/global"
|
|
|
|
|
|
mock.module("@opentui/solid/preload", () => ({}))
|
|
|
mock.module("@opentui/solid/jsx-runtime", () => ({
|
|
|
@@ -14,6 +15,7 @@ mock.module("@opentui/solid/jsx-runtime", () => ({
|
|
|
jsxs: () => null,
|
|
|
jsxDEV: () => null,
|
|
|
}))
|
|
|
+const { allThemes } = await import("../../../src/cli/cmd/tui/context/theme")
|
|
|
const { TuiPlugin } = await import("../../../src/cli/cmd/tui/plugin")
|
|
|
|
|
|
async function waitForLog(text: string, timeout = 1000) {
|
|
|
@@ -33,16 +35,35 @@ async function waitForLog(text: string, timeout = 1000) {
|
|
|
.catch(() => "")
|
|
|
}
|
|
|
|
|
|
-test("ignores function-only tui exports and loads object exports", async () => {
|
|
|
+test("loads plugin theme API with scoped theme installation", async () => {
|
|
|
+ const stamp = Date.now()
|
|
|
+ const globalConfigPath = path.join(Global.Path.config, "tui.json")
|
|
|
+ const backup = await Bun.file(globalConfigPath)
|
|
|
+ .text()
|
|
|
+ .catch(() => undefined)
|
|
|
+
|
|
|
await using tmp = await tmpdir({
|
|
|
init: async (dir) => {
|
|
|
- const pluginPath = path.join(dir, "plugin.ts")
|
|
|
+ const localPluginPath = path.join(dir, "local-plugin.ts")
|
|
|
+ const globalPluginPath = path.join(dir, "global-plugin.ts")
|
|
|
+ const localThemeFile = `local-theme-${stamp}.json`
|
|
|
+ const globalThemeFile = `global-theme-${stamp}.json`
|
|
|
+ const localThemeName = localThemeFile.replace(/\.json$/, "")
|
|
|
+ const globalThemeName = globalThemeFile.replace(/\.json$/, "")
|
|
|
+ const localThemePath = path.join(dir, localThemeFile)
|
|
|
+ const globalThemePath = path.join(dir, globalThemeFile)
|
|
|
+ const localDest = path.join(dir, ".opencode", "themes", localThemeFile)
|
|
|
+ const globalDest = path.join(Global.Path.config, "themes", globalThemeFile)
|
|
|
const fnMarker = path.join(dir, "function-called.txt")
|
|
|
- const objMarker = path.join(dir, "object-called.txt")
|
|
|
- const configPath = path.join(dir, "tui.json")
|
|
|
+ const localMarker = path.join(dir, "local-called.json")
|
|
|
+ const globalMarker = path.join(dir, "global-called.json")
|
|
|
+ const localConfigPath = path.join(dir, "tui.json")
|
|
|
+
|
|
|
+ await Bun.write(localThemePath, JSON.stringify({ theme: { primary: "#101010" } }, null, 2))
|
|
|
+ await Bun.write(globalThemePath, JSON.stringify({ theme: { primary: "#202020" } }, null, 2))
|
|
|
|
|
|
await Bun.write(
|
|
|
- pluginPath,
|
|
|
+ localPluginPath,
|
|
|
[
|
|
|
"export default async (_input, options) => {",
|
|
|
" if (!options?.fn_marker) return",
|
|
|
@@ -50,9 +71,21 @@ test("ignores function-only tui exports and loads object exports", async () => {
|
|
|
"}",
|
|
|
"",
|
|
|
"export const object_plugin = {",
|
|
|
- " tui: async (_input, options) => {",
|
|
|
- " if (!options?.obj_marker) return",
|
|
|
- " await Bun.write(options.obj_marker, 'called')",
|
|
|
+ " tui: async (input, options) => {",
|
|
|
+ " if (!options?.marker) return",
|
|
|
+ " const before = input.api.theme.has(options.theme_name)",
|
|
|
+ " const set_missing = input.api.theme.set(options.theme_name)",
|
|
|
+ " await input.api.theme.install(options.theme_path)",
|
|
|
+ " const after = input.api.theme.has(options.theme_name)",
|
|
|
+ " const set_installed = input.api.theme.set(options.theme_name)",
|
|
|
+ " const first = await Bun.file(options.dest).text()",
|
|
|
+ " await Bun.write(options.source, JSON.stringify({ theme: { primary: '#fefefe' } }, null, 2))",
|
|
|
+ " await input.api.theme.install(options.theme_path)",
|
|
|
+ " const second = await Bun.file(options.dest).text()",
|
|
|
+ " await Bun.write(",
|
|
|
+ " options.marker,",
|
|
|
+ " JSON.stringify({ before, set_missing, after, set_installed, selected: input.api.theme.selected, same: first === second }),",
|
|
|
+ " )",
|
|
|
" },",
|
|
|
"}",
|
|
|
"",
|
|
|
@@ -60,10 +93,54 @@ test("ignores function-only tui exports and loads object exports", async () => {
|
|
|
)
|
|
|
|
|
|
await Bun.write(
|
|
|
- configPath,
|
|
|
+ globalPluginPath,
|
|
|
+ [
|
|
|
+ "export default {",
|
|
|
+ " tui: async (input, options) => {",
|
|
|
+ " if (!options?.marker) return",
|
|
|
+ " await input.api.theme.install(options.theme_path)",
|
|
|
+ " const has = input.api.theme.has(options.theme_name)",
|
|
|
+ " const set_installed = input.api.theme.set(options.theme_name)",
|
|
|
+ " await Bun.write(options.marker, JSON.stringify({ has, set_installed, selected: input.api.theme.selected }))",
|
|
|
+ " },",
|
|
|
+ "}",
|
|
|
+ "",
|
|
|
+ ].join("\n"),
|
|
|
+ )
|
|
|
+
|
|
|
+ await Bun.write(
|
|
|
+ globalConfigPath,
|
|
|
+ JSON.stringify(
|
|
|
+ {
|
|
|
+ plugin: [
|
|
|
+ [
|
|
|
+ pathToFileURL(globalPluginPath).href,
|
|
|
+ { marker: globalMarker, theme_path: `./${globalThemeFile}`, theme_name: globalThemeName },
|
|
|
+ ],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ null,
|
|
|
+ 2,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ await Bun.write(
|
|
|
+ localConfigPath,
|
|
|
JSON.stringify(
|
|
|
{
|
|
|
- plugin: [[pathToFileURL(pluginPath).href, { fn_marker: fnMarker, obj_marker: objMarker }]],
|
|
|
+ plugin: [
|
|
|
+ [
|
|
|
+ pathToFileURL(localPluginPath).href,
|
|
|
+ {
|
|
|
+ fn_marker: fnMarker,
|
|
|
+ marker: localMarker,
|
|
|
+ source: localThemePath,
|
|
|
+ dest: localDest,
|
|
|
+ theme_path: `./${localThemeFile}`,
|
|
|
+ theme_name: localThemeName,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ ],
|
|
|
},
|
|
|
null,
|
|
|
2,
|
|
|
@@ -71,15 +148,21 @@ test("ignores function-only tui exports and loads object exports", async () => {
|
|
|
)
|
|
|
|
|
|
return {
|
|
|
- configPath,
|
|
|
+ localThemeFile,
|
|
|
+ globalThemeFile,
|
|
|
+ localThemeName,
|
|
|
+ globalThemeName,
|
|
|
+ localDest,
|
|
|
+ globalDest,
|
|
|
fnMarker,
|
|
|
- objMarker,
|
|
|
+ localMarker,
|
|
|
+ globalMarker,
|
|
|
}
|
|
|
},
|
|
|
})
|
|
|
|
|
|
- process.env.OPENCODE_TUI_CONFIG = tmp.extra.configPath
|
|
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
|
|
+ let selected = "opencode"
|
|
|
|
|
|
const renderer = {
|
|
|
...Object.create(null),
|
|
|
@@ -133,7 +216,18 @@ test("ignores function-only tui exports and loads object exports", async () => {
|
|
|
return {}
|
|
|
},
|
|
|
get selected() {
|
|
|
- return "opencode"
|
|
|
+ return selected
|
|
|
+ },
|
|
|
+ has(name) {
|
|
|
+ return allThemes()[name] !== undefined
|
|
|
+ },
|
|
|
+ set(name) {
|
|
|
+ if (!allThemes()[name]) return false
|
|
|
+ selected = name
|
|
|
+ return true
|
|
|
+ },
|
|
|
+ async install() {
|
|
|
+ throw new Error("base theme.install should not run")
|
|
|
},
|
|
|
mode() {
|
|
|
return "dark" as const
|
|
|
@@ -145,15 +239,52 @@ test("ignores function-only tui exports and loads object exports", async () => {
|
|
|
},
|
|
|
})
|
|
|
|
|
|
- expect(await fs.readFile(tmp.extra.objMarker, "utf8")).toBe("called")
|
|
|
+ const local = JSON.parse(await fs.readFile(tmp.extra.localMarker, "utf8"))
|
|
|
+ expect(local.before).toBe(false)
|
|
|
+ expect(local.set_missing).toBe(false)
|
|
|
+ expect(local.after).toBe(true)
|
|
|
+ expect(local.set_installed).toBe(true)
|
|
|
+ expect(local.selected).toBe(tmp.extra.localThemeName)
|
|
|
+ expect(local.same).toBe(true)
|
|
|
+
|
|
|
+ const global = JSON.parse(await fs.readFile(tmp.extra.globalMarker, "utf8"))
|
|
|
+ expect(global.has).toBe(true)
|
|
|
+ expect(global.set_installed).toBe(true)
|
|
|
+ expect(global.selected).toBe(tmp.extra.globalThemeName)
|
|
|
+
|
|
|
await expect(fs.readFile(tmp.extra.fnMarker, "utf8")).rejects.toThrow()
|
|
|
|
|
|
+ const localInstalled = await fs.readFile(tmp.extra.localDest, "utf8")
|
|
|
+ expect(localInstalled).toContain("#101010")
|
|
|
+ expect(localInstalled).not.toContain("#fefefe")
|
|
|
+
|
|
|
+ const globalInstalled = await fs.readFile(tmp.extra.globalDest, "utf8")
|
|
|
+ expect(globalInstalled).toContain("#202020")
|
|
|
+
|
|
|
+ expect(
|
|
|
+ await fs
|
|
|
+ .stat(path.join(Global.Path.config, "themes", tmp.extra.localThemeFile))
|
|
|
+ .then(() => true)
|
|
|
+ .catch(() => false),
|
|
|
+ ).toBe(false)
|
|
|
+ expect(
|
|
|
+ await fs
|
|
|
+ .stat(path.join(tmp.path, ".opencode", "themes", tmp.extra.globalThemeFile))
|
|
|
+ .then(() => true)
|
|
|
+ .catch(() => false),
|
|
|
+ ).toBe(false)
|
|
|
+
|
|
|
const log = await waitForLog("ignoring non-object tui plugin export")
|
|
|
expect(log).toContain("ignoring non-object tui plugin export")
|
|
|
expect(log).toContain("name=default")
|
|
|
expect(log).toContain("type=function")
|
|
|
} finally {
|
|
|
cwd.mockRestore()
|
|
|
- delete process.env.OPENCODE_TUI_CONFIG
|
|
|
+ if (backup === undefined) {
|
|
|
+ await fs.rm(globalConfigPath, { force: true })
|
|
|
+ } else {
|
|
|
+ await Bun.write(globalConfigPath, backup)
|
|
|
+ }
|
|
|
+ await fs.rm(tmp.extra.globalDest, { force: true }).catch(() => {})
|
|
|
}
|
|
|
})
|