дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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
Цей коміт міститься в:
1 джерело
b2fec4ae30
коміт
30310810e6
22 files changed
+1885
-34
No files matched your search
@@ -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" });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Заблокувати користувача