Файли
openidea/src/features/plugins/lineMarkerGutter.ts
T
Arnaud Ritti 0bee9b7aab 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]>
2026-11-08 03:12:01 +08:00

167 рядки
5.6 KiB
TypeScript

/**
* Gutter icons from plugins' `line-markers` providers. Asked once per document version
* (debounced); markers follow their lines in between; a click opens the
* marker's target. The gutter column exists only while there are markers,
* so editors without plugin providers keep their usual width.
*/
import { Compartment, RangeSet, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view";
import type { EditorDocContext } from "@/editor/api";
import { navigationButton } from "./navigationButton";
import { pluginsBackend } from "./backend";
import { commands, type LineMarkerInfo } from "@/platform/ipc";
import { gutterIconMarkup, isIconName } from "@/ui/icons";
import { onMarkerIconsChanged, resolveMarkerIcon } from "./markerIcons";
import styles from "@/editor/navigation/LineMarkers.module.css";
const DEBOUNCE_MS = 400;
const ICONS: Record<string, string> = {
implemented: "gutter-implemented",
implementing: "gutter-implementing",
overridden: "gutter-overridden",
overriding: "gutter-overriding",
run: "gutter-run",
navigate: "gutter-implementing",
};
/** Legacy aliases and ordinary registered icon names; navigate when unknown. */
export function markerIcon(icon: string | null): string {
const name = icon ?? "navigate";
return Object.hasOwn(ICONS, name) ? ICONS[name]! : isIconName(name) ? name : ICONS.navigate!;
}
interface Placed {
marker: LineMarkerInfo;
pos: number;
}
const setMarkers = StateEffect.define<Placed[]>();
const markersField = StateField.define<Placed[]>({
create: () => [],
update(value, tr) {
for (const e of tr.effects) if (e.is(setMarkers)) return e.value;
if (!tr.docChanged || !value.length) return value;
return value.map((m) => ({ ...m, pos: tr.state.doc.lineAt(tr.changes.mapPos(m.pos, -1)).from }));
},
});
/** Markers on existing lines, at their line starts. */
export function placeMarkers(doc: Text, markers: readonly LineMarkerInfo[]): Placed[] {
return markers.filter((m) => m.line < doc.lines).map((marker) => ({ marker, pos: doc.line(marker.line + 1).from }));
}
class PluginMarker extends GutterMarker {
constructor(readonly marker: LineMarkerInfo) {
super();
}
override eq(other: GutterMarker): boolean {
return (
other instanceof PluginMarker &&
other.marker.tooltip === this.marker.tooltip &&
other.marker.icon === this.marker.icon &&
JSON.stringify(other.marker.target) === JSON.stringify(this.marker.target)
);
}
override toDOM(): Node {
const el = this.marker.target
? navigationButton([this.marker.target], markerIcon(this.marker.icon))
: document.createElement("span");
el.className = `${styles.marker} cm-plugin-mapping-button`;
el.title = this.marker.tooltip;
el.setAttribute("aria-label", this.marker.tooltip);
el.innerHTML = gutterIconMarkup(markerIcon(this.marker.icon)) ?? "";
return el;
}
}
class MarkersPlugin {
private timer: ReturnType<typeof setTimeout> | null = null;
private generation = 0;
private shown = false;
private off: () => void;
constructor(
private view: EditorView,
private doc: number,
private column: Compartment,
) {
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();
}
update(u: ViewUpdate) {
if (u.docChanged) this.schedule();
}
private schedule() {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => void this.run(), DEBOUNCE_MS);
}
private async run() {
this.timer = null;
const gen = ++this.generation;
const text = this.view.state.doc;
try {
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 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) {
this.shown = placed.length > 0;
effects.push(this.column.reconfigure(this.shown ? markerColumn : []));
}
this.view.dispatch({ effects });
} catch (e) {
console.debug("[plugin line markers]", e);
}
}
destroy() {
this.off();
if (this.timer) clearTimeout(this.timer);
this.generation++;
}
}
const markerColumn: Extension = gutter({
class: `${styles.gutter} cm-openidea-gutter-pluginMarkers`,
markers: (view) =>
RangeSet.of(
view.state.field(markersField).map((m) => new PluginMarker(m.marker).range(m.pos)),
true,
),
});
export function pluginLineMarkerGutter(ctx: EditorDocContext): Extension {
if (ctx.doc < 0) return [];
const doc = ctx.doc;
const column = new Compartment();
return [markersField, column.of([]), ViewPlugin.define((view) => new MarkersPlugin(view, doc, column))];
}