дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
167 рядки
5.6 KiB
TypeScript
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))];
|
|
}
|