Файли
openidea/src/platform/themes.test.ts
T
Lee Dong Geon 6dec1252db feat(plugins): import IntelliJ theme plugins (#1184)
Settings › Plugins (gear menu) gets Import Theme from IntelliJ IDE… (theme plugins of the JetBrains IDEs on this machine, the one each IDE uses first) and Import IntelliJ Theme from File… (a Marketplace .jar / .zip or a .theme.json). The host reads the plugin with size limits; the UI converts it with the IntelliJ mapping (moved from scripts/theme to src/features/plugins/intellijTheme.ts, still used by pnpm theme:import-intellij) into one OpenIDEA theme plugin (engines.openidea ^1.0) with all of its themes, installed through the usual install page, and applies the chosen theme.

Theme pickers separate each plugin's themes, and Quick Switch Scheme › Theme previews the theme under the pointer or keyboard after 500 ms and restores the selected one when closed without a choice.

Co-authored-by: ldg030201 <[email protected]>
2026-11-04 15:28:00 +08:00

182 рядки
8.2 KiB
TypeScript

import { readFileSync } from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import {
addContributedTheme,
BUILTIN_THEMES,
isBuiltinTheme,
isThemeAvailable,
parsePluginThemeRef,
pluginThemeRef,
themeChoices,
themeGroups,
themeKind,
useContributedThemes,
} from "./themes";
import { _setSchemeSource, effectiveScheme, schemeCss, schemeTokens, themeScheme } from "./colorScheme";
import {
applyThemeAttributes,
resolveTheme,
resolveThemeChoice,
resolveThemeId,
themeSelectOptions,
} from "@/workbench/theme";
import { themeItems } from "@/workbench/quickSwitch";
import { validateSettings } from "@/features/settings/model/schema";
import { applyPluginTheme } from "@/features/plugins/bridge";
const css = readFileSync("src/styles/tokens.css", "utf8");
_setSchemeSource(css);
afterEach(() => {
applyPluginTheme(null);
delete document.documentElement.dataset.colorTheme;
delete document.documentElement.dataset.contrast;
});
describe("built-in palettes", () => {
it.each(BUILTIN_THEMES)("preserves $id through validation and system resolution", ({ id, kind }) => {
const a = { theme: id, preferredLightTheme: id, preferredDarkTheme: id };
expect(validateSettings({ appearance: a }).appearance).toMatchObject(a);
expect(resolveThemeId(a, false)).toBe(id);
expect(resolveTheme(id, false)).toBe(kind);
expect(resolveThemeId({ ...a, syncWithOs: true }, true)).toBe(id);
expect(resolveThemeId({ ...a, theme: "system" }, false)).toBe(id);
applyThemeAttributes(id);
expect(document.documentElement.dataset.theme).toBe(kind);
expect(themeScheme()).toBe(id);
expect(effectiveScheme(id)).toBe(id);
expect(schemeCss("theme-default", undefined)).toBe("");
expect(schemeTokens(id).get("--bg-editor")).not.toContain("var(");
});
it("rejects unknown ids and retains legacy fallbacks", () => {
expect(isBuiltinTheme("not-a-theme")).toBe(false);
expect(resolveThemeId({ theme: "bad" }, true)).toBe("light");
expect(resolveThemeId({ theme: "system", preferredDarkTheme: "bad" }, false)).toBe("dark");
expect(themeKind("solarized-light")).toBe("light");
});
// Token coverage of every palette theme: settings/__tests__/contrast.test.ts.
it("resolves editor scheme tokens from the palettes", () => {
expect(schemeTokens("solarized-light").get("--bg-editor")).toBe("#fdf6e3");
expect(schemeTokens("solarized-dark").get("--bg-editor")).toBe("#002b36");
expect(schemeTokens("solarized-dark").get("--syn-string")).toBe("#2aa198");
expect(schemeTokens("solarized-light").get("--ansi-bright-blue")).toBe("#839496");
expect(schemeTokens("one-light").get("--syn-keyword")).toBe("hsl(301, 63%, 40%)");
expect(schemeTokens("one-dark").get("--syn-string")).toBe("hsl(95, 38%, 62%)");
});
it("keeps explicit editor schemes and per-scheme edits independent of UI palettes", () => {
applyThemeAttributes("solarized-light");
expect(schemeCss("one-dark", undefined)).toContain("--bg-editor: hsl(220, 13%, 18%) !important");
expect(schemeCss("theme-default", { "solarized-light": { keyword: { foreground: "#123456" } } })).toContain(
"--syn-keyword: #123456 !important",
);
applyThemeAttributes("one-light");
expect(schemeCss("theme-default", { "solarized-light": { keyword: { foreground: "#123456" } } })).toBe("");
});
it("clears the palette for plugins and restores it when the plugin is removed", () => {
const root = document.documentElement;
applyThemeAttributes("one-dark", root);
root.dataset.pluginThemeKind = "light";
applyThemeAttributes("one-dark", root);
expect(root.dataset.colorTheme).toBeUndefined();
expect(root.dataset.theme).toBe("light");
delete root.dataset.pluginThemeKind;
applyThemeAttributes("one-dark", root);
expect(root.dataset.colorTheme).toBe("one-dark");
applyThemeAttributes("dark-hc", root);
expect(root.dataset.colorTheme).toBeUndefined();
expect(root.dataset.contrast).toBe("high");
applyThemeAttributes("solarized-dark", root);
expect(root.dataset.contrast).toBeUndefined();
});
});
describe("plugin themes", () => {
const ref = pluginThemeRef("acme.night", "night");
afterEach(() => {
useContributedThemes.setState({ themes: [] });
localStorage.removeItem("openidea.themeKinds");
});
it("are settings values like the built-in ones, in every picker while their plugin is live", () => {
const a = { theme: ref, preferredLightTheme: "light", preferredDarkTheme: ref };
expect(validateSettings({ appearance: a }).appearance).toMatchObject(a);
expect(resolveThemeChoice(a, false)).toBe(ref);
expect(resolveThemeChoice({ ...a, syncWithOs: true }, false)).toBe(ref);
expect(resolveThemeChoice({ ...a, syncWithOs: true }, true)).toBe("light");
const remove = addContributedTheme({ id: ref, label: "Night", kind: "dark", origin: "Acme" });
expect(isThemeAvailable(ref)).toBe(true);
expect(themeChoices().at(-1)).toMatchObject({ id: ref, label: "Night" });
expect(themeSelectOptions(themeChoices(), ref).slice(-2)).toEqual([
{ value: "plugin:acme.night", label: "Acme", section: true },
{ value: ref, label: "Night" },
]);
// Its tokens apply over the default of its kind.
expect(resolveThemeId(a, false)).toBe("dark");
remove();
expect(isThemeAvailable(ref)).toBe(false);
// Its plugin off: the base of the kind it had shows, and the pickers keep it listed.
expect(resolveThemeId(a, false)).toBe("dark");
expect(themeSelectOptions(themeChoices(), ref).slice(-2)).toMatchObject([
{ value: "plugin:acme.night", label: "acme.night", section: true },
{ value: ref, label: "night", detail: "Plugin not enabled" },
]);
// Its plugin live but the theme gone (an update dropped it): listed under that plugin's heading.
const stay = addContributedTheme({
id: pluginThemeRef("acme.night", "dawn"),
label: "Dawn",
kind: "dark",
origin: "Acme",
});
addContributedTheme({ id: pluginThemeRef("zeta.blue", "blue"), label: "Blue", kind: "dark", origin: "Zeta" });
expect(
themeSelectOptions(themeChoices(), ref)
.slice(BUILTIN_THEMES.length)
.map((o) => (o.section ? `[${o.label}]` : o.label)),
).toEqual(["[Acme]", "Dawn", "night", "[Zeta]", "Blue"]);
stay();
});
it("are grouped by plugin: the built-in ones first, then each plugin's by name", () => {
const theme = (plugin: string, id: string, label: string, origin: string) =>
addContributedTheme({ id: pluginThemeRef(plugin, id), label, kind: "dark", origin });
theme("intellij.dracula", "dracula", "Dracula", "Dracula Theme");
theme("acme.night", "night", "Night", "Acme");
theme("intellij.dracula", "alucard", "Dracula Alucard", "Dracula Theme");
theme("acme.night", "dawn", "Dawn", "Acme");
const groups = themeGroups();
expect(groups.map((g) => [g.origin, g.themes.map((t) => t.label)])).toEqual([
[undefined, BUILTIN_THEMES.map((t) => t.label)],
["Acme", ["Dawn", "Night"]],
["Dracula Theme", ["Dracula", "Dracula Alucard"]],
]);
expect(themeChoices().map((t) => t.label)).toEqual(groups.flatMap((g) => g.themes.map((t) => t.label)));
// Menus draw a line between the groups, as IDEA's Theme list.
const items = themeItems("light");
const lines = items.flatMap((item, i) => (item.kind === "separator" ? [i] : []));
expect(lines).toEqual([BUILTIN_THEMES.length, BUILTIN_THEMES.length + 3]);
expect(items.find((i) => i.kind !== "separator" && "checked" in i && i.checked)).toMatchObject({
label: "Islands Light",
});
// Selects head each plugin's themes with its name.
expect(
themeSelectOptions(themeChoices())
.slice(BUILTIN_THEMES.length)
.map((o) => (o.section ? `[${o.label}]` : o.label)),
).toEqual(["[Acme]", "Dawn", "Night", "[Dracula Theme]", "Dracula", "Dracula Alucard"]);
});
it("parse only well-formed references", () => {
expect(parsePluginThemeRef(ref)).toEqual({ pluginId: "acme.night", themeId: "night" });
for (const bad of ["plugin:", "plugin:/night", "plugin:acme/", "acme/night", 3])
expect(parsePluginThemeRef(bad)).toBeNull();
expect(validateSettings({ appearance: { theme: "plugin:/x" } }).appearance.theme).toBe("light");
});
});