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]>
Цей коміт міститься в:
Lee Dong Geon authored and GitHub committed 2026-11-04 15:28:00 +08:00
1 джерело a610982655
коміт 6dec1252db
33 files changed
+2284 -87

No files matched your search

+15
Переглянути файл
@@ -5776,6 +5776,21 @@
"Unsigned": "未签名",
"Roll Back to {previousVersion}": "回滚到 {previousVersion}",
"Install Plugin from Package File…": "从插件包文件安装…",
"Import Theme from IntelliJ IDE…": "从 IntelliJ IDE 导入主题…",
"Import IntelliJ Theme from File…": "从文件导入 IntelliJ 主题…",
"Import Theme from IntelliJ IDE": "从 IntelliJ IDE 导入主题",
"Import IntelliJ Theme": "导入 IntelliJ 主题",
"IntelliJ theme plugin or theme file": "IntelliJ 主题插件或主题文件",
"Could not import the IntelliJ theme": "无法导入 IntelliJ 主题",
"Import": "导入",
"In use": "使用中",
"IntelliJ themes": "IntelliJ 主题",
"Looking for IntelliJ themes": "正在查找 IntelliJ 主题",
"Looking for IntelliJ themes…": "正在查找 IntelliJ 主题…",
"Theme plugins installed in the JetBrains IDEs on this computer. Importing a theme brings every theme of its plugin and applies the chosen one. Colours without an OpenIDEA counterpart keep the default theme colours.": "此计算机上 JetBrains IDE 中安装的主题插件。导入某个主题会一并导入其插件的全部主题,并应用所选主题。没有对应 OpenIDEA 颜色的项保留默认主题的颜色。",
"1 theme": "1 个主题",
"{count} themes": "{count} 个主题",
"No theme plugins found in JetBrains IDEs on this computer. Choose a theme plugin downloaded from the JetBrains Marketplace (.jar or .zip) or a .theme.json file instead.": "在此计算机上的 JetBrains IDE 中未找到主题插件。请改为选择从 JetBrains Marketplace 下载的主题插件(.jar 或 .zip)或 .theme.json 文件。",
"Marketplace index URL": "插件市场索引 URL",
"Refresh Marketplace": "刷新插件市场",
"Marketplace": "插件市场",
+55 -2
Переглянути файл
@@ -8,6 +8,7 @@ import {
parsePluginThemeRef,
pluginThemeRef,
themeChoices,
themeGroups,
themeKind,
useContributedThemes,
} from "./themes";
@@ -19,6 +20,7 @@ import {
resolveThemeId,
themeSelectOptions,
} from "@/workbench/theme";
import { themeItems } from "@/workbench/quickSwitch";
import { validateSettings } from "@/features/settings/model/schema";
import { applyPluginTheme } from "@/features/plugins/bridge";
@@ -108,7 +110,10 @@ describe("plugin themes", () => {
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).at(-1)).toEqual({ value: ref, label: "Night", detail: "Acme" });
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");
@@ -116,7 +121,55 @@ describe("plugin themes", () => {
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).at(-1)).toMatchObject({ value: ref, label: "night" });
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", () => {
+49 -2
Переглянути файл
@@ -77,11 +77,58 @@ export function addContributedTheme(theme: ThemeChoice): () => void {
return () => useContributedThemes.setState((s) => ({ themes: s.themes.filter((t) => t !== theme) }));
}
/** Every theme that can be selected: the built-in ones, then the contributed ones by name. */
/** Themes of one origin: the built-in ones, or one plugin's. */
export interface ThemeGroup {
/** The contributing plugin; none for the built-in themes. */
pluginId?: string;
/** The plugin's name. */
origin?: string;
themes: ThemeChoice[];
}
/** The selectable themes by origin: the built-in ones, then each plugin's by its name, themes by name. */
export function themeGroups(
contributed: readonly ThemeChoice[] = useContributedThemes.getState().themes,
): ThemeGroup[] {
const plugins = new Map<string, ThemeGroup>();
for (const theme of contributed) {
const pluginId = parsePluginThemeRef(theme.id)?.pluginId ?? "";
const group = plugins.get(pluginId) ?? { pluginId, origin: theme.origin, themes: [] };
plugins.set(pluginId, group);
group.themes.push(theme);
}
const name = (g: ThemeGroup) => g.origin ?? g.pluginId ?? "";
const groups = [...plugins.values()].sort(
(a, b) => name(a).localeCompare(name(b)) || (a.pluginId ?? "").localeCompare(b.pluginId ?? ""),
);
for (const group of groups) group.themes.sort((a, b) => a.label.localeCompare(b.label));
return [{ themes: [...BUILTIN_THEMES] }, ...groups];
}
/** Every theme that can be selected, in the order of `themeGroups`. */
export function themeChoices(
contributed: readonly ThemeChoice[] = useContributedThemes.getState().themes,
): ThemeChoice[] {
return [...BUILTIN_THEMES, ...[...contributed].sort((a, b) => a.label.localeCompare(b.label))];
return themeGroups(contributed).flatMap((g) => g.themes);
}
/**
* Shows a plugin theme in this window without selecting it, `null` none, or
* `"selected"` the one selected again. The plugins feature provides it.
*/
export type PluginThemePreview = (theme: PluginThemeRef | null | "selected") => void;
let pluginThemePreview: PluginThemePreview | null = null;
export function providePluginThemePreview(preview: PluginThemePreview): () => void {
pluginThemePreview = preview;
return () => {
if (pluginThemePreview === preview) pluginThemePreview = null;
};
}
export function previewPluginTheme(theme: PluginThemeRef | null | "selected"): void {
pluginThemePreview?.(theme);
}
/** Whether `id` can be applied now (a contributed theme's plugin may be off). */