дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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
Цей коміт міститься в:
1 джерело
9d8280104f
коміт
24330192c0
43 files changed
+2035
-180
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Заблокувати користувача