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

+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;
}