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

+46
Переглянути файл
@@ -987,6 +987,12 @@ export const commands = {
pluginMarketDownload: (id: string) => typedError<string, IdeError>(__TAURI_INVOKE("plugin_market_download", { id })),
/** Unpacks a package file (`.oiplugin` or `.zip`); returns the folder for the install page. */
pluginUnpackPackage: (path: string) => typedError<string, IdeError>(__TAURI_INVOKE("plugin_unpack_package", { path })),
/** Reads an IntelliJ theme plugin (`.jar`, Marketplace `.zip`, plugin folder) or `.theme.json` to import. */
pluginReadIntellijTheme: (path: string) => typedError<IntellijThemeSource, IdeError>(__TAURI_INVOKE("plugin_read_intellij_theme", { path })),
/** Theme plugins installed in the JetBrains IDEs of this user, the ones in use first. */
pluginInstalledIntellijThemes: () => typedError<InstalledIntellijTheme[], IdeError>(__TAURI_INVOKE("plugin_installed_intellij_themes")),
/** Writes a package the UI generated (an imported theme); returns the folder for the install page. */
pluginStagePackage: (name: string, files: PackageFile[]) => typedError<string, IdeError>(__TAURI_INVOKE("plugin_stage_package", { name, files })),
/** Goes back to the version the last update of `id` replaced. */
pluginRollBack: (id: string) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_roll_back", { id })),
pluginSecrets: (pluginId: string) => typedError<PluginSecretState[], IdeError>(__TAURI_INVOKE("plugin_secrets", { pluginId })),
@@ -4292,6 +4298,40 @@ export type InstallPreview = {
publisher: string | null,
};
/** A theme of a plugin installed in a JetBrains IDE. */
export type InstalledIntellijTheme = {
/** The IDE and its version: `IntelliJ IDEA 2025.2`. */
ide: string,
/** The plugin's name. */
plugin: string,
/** What to import: the plugin's folder or jar. */
path: IpcPath,
themeId: string,
themeName: string,
dark: boolean,
/** The IDE is set to this theme. */
active: boolean,
};
export type IntellijThemeFile = {
/** The `themeProvider` id (what `laf.xml` names), or the file name without `.theme.json`. */
id: string,
/** The `.theme.json`, comments removed. */
theme: string,
/** The editor scheme (`.icls` XML) its `editorScheme` names, when the plugin has it. */
editorScheme: string | null,
};
/** A theme plugin's themes, as read from its files. */
export type IntellijThemeSource = {
/** The plugin's `<name>`, or the theme's for a loose file. */
name: string,
pluginId: string | null,
version: string | null,
vendor: string | null,
themes: IntellijThemeFile[],
};
/** Optional parts of the invalidation; the symbol index is always rebuilt. */
export type InvalidateCachesOptions = {
/** Local History of every project. */
@@ -5151,6 +5191,12 @@ export type OutputChunk = { kind: "stdout"; text: string } | { kind: "stderr"; t
/** System message from the IDE (command line echo, exit code). */
{ kind: "system"; text: string };
/** A text file of a package the UI generated, at its `/`-separated path in the package. */
export type PackageFile = {
path: string,
text: string,
};
/** A file of a partial commit: which of its changes against HEAD to commit. */
export type PartialCommitFile = {
path: IpcPath,
+140
Переглянути файл
@@ -0,0 +1,140 @@
/**
* Import Theme from IntelliJ IDE: the theme plugins installed in the JetBrains
* IDEs on this machine, each under its plugin's heading, the plugin of the
* theme in use first. Importing a theme brings every theme of its plugin, as
* installing the plugin in IntelliJ does, and applies the chosen one
* (`importIntellijTheme`). A file can be chosen instead.
*/
import { useEffect, useState } from "react";
import clsx from "clsx";
import type { InstalledIntellijTheme } from "@/bindings";
import { tx } from "@/platform/i18n";
import { Badge, Dialog, List, Spinner, T } from "@/ui";
import { pluginsBackend } from "./backend";
import { closeInstalledIntellijThemes, importIntellijTheme, useIntellijThemesDialog } from "./intellijImport";
import { errorText } from "./store";
import styles from "./PluginsPage.module.css";
type Row =
| { kind: "plugin"; key: string; plugin: string; ide: string; count: number; first: boolean }
| { kind: "theme"; key: string; theme: InstalledIntellijTheme };
/** A heading per plugin (of an IDE) with its themes under it, in the order found (the one in use first). */
function themeRows(themes: readonly InstalledIntellijTheme[]): Row[] {
const plugins = new Map<string, InstalledIntellijTheme[]>();
for (const t of themes) {
const key = `${t.ide}\n${t.path}`;
plugins.set(key, [...(plugins.get(key) ?? []), t]);
}
return [...plugins].flatMap(([key, list], i): Row[] => [
{ kind: "plugin", key, plugin: list[0]!.plugin, ide: list[0]!.ide, count: list.length, first: i === 0 },
...list.map((theme): Row => ({ kind: "theme", key: `${key}\n${theme.themeId}`, theme })),
]);
}
function IntellijThemesDialog() {
const [themes, setThemes] = useState<InstalledIntellijTheme[] | null>(null);
const [error, setError] = useState<string | null>(null);
// The first theme, under the first heading.
const [active, setActive] = useState(1);
useEffect(() => {
let live = true;
pluginsBackend()
.installedIntellijThemes()
.then(
(found) => live && setThemes(found),
(e: unknown) => {
if (!live) return;
setError(errorText(e));
setThemes([]);
},
);
return () => {
live = false;
};
}, []);
const run = (theme?: InstalledIntellijTheme) => {
const { onImported } = useIntellijThemesDialog.getState();
closeInstalledIntellijThemes();
void importIntellijTheme(theme?.path, theme?.themeId).then((id) => id && onImported?.(id));
};
const rows = themeRows(themes ?? []);
const row = rows[active];
const chosen = row?.kind === "theme" ? row.theme : undefined;
return (
<Dialog
open
title="Import Theme from IntelliJ IDE"
width={640}
onClose={closeInstalledIntellijThemes}
buttons={[
{ label: "Choose File…", role: "extra", onClick: () => run() },
{ label: "Cancel", role: "cancel" },
{ label: "Import", role: "default", disabled: !chosen, onClick: () => chosen && run(chosen) },
]}
>
<div className={styles.intellijThemes}>
<p className={styles.muted}>
<T>
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.
</T>
</p>
{themes === null ? (
<div className={styles.intellijThemesStatus}>
<Spinner size={14} label="Looking for IntelliJ themes" />
<T>Looking for IntelliJ themes…</T>
</div>
) : (
<List
items={rows}
getKey={(r) => r.key}
isSection={(r) => r.kind === "plugin"}
activeIndex={chosen ? active : -1}
onActiveChange={setActive}
rowHeight={28}
sectionHeight={30}
className={styles.intellijThemesList}
aria-label={tx("IntelliJ themes")}
empty={
<div className={styles.intellijThemesStatus} role="note">
{error ?? (
<T>
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.
</T>
)}
</div>
}
renderSection={(r) =>
r.kind === "plugin" && (
<div className={clsx(styles.intellijPlugin, !r.first && styles.intellijPluginNext)}>
<span className={styles.intellijPluginName}>{r.plugin}</span>
<span>{r.count === 1 ? tx("1 theme") : tx`${r.count} themes`}</span>
<span className={styles.intellijPluginIde}>{r.ide}</span>
</div>
)
}
renderItem={(r) =>
r.kind === "theme" && (
<div className={styles.intellijTheme}>
<span className={styles.itemMain}>{r.theme.themeName}</span>
{r.theme.active && <Badge tone="accent">{tx("In use")}</Badge>}
<span className={styles.muted}>{r.theme.dark ? tx("Dark") : tx("Light")}</span>
</div>
)
}
/>
)}
</div>
</Dialog>
);
}
/** Overlay of the dialog while `showInstalledIntellijThemes` has it open. */
export function IntellijThemesDialogHost() {
const open = useIntellijThemesDialog((s) => s.open);
return open ? <IntellijThemesDialog /> : null;
}
+60
Переглянути файл
@@ -384,3 +384,63 @@
padding: 12px 16px;
overflow: auto;
}
/* Import Theme from IntelliJ IDE */
.intellijThemes {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
padding: 14px;
}
.intellijThemes > p {
margin: 0;
}
/* As tall as its rows (the list sizes them), scrolling past seven. */
.intellijThemesList {
max-height: 316px;
padding: 4px 0;
border: 1px solid var(--border);
border-radius: var(--radius-control);
}
.intellijThemesStatus {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
color: var(--fg-muted);
}
.intellijTheme {
display: flex;
flex: 1;
align-items: center;
gap: 8px;
min-width: 0;
}
.intellijTheme > .muted {
flex-shrink: 0;
font-size: var(--fs-small);
}
.intellijPlugin {
display: flex;
flex: 1;
align-items: center;
gap: 8px;
align-self: stretch;
min-width: 0;
}
/* A line above each plugin but the first, as in IDEA's Theme list. */
.intellijPluginNext {
border-top: 1px solid var(--border);
}
.intellijPluginName {
overflow: hidden;
color: var(--fg-secondary);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.intellijPluginIde {
flex-shrink: 0;
margin-left: auto;
}
+30 -10
Переглянути файл
@@ -43,6 +43,8 @@ import {
uninstallPlugin,
usePlugins,
} from "./store";
import { useProject } from "@/platform/project";
import { importIntellijTheme, showInstalledIntellijThemes } from "./intellijImport";
import styles from "./PluginsPage.module.css";
import { tx } from "@/platform/i18n";
@@ -526,11 +528,26 @@ function CatalogDetails({ entry }: { entry: CatalogPlugin }) {
);
}
function gearMenu(plugins: readonly PluginInfo[], install: (packageFile: boolean) => void): MenuItem[] {
interface GearActions {
/** Installing needs a trusted project's window (the host refuses it elsewhere). */
canImport: boolean;
install: (packageFile: boolean) => void;
importIntellijFile: () => void;
importInstalledIntellij: () => void;
}
function gearMenu(plugins: readonly PluginInfo[], actions: GearActions): MenuItem[] {
const installed = plugins.filter((p) => p.source === "installed");
return [
{ label: "Install Plugin from Disk…", icon: "package", run: () => install(false) },
{ label: "Install Plugin from Package File…", run: () => install(true) },
{ label: "Install Plugin from Disk…", icon: "package", run: () => actions.install(false) },
{ label: "Install Plugin from Package File…", run: () => actions.install(true) },
...(actions.canImport
? [
{ kind: "separator" as const },
{ label: "Import Theme from IntelliJ IDE…", run: actions.importInstalledIntellij },
{ label: "Import IntelliJ Theme from File…", run: actions.importIntellijFile },
]
: []),
{ kind: "separator" },
{
label: "Disable All Downloaded Plugins",
@@ -700,19 +717,22 @@ export function PluginsPage() {
const catalogShown = useMemo(() => filterCatalog(catalog ?? [], query), [catalog, query]);
const selectedEntry = catalogShown.find((c) => c.id === selectedCatalogId) ?? catalogShown[0] ?? null;
const install = async (packageFile = false) => {
const id = await installFromDisk(undefined, packageFile);
if (id) {
setTab("installed");
setSelectedId(id);
}
const showInstalled = (id: string | null) => {
if (!id) return;
setTab("installed");
setSelectedId(id);
};
const openGear = (e: MouseEvent<HTMLButtonElement>) => {
const r = e.currentTarget.getBoundingClientRect();
showContextMenu(
{ x: r.left, y: r.bottom + 2 },
gearMenu(plugins, (packageFile) => void install(packageFile)),
gearMenu(plugins, {
canImport: useProject.getState().project?.trusted === true,
install: (packageFile) => void installFromDisk(undefined, packageFile).then(showInstalled),
importIntellijFile: () => void importIntellijTheme().then(showInstalled),
importInstalledIntellij: () => showInstalledIntellijThemes(showInstalled),
}),
);
};
+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();
+30
Переглянути файл
@@ -10,7 +10,9 @@ import type {
CatalogPlugin,
Consent,
EventKind,
InstalledIntellijTheme,
InstallPreview,
IntellijThemeSource,
MarketPlugin,
PluginEditor,
PluginEvent,
@@ -71,6 +73,14 @@ export interface PluginsBackend {
rollBack(id: string): Promise<PluginInfo>;
/** File picker for a package: an `.oiplugin`, a `.zip` or a folder's `plugin.toml`; `null` when cancelled. */
pickPackage(): Promise<string | null>;
/** File picker for an IntelliJ theme plugin (`.jar`, `.zip`) or `.theme.json`; `null` when cancelled. */
pickIntellijTheme(): Promise<string | null>;
/** Reads the themes of an IntelliJ theme plugin or `.theme.json`. */
readIntellijTheme(path: string): Promise<IntellijThemeSource>;
/** Theme plugins installed in the JetBrains IDEs on this machine, the ones in use first. */
installedIntellijThemes(): Promise<InstalledIntellijTheme[]>;
/** Writes a generated package (`name`: its id); the folder to install from. */
stagePackage(name: string, files: { path: string; text: string }[]): Promise<string>;
/** Content of a plugin view (`doc`: the document of a custom editor). */
renderView(pluginId: string, view: string, viewport: Viewport | null, doc: number | null): Promise<View>;
viewEvent(pluginId: string, view: string, event: ViewEvent, doc: number | null): Promise<void>;
@@ -166,6 +176,18 @@ export const tauriPluginsBackend: PluginsBackend = {
});
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
pickIntellijTheme: async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
multiple: false,
title: tx("Import IntelliJ Theme"),
filters: [{ name: tx("IntelliJ theme plugin or theme file"), extensions: ["jar", "zip", "json"] }],
});
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
readIntellijTheme: (path) => call(commands.pluginReadIntellijTheme(path)),
installedIntellijThemes: () => call(commands.pluginInstalledIntellijThemes()),
stagePackage: (name, files) => call(commands.pluginStagePackage(name, files)),
// A tool window's calls (no `doc`) carry the window's active editor.
renderView: (pluginId, view, viewport, doc) =>
call(commands.pluginViewRender(pluginId, view, viewport, doc, doc == null ? editorContext() : null)),
@@ -488,6 +510,14 @@ export function createMemoryPluginsBackend(
throw new IpcError({ kind: "notFound", message: `${id} has no earlier version to roll back to` });
},
pickPackage: async () => null,
pickIntellijTheme: async () => null,
readIntellijTheme: async (path) => {
throw new IpcError({ kind: "unavailable", message: `reading ${path} requires the desktop app` });
},
installedIntellijThemes: async () => [],
stagePackage: async () => {
throw new IpcError({ kind: "unavailable", message: "installing a generated plugin requires the desktop app" });
},
renderView: async (pluginId, view) => ({
toolbar: [],
body: { kind: "message", value: `${pluginId} · ${view} (plugin views need the desktop app)` },
+22 -1
Переглянути файл
@@ -12,7 +12,12 @@ import { refreshLanguageInfos } from "@/editor/core/cm/languages";
import { notifications } from "@/workbench/contributions";
import { useProducerNames } from "@/platform/inspections";
import { languageListChanged } from "@/platform/languageList";
import { isThemeAvailable, parsePluginThemeRef, useContributedThemes } from "@/platform/themes";
import {
isThemeAvailable,
parsePluginThemeRef,
providePluginThemePreview,
useContributedThemes,
} from "@/platform/themes";
import {
applyThemeAttributes,
currentThemeChoice,
@@ -92,6 +97,22 @@ export function applyPluginTheme(theme: ActiveTheme | null, root: HTMLElement =
if (had || theme) syncWindowBackground();
}
/**
* Quick Switch's theme preview of plugin themes: a live plugin's theme as it
* contributes it, shown in this window only; nothing is selected or saved.
*/
export function providePluginThemePreviews(): () => void {
return providePluginThemePreview((ref) => {
const state = usePlugins.getState();
if (ref === "selected") return applyPluginTheme(state.activeTheme);
const want = ref ? parsePluginThemeRef(ref) : null;
const item = want
? state.ui.find((c) => c.point === "themes" && c.pluginId === want.pluginId && c.key === want.themeId)
: undefined;
applyPluginTheme(item ? { pluginId: item.pluginId, theme: item.decl as ActiveTheme["theme"] } : null);
});
}
/** localStorage key read by public/boot.js: the selected theme's stylesheet, applied before the app loads. */
export const PLUGIN_THEME_CACHE_KEY = "openidea.pluginTheme";
+35 -1
Переглянути файл
@@ -5,6 +5,8 @@
* projected by `projection.ts` / `points.tsx`.
*
* Actions: `plugins.settings`, `plugins.installFromDisk`, `plugins.reloadAll`,
* `plugins.importIntellijTheme` (a theme plugin file; `arg`: its path),
* `plugins.importInstalledIntellijTheme` (from the JetBrains IDEs on this machine),
* plus one action per plugin command: `plugin.<pluginId>.<commandId>`
* (searchable in Search Everywhere › Actions and bindable in the keymap editor;
* a string arg is forwarded to the plugin).
@@ -17,12 +19,20 @@ import { lazyComponent } from "@/ui";
import { executeAction, registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { editorService, registerOverlay, registerSettingsPage } from "@/workbench/contributions";
import { followPluginChanges, startPluginBridge, syncProjection, trackPluginTheme } from "./bridge";
import {
followPluginChanges,
providePluginThemePreviews,
startPluginBridge,
syncProjection,
trackPluginTheme,
} from "./bridge";
import { ConsentDialogHost, provideConsentDialog } from "./consent";
import { trackIconTheme } from "./iconThemes";
import { pluginLineMarkerGutter } from "./lineMarkerGutter";
import { defineBuiltinUiPoints } from "./points";
import { startViewUi } from "./uiPoints";
import { importIntellijTheme, showInstalledIntellijThemes } from "./intellijImport";
import { IntellijThemesDialogHost } from "./IntellijThemesDialog";
import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, usePlugins } from "./store";
function registerPluginsPage(): () => void {
@@ -41,6 +51,7 @@ export function activateSettingsPages(): () => void {
const disposers = [
registerPluginsPage(),
registerOverlay({ id: "plugins.consent", component: ConsentDialogHost }),
registerOverlay({ id: "plugins.intellijThemes", component: IntellijThemesDialogHost }),
provideConsentDialog(),
defineBuiltinUiPoints(),
followPluginChanges(),
@@ -56,7 +67,9 @@ export function activate(): () => void {
const disposers = [
registerPluginsPage(),
registerOverlay({ id: "plugins.consent", component: ConsentDialogHost }),
registerOverlay({ id: "plugins.intellijThemes", component: IntellijThemesDialogHost }),
provideConsentDialog(),
providePluginThemePreviews(),
registerActions([
{
id: "plugins.settings",
@@ -76,6 +89,27 @@ export function activate(): () => void {
await installFromDisk(typeof arg === "string" ? arg : undefined);
},
},
{
id: "plugins.importIntellijTheme",
title: "Import IntelliJ Theme from File…",
category: "Plugins",
icon: "plugin",
keywords: ["intellij", "jetbrains", "theme", "jar", "theme.json", "color scheme"],
// Installing needs a project's window.
when: "projectOpen",
run: async ({ arg }) => {
await importIntellijTheme(typeof arg === "string" ? arg : undefined);
},
},
{
id: "plugins.importInstalledIntellijTheme",
title: "Import Theme from IntelliJ IDE…",
category: "Plugins",
icon: "plugin",
keywords: ["intellij", "jetbrains", "theme", "color scheme", "migrate"],
when: "projectOpen",
run: () => showInstalledIntellijThemes(),
},
{
id: "plugins.reloadAll",
title: "Reload Plugins",
+61
Переглянути файл
@@ -0,0 +1,61 @@
/**
* Settings › Plugins › Import IntelliJ Theme: an IntelliJ theme plugin (a
* Marketplace `.jar` / `.zip`, a plugin folder of an installed IDE) or a
* `.theme.json` becomes an OpenIDEA theme plugin — read by the host, converted
* here (`intellijTheme.ts`), installed through the usual install page — and
* its theme is applied.
*/
import { create } from "zustand";
import { pluginThemeRef } from "@/platform/themes";
import { notifications } from "@/workbench/contributions";
import { setTheme } from "@/workbench/theme";
import { pluginsBackend } from "./backend";
import { intellijThemePackage, type ThemePackage } from "./intellijTheme";
import { errorText, installFromDisk } from "./store";
/**
* Imports the IntelliJ theme plugin at `path` (asks for a file without one)
* and applies its theme `themeId` (an IntelliJ theme id), or its first.
* Returns the installed plugin's id.
*/
export async function importIntellijTheme(path?: string, themeId?: string): Promise<string | null> {
const backend = pluginsBackend();
let pkg: ThemePackage;
let dir: string;
try {
const picked = path ?? (await backend.pickIntellijTheme());
if (!picked) return null;
pkg = intellijThemePackage(await backend.readIntellijTheme(picked));
dir = await backend.stagePackage(pkg.id, pkg.files);
} catch (e) {
notifications().notify({
group: "plugins",
level: "error",
title: "Could not import the IntelliJ theme",
body: errorText(e),
});
return null;
}
const id = await installFromDisk(dir);
if (!id) return null;
const theme = pkg.themes.find((t) => t.sourceId === themeId) ?? pkg.themes[0];
if (theme) await setTheme(pluginThemeRef(id, theme.id));
return id;
}
interface DialogState {
open: boolean;
/** Called with the plugin id after an import from the dialog. */
onImported: ((id: string) => void) | null;
}
export const useIntellijThemesDialog = create<DialogState>(() => ({ open: false, onImported: null }));
/** Shows the themes of the JetBrains IDEs on this machine to pick one to import. */
export function showInstalledIntellijThemes(onImported?: (id: string) => void): void {
useIntellijThemesDialog.setState({ open: true, onImported: onImported ?? null });
}
export function closeInstalledIntellijThemes(): void {
useIntellijThemesDialog.setState({ open: false, onImported: null });
}
+414
Переглянути файл
@@ -0,0 +1,414 @@
/**
* Converts an IntelliJ theme (`.theme.json`, optionally with its editor scheme
* `.icls`) into an OpenIDEA theme + editor scheme, and an IntelliJ theme
* plugin into an OpenIDEA theme plugin package. The mapping covers the main
* keys only; anything unmapped keeps the built-in value of the base (`kind`).
*
* Pure functions without imports: Settings › Plugins › Import IntelliJ Theme
* uses them on what the host read from the plugin (`plugin_read_intellij_theme`),
* and so does the command line (`scripts/theme/import-intellij-theme.ts`).
*/
/** OpenIDEA token ← IntelliJ `ui` keys, first present wins. `*.prop` wildcards apply too. */
export const UI_KEYS: Record<string, string[]> = {
"--bg-app": ["Panel.background"],
"--bg-frame": ["MainWindow.background", "MainToolbar.background", "Panel.background"],
"--bg-island": ["ToolWindow.background", "Panel.background"],
"--bg-elev": ["ToolWindow.background", "SidePanel.background"],
"--bg-popup": ["Popup.background", "PopupMenu.background"],
"--bg-floating-toolbar": ["Toolbar.Floating.background"],
"--bg-backdrop": ["Popup.background"],
"--bg-hover": ["List.hoverBackground", "Tree.hoverBackground"],
"--bg-selected": ["List.selectionBackground", "Tree.selectionBackground"],
"--bg-selected-strong": ["List.selectionBackground"],
"--bg-tree-selected": ["Tree.selectionBackground", "List.selectionBackground"],
"--bg-tree-selected-inactive": ["Tree.selectionInactiveBackground", "List.selectionInactiveBackground"],
"--bg-input": ["TextField.background"],
"--bg-field": ["TextField.background"],
"--bg-dialog": ["Dialog.background", "Panel.background"],
"--border": ["Borders.color"],
"--border-popup": ["Popup.borderColor", "Popup.Border.color"],
"--border-strong": ["Component.borderColor", "Borders.ContrastBorderColor"],
"--border-window": ["Borders.ContrastBorderColor", "Component.borderColor"],
"--fg": ["Label.foreground"],
"--fg-emphasis": ["Label.foreground"],
"--fg-strong": ["Label.selectedForeground", "Label.foreground"],
"--fg-secondary": ["Label.infoForeground", "Component.infoForeground"],
"--fg-muted": ["Label.infoForeground", "Component.infoForeground"],
"--fg-subtle": ["Label.disabledForeground"],
"--fg-faint": ["Label.disabledForeground"],
"--accent": ["Button.default.startBackground", "Button.default.background", "Component.focusColor"],
"--fg-on-accent": ["Button.default.foreground"],
"--fg-button-primary": ["Button.default.foreground"],
"--focus-ring-soft": ["Component.focusColor"],
"--link": ["Link.activeForeground", "Hyperlink.linkColor"],
"--link-hover": ["Link.hoverForeground", "Link.activeForeground"],
"--danger": ["Component.errorFocusColor"],
"--warning": ["Component.warningFocusColor"],
"--bg-tooltip": ["ToolTip.background"],
"--fg-tooltip": ["ToolTip.foreground"],
"--border-tooltip": ["ToolTip.borderColor"],
"--bg-button": ["Button.startBackground", "Button.background"],
"--border-button": ["Button.startBorderColor", "Button.borderColor"],
"--bg-checkbox": ["Checkbox.Background.Default"],
"--border-checkbox": ["Checkbox.Border.Default"],
"--bg-tab-selected": ["EditorTabs.underlinedTabBackground", "EditorTabs.selectedBackground"],
"--border-tab-selected": ["EditorTabs.underlineColor"],
"--bg-tab-hover": ["EditorTabs.hoverBackground"],
"--border-editor-tabs": ["EditorTabs.borderColor"],
"--bg-toolbar-hover": ["ActionButton.hoverBackground"],
"--bg-toolbar-pressed": ["ActionButton.pressedBackground"],
"--bg-stripe-hover": ["ToolWindow.Button.hoverBackground"],
"--bg-stripe-selected": ["ToolWindow.Button.selectedBackground"],
"--scrollbar-thumb": ["ScrollBar.Mac.thumbColor", "ScrollBar.thumbColor"],
"--scrollbar-thumb-hover": ["ScrollBar.Mac.hoverThumbColor", "ScrollBar.hoverThumbColor"],
"--bg-progress-track": ["ProgressBar.trackColor"],
"--bg-counter": ["Counter.background"],
"--fg-counter": ["Counter.foreground"],
"--bg-search-match": ["SearchMatch.startBackground", "SearchMatch.endBackground"],
"--bg-toggle-on": ["ToggleButton.onBackground"],
"--fg-toggle-on": ["ToggleButton.onForeground"],
"--bg-toggle-off": ["ToggleButton.offBackground"],
"--fg-toggle-off": ["ToggleButton.offForeground"],
"--border-toggle": ["ToggleButton.borderColor"],
"--bg-toggle-knob": ["ToggleButton.buttonColor"],
"--bg-feedback-info": ["Banner.infoBackground"],
"--border-feedback-info": ["Banner.infoBorderColor"],
"--bg-feedback-error": ["Banner.errorBackground"],
"--border-feedback-error": ["Banner.errorBorderColor"],
"--bg-feedback-warning": ["Banner.warningBackground"],
"--border-feedback-warning": ["Banner.warningBorderColor"],
"--bg-feedback-success": ["Banner.successBackground"],
"--border-feedback-success": ["Banner.successBorderColor"],
};
/** `.icls` `<colors>` options → tokens. */
export const ICLS_COLORS: Record<string, string[]> = {
CARET_COLOR: ["--caret"],
CARET_ROW_COLOR: ["--bg-current-line"],
SELECTION_BACKGROUND: ["--bg-editor-selection"],
LINE_NUMBERS_COLOR: ["--fg-line-number"],
LINE_NUMBER_ON_CARET_ROW_COLOR: ["--fg-line-number-current"],
GUTTER_BACKGROUND: ["--bg-editor-gutter"],
INDENT_GUIDE: ["--border-indent-guide"],
SELECTED_INDENT_GUIDE: ["--border-indent-guide-active"],
RIGHT_MARGIN_COLOR: ["--border-right-margin"],
WHITESPACES: ["--fg-whitespace"],
METHOD_SEPARATORS_COLOR: ["--border-method-separator"],
FILESTATUS_MODIFIED: ["--vcs-modified"],
FILESTATUS_ADDED: ["--vcs-added"],
FILESTATUS_DELETED: ["--vcs-deleted"],
FILESTATUS_UNKNOWN: ["--vcs-untracked"],
FILESTATUS_IDEA_FILESTATUS_IGNORED: ["--vcs-ignored"],
FILESTATUS_IDEA_FILESTATUS_MERGED_WITH_CONFLICTS: ["--vcs-conflict"],
MODIFIED_LINES_COLOR: ["--gutter-modified"],
ADDED_LINES_COLOR: ["--gutter-added"],
DELETED_LINES_COLOR: ["--gutter-deleted"],
};
type Part = "FOREGROUND" | "BACKGROUND" | "EFFECT_COLOR";
/** `.icls` `<attributes>` options → [part, token] pairs. */
export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
TEXT: [
["FOREGROUND", "--fg-editor"],
["BACKGROUND", "--bg-editor"],
],
DIFF_MODIFIED: [["BACKGROUND", "--diff-modified"]],
DIFF_INSERTED: [["BACKGROUND", "--diff-inserted"]],
DIFF_DELETED: [["BACKGROUND", "--diff-deleted"]],
DEFAULT_KEYWORD: [["FOREGROUND", "--syn-keyword"]],
DEFAULT_STRING: [["FOREGROUND", "--syn-string"]],
DEFAULT_NUMBER: [["FOREGROUND", "--syn-number"]],
DEFAULT_LINE_COMMENT: [["FOREGROUND", "--syn-comment"]],
DEFAULT_DOC_COMMENT: [["FOREGROUND", "--syn-doc-comment"]],
DEFAULT_FUNCTION_DECLARATION: [["FOREGROUND", "--syn-function"]],
DEFAULT_FUNCTION_CALL: [["FOREGROUND", "--syn-function-call"]],
DEFAULT_CLASS_NAME: [["FOREGROUND", "--syn-type"]],
DEFAULT_CONSTANT: [["FOREGROUND", "--syn-constant"]],
DEFAULT_INSTANCE_FIELD: [["FOREGROUND", "--syn-property"]],
DEFAULT_IDENTIFIER: [["FOREGROUND", "--syn-variable"]],
DEFAULT_LOCAL_VARIABLE: [["FOREGROUND", "--syn-variable"]],
DEFAULT_PARAMETER: [["FOREGROUND", "--syn-param"]],
DEFAULT_PREDEFINED_SYMBOL: [["FOREGROUND", "--syn-builtin"]],
DEFAULT_METADATA: [
["FOREGROUND", "--syn-annotation"],
["FOREGROUND", "--syn-decorator"],
],
DEFAULT_OPERATION_SIGN: [["FOREGROUND", "--syn-punct"]],
DEFAULT_VALID_STRING_ESCAPE: [["FOREGROUND", "--syn-escape"]],
DEFAULT_TAG: [["FOREGROUND", "--syn-tag"]],
DEFAULT_ATTRIBUTE: [["FOREGROUND", "--syn-attribute"]],
TODO_DEFAULT_ATTRIBUTES: [["FOREGROUND", "--syn-todo"]],
BAD_CHARACTER: [["FOREGROUND", "--syn-invalid"]],
RAINBOW_COLOR1: [["FOREGROUND", "--syn-rainbow-1"]],
RAINBOW_COLOR2: [["FOREGROUND", "--syn-rainbow-2"]],
RAINBOW_COLOR3: [["FOREGROUND", "--syn-rainbow-3"]],
RAINBOW_COLOR4: [["FOREGROUND", "--syn-rainbow-4"]],
RAINBOW_COLOR5: [["FOREGROUND", "--syn-rainbow-5"]],
ERRORS_ATTRIBUTES: [["EFFECT_COLOR", "--wave-error"]],
WARNING_ATTRIBUTES: [["EFFECT_COLOR", "--wave-warning"]],
NOT_USED_ELEMENT_ATTRIBUTES: [["FOREGROUND", "--fg-unused"]],
MATCHED_BRACE_ATTRIBUTES: [["BACKGROUND", "--bg-matched-brace"]],
SEARCH_RESULT_ATTRIBUTES: [["BACKGROUND", "--bg-search-match"]],
TEXT_SEARCH_RESULT_ATTRIBUTES: [["BACKGROUND", "--bg-text-search-result"]],
IDENTIFIER_UNDER_CARET_ATTRIBUTES: [["BACKGROUND", "--bg-identifier-under-caret"]],
WRITE_IDENTIFIER_UNDER_CARET_ATTRIBUTES: [["BACKGROUND", "--bg-identifier-under-caret-write"]],
FOLDED_TEXT_ATTRIBUTES: [
["FOREGROUND", "--fg-fold-placeholder"],
["BACKGROUND", "--bg-fold-placeholder"],
],
INLINE_PARAMETER_HINT: [
["FOREGROUND", "--fg-inlay"],
["BACKGROUND", "--bg-inlay"],
],
BREAKPOINT_ATTRIBUTES: [["BACKGROUND", "--bg-breakpoint-line"]],
DEBUGGER_INLINED_VALUES: [["FOREGROUND", "--syn-inline-value"]],
"PY.SELF_PARAMETER": [["FOREGROUND", "--syn-self"]],
"PY.KEYWORD_ARGUMENT": [["FOREGROUND", "--syn-keyword-arg"]],
"PY.DOC_COMMENT": [["FOREGROUND", "--syn-docstring"]],
"PY.BUILTIN_NAME": [["FOREGROUND", "--syn-predefined"]],
MARKDOWN_HEADER_LEVEL_1: [["FOREGROUND", "--syn-markup-heading"]],
MARKDOWN_CODE_SPAN: [["FOREGROUND", "--syn-markup-code"]],
MARKDOWN_LINK_TEXT: [["FOREGROUND", "--syn-link"]],
CONSOLE_BLACK_OUTPUT: [["FOREGROUND", "--ansi-black"]],
CONSOLE_RED_OUTPUT: [["FOREGROUND", "--ansi-red"]],
CONSOLE_GREEN_OUTPUT: [["FOREGROUND", "--ansi-green"]],
CONSOLE_YELLOW_OUTPUT: [["FOREGROUND", "--ansi-yellow"]],
CONSOLE_BLUE_OUTPUT: [["FOREGROUND", "--ansi-blue"]],
CONSOLE_MAGENTA_OUTPUT: [["FOREGROUND", "--ansi-magenta"]],
CONSOLE_CYAN_OUTPUT: [["FOREGROUND", "--ansi-cyan"]],
CONSOLE_GRAY_OUTPUT: [["FOREGROUND", "--ansi-white"]],
CONSOLE_DARKGRAY_OUTPUT: [["FOREGROUND", "--ansi-bright-black"]],
CONSOLE_RED_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-red"]],
CONSOLE_GREEN_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-green"]],
CONSOLE_YELLOW_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-yellow"]],
CONSOLE_BLUE_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-blue"]],
CONSOLE_MAGENTA_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-magenta"]],
CONSOLE_CYAN_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-cyan"]],
CONSOLE_WHITE_OUTPUT: [["FOREGROUND", "--ansi-bright-white"]],
};
export interface ConvertedTheme {
theme: { id: string; name: string; kind: "dark" | "light"; tokens: Record<string, string> };
scheme: { name: string; tokens: Record<string, string> } | null;
}
type Json = string | number | boolean | null | Json[] | { [k: string]: Json };
/** `"Button": {"default": {"startBackground": x}}` → `Button.default.startBackground`; `"*": {...}` → `*.prop`. */
function flatten(obj: { [k: string]: Json }, prefix = "", out = new Map<string, string>()): Map<string, string> {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (typeof v === "string") out.set(key, v);
else if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
}
return out;
}
const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
/** IntelliJ colour (`#rrggbb`, `#rrggbbaa`, or a named colour from `colors`) → CSS hex. */
function resolveColor(value: string, named: Map<string, string>, depth = 0): string | null {
const m = HEX.exec(value.trim());
if (m) return `#${m[1]!.toLowerCase()}`;
const ref = named.get(value);
return ref !== undefined && depth < 8 ? resolveColor(ref, named, depth + 1) : null;
}
/** `.icls` hex: RGB without `#`, leading zeros dropped (`"0"` is black, `"ff"` is #0000ff). */
function iclsColor(value: string): string | null {
const v = value.trim().toLowerCase();
if (!/^[0-9a-f]{1,8}$/.test(v)) return null;
return `#${v.length <= 6 ? v.padStart(6, "0") : v.padStart(8, "0")}`;
}
const attr = (tag: string, name: string) => new RegExp(`${name}="([^"]*)"`).exec(tag)?.[1];
/** Parses the colours and attributes of an `.icls` file into tokens. */
export function convertIcls(xml: string): { name: string; dark: boolean | null; tokens: Record<string, string> } {
const tokens: Record<string, string> = {};
const set = (names: string[], color: string | null) => {
if (color) for (const n of names) tokens[n] ??= color;
};
const schemeTag = /<scheme\b[^>]*>/.exec(xml)?.[0] ?? "";
const name = attr(schemeTag, "name") ?? "Imported";
const parent = attr(schemeTag, "parent_scheme") ?? "";
const dark = parent ? /darcula|dark/i.test(parent) : null;
const colors = /<colors>([\s\S]*?)<\/colors>/.exec(xml)?.[1] ?? "";
for (const m of colors.matchAll(/<option\b[^>]*\/>/g)) {
const key = attr(m[0], "name");
const value = attr(m[0], "value");
if (key && value !== undefined && ICLS_COLORS[key]) set(ICLS_COLORS[key], iclsColor(value));
}
const attributes = /<attributes>([\s\S]*?)<\/attributes>/.exec(xml)?.[1] ?? "";
for (const m of attributes.matchAll(/<option name="([^"]+)"[^>]*>\s*<value>([\s\S]*?)<\/value>\s*<\/option>/g)) {
const map = ICLS_ATTRIBUTES[m[1]!];
if (!map) continue;
const parts = new Map<string, string>();
for (const o of m[2]!.matchAll(/<option\b[^>]*\/>/g)) {
const k = attr(o[0], "name");
const v = attr(o[0], "value");
if (k && v !== undefined) parts.set(k, v);
}
for (const [part, token] of map) set([token], iclsColor(parts.get(part) ?? ""));
// FONT_TYPE: 1 bold, 2 italic, 3 bold italic.
if (m[1] === "DEFAULT_LINE_COMMENT" && parts.has("FONT_TYPE")) {
tokens["--syn-comment-style"] = Number(parts.get("FONT_TYPE")) & 2 ? "italic" : "normal";
}
}
if (tokens["--wave-error"]) tokens["--editor-error-decoration"] = `underline wavy ${tokens["--wave-error"]}`;
if (tokens["--bg-editor"]) tokens["--bg-editor-gutter"] ??= tokens["--bg-editor"];
return { name, dark, tokens };
}
export function slug(name: string): string {
return (
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48) || "imported"
);
}
/** Converts an IntelliJ `.theme.json` (comments removed) and optionally its `.icls` into OpenIDEA theme files. */
export function convertIntellijTheme(themeJson: string, icls?: string, id?: string): ConvertedTheme {
const parsed = JSON.parse(themeJson) as Json;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Not an IntelliJ theme");
const src = parsed;
const name = typeof src.name === "string" ? src.name : "Imported Theme";
const named = new Map<string, string>();
if (src.colors && typeof src.colors === "object" && !Array.isArray(src.colors)) {
for (const [k, v] of Object.entries(src.colors)) if (typeof v === "string") named.set(k, v);
}
const ui =
src.ui && typeof src.ui === "object" && !Array.isArray(src.ui) ? flatten(src.ui) : new Map<string, string>();
const lookup = (key: string): string | null => {
const v = ui.get(key) ?? ui.get(`*.${key.slice(key.lastIndexOf(".") + 1)}`);
return v === undefined ? null : resolveColor(v, named);
};
const tokens: Record<string, string> = {};
for (const [token, keys] of Object.entries(UI_KEYS)) {
for (const key of keys) {
const color = lookup(key);
if (color) {
tokens[token] = color;
break;
}
}
}
const scheme = icls ? convertIcls(icls) : null;
const dark = typeof src.dark === "boolean" ? src.dark : (scheme?.dark ?? true);
// The editor surface sits on an island in OpenIDEA: keep it on the editor background.
if (scheme?.tokens["--bg-editor"] && !tokens["--bg-island"]) tokens["--bg-island"] = scheme.tokens["--bg-editor"];
return {
theme: { id: id ?? slug(name), name, kind: dark ? "dark" : "light", tokens },
scheme: scheme ? { name: scheme.name, tokens: scheme.tokens } : null,
};
}
/** A TOML basic string: quotes, backslashes and control characters escaped, lone surrogates replaced. */
function tomlString(s: string): string {
let body = "";
for (const ch of s) {
const code = ch.codePointAt(0)!;
if (ch === '"' || ch === "\\") body += `\\${ch}`;
else if (code < 0x20 || code === 0x7f) body += `\\u${code.toString(16).padStart(4, "0")}`;
// `for…of` pairs surrogates, so one in this range is alone.
else if (code >= 0xd800 && code <= 0xdfff) body += "�";
else body += ch;
}
return `"${body}"`;
}
export interface ThemePackageInfo {
id: string;
name: string;
version: string;
description: string;
authors: string[];
}
/** `plugin.toml` for converted themes, each at `themes/<id>.theme.json` (+ `.scheme.json`). */
export function pluginManifest(info: ThemePackageInfo, themes: readonly ConvertedTheme[], comment?: string): string {
return [
...(comment ? [`# ${comment.replace(/[\r\n]+/g, " ")}`] : []),
`id = ${tomlString(info.id)}`,
`name = ${tomlString(info.name)}`,
`version = ${tomlString(info.version)}`,
`description = ${tomlString(info.description)}`,
`authors = [${info.authors.map(tomlString).join(", ")}]`,
`engines.openidea = "^1.0"`,
...themes.flatMap((c) => [
"",
"[[contributes.themes]]",
`path = ${tomlString(`themes/${c.theme.id}.theme.json`)}`,
...(c.scheme ? [`editor-scheme = ${tomlString(`themes/${c.theme.id}.scheme.json`)}`] : []),
]),
"",
].join("\n");
}
/** An IntelliJ theme plugin as the host read it (`IntellijThemeSource`). */
export interface IntellijThemeSource {
name: string;
pluginId: string | null;
version: string | null;
vendor: string | null;
themes: { id: string; theme: string; editorScheme: string | null }[];
}
export interface ThemePackage {
id: string;
name: string;
files: { path: string; text: string }[];
/** The package's themes: their id here and in the IntelliJ plugin (`sourceId`). */
themes: { id: string; sourceId: string; name: string }[];
}
/** `2.3` → `2.3.0`; IntelliJ plugin versions are not always semver. */
function semver(version: string | null): string {
const m = /^\s*v?(\d{1,9})(?:\.(\d{1,9}))?(?:\.(\d{1,9}))?/.exec(version ?? "");
return m ? `${Number(m[1])}.${Number(m[2] ?? 0)}.${Number(m[3] ?? 0)}` : "1.0.0";
}
/** An OpenIDEA theme plugin (`intellij.<plugin>`) with every theme of an IntelliJ theme plugin. */
export function intellijThemePackage(source: IntellijThemeSource): ThemePackage {
const ids = new Set<string>();
const converted = source.themes.map((t) => {
const c = convertIntellijTheme(t.theme, t.editorScheme ?? undefined);
let id = c.theme.id;
for (let n = 2; ids.has(id); n++) id = `${c.theme.id.slice(0, 44)}-${n}`;
ids.add(id);
c.theme.id = id;
return { sourceId: t.id, converted: c };
});
if (converted.length === 0) throw new Error("The plugin has no themes");
const themes = converted.map((c) => c.converted);
// Package metadata, not UI text: the plugin list shows it as written.
const description = `${source.name}, imported from an IntelliJ theme plugin.`;
const info: ThemePackageInfo = {
id: `intellij.${slug(source.pluginId ?? source.name)}`,
name: source.name,
version: semver(source.version),
description,
authors: source.vendor ? [source.vendor] : [],
};
const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
return {
id: info.id,
name: info.name,
files: [
{ path: "plugin.toml", text: pluginManifest(info, themes) },
...themes.flatMap((c) => [
{ path: `themes/${c.theme.id}.theme.json`, text: json(c.theme) },
...(c.scheme ? [{ path: `themes/${c.theme.id}.scheme.json`, text: json(c.scheme) }] : []),
]),
],
themes: converted.map(({ sourceId, converted: c }) => ({ id: c.theme.id, sourceId, name: c.theme.name })),
};
}
+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). */
+11 -3
Переглянути файл
@@ -45,6 +45,8 @@ export type MenuItem =
checked?: boolean;
danger?: boolean;
run?: () => void;
/** The pointer or the keyboard made it the active item (not on opening the menu). */
onActive?: () => void;
submenu?: MenuItem[];
};
@@ -58,6 +60,7 @@ interface Resolved {
checked?: boolean;
danger?: boolean;
run?: () => void;
onActive?: () => void;
submenu?: MenuItem[];
}
@@ -132,6 +135,11 @@ export function MenuList({ items, onDone, onCloseLevel, autoFocusFirst = true, .
}, [active, openSub]);
useEffect(() => () => clearTimeout(hoverTimer.current), []);
const focusItem = (i: number) => {
setActive(i);
resolved[i]?.onActive?.();
};
const move = (delta: number, from = active) => {
const n = resolved.length;
if (!n) return;
@@ -139,7 +147,7 @@ export function MenuList({ items, onDone, onCloseLevel, autoFocusFirst = true, .
for (let step = 0; step < n; step++) {
i = (i + delta + n) % n;
if (selectable(resolved[i])) {
setActive(i);
focusItem(i);
return;
}
}
@@ -191,7 +199,7 @@ export function MenuList({ items, onDone, onCloseLevel, autoFocusFirst = true, .
const i = (active + s + n) % n;
const r = resolved[i]!;
if (selectable(r) && r.label.toLowerCase().startsWith(ch)) {
setActive(i);
focusItem(i);
break;
}
}
@@ -235,7 +243,7 @@ export function MenuList({ items, onDone, onCloseLevel, autoFocusFirst = true, .
)}
onPointerEnter={() => {
if (r.disabled) return;
setActive(i);
focusItem(i);
clearTimeout(hoverTimer.current);
hoverTimer.current = setTimeout(() => setOpenSub(hasSub ? i : null), hasSub ? 120 : 200);
}}
+116
Переглянути файл
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { ContextMenuHost, hideContextMenu, MenuList } from "@/ui";
import { showQuickSwitch, THEME_PREVIEW_DELAY, themePreview } from "./quickSwitch";
import {
applyThemeAttributes,
currentThemeId,
STARTUP_THEME_KEY,
syncWindowBackground,
useThemeOverride,
} from "./theme";
const root = document.documentElement;
const shown = () => [root.dataset.theme, root.dataset.colorTheme ?? null];
beforeEach(() => {
vi.useFakeTimers();
useThemeOverride.setState({ theme: null });
applyThemeAttributes(currentThemeId());
});
afterEach(() => {
act(() => hideContextMenu());
vi.useRealTimers();
useThemeOverride.setState({ theme: null });
applyThemeAttributes(currentThemeId());
});
describe("menu items", () => {
it("report becoming active by pointer or keyboard, not on opening", () => {
const active: string[] = [];
const item = (label: string) => ({ label, onActive: () => active.push(label), run: () => {} });
render(<MenuList items={[item("One"), item("Two"), item("Three")]} onDone={() => {}} />);
expect(active).toEqual([]);
fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowDown" });
fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Three" }));
fireEvent.keyDown(screen.getByRole("menu"), { key: "o" });
expect(active).toEqual(["Two", "Three", "One"]);
});
});
describe("Quick Switch theme preview", () => {
it("shows the active theme after a moment, in this window only, and takes it back", () => {
syncWindowBackground();
const startup = localStorage.getItem(STARTUP_THEME_KEY);
const before = shown();
const preview = themePreview();
preview.preview("solarized-dark");
vi.advanceTimersByTime(THEME_PREVIEW_DELAY - 1);
expect(shown()).toEqual(before);
// Moving on restarts the delay: only the theme the user stays on shows.
preview.preview("one-light");
vi.advanceTimersByTime(THEME_PREVIEW_DELAY - 1);
expect(shown()).toEqual(before);
vi.advanceTimersByTime(1);
expect(shown()).toEqual(["light", "one-light"]);
// Nothing is selected or saved, not even as the next start-up's theme.
expect(useThemeOverride.getState().theme).toBeNull();
expect(localStorage.getItem(STARTUP_THEME_KEY)).toBe(startup);
preview.restore();
expect(shown()).toEqual(before);
expect(localStorage.getItem(STARTUP_THEME_KEY)).toBe(startup);
preview.preview("solarized-dark");
vi.advanceTimersByTime(THEME_PREVIEW_DELAY);
preview.end();
expect(shown()).toEqual(before);
});
it("selects the chosen theme and keeps it when the menu closes", () => {
const preview = themePreview();
preview.preview("solarized-dark");
vi.advanceTimersByTime(THEME_PREVIEW_DELAY);
preview.choose("one-dark");
expect(useThemeOverride.getState().theme).toBe("one-dark");
expect(shown()).toEqual(["dark", "one-dark"]);
preview.end();
expect(shown()).toEqual(["dark", "one-dark"]);
});
it("previews from the Theme submenu and takes the preview back when the menu closes", async () => {
const before = shown();
render(<ContextMenuHost />);
act(() => showQuickSwitch({ x: 10, y: 10 }));
fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Theme" }));
act(() => vi.advanceTimersByTime(200));
fireEvent.pointerEnter(screen.getByRole("menuitemcheckbox", { name: "Solarized Light" }));
act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY));
expect(shown()).toEqual(["light", "solarized-light"]);
// Back on another entry: the selected theme again.
fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Keymap" }));
expect(shown()).toEqual(before);
fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Theme" }));
act(() => vi.advanceTimersByTime(200));
fireEvent.pointerEnter(screen.getByRole("menuitemcheckbox", { name: "Solarized Light" }));
act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY));
act(() => hideContextMenu());
await act(async () => {});
expect(shown()).toEqual(before);
expect(useThemeOverride.getState().theme).toBeNull();
});
it("selects a theme clicked in the Theme submenu", async () => {
render(<ContextMenuHost />);
act(() => showQuickSwitch({ x: 10, y: 10 }));
fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Theme" }));
act(() => vi.advanceTimersByTime(200));
fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Solarized Dark" }));
await act(async () => {});
expect(useThemeOverride.getState().theme).toBe("solarized-dark");
expect(shown()).toEqual(["dark", "solarized-dark"]);
expect(screen.queryByRole("menu")).toBeNull();
});
});
+84 -17
Переглянути файл
@@ -7,9 +7,9 @@ import { listSchemes } from "@/platform/colorScheme";
import { editorPrefs } from "@/platform/editorSettings";
import { PRESET_TITLES, useKeymap } from "@/platform/keymap";
import { useProject } from "@/platform/project";
import type { MenuItem } from "@/ui";
import { themeChoices } from "@/platform/themes";
import { currentThemeChoice, setTheme } from "./theme";
import { showContextMenu, useContextMenuStore, type MenuItem } from "@/ui";
import { themeGroups, type ThemeChoiceId } from "@/platform/themes";
import { currentThemeChoice, previewTheme, setTheme } from "./theme";
async function setGlobal(key: string, value: unknown, what: string) {
if (!isTauri()) return;
@@ -22,15 +22,67 @@ async function setGlobal(key: string, value: unknown, what: string) {
export const selectKeymap = (id: string) => setGlobal("keymap", id, "keymap");
export function themeItems(themeId: string = currentThemeChoice()): MenuItem[] {
return themeChoices().map<MenuItem>((c) => ({
id: `theme.${c.id}`,
label: c.label,
// Plugin themes keep their own names.
verbatim: !!c.origin,
checked: c.id === themeId,
run: () => void setTheme(c.id),
}));
/** How long a theme stays under the pointer or keyboard before Quick Switch shows it (IDEA's delay). */
export const THEME_PREVIEW_DELAY = 500;
/**
* IDEA's instant theme switch (`ide.instant.theme.switch`): in Quick Switch
* the active theme shows after a moment, in this window only; choosing one
* selects it, and closing the menu without a choice brings the selected
* theme back.
*/
export interface ThemePreview {
preview(id: ThemeChoiceId): void;
/** Back to the selected theme (another Quick Switch entry became active). */
restore(): void;
choose(id: ThemeChoiceId): void;
/** The menu closed. */
end(): void;
}
export function themePreview(): ThemePreview {
let timer: ReturnType<typeof setTimeout> | undefined;
let shown: ThemeChoiceId | null = null;
let chosen = false;
const restore = () => {
clearTimeout(timer);
if (shown !== null && !chosen) previewTheme(null);
shown = null;
};
return {
preview(id) {
clearTimeout(timer);
timer = setTimeout(() => {
shown = id;
previewTheme(id);
}, THEME_PREVIEW_DELAY);
},
restore,
choose(id) {
clearTimeout(timer);
chosen = true;
// Shown right away; selecting it then changes nothing visible.
if (shown !== id) previewTheme(id);
void setTheme(id);
},
end: restore,
};
}
/** The built-in themes, then each plugin's, a separator between them (as IDEA's Theme list). */
export function themeItems(themeId: string = currentThemeChoice(), preview?: ThemePreview): MenuItem[] {
return themeGroups().flatMap<MenuItem>((group, i) => [
...(i > 0 ? [{ kind: "separator" as const }] : []),
...group.themes.map<MenuItem>((c) => ({
id: `theme.${c.id}`,
label: c.label,
// Plugin themes keep their own names.
verbatim: !!c.origin,
checked: c.id === themeId,
onActive: preview && (() => preview.preview(c.id)),
run: () => (preview ? preview.choose(c.id) : void setTheme(c.id)),
})),
]);
}
export function keymapItems(keymapId: string, customKeymaps: string[]): MenuItem[] {
@@ -78,17 +130,32 @@ export function keymapState(): { keymapId: string; customs: string[] } {
};
}
/** View › Quick Switch Scheme…: IDEA's popup of the switchable schemes. */
export function quickSwitchItems(): MenuItem[] {
/** View › Quick Switch Scheme…: IDEA's popup of the switchable schemes, its themes previewed. */
export function quickSwitchItems(preview?: ThemePreview): MenuItem[] {
const { keymapId, customs } = keymapState();
const restore = preview && (() => preview.restore());
return [
{
id: "qs.scheme",
label: "Editor Color Scheme",
onActive: restore,
submenu: colorSchemeItems(useProject.getState().settings?.editor?.colorScheme),
},
{ id: "qs.keymap", label: "Keymap", submenu: keymapItems(keymapId, customs) },
{ id: "qs.viewMode", label: "View Mode", submenu: VIEW_MODE_ITEMS },
{ id: "qs.theme", label: "Theme", submenu: themeItems() },
{ id: "qs.keymap", label: "Keymap", onActive: restore, submenu: keymapItems(keymapId, customs) },
{ id: "qs.viewMode", label: "View Mode", onActive: restore, submenu: VIEW_MODE_ITEMS },
{ id: "qs.theme", label: "Theme", submenu: themeItems(undefined, preview) },
];
}
/** Opens Quick Switch Scheme; a theme previewed there goes away with the menu unless chosen. */
export function showQuickSwitch(at: { x: number; y: number }) {
const preview = themePreview();
showContextMenu(at, quickSwitchItems(preview));
const menu = useContextMenuStore.getState().menu;
const unsubscribe = useContextMenuStore.subscribe((s) => {
if (s.menu === menu) return;
unsubscribe();
// A chosen item runs right after the menu closes: let it choose first.
queueMicrotask(() => preview.end());
});
}
+2 -2
Переглянути файл
@@ -3,7 +3,7 @@ import { isTauri } from "@/platform/ipc";
import { closeProject, confirmLeaveProject, openProject, useProject } from "@/platform/project";
import { runSearchFieldCommand, showContextMenu } from "@/ui";
import { showCustomizeToolbar } from "./MainToolbarDialog";
import { quickSwitchItems } from "./quickSwitch";
import { showQuickSwitch } from "./quickSwitch";
import { notify } from "./notificationStore";
import { NOTIFICATIONS_TOOL_WINDOW } from "./Notifications";
import { activateToolWindow } from "./toolwindows/store";
@@ -96,7 +96,7 @@ export function registerShellActions(): () => void {
title: "Quick Switch Scheme…",
category: "View",
keywords: ["theme", "color scheme", "keymap", "view mode"],
run: () => showContextMenu({ x: window.innerWidth / 2 - 120, y: 96 }, quickSwitchItems()),
run: () => showQuickSwitch({ x: window.innerWidth / 2 - 120, y: 96 }),
},
{
id: "toolbar.customize",
+48 -8
Переглянути файл
@@ -6,8 +6,10 @@ import { call, commands, isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import {
baseTheme,
isBuiltinTheme,
isThemeChoiceId,
parsePluginThemeRef,
previewPluginTheme,
themeChoices,
themeKind,
useContributedThemes,
@@ -181,7 +183,7 @@ export function rememberStartupTheme(root: HTMLElement = document.documentElemen
* for the next window's start-up.
*/
export function syncWindowBackground() {
rememberStartupTheme();
if (!previewing) rememberStartupTheme();
if (!isTauri()) return;
const color = windowBackgroundColor();
if (!color) return;
@@ -231,6 +233,28 @@ export async function setTheme(next: ThemeSetting) {
}
}
/** A previewed theme is not the next start-up's (`rememberStartupTheme`). */
let previewing = false;
/**
* Shows `id` in this window without selecting it (Quick Switch's instant
* theme switch); `null` shows the selected theme again. Nothing is saved and
* other windows keep theirs.
*/
export function previewTheme(id: ThemeChoiceId | null) {
previewing = id !== null;
if (id === null) {
previewPluginTheme("selected");
applyThemeAttributes(currentThemeId());
} else if (isBuiltinTheme(id)) {
previewPluginTheme(null);
applyThemeAttributes(id);
} else {
previewPluginTheme(id);
}
syncWindowBackground();
}
export async function toggleTheme() {
await setTheme(currentTheme() === "dark" ? "light" : "dark");
}
@@ -244,14 +268,30 @@ export function originDetail(t: { label: string; origin?: string }): string | un
* Options for a theme picker: built-in themes, then plugins' (with the plugin
* named beside them). A selected plugin theme whose plugin is off stays listed.
*/
/** The themes for a picker: each plugin's under a heading with its name (`choices` in `themeGroups` order). */
export function themeSelectOptions(choices: readonly ThemeChoice[], selected?: string): SelectOption<ThemeChoiceId>[] {
const options: SelectOption<ThemeChoiceId>[] = choices.map((c) => ({
value: c.id,
label: c.label,
detail: originDetail(c),
}));
const options: SelectOption<ThemeChoiceId>[] = [];
let group: string | undefined;
for (const c of choices) {
const pluginId = parsePluginThemeRef(c.id)?.pluginId;
if (pluginId && pluginId !== group)
options.push({ value: `plugin:${pluginId}`, label: c.origin ?? pluginId, section: true });
group = pluginId;
// Under its plugin's heading a theme needs no origin.
options.push({ value: c.id, label: c.label });
}
const missing = parsePluginThemeRef(selected);
if (missing && !options.some((o) => o.value === selected))
options.push({ value: selected as ThemeChoiceId, label: missing.themeId, detail: "Plugin not enabled" });
if (missing && !options.some((o) => o.value === selected)) {
// Under its own plugin's heading, not the last one listed.
const option = { value: selected as ThemeChoiceId, label: missing.themeId, detail: "Plugin not enabled" };
const heading = options.findIndex((o) => o.section && o.value === `plugin:${missing.pluginId}`);
if (heading < 0)
options.push({ value: `plugin:${missing.pluginId}`, label: missing.pluginId, section: true }, option);
else {
let end = heading + 1;
while (end < options.length && !options[end]!.section) end++;
options.splice(end, 0, option);
}
}
return options;
}