feat(plugins): Theme plugin template, theme docs and IntelliJ theme import (#54)

Per the maintainer's decision for #24, no third-party themes are bundled; instead a theme plugin template and docs let the community port themes (IntelliJ also ships few themes, the rest are plugins).
- templates/theme-plugin: manifest-only plugin with an original example (Harbor Night) covering every UI token and an editor scheme (.icls equivalent).
- docs/plugins/themes.md: format, IntelliJ / VS Code porting tables, generated token reference (pnpm gen:theme-docs); drift test fails when tokens.css and the template/reference disagree.
- pnpm theme:import-intellij: dependency-free converter for IntelliJ .theme.json (+ .icls).
- Plugin host: themes may name an editor-scheme merged over the theme; comments allowed in theme files.
- Fixes: a plugin theme's kind now switches the light/dark base; plugin themes go in their own stylesheet so the Appearance accent and editor scheme still layer on top.
Verified: ide-plugin tests, vitest (44), in-app on macOS: installed the template from disk, applied Harbor Night (dark base + teal accent over Islands Light), back to default restored the light theme.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 10:39:08 +08:00
1 джерело b2fec4ae30
коміт 30310810e6
22 files changed
+1885 -34

No files matched your search

+26 -4
Переглянути файл
@@ -7,10 +7,12 @@ import type { PluginInfo } from "@/bindings";
import { _setActiveEditor } from "@/editor/api";
import { executeAction, getAction } from "@/platform/actions";
import { provideNotificationService, type NotificationService } from "@/workbench/contributions";
import { applyThemeAttributes, useThemeOverride } from "@/workbench/theme";
import { createMemoryPluginsBackend, sampleCatalog, setPluginsBackend, type PluginsBackend } from "../backend";
import {
applyPluginTheme,
applyTextToActiveEditor,
PLUGIN_THEME_STYLE_ID,
handlePluginEvent,
pluginActionId,
pluginActions,
@@ -160,20 +162,40 @@ describe("store", () => {
});
describe("bridge events", () => {
it("applies theme tokens as CSS variables and removes them again", () => {
it("applies theme tokens as a stylesheet and removes it again", () => {
const root = document.documentElement;
const sheet = () => document.getElementById(PLUGIN_THEME_STYLE_ID)?.textContent ?? null;
applyPluginTheme({
pluginId: "p",
theme: { id: "t", name: "T", kind: "dark", tokens: { "--bg-app": "#002b36", "--evil": "red; x: y", bad: "1" } },
});
expect(root.style.getPropertyValue("--bg-app")).toBe("#002b36");
expect(root.style.getPropertyValue("--evil")).toBe("");
expect(sheet()).toContain("--bg-app: #002b36;");
expect(sheet()).toContain("color-scheme: dark;");
expect(sheet()).not.toContain("evil");
expect(sheet()).not.toContain("bad");
expect(root.dataset.pluginTheme).toBe("p/t");
applyPluginTheme(null);
expect(root.style.getPropertyValue("--bg-app")).toBe("");
expect(sheet()).toBeNull();
expect(root.dataset.pluginTheme).toBeUndefined();
});
it("switches the built-in base to the theme's kind and restores it", () => {
const root = document.documentElement;
useThemeOverride.setState({ theme: "dark-hc" });
applyThemeAttributes("dark-hc", root);
expect([root.dataset.theme, root.dataset.contrast]).toEqual(["dark", "high"]);
applyPluginTheme({ pluginId: "p", theme: { id: "l", name: "L", kind: "light", tokens: { "--accent": "#123456" } } });
expect([root.dataset.theme, root.dataset.contrast]).toEqual(["light", undefined]);
expect(root.dataset.pluginThemeKind).toBe("light");
// The appearance runtime re-applying the built-in theme keeps the plugin's base.
applyThemeAttributes("dark-hc", root);
expect(root.dataset.theme).toBe("light");
applyPluginTheme(null);
expect([root.dataset.theme, root.dataset.contrast]).toEqual(["dark", "high"]);
expect(root.dataset.pluginThemeKind).toBeUndefined();
useThemeOverride.setState({ theme: null });
});
it("inserts at the cursor and replaces selections", () => {
const v = editor("hello world", 6, 11);
handlePluginEvent({ type: "replaceSelection", pluginId: "p", text: "there" });
+36 -14
Переглянути файл
@@ -1,13 +1,14 @@
/**
* Glue between backend plugins and the workbench: plugin commands become
* actions, editor requests are applied to the active editor, theme overrides
* become CSS variables, notifications become balloons.
* actions, editor requests are applied to the active editor, the selected
* theme becomes a stylesheet of CSS variables, notifications become balloons.
*/
import { EditorSelection } from "@codemirror/state";
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 { pluginsBackend } from "./backend";
import { refreshPlugins, runPluginCommand, usePlugins } from "./store";
@@ -64,23 +65,44 @@ export function syncPluginActions(): () => void {
// --- theme ------------------------------------------------------------------
let appliedTokens: string[] = [];
export const PLUGIN_THEME_STYLE_ID = "openidea-plugin-theme";
/** Applies a plugin theme's token overrides as inline CSS variables on :root (null clears). */
/** Stylesheet for a plugin theme. Its selector outranks every built-in theme block. */
export function pluginThemeCss(theme: ActiveTheme["theme"]): string {
const kind = theme.kind === "light" ? "light" : "dark";
const decls = [` color-scheme: ${kind};`];
for (const [name, value] of Object.entries(theme.tokens)) {
// Backend validates; keep the guard so a malformed payload can't inject declarations.
if (!/^--[a-z0-9-]+$/.test(name) || value == null || /[;{}<>\\]/.test(value)) continue;
decls.push(` ${name}: ${value};`);
}
return `:root[data-plugin-theme]:not([data-plugin-theme=""]) {\n${decls.join("\n")}\n}\n`;
}
/**
* Applies a plugin theme (null restores the built-in one): its tokens as a
* stylesheet over the built-in light or dark base named by its `kind`. A
* stylesheet rather than inline styles, so the accent picked in Settings ›
* Appearance and the editor colour scheme still layer on top.
*/
export function applyPluginTheme(theme: ActiveTheme | null, root: HTMLElement = document.documentElement) {
for (const name of appliedTokens) root.style.removeProperty(name);
appliedTokens = [];
const doc = root.ownerDocument;
doc.getElementById(PLUGIN_THEME_STYLE_ID)?.remove();
const had = root.dataset.pluginTheme !== undefined;
if (!theme) {
delete root.dataset.pluginTheme;
return;
delete root.dataset.pluginThemeKind;
if (had) applyThemeAttributes(currentThemeId(), root);
} else {
const style = doc.createElement("style");
style.id = PLUGIN_THEME_STYLE_ID;
style.textContent = pluginThemeCss(theme.theme);
doc.head.appendChild(style);
root.dataset.pluginTheme = `${theme.pluginId}/${theme.theme.id}`;
root.dataset.pluginThemeKind = theme.theme.kind === "light" ? "light" : "dark";
applyThemeAttributes(currentThemeId(), root);
}
for (const [name, value] of Object.entries(theme.theme.tokens)) {
// Backend validates; keep the guard so a malformed payload can't inject declarations.
if (!/^--[a-z0-9-]+$/.test(name) || value == null || /[;{}<>]/.test(value)) continue;
root.style.setProperty(name, value);
appliedTokens.push(name);
}
root.dataset.pluginTheme = `${theme.pluginId}/${theme.theme.id}`;
if (had || theme) syncWindowBackground();
}
export function trackPluginTheme(): () => void {
+2
Переглянути файл
@@ -384,6 +384,7 @@
--fg-gutter: #aeb3c2;
--fg-icon: #6c707e;
/* accent */
--accent: #3871e1;
--link: #2f5eb9;
--link-hover: #2e4d89;
@@ -495,6 +496,7 @@
--ansi-bright-cyan: #1b9ea3;
--ansi-bright-white: #5f6269;
/* shadows and window controls */
--shadow-popup: 0 6px 20px rgba(0, 0, 0, 0.12);
--shadow-dialog: 0 0 0 0.5px rgba(0, 0, 0, 0.1), 0 10px 24px rgba(0, 0, 0, 0.1), 0 24px 72px rgba(0, 0, 0, 0.2);
--traffic-close: #ff5f57;
+18 -4
Переглянути файл
@@ -72,9 +72,7 @@ export function useApplyTheme() {
useEffect(() => {
const root = document.documentElement;
if (pending && root.dataset.theme) return;
root.dataset.theme = theme;
if (id === "dark-hc") root.dataset.contrast = "high";
else delete root.dataset.contrast;
applyThemeAttributes(id, root);
// Each theme ships its own accent; only a deliberate choice overrides it.
const custom = !!accent && ACCENT_RE.test(accent) && !THEME_ACCENTS.has(accent.toLowerCase());
if (custom) root.style.setProperty("--accent", accent);
@@ -88,12 +86,28 @@ export function useApplyTheme() {
}, [pending, theme, id, accent]);
}
/**
* Sets `data-theme` / `data-contrast` for a built-in theme id. While a plugin
* theme is active (`data-plugin-theme-kind`), its light / dark base wins.
*/
export function applyThemeAttributes(id: "dark" | "light" | "dark-hc", root: HTMLElement = document.documentElement) {
const pluginKind = root.dataset.pluginThemeKind;
if (pluginKind === "light" || pluginKind === "dark") {
root.dataset.theme = pluginKind;
delete root.dataset.contrast;
return;
}
root.dataset.theme = id === "light" ? "light" : "dark";
if (id === "dark-hc") root.dataset.contrast = "high";
else delete root.dataset.contrast;
}
/**
* Where a live resize outruns the page, the native window and the webview (WebView2's default
* background) show their own colours; match both to the frame. The webview one is only set at
* window creation otherwise, so a theme switched later left it light.
*/
function syncWindowBackground() {
export function syncWindowBackground() {
if (!isTauri()) return;
const color = getComputedStyle(document.documentElement).getPropertyValue("--bg-frame").trim();
if (!color) return;