feat(themes): Solarized and One theme plugins (#92)

Solarized Light/Dark and One Light/Dark ship as theme plugins (plugins/theme-solarized, plugins/theme-one) with complete UI tokens, editor schemes and terminal colours, and the original palettes' MIT notices. Installing a plugin now also copies its LICENSE/NOTICE/COPYING.

Theme handling that benefits every plugin theme:
- The last plugin theme is restored before first paint (splash, workbench); the native window background follows the theme.
- HSL / color-mix() / var() colours are resolved to RGB for native windows, the Settings window and Mermaid.
- Mermaid, the terminal and editor schemes follow switches between two themes of the same kind.
- An explicitly chosen editor scheme wins over a plugin theme's editor colours.

Supersedes #85.

Co-authored-by: bghtyu <[email protected]>
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
l0ng-aiтаbghtyu authored and GitHub committed 2026-10-27 17:53:07 +08:00
1 джерело 9d8280104f
коміт 24330192c0
43 files changed
+2035 -180

No files matched your search

+3 -3
Переглянути файл
@@ -144,7 +144,7 @@ describe("store", () => {
it("selects and clears a plugin theme", async () => {
await refreshPlugins();
await selectPluginTheme("theme-solarized-dark", "solarized-dark");
await selectPluginTheme("theme-solarized", "solarized-dark");
expect(usePlugins.getState().activeTheme?.theme.id).toBe("solarized-dark");
await selectPluginTheme(null, null);
expect(usePlugins.getState().activeTheme).toBeNull();
@@ -238,7 +238,7 @@ describe("PluginsPage", () => {
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Enable Hello" })).toBeInTheDocument());
expect(screen.getByRole("button", { name: "Run Hello: Insert Date" })).toBeDisabled();
await user.click(within(list).getByText("Solarized Dark Theme"));
await user.click(within(list).getByText("Solarized Themes"));
await user.click(screen.getByRole("button", { name: "Apply theme Solarized Dark" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Use Default Theme" })).toBeInTheDocument());
@@ -309,7 +309,7 @@ describe("catalog helpers", () => {
expect(catalogState(entry, [plugin({ id: "hello", version: "0.0.9" })])).toBe("update");
expect(catalogState(entry, [plugin({ id: "hello", version: "0.1.0" })])).toBe("installed");
expect(catalogState(entry, [plugin({ id: "hello", source: "dev" })])).toBe("dev");
expect(filterCatalog(sampleCatalog(), "solarized").map((c) => c.id)).toEqual(["theme-solarized-dark"]);
expect(filterCatalog(sampleCatalog(), "solarized").map((c) => c.id)).toEqual(["theme-solarized"]);
});
it("disables and re-enables all downloaded plugins, leaving dev plugins alone", async () => {
+153
Переглянути файл
@@ -0,0 +1,153 @@
import { readFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ActiveTheme } from "@/bindings";
import { STARTUP_THEME_KEY, syncWindowBackground } from "@/workbench/theme";
import { createMemoryPluginsBackend, setPluginsBackend, type PluginsBackend } from "../backend";
import {
applyPluginTheme,
PLUGIN_THEME_CACHE_KEY,
PLUGIN_THEME_STYLE_ID,
trackPluginTheme,
type CachedPluginTheme,
} from "../bridge";
import { usePlugins } from "../store";
const BOOT_JS = readFileSync("public/boot.js", "utf8");
/** Runs public/boot.js as the next window would, before any app code. */
const boot = () => new Function(BOOT_JS)();
const ONE_DARK: ActiveTheme = {
pluginId: "theme-one",
theme: {
id: "one-dark",
name: "One Dark",
kind: "dark",
tokens: {
"--bg-frame": "hsl(220, 13%, 15%)",
"--fg-muted": "hsl(220, 9%, 55%)",
"--accent": "hsl(220, 100%, 66%)",
},
},
};
const SOLARIZED_LIGHT: ActiveTheme = {
pluginId: "theme-solarized",
theme: { id: "solarized-light", name: "Solarized Light", kind: "light", tokens: { "--bg-frame": "#eee8d5" } },
};
/** Computed style as a browser would resolve the active plugin theme. */
function computedColors(values: Record<string, string>) {
return vi.spyOn(globalThis, "getComputedStyle").mockImplementation(
() =>
({
getPropertyValue: (name: string) => values[name] ?? "",
color: "",
}) as unknown as CSSStyleDeclaration,
);
}
function resetRoot() {
const root = document.documentElement;
for (const key of Object.keys(root.dataset)) delete root.dataset[key];
document.getElementById(PLUGIN_THEME_STYLE_ID)?.remove();
document.getElementById("openidea-boot-colors")?.remove();
}
let backend: PluginsBackend;
beforeEach(() => {
localStorage.clear();
resetRoot();
backend = createMemoryPluginsBackend();
setPluginsBackend(backend);
usePlugins.setState({ plugins: [], activeTheme: null, loaded: false, error: null });
});
afterEach(() => {
vi.restoreAllMocks();
applyPluginTheme(null);
setPluginsBackend(null);
localStorage.clear();
resetRoot();
});
describe("plugin theme at start-up", () => {
it("restores the last plugin theme and splash colours before the app loads", () => {
const style = computedColors({ "--bg-frame": "#21252b", "--fg-muted": "#828997", "--accent": "#5c8aff" });
const stop = trackPluginTheme();
usePlugins.setState({ loaded: true, activeTheme: ONE_DARK });
syncWindowBackground();
stop();
style.mockRestore();
resetRoot();
boot();
const root = document.documentElement;
expect(root.dataset.pluginTheme).toBe("theme-one/one-dark");
expect(root.dataset.pluginThemeKind).toBe("dark");
expect(root.dataset.theme).toBe("dark");
expect(document.getElementById(PLUGIN_THEME_STYLE_ID)?.textContent).toContain("--bg-frame: hsl(220, 13%, 15%);");
expect(document.getElementById("openidea-boot-colors")?.textContent).toBe(
":root { --boot-frame: #21252b; --boot-fg: #828997; --boot-accent: #5c8aff; }",
);
});
it("keeps the restored theme until the plugin list arrives, then follows it", () => {
const cached: CachedPluginTheme = {
id: "theme-one/one-dark",
kind: "dark",
css: ':root[data-plugin-theme]:not([data-plugin-theme=""]) {\n --bg-frame: #21252b;\n}\n',
};
localStorage.setItem(PLUGIN_THEME_CACHE_KEY, JSON.stringify(cached));
boot();
const root = document.documentElement;
const stop = trackPluginTheme();
// Nothing listed yet: the boot theme stays.
expect(root.dataset.pluginTheme).toBe("theme-one/one-dark");
expect(document.getElementById(PLUGIN_THEME_STYLE_ID)).not.toBeNull();
// A failed listing doesn't drop it either.
usePlugins.setState({ loaded: true, error: "backend unavailable" });
expect(root.dataset.pluginTheme).toBe("theme-one/one-dark");
usePlugins.setState({ error: null, activeTheme: SOLARIZED_LIGHT });
expect(root.dataset.pluginTheme).toBe("theme-solarized/solarized-light");
expect(root.dataset.theme).toBe("light");
expect(JSON.parse(localStorage.getItem(PLUGIN_THEME_CACHE_KEY)!)).toMatchObject({
id: "theme-solarized/solarized-light",
kind: "light",
});
// The plugin was disabled or uninstalled meanwhile.
usePlugins.setState({ activeTheme: null });
expect(root.dataset.pluginTheme).toBeUndefined();
expect(document.getElementById(PLUGIN_THEME_STYLE_ID)).toBeNull();
expect(localStorage.getItem(PLUGIN_THEME_CACHE_KEY)).toBeNull();
stop();
});
it("ignores a cached stylesheet that could load resources", () => {
localStorage.setItem(
PLUGIN_THEME_CACHE_KEY,
JSON.stringify({ id: "p/t", kind: "dark", css: ":root { --bg-app: url(https://example.com/x) }" }),
);
localStorage.setItem(STARTUP_THEME_KEY, JSON.stringify({ theme: "light", frame: "red; color: blue" }));
boot();
expect(document.documentElement.dataset.pluginTheme).toBeUndefined();
expect(document.documentElement.dataset.theme).toBe("light");
expect(document.getElementById(PLUGIN_THEME_STYLE_ID)).toBeNull();
expect(document.getElementById("openidea-boot-colors")).toBeNull();
});
it("reports the resolved frame colour once per selected theme", () => {
const setFrame = vi.spyOn(backend, "setThemeFrame");
computedColors({ "--bg-frame": "rgb(33, 37, 43)" });
const stop = trackPluginTheme();
usePlugins.setState({ loaded: true, activeTheme: ONE_DARK });
usePlugins.setState({ activeTheme: { ...ONE_DARK } });
expect(setFrame.mock.calls).toEqual([["#21252b"]]);
// Selecting it again after the default theme reports it again: the backend forgot it.
usePlugins.setState({ activeTheme: null });
usePlugins.setState({ activeTheme: ONE_DARK });
expect(setFrame).toHaveBeenCalledTimes(2);
stop();
});
});
+45 -21
Переглянути файл
@@ -18,6 +18,8 @@ export interface PluginsBackend {
runCommand(pluginId: string, commandId: string, arg: string | null): Promise<string | null>;
setActiveFile(path: string | null): Promise<void>;
setTheme(pluginId: string | null, themeId: string | null): Promise<ActiveTheme | null>;
/** The selected theme's resolved frame colour (`#rrggbb[aa]`), which new windows open in. */
setThemeFrame(color: string | null): Promise<void>;
/** Folder picker; `null` when cancelled. */
pickFolder(): Promise<string | null>;
/** Subscribes to backend plugin events (this window only). */
@@ -40,6 +42,9 @@ export const tauriPluginsBackend: PluginsBackend = {
await commands.pluginSetActiveFile(path);
},
setTheme: (pluginId, themeId) => call(commands.pluginSetTheme(pluginId, themeId)),
setThemeFrame: async (color) => {
await call(commands.pluginSetThemeFrame(color));
},
pickFolder: async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ directory: true, multiple: false, title: "Install Plugin from Disk" });
@@ -71,21 +76,38 @@ function ensureSubscribed() {
// ---------------------------------------------------------------------------
// Browser simulation: the bundled sample plugins, with `hello` implemented in JS.
const SOLARIZED: ActiveTheme["theme"] = {
id: "solarized-dark",
name: "Solarized Dark",
kind: "dark",
tokens: {
"--bg-app": "#002b36",
"--bg-elev": "#03313c",
"--bg-popup": "#073642",
"--fg": "#93a1a1",
"--fg-strong": "#eee8d5",
"--accent": "#268bd2",
"--syn-keyword": "#859900",
"--syn-string": "#2aa198",
const SOLARIZED: ActiveTheme["theme"][] = [
{
id: "solarized-light",
name: "Solarized Light",
kind: "light",
tokens: {
"--bg-app": "#fdf6e3",
"--bg-island": "#fdf6e3",
"--bg-frame": "#eee8d5",
"--bg-elev": "#eee8d5",
"--fg": "#657b83",
"--accent": "#268bd2",
"--syn-keyword": "#859900",
"--syn-string": "#2aa198",
},
},
};
{
id: "solarized-dark",
name: "Solarized Dark",
kind: "dark",
tokens: {
"--bg-app": "#002b36",
"--bg-island": "#002b36",
"--bg-frame": "#073642",
"--bg-elev": "#073642",
"--fg": "#839496",
"--accent": "#268bd2",
"--syn-keyword": "#859900",
"--syn-string": "#2aa198",
},
},
];
function samplePlugins(): PluginInfo[] {
const base = {
@@ -118,14 +140,14 @@ function samplePlugins(): PluginInfo[] {
},
{
...base,
id: "theme-solarized-dark",
name: "Solarized Dark Theme",
id: "theme-solarized",
name: "Solarized Themes",
version: "1.0.0",
description: "Ethan Schoonover's Solarized palette, dark variant.",
path: "~/Library/Application Support/ai.l0ng.openidea/plugins/theme-solarized-dark",
description: "Solarized Light and Solarized Dark: Ethan Schoonover's Solarized palette.",
path: "~/Library/Application Support/ai.l0ng.openidea/plugins/theme-solarized",
status: "static",
hasCode: false,
themes: [{ id: "solarized-dark", name: "Solarized Dark", kind: "dark" }],
themes: SOLARIZED.map(({ id, name, kind }) => ({ id, name, kind })),
},
{
...base,
@@ -179,8 +201,9 @@ export function createMemoryPluginsBackend(
const activeTheme = (): ActiveTheme | null => {
if (!theme) return null;
const p = plugins.find((x) => x.id === theme?.pluginId);
if (!p?.enabled || theme.themeId !== SOLARIZED.id) return null;
return { pluginId: p.id, theme: SOLARIZED };
const t = SOLARIZED.find((x) => x.id === theme?.themeId);
if (!p?.enabled || !t) return null;
return { pluginId: p.id, theme: t };
};
return {
list: async () => ({ plugins: plugins.map((p) => ({ ...p })), activeTheme: activeTheme() }),
@@ -237,6 +260,7 @@ export function createMemoryPluginsBackend(
emit({ type: "themeChanged", theme: t });
return t;
},
setThemeFrame: async () => {},
pickFolder: async () => null,
onEvent: (handler) => {
listeners.add(handler);
+60 -3
Переглянути файл
@@ -8,7 +8,7 @@ import type { ActiveTheme, PluginEvent, PluginInfo } from "@/bindings";
import { activeEditor, useEditorRegistry } from "@/editor/api";
import { registerAction, type Action } from "@/platform/actions";
import { notifications } from "@/workbench/contributions";
import { applyThemeAttributes, currentThemeId, syncWindowBackground } from "@/workbench/theme";
import { applyThemeAttributes, currentThemeId, syncWindowBackground, windowBackgroundHex } from "@/workbench/theme";
import { pluginsBackend } from "./backend";
import { refreshPlugins, runPluginCommand, usePlugins } from "./store";
@@ -105,10 +105,67 @@ export function applyPluginTheme(theme: ActiveTheme | null, root: HTMLElement =
if (had || theme) syncWindowBackground();
}
/** localStorage key read by public/boot.js: the selected theme's stylesheet, applied before the app loads. */
export const PLUGIN_THEME_CACHE_KEY = "openidea.pluginTheme";
export interface CachedPluginTheme {
/** `data-plugin-theme`: `<pluginId>/<themeId>`. */
id: string;
kind: "light" | "dark";
css: string;
}
function cachePluginTheme(theme: ActiveTheme | null) {
try {
if (!theme) {
localStorage.removeItem(PLUGIN_THEME_CACHE_KEY);
return;
}
const cached: CachedPluginTheme = {
id: `${theme.pluginId}/${theme.theme.id}`,
kind: theme.theme.kind === "light" ? "light" : "dark",
css: pluginThemeCss(theme.theme),
};
localStorage.setItem(PLUGIN_THEME_CACHE_KEY, JSON.stringify(cached));
} catch {
/* storage unavailable */
}
}
let reportedFrame: string | undefined;
/**
* Tells the backend the theme's resolved frame colour, which new windows open
* in. Selecting a theme clears it there, so it is sent once per selection.
*/
function reportThemeFrame(theme: ActiveTheme | null) {
const frame = theme ? windowBackgroundHex() : null;
if (!theme || !frame) {
reportedFrame = undefined;
return;
}
const key = `${theme.pluginId}/${theme.theme.id}|${frame}`;
if (key === reportedFrame) return;
reportedFrame = key;
void pluginsBackend()
.setThemeFrame(frame)
.catch(() => (reportedFrame = undefined));
}
/**
* Applies the selected plugin theme and keeps it for the next start-up. Until
* the first plugin list arrives, the theme public/boot.js restored stays.
*/
export function trackPluginTheme(): () => void {
applyPluginTheme(usePlugins.getState().activeTheme);
const sync = (s: ReturnType<typeof usePlugins.getState>) => {
if (!s.activeTheme && (!s.loaded || s.error)) return;
applyPluginTheme(s.activeTheme);
cachePluginTheme(s.activeTheme);
reportThemeFrame(s.activeTheme);
};
sync(usePlugins.getState());
const unsubscribe = usePlugins.subscribe((s, prev) => {
if (s.activeTheme !== prev.activeTheme) applyPluginTheme(s.activeTheme);
if (s.activeTheme !== prev.activeTheme || s.loaded !== prev.loaded) sync(s);
});
return () => {
unsubscribe();