дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
3efec1c848
коміт
0bee9b7aab
21 files changed
+499
-12
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Заблокувати користувача