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

+194
Переглянути файл
@@ -0,0 +1,194 @@
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { InstalledIntellijTheme, InstallPreview, IntellijThemeSource } from "@/bindings";
import { IpcError } from "@/platform/ipc";
import { provideNotificationService, type NotificationService } from "@/workbench/contributions";
import { useThemeOverride } from "@/workbench/theme";
import { createMemoryPluginsBackend, setPluginsBackend, type PluginsBackend } from "../backend";
import { closeInstalledIntellijThemes, importIntellijTheme, showInstalledIntellijThemes } from "../intellijImport";
import { IntellijThemesDialogHost } from "../IntellijThemesDialog";
import { setConsentPrompt } from "../store";
type Note = Parameters<NotificationService["notify"]>[0];
const ID = "intellij.com-example-deepsea";
const SOURCE: IntellijThemeSource = {
name: "Deep Sea Themes",
pluginId: "com.example.deepsea",
version: "2.3",
vendor: "Someone",
themes: [
{ id: "deep-id", theme: '{"name":"Deep Sea","dark":true,"ui":{"*":{"background":"#101820"}}}', editorScheme: null },
{ id: "shallow-id", theme: '{"name":"Shallow","dark":false}', editorScheme: null },
],
};
const INSTALLED: InstalledIntellijTheme[] = [
{
ide: "IntelliJ IDEA 2025.2",
plugin: "Deep Sea Themes",
path: "/ij/plugins/DeepSea",
themeId: "shallow-id",
themeName: "Shallow",
dark: false,
active: true,
},
{
ide: "IntelliJ IDEA 2025.2",
plugin: "Deep Sea Themes",
path: "/ij/plugins/DeepSea",
themeId: "deep-id",
themeName: "Deep Sea",
dark: true,
active: false,
},
];
let notes: Note[];
let read: Mock<PluginsBackend["readIntellijTheme"]>;
let stage: Mock<PluginsBackend["stagePackage"]>;
let installed: string[];
function backend(overrides: Partial<PluginsBackend> = {}): PluginsBackend {
const base = createMemoryPluginsBackend();
const preview: InstallPreview = {
id: ID,
name: SOURCE.name,
version: "2.3.0",
description: "",
authors: ["Someone"],
hasCode: false,
capabilities: [],
contributions: [{ point: "themes", count: 2 }],
warnings: [],
replaces: null,
unsigned: true,
publisher: null,
};
return {
...base,
readIntellijTheme: read,
stagePackage: stage,
installedIntellijThemes: async () => INSTALLED,
previewInstall: async () => preview,
installDir: async (path) => {
installed.push(path);
const [template] = (await base.list()).plugins;
return { ...template!, id: ID, name: SOURCE.name, hasCode: false, status: "static" };
},
...overrides,
};
}
beforeEach(() => {
notes = [];
installed = [];
provideNotificationService({ notify: (n) => void notes.push(n) });
read = vi.fn<PluginsBackend["readIntellijTheme"]>(async () => SOURCE);
stage = vi.fn<PluginsBackend["stagePackage"]>(async () => "/app/plugins/.downloads/generated");
setPluginsBackend(backend());
setConsentPrompt(async () => ({ optional: [] }));
useThemeOverride.setState({ theme: null });
});
afterEach(() => {
closeInstalledIntellijThemes();
setConsentPrompt(null);
setPluginsBackend(null);
useThemeOverride.setState({ theme: null });
});
describe("IntelliJ theme import", () => {
it("installs the converted plugin through the install page and applies the chosen theme", async () => {
expect(await importIntellijTheme("/downloads/DeepSea.zip", "shallow-id")).toBe(ID);
expect(read).toHaveBeenCalledWith("/downloads/DeepSea.zip");
const [name, files] = stage.mock.calls[0]!;
expect(name).toBe(ID);
expect(files.map((f) => f.path)).toEqual([
"plugin.toml",
"themes/deep-sea.theme.json",
"themes/shallow.theme.json",
]);
expect(installed).toEqual(["/app/plugins/.downloads/generated"]);
expect(useThemeOverride.getState().theme).toBe(`plugin:${ID}/shallow`);
});
it("applies the first theme without a choice, and nothing when the install is declined", async () => {
expect(await importIntellijTheme("/downloads/DeepSea.jar")).toBe(ID);
expect(useThemeOverride.getState().theme).toBe(`plugin:${ID}/deep-sea`);
useThemeOverride.setState({ theme: null });
setConsentPrompt(async () => null);
expect(await importIntellijTheme("/downloads/DeepSea.jar")).toBeNull();
expect(useThemeOverride.getState().theme).toBeNull();
});
it("asks for a file without a path, and does nothing when the picker is cancelled", async () => {
const pick = vi.fn(async () => null);
setPluginsBackend(backend({ pickIntellijTheme: pick }));
expect(await importIntellijTheme()).toBeNull();
expect(pick).toHaveBeenCalledOnce();
expect(read).not.toHaveBeenCalled();
expect(notes).toEqual([]);
});
it("reports a file that is not a theme plugin", async () => {
read.mockRejectedValueOnce(
new IpcError({
kind: "invalidInput",
message: "tools.jar is not a theme plugin: its plugin.xml declares no themeProvider",
}),
);
expect(await importIntellijTheme("/downloads/tools.jar")).toBeNull();
expect(stage).not.toHaveBeenCalled();
expect(notes).toEqual([
expect.objectContaining({
level: "error",
title: "Could not import the IntelliJ theme",
body: expect.stringContaining("not a theme plugin"),
}),
]);
});
});
describe("Import Theme from IntelliJ IDE", () => {
it("lists the installed themes by plugin, the one in use first, and imports the chosen one", async () => {
const user = userEvent.setup();
const onImported = vi.fn();
render(<IntellijThemesDialogHost />);
act(() => showInstalledIntellijThemes(onImported));
const list = await screen.findByRole("listbox", { name: "IntelliJ themes" });
const options = await waitFor(() => {
const found = Array.from(list.querySelectorAll('[role="option"]'));
expect(found).toHaveLength(2);
return found;
});
// Both under their plugin's heading: importing one brings the plugin's every theme.
expect(within(list).getByText("Deep Sea Themes")).toBeInTheDocument();
expect(within(list).getByText("2 themes")).toBeInTheDocument();
expect(within(list).getByText("IntelliJ IDEA 2025.2")).toBeInTheDocument();
expect(options[0]).toHaveTextContent("Shallow");
expect(options[0]).toHaveTextContent("In use");
expect(options[1]).toHaveTextContent("Deep Sea");
expect(options[1]).not.toHaveTextContent("In use");
await user.click(screen.getByText("Deep Sea"));
await user.click(screen.getByRole("button", { name: "Import" }));
await waitFor(() => expect(onImported).toHaveBeenCalledWith(ID));
expect(read).toHaveBeenCalledWith("/ij/plugins/DeepSea");
expect(useThemeOverride.getState().theme).toBe(`plugin:${ID}/deep-sea`);
expect(screen.queryByRole("listbox", { name: "IntelliJ themes" })).toBeNull();
});
it("offers a file when no IDE has theme plugins", async () => {
const user = userEvent.setup();
const pick = vi.fn(async () => "/downloads/DeepSea.jar");
setPluginsBackend(backend({ installedIntellijThemes: async () => [], pickIntellijTheme: pick }));
render(<IntellijThemesDialogHost />);
act(() => showInstalledIntellijThemes());
expect(await screen.findByText(/No theme plugins found/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Import" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Choose File…" }));
await waitFor(() => expect(read).toHaveBeenCalledWith("/downloads/DeepSea.jar"));
expect(pick).toHaveBeenCalledOnce();
});
});
+61 -3
Переглянути файл
@@ -3,19 +3,21 @@ import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { PluginInfo } from "@/bindings";
import type { PluginInfo, ProjectInfo } from "@/bindings";
import { _setActiveEditor } from "@/editor/api";
import { executeAction, executeActionFrom, getAction, registerAction } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { useContributedIconThemes } from "@/platform/themes";
import { pluginThemeRef, useContributedIconThemes } from "@/platform/themes";
import { ContextMenuHost, hideContextMenu } from "@/ui";
import { provideNotificationService, type NotificationService } from "@/workbench/contributions";
import { applyThemeAttributes, useThemeOverride } from "@/workbench/theme";
import { applyThemeAttributes, previewTheme, useThemeOverride } from "@/workbench/theme";
import { createMemoryPluginsBackend, sampleCatalog, setPluginsBackend, type PluginsBackend } from "../backend";
import {
applyPluginTheme,
followThemeSetting,
PLUGIN_THEME_STYLE_ID,
handlePluginEvent,
providePluginThemePreviews,
pluginActionId,
startPluginBridge,
syncProjection,
@@ -389,6 +391,36 @@ describe("bridge events", () => {
useThemeOverride.setState({ theme: null });
});
it("previews a live plugin's theme in this window without selecting it", () => {
const root = document.documentElement;
const sheet = () => document.getElementById(PLUGIN_THEME_STYLE_ID)?.textContent ?? null;
const setTheme = vi.spyOn(backend, "setTheme");
const night = { id: "night", name: "Night", kind: "dark", tokens: { "--bg-app": "#101820" } };
const dawn = { id: "dawn", name: "Dawn", kind: "light" as const, tokens: { "--bg-app": "#fff8e7" } };
usePlugins.setState({
ui: [{ pluginId: "acme", point: "themes", key: "night", decl: night }],
activeTheme: { pluginId: "acme", theme: dawn },
});
applyPluginTheme(usePlugins.getState().activeTheme);
const stop = providePluginThemePreviews();
previewTheme(pluginThemeRef("acme", "night"));
expect(sheet()).toContain("--bg-app: #101820;");
expect([root.dataset.pluginTheme, root.dataset.theme]).toEqual(["acme/night", "dark"]);
// A built-in theme shows without the plugin's tokens.
previewTheme("solarized-light");
expect(sheet()).toBeNull();
expect([root.dataset.theme, root.dataset.colorTheme]).toEqual(["light", "solarized-light"]);
// Back to the selected one; nothing was selected on the way.
previewTheme(null);
expect(sheet()).toContain("--bg-app: #fff8e7;");
expect(root.dataset.pluginTheme).toBe("acme/dawn");
expect(setTheme).not.toHaveBeenCalled();
stop();
applyPluginTheme(null);
});
it("runs commands with the focused editor's path, language and UTF-16 selections", async () => {
const calls: unknown[] = [];
setPluginsBackend({
@@ -464,6 +496,32 @@ describe("PluginsPage", () => {
expect(screen.getByText("Toy (.toy)")).toBeInTheDocument();
});
it("offers the IntelliJ theme imports only in a trusted project's window", async () => {
const user = userEvent.setup();
render(
<>
<PluginsPage />
<ContextMenuHost />
</>,
);
const imports = async () => {
await user.click(screen.getByRole("button", { name: "Manage Plugins" }));
const found = ["Import Theme from IntelliJ IDE…", "Import IntelliJ Theme from File…"].filter((name) =>
screen.queryByRole("menuitem", { name }),
);
act(() => hideContextMenu());
return found;
};
// The welcome screen: the host refuses installs without a trusted project.
expect(await imports()).toEqual([]);
const project = { trusted: false, root: "/p", name: "p", isGit: false } as unknown as ProjectInfo;
act(() => useProject.setState({ project }));
expect(await imports()).toEqual([]);
act(() => useProject.setState({ project: { ...project, trusted: true } }));
expect(await imports()).toHaveLength(2);
act(() => useProject.setState({ project: null }));
});
it("restarts a failed plugin instead of disabling it", async () => {
setPluginsBackend(createMemoryPluginsBackend([plugin({ id: "broken", status: "failed", error: "trap" })]));
const user = userEvent.setup();