feat(plugins): allow custom gutter marker icons (#2044)

Closes #1951

Gutter markers only recognised a short alias list and the runtime truncated icon references at 32 bytes, so ordinary icon names and package paths were dropped. Markers now accept any built-in icon name, and API 1.3 `contributes.markerIcons` declares package SVGs (with an optional dark variant) that are size-limited, sanitised and rendered through the icon registry. The shared SVG sanitiser now also rejects URL strings in any CSS function (e.g. image-set), not only url(). Verified on macOS in light and dark themes.

Co-authored-by: arnaud-ritti <[email protected]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-08 03:12:01 +08:00
1 джерело 3efec1c848
коміт 0bee9b7aab
21 files changed
+499 -12

No files matched your search

+1
Переглянути файл
@@ -779,6 +779,7 @@ describe("plugin line markers", () => {
const doc = EditorState.create({ doc: "a\nbb\nccc" }).doc;
const marker = (line: number, icon: string | null) => ({ pluginId: "m", line, icon, tooltip: "t", target: null });
expect(placeMarkers(doc, [marker(1, "run"), marker(7, null)]).map((m) => m.pos)).toEqual([2]);
expect(markerIcon("database")).toBe("database");
expect(markerIcon("run")).toBe("gutter-run");
expect(markerIcon("implemented")).toBe("gutter-implemented");
expect(markerIcon(null)).toBe("gutter-implementing");
+59
Переглянути файл
@@ -4,8 +4,11 @@ import { EditorView } from "@codemirror/view";
import type { EditorDocContext } from "@/editor/api";
import type { PluginEvent } from "@/bindings";
import { pluginLineMarkerGutter } from "./lineMarkerGutter";
import { pluginIconName } from "./pluginIcon";
import { projectMarkerIcon } from "./markerIcons";
const mock = vi.hoisted(() => ({
markers: vi.fn(),
assets: vi.fn(),
event: undefined as ((event: PluginEvent) => void) | undefined,
off: vi.fn(),
}));
@@ -15,6 +18,7 @@ vi.mock(import("@/platform/ipc"), async (original) => {
});
vi.mock("./backend", () => ({
pluginsBackend: () => ({
assets: mock.assets,
onEvent: (fn: (event: PluginEvent) => void) => {
mock.event = fn;
return mock.off;
@@ -54,3 +58,58 @@ it("withdraws old interactive markers on reload and does not refresh on idle sta
view = undefined;
expect(mock.off).toHaveBeenCalledOnce();
});
it("renders ordinary and custom registered icons alongside legacy marker aliases", async () => {
vi.useFakeTimers();
const icon = pluginIconName("markers", {
light: '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/></svg>',
dark: null,
});
mock.markers.mockResolvedValue({
status: "ok",
data: ["run", "database", icon].map((icon, line) => ({
pluginId: "markers",
line,
icon,
tooltip: `marker ${line}`,
target: null,
})),
});
view = new EditorView({
parent: document.body,
state: EditorState.create({
doc: "legacy\nordinary\ncustom",
extensions: pluginLineMarkerGutter({ doc: 1 } as EditorDocContext),
}),
});
await vi.advanceTimersByTimeAsync(450);
const markers = view.dom.querySelectorAll(".cm-plugin-mapping-button");
expect(markers).toHaveLength(3);
expect([...markers].map((el) => el.getAttribute("aria-label"))).toEqual(["marker 0", "marker 1", "marker 2"]);
expect(markers[0]!.innerHTML).not.toBe(markers[1]!.innerHTML);
expect(markers[2]!.querySelector("circle")).not.toBeNull();
});
it("refreshes a fallback when the package icon declaration arrives after its markers", async () => {
vi.useFakeTimers();
mock.markers.mockResolvedValue({
status: "ok",
data: [{ pluginId: "late", line: 0, icon: "icons/late.svg", tooltip: "late icon", target: null }],
});
view = new EditorView({
parent: document.body,
state: EditorState.create({ doc: "late", extensions: pluginLineMarkerGutter({ doc: 1 } as EditorDocContext) }),
});
await vi.advanceTimersByTimeAsync(450);
expect(view.dom.querySelector(".cm-plugin-mapping-button circle")).toBeNull();
mock.assets.mockResolvedValue([
["markerIcon/late/light", '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/></svg>'],
]);
const remove = projectMarkerIcon("late", { id: "late", icon: "icons/late.svg" });
try {
await vi.advanceTimersByTimeAsync(450);
expect(view.dom.querySelector(".cm-plugin-mapping-button circle")).not.toBeNull();
} finally {
remove();
}
});
+22 -5
Переглянути файл
@@ -10,7 +10,8 @@ import type { EditorDocContext } from "@/editor/api";
import { navigationButton } from "./navigationButton";
import { pluginsBackend } from "./backend";
import { commands, type LineMarkerInfo } from "@/platform/ipc";
import { gutterIconMarkup } from "@/ui";
import { gutterIconMarkup, isIconName } from "@/ui/icons";
import { onMarkerIconsChanged, resolveMarkerIcon } from "./markerIcons";
import styles from "@/editor/navigation/LineMarkers.module.css";
const DEBOUNCE_MS = 400;
@@ -24,9 +25,10 @@ const ICONS: Record<string, string> = {
navigate: "gutter-implementing",
};
/** Icon name for a plugin marker (`navigate` when unknown). */
/** Legacy aliases and ordinary registered icon names; navigate when unknown. */
export function markerIcon(icon: string | null): string {
return ICONS[icon ?? "navigate"] ?? ICONS.navigate!;
const name = icon ?? "navigate";
return Object.hasOwn(ICONS, name) ? ICONS[name]! : isIconName(name) ? name : ICONS.navigate!;
}
interface Placed {
@@ -85,13 +87,21 @@ class MarkersPlugin {
private doc: number,
private column: Compartment,
) {
this.off = pluginsBackend().onEvent((event) => {
const offBackend = pluginsBackend().onEvent((event) => {
if (event.type !== "documentIndexesChanged") return;
this.generation++;
this.shown = false;
this.view.dispatch({ effects: [setMarkers.of([]), this.column.reconfigure([])] });
this.schedule();
});
const offIcons = onMarkerIconsChanged(() => {
this.generation++;
this.schedule();
});
this.off = () => {
offBackend();
offIcons();
};
this.schedule();
}
@@ -112,7 +122,14 @@ class MarkersPlugin {
const markers = await commands.pluginLineMarkers(this.doc);
if (gen !== this.generation || this.view.state.doc !== text || !this.view.dom.isConnected) return;
if (markers.status !== "ok") return;
const placed = placeMarkers(text, markers.data);
const resolved = await Promise.all(
markers.data.map(async (marker) => ({
...marker,
icon: await resolveMarkerIcon(marker.pluginId, marker.icon),
})),
);
if (gen !== this.generation || this.view.state.doc !== text || !this.view.dom.isConnected) return;
const placed = placeMarkers(text, resolved);
if (!placed.length && !this.view.state.field(markersField).length) return;
const effects: StateEffect<unknown>[] = [setMarkers.of(placed)];
if (placed.length > 0 !== this.shown) {
+58
Переглянути файл
@@ -0,0 +1,58 @@
import { afterEach, expect, it, vi } from "vitest";
import { gutterIconMarkup } from "@/ui/icons";
import { projectMarkerIcon, resolveMarkerIcon } from "./markerIcons";
import { markerIcon } from "./lineMarkerGutter";
const mocks = vi.hoisted(() => ({ assets: vi.fn() }));
vi.mock("./backend", () => ({ pluginsBackend: () => ({ assets: mocks.assets }) }));
afterEach(() => vi.clearAllMocks());
const svg = (shape: string) => `<svg viewBox="0 0 16 16">${shape}</svg>`;
it("resolves declared package paths within their plugin and uses the dark SVG", async () => {
mocks.assets.mockResolvedValue([
["markerIcon/resource/light", svg('<rect width="16" height="16"/>')],
["markerIcon/resource/dark", svg('<circle cx="8" cy="8" r="7"/>')],
]);
const remove = projectMarkerIcon("p", { id: "resource", icon: "icons/resource.svg" });
try {
const name = (await resolveMarkerIcon("p", "icons/resource.svg"))!;
expect(mocks.assets).toHaveBeenCalledWith("p", "markerIcon/resource/");
expect(markerIcon(name)).toBe(name);
expect(gutterIconMarkup(name)).toContain("<circle");
expect(await resolveMarkerIcon("other", "icons/resource.svg")).toBe("icons/resource.svg");
} finally {
remove();
}
expect(await resolveMarkerIcon("p", "icons/resource.svg")).toBe("icons/resource.svg");
});
it("retains built-in names, legacy aliases and unknown-icon fallback", async () => {
expect(markerIcon(await resolveMarkerIcon("p", "database"))).toBe("database");
expect(markerIcon("overriding")).toBe("gutter-overriding");
expect(markerIcon("not-an-icon")).toBe("gutter-implementing");
expect(markerIcon("__proto__")).toBe("gutter-implementing");
expect(markerIcon("constructor")).toBe("gutter-implementing");
expect(markerIcon(null)).toBe("gutter-implementing");
expect(mocks.assets).not.toHaveBeenCalled();
});
it("keeps a replacement when an older declaration is disposed", async () => {
mocks.assets.mockResolvedValue([["markerIcon/resource/light", svg('<rect width="16" height="16"/>')]]);
const removeOld = projectMarkerIcon("p", { id: "resource", icon: "icons/resource.svg" });
const removeNew = projectMarkerIcon("p", { id: "resource", icon: "icons/resource.svg" });
removeOld();
expect(await resolveMarkerIcon("p", "icons/resource.svg")).toMatch(/^ext:/);
removeNew();
});
it("falls back when an asset fails to load", async () => {
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
mocks.assets.mockRejectedValue(new Error("disabled"));
const remove = projectMarkerIcon("p", { id: "resource", icon: "icons/resource.svg" });
try {
expect(markerIcon(await resolveMarkerIcon("p", "icons/resource.svg"))).toBe("gutter-implementing");
} finally {
remove();
warning.mockRestore();
}
});
+51
Переглянути файл
@@ -0,0 +1,51 @@
import { pluginsBackend } from "./backend";
import { pluginIconName } from "./pluginIcon";
export interface MarkerIconDecl {
id: string;
icon: string;
}
const icons = new Map<string, Promise<string | null>>();
const listeners = new Set<() => void>();
export function onMarkerIconsChanged(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function changed() {
for (const listener of listeners) listener();
}
const key = (pluginId: string, path: string) => `${pluginId}\0${path}`;
/** Package paths are resolved only through declarations validated by the host. */
export function projectMarkerIcon(pluginId: string, decl: MarkerIconDecl): () => void {
const pending = pluginsBackend()
.assets(pluginId, `markerIcon/${decl.id}/`)
.then((assets) => {
const svg = (variant: string) => assets.find(([id]) => id === `markerIcon/${decl.id}/${variant}`)?.[1];
const light = svg("light");
return light ? pluginIconName(pluginId, { light, dark: svg("dark") ?? null }) : null;
})
.catch((e: unknown) => {
console.warn(`plugin ${pluginId}: marker icon ${decl.id}`, e);
return null;
});
const id = key(pluginId, decl.icon);
icons.set(id, pending);
changed();
return () => {
if (icons.get(id) === pending) {
icons.delete(id);
changed();
}
};
}
export async function resolveMarkerIcon(pluginId: string, icon: string | null): Promise<string | null> {
if (!icon) return null;
return (await icons.get(key(pluginId, icon))) ?? icon;
}
+5
Переглянути файл
@@ -47,6 +47,7 @@ import {
} from "@/workbench/contributions";
import { pluginsBackend } from "./backend";
import { pluginIconName } from "./pluginIcon";
import { projectMarkerIcon, type MarkerIconDecl } from "./markerIcons";
import { usePlugins } from "./store";
import { pluginActionId } from "./points";
import { defineUiPoint } from "./projection";
@@ -568,6 +569,10 @@ export function defineViewUiPoints(): () => void {
};
},
}),
defineUiPoint<MarkerIconDecl>("markerIcons", {
title: "Marker Icons",
project: ({ pluginId, decl }) => projectMarkerIcon(pluginId, decl),
}),
defineUiPoint<FileIconDecl>("fileIcons", {
title: "File Icons",
project: ({ pluginId, decl }) => projectFileIcon(pluginId, decl),