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