дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
a610982655
коміт
6dec1252db
33 files changed
+2284
-87
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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)` },
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 })),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача