feat(plugins): let a plugin set its own icon (#1761)

A plugin can set `icon` (and `icon-dark`) in plugin.toml. The SVGs go through the existing sanitiser (16 KiB each), ship with the package, and show in Settings › Plugins (list rows and detail header) and the marketplace, whose index entries carry them; the IDE sanitises index icons again and drops unsafe ones.

IDEs before plugin API 1.2 refuse a manifest with `icon`, so Manifest::needs_api refuses one whose engines.openidea still admits them (check and install), keeping the index from offering such a package to older IDEs.

Closes #1732

Co-authored-by: arnaud-ritti <[email protected]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-06 22:45:42 +08:00
1 джерело 2dc59ef2e2
коміт e65825e661
18 files changed
+428 -33

No files matched your search

+17 -9
Переглянути файл
@@ -8,7 +8,14 @@
*/
import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react";
import clsx from "clsx";
import type { CapabilityInfo, CatalogPlugin, MarketPlugin, PluginInfo, PluginStatus } from "@/bindings";
import type {
CapabilityInfo,
CatalogPlugin,
MarketPlugin,
PluginIcon as PluginIconData,
PluginInfo,
PluginStatus,
} from "@/bindings";
import {
Badge,
Button,
@@ -25,6 +32,7 @@ import {
T,
} from "@/ui";
import { RISK_LABEL, sortByRisk } from "./consent";
import { pluginIconName } from "./pluginIcon";
import { itemsOf, uiPoint } from "./projection";
import {
compareVersions,
@@ -105,10 +113,10 @@ export function catalogState(
const vendorOf = (authors: readonly string[]) => authors.join(", ");
function PluginIcon({ large }: { large?: boolean }) {
function PluginIcon({ id, icon, large }: { id: string; icon: PluginIconData | null | undefined; large?: boolean }) {
return (
<span className={clsx(styles.icon, large && styles.iconLarge)} aria-hidden>
<Icon name="plugin" size={large ? 32 : 20} />
<Icon name={pluginIconName(id, icon)} size={large ? 32 : 20} />
</span>
);
}
@@ -133,7 +141,7 @@ function InstalledRow({ plugin, selected, onSelect }: { plugin: PluginInfo; sele
onClick={onSelect}
title={plugin.description || plugin.name}
>
<PluginIcon />
<PluginIcon id={plugin.id} icon={plugin.icon} />
<span className={styles.rowText}>
<span className={styles.rowName}>{plugin.name}</span>
<span className={styles.rowMeta}>
@@ -201,7 +209,7 @@ function CatalogRow({ entry, selected, onSelect }: { entry: CatalogPlugin; selec
onClick={onSelect}
title={entry.description || entry.name}
>
<PluginIcon />
<PluginIcon id={entry.id} icon={entry.icon} />
<span className={styles.rowText}>
<span className={styles.rowName}>{entry.name}</span>
<span className={styles.rowMeta}>
@@ -398,7 +406,7 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
return (
<div className={styles.details} aria-label={`${plugin.name} details`}>
<header className={styles.header}>
<PluginIcon large />
<PluginIcon id={plugin.id} icon={plugin.icon} large />
<div className={styles.headerText}>
<h2 className={styles.title}>{plugin.name}</h2>
{plugin.authors.length > 0 && <div className={styles.authors}>{vendorOf(plugin.authors)}</div>}
@@ -514,7 +522,7 @@ function CatalogDetails({ entry }: { entry: CatalogPlugin }) {
return (
<div className={styles.details} aria-label={`${entry.name} details`}>
<header className={styles.header}>
<PluginIcon large />
<PluginIcon id={entry.id} icon={entry.icon} large />
<div className={styles.headerText}>
<h2 className={styles.title}>{entry.name}</h2>
{entry.authors.length > 0 && <div className={styles.authors}>{vendorOf(entry.authors)}</div>}
@@ -681,7 +689,7 @@ function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selecte
onClick={onSelect}
title={entry.description || entry.name}
>
<PluginIcon />
<PluginIcon id={entry.id} icon={entry.icon} />
<span className={styles.rowText}>
<span className={styles.rowName}>{entry.name}</span>
<span className={styles.rowMeta}>
@@ -711,7 +719,7 @@ function MarketDetails({ entry, onInstalled }: { entry: MarketPlugin; onInstalle
return (
<div className={styles.details} aria-label={`${entry.name} details`}>
<header className={styles.header}>
<PluginIcon large />
<PluginIcon id={entry.id} icon={entry.icon} large />
<div className={styles.headerText}>
<h2 className={styles.title}>{entry.name}</h2>
<div className={styles.authors}>{entry.authors?.length ? vendorOf(entry.authors) : entry.publisher}</div>
+64
Переглянути файл
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { Icon } from "@/ui/icons";
import { pluginIconName } from "../pluginIcon";
const svg = (shape: string, root = "") => `<svg viewBox="0 0 16 16"${root}>${shape}</svg>`;
const rendered = (name: string) => render(<Icon name={name} size={20} />).container;
const markup = (name: string) => rendered(name).innerHTML;
describe("pluginIconName", () => {
it("gives plugins without an icon the generic one", () => {
expect(pluginIconName("a.b", null)).toBe("plugin");
expect(pluginIconName("a.b", undefined)).toBe("plugin");
});
it("renders a plugin's icon under a name of its own", () => {
const name = pluginIconName("a.b", { light: svg('<rect width="16" height="16"/>'), dark: null });
expect(name).toMatch(/^ext:pluginIcon\/a\.b\//);
expect(markup(name)).toContain("<rect");
});
it("keeps two icons of the same plugin id apart (a marketplace and a local copy)", () => {
const published = pluginIconName("a.b", { light: svg('<rect width="16" height="16"/>'), dark: null });
const local = pluginIconName("a.b", { light: svg('<circle cx="8" cy="8" r="8"/>'), dark: null });
expect(local).not.toBe(published);
expect(markup(published)).toContain("<rect");
expect(markup(local)).toContain("<circle");
});
it("registers the dark variant with it", () => {
const name = pluginIconName("c.d", {
light: svg('<rect width="16" height="16"/>'),
dark: svg('<circle cx="8" cy="8" r="8"/>'),
});
expect(markup(name)).toContain("<circle");
});
it("keeps the icons of plugins whose ids differ by _dark apart", () => {
const plain = pluginIconName("g", { light: svg('<rect width="16" height="16"/>'), dark: null });
const other = pluginIconName("g_dark", { light: svg('<circle cx="8" cy="8" r="8"/>'), dark: null });
expect(markup(plain)).not.toContain("<circle");
expect(markup(other)).toContain("<circle");
});
it("gives a changed icon without a dark variant none", () => {
pluginIconName("e.f", { light: svg('<rect width="16" height="16"/>'), dark: svg('<circle cx="8" cy="8" r="8"/>') });
const name = pluginIconName("e.f", { light: svg('<rect width="16" height="16"/>'), dark: null });
expect(markup(name)).not.toContain("<circle");
});
it("keeps the root's fill and stroke, and SVG's default black fill", () => {
const filled = rendered(
pluginIconName("h.i", {
light: svg('<circle cx="8" cy="8" r="7"/>', ' fill="#E5A50A" stroke="#FFFFFF"'),
dark: null,
}),
);
const group = filled.querySelector("circle")?.closest("g");
expect(group?.getAttribute("fill")).toBe("#E5A50A");
expect(group?.getAttribute("stroke")).toBe("#FFFFFF");
const plain = rendered(pluginIconName("j.k", { light: svg('<circle cx="8" cy="8" r="7"/>'), dark: null }));
expect(plain.querySelector("circle")?.closest("g")?.getAttribute("fill")).toBe("#000");
});
});
+11
Переглянути файл
@@ -60,6 +60,7 @@ function plugin(partial: Partial<PluginInfo> & Pick<PluginInfo, "id">): PluginIn
authors: [],
homepage: null,
changeNotes: null,
icon: null,
source: "installed",
path: `/plugins/${partial.id}`,
enabled: true,
@@ -533,6 +534,16 @@ describe("PluginsPage", () => {
act(() => useProject.setState({ project: null }));
});
it("shows a plugin's own icon in its row and details", async () => {
const icon = { light: '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="8"/></svg>', dark: null };
setPluginsBackend(createMemoryPluginsBackend([plugin({ id: "iconic", name: "Iconic", icon })]));
render(<PluginsPage />);
const list = await screen.findByRole("list", { name: "Installed plugins" });
await waitFor(() => expect(within(list).getByText("Iconic")).toBeInTheDocument());
expect(list.querySelector("circle")).not.toBeNull();
expect(screen.getByLabelText("Iconic details").querySelector("circle")).not.toBeNull();
});
it("restarts a failed plugin instead of disabling it", async () => {
setPluginsBackend(createMemoryPluginsBackend([plugin({ id: "broken", status: "failed", error: "trap" })]));
const user = userEvent.setup();
+2
Переглянути файл
@@ -300,6 +300,7 @@ function samplePlugins(): PluginInfo[] {
authors: ["OpenIDEA contributors"],
homepage: null,
changeNotes: null,
icon: null,
source: "installed" as const,
enabled: true,
error: null,
@@ -377,6 +378,7 @@ export function sampleCatalog(): CatalogPlugin[] {
authors: p.authors,
homepage: p.homepage,
changeNotes: `${p.version} — first release.`,
icon: null,
path: `plugins/${p.id}`,
hasCode: p.hasCode,
capabilities: p.capabilities.map((c) => ({ ...c, granted: false })),
+53
Переглянути файл
@@ -0,0 +1,53 @@
import type { PluginIcon } from "@/bindings";
import { registerExternalIcons } from "@/ui/icons";
// ponytail: never unregistered; bounded by the distinct icons seen this session.
const registered = new Set<string>();
/** FNV-1a: a short key for an icon's markup. */
function hash(text: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193);
return (h >>> 0).toString(16);
}
const ROOT_ONLY = new Set(["viewBox", "width", "height", "id", "version"]);
/**
* `Icon` keeps only the root's viewBox and draws on `fill="none"`: move the
* root's other attributes onto a group, with SVG's default black fill.
*/
function standalone(svg: string): string {
const open = svg.indexOf("<svg");
const start = svg.indexOf(">", open) + 1;
const end = svg.lastIndexOf("</svg>");
if (open < 0 || start <= open || end < start) return svg;
const tag = svg.slice(open, start);
const inherited = [...tag.matchAll(/\s([\w:-]+)="[^"]*"/g)]
.filter((m) => !ROOT_ONLY.has(m[1] ?? "") && !m[1]?.startsWith("xmlns"))
.map((m) => m[0])
.join("");
const fill = /\sfill="/.test(inherited) ? "" : ' fill="#000"';
return `${tag}<g${fill}${inherited}>${svg.slice(start, end)}</g></svg>`;
}
/**
* The icon name of a plugin's own icon, else the generic one. Names follow the
* markup, so copies of a plugin with different icons (marketplace, local) keep
* their own; plugin ids have no `/`, so no id yields another's name or `_dark`.
*/
export function pluginIconName(pluginId: string, icon: PluginIcon | null | undefined): string {
if (!icon) return "plugin";
const name = `ext:pluginIcon/${pluginId}/${hash(`${icon.light}\n${icon.dark ?? ""}`)}`;
if (registered.has(name)) return name;
registered.add(name);
registerExternalIcons(
icon.dark
? [
[name, standalone(icon.light)],
[`${name}_dark`, standalone(icon.dark)],
]
: [[name, standalone(icon.light)]],
);
return name;
}