feat(plugins): brand document-indexer gutter markers with marker icons (#2196)

Indexed markers (Edges.markers) were always drawn with the navigation glyph,
so a plugin couldn't tell its marker kinds apart. A [[contributes.markerIcons]]
entry can now claim them by key prefix (`markers`), with an optional
`tooltip` shown above the targets. The host matches the longest prefix and
sends the declaration id with each marker; the editor resolves the icon and
the translated tooltip from the projected declaration. Manifest only, under
the existing API 1.3 gate of markerIcons.

Closes #2194
Цей коміт міститься в:
Pedro Pessoa authored and GitHub committed 2026-11-09 04:53:03 +08:00
1 джерело e1be533bd2
коміт e0982cc51d
15 files changed
+341 -31

No files matched your search

+9 -1
Переглянути файл
@@ -3954,7 +3954,7 @@ export type FileLineStats = {
export type FileMarkers = {
path: string,
version: number | null,
markers: NavigationResult[],
markers: IndexedMarker[],
};
export type FileMatch = {
@@ -4733,6 +4733,14 @@ export type IndexStatus = {
symbols: number,
};
export type IndexedMarker = {
range: Range,
targets: Location[],
pluginId: string,
/** The `[[contributes.markerIcons]]` id whose `markers` prefix matches the marker's key. */
icon: string | null,
};
export type InlayHint = {
position: Pos,
label: string,
+48 -2
Переглянути файл
@@ -4,11 +4,13 @@ import { EditorView } from "@codemirror/view";
import { offsetsToRange } from "@/editor/lsp/positions";
import type { FileMarkers } from "@/platform/ipc";
import { indexedMarkers } from "./indexedMarkers";
import { projectMarkerIcon } from "./markerIcons";
import { usePluginMarkers } from "./markerStore";
const calls = vi.hoisted(() => ({ navigate: vi.fn(), popup: vi.fn() }));
const calls = vi.hoisted(() => ({ navigate: vi.fn(), popup: vi.fn(), assets: vi.fn() }));
vi.mock("@/editor/lsp/navigation", () => ({ navigateTo: calls.navigate, locationItems: async () => [] }));
vi.mock("@/editor/lsp/popupStore", () => ({ openPopup: calls.popup }));
vi.mock("./backend", () => ({ pluginsBackend: () => ({ assets: calls.assets }) }));
let view: EditorView | undefined;
afterEach(() => {
@@ -30,7 +32,7 @@ function setup() {
const entry: FileMarkers = {
path: "/p/Query.xml",
version: 1,
markers: [{ range: range(at, at + 4), targets: [target] }],
markers: [{ range: range(at, at + 4), targets: [target], pluginId: "p", icon: null }],
};
usePluginMarkers.setState({ entries: new Map([[entry.path, entry]]) });
return { entry, target };
@@ -68,3 +70,47 @@ it("merges a line's targets and offers a chooser for several", async () => {
button()!.click();
await vi.waitFor(() => expect(calls.popup).toHaveBeenCalled());
});
it("draws the declared icon and tooltip of the marker's key prefix", async () => {
calls.assets.mockResolvedValue([
["markerIcon/usages/light", '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="7"/></svg>'],
]);
const remove = projectMarkerIcon("p", { id: "usages", icon: "icons/usages.svg", tooltip: "Usages of this class" });
try {
const { entry } = setup();
await vi.waitFor(() => expect(button()).not.toBeNull());
expect(button()!.querySelector("circle")).toBeNull();
expect(button()!.title).toBe("Query.java:1");
const plain = entry.markers[0]!;
usePluginMarkers.setState({
entries: new Map([
[entry.path, { ...entry, markers: [plain, { ...plain, icon: "unknown" }, { ...plain, icon: "usages" }] }],
]),
});
await vi.waitFor(() => expect(button()!.querySelector("circle")).not.toBeNull());
expect(button()!.title).toBe("Usages of this class\nQuery.java:1");
expect(button()!.getAttribute("aria-label")).toContain("Usages of this class");
usePluginMarkers.setState({
entries: new Map([[entry.path, { ...entry, markers: [{ ...plain, icon: "unknown" }] }]]),
});
await vi.waitFor(() => expect(button()!.querySelector("circle")).toBeNull());
expect(button()!.title).toBe("Query.java:1");
} finally {
remove();
}
});
it("redraws when a marker icon declaration goes away", async () => {
calls.assets.mockResolvedValue([
["markerIcon/usages/light", '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="7"/></svg>'],
]);
const remove = projectMarkerIcon("p", { id: "usages", icon: "icons/usages.svg", tooltip: "Usages" });
const { entry } = setup();
usePluginMarkers.setState({
entries: new Map([[entry.path, { ...entry, markers: [{ ...entry.markers[0]!, icon: "usages" }] }]]),
});
await vi.waitFor(() => expect(button()!.querySelector("circle")).not.toBeNull());
remove();
await vi.waitFor(() => expect(button()!.querySelector("circle")).toBeNull());
expect(button()!.title).toBe("Query.java:1");
});
+52 -13
Переглянути файл
@@ -9,23 +9,45 @@ import { registerEditorContribution } from "@/editor/api";
import { rangeToOffsets } from "@/editor/lsp/positions";
import { editorPrefs } from "@/platform/editorSettings";
import { useProject } from "@/platform/project";
import type { FileMarkers, Location } from "@/platform/ipc";
import type { FileMarkers, IndexedMarker, Location } from "@/platform/ipc";
import { markerIcon } from "./lineMarkerGutter";
import { onMarkerIconsChanged, resolveIndexedMarkerIcon, type IndexedMarkerIcon } from "./markerIcons";
import { usePluginMarkers, watchPluginMarkers } from "./markerStore";
import { navigationButton } from "./navigationButton";
class MappingMarker extends GutterMarker {
constructor(readonly targets: Location[]) {
constructor(
readonly targets: Location[],
readonly icon: IndexedMarkerIcon | undefined,
) {
super();
}
override eq(other: GutterMarker) {
return other instanceof MappingMarker && JSON.stringify(other.targets) === JSON.stringify(this.targets);
return (
other instanceof MappingMarker &&
other.icon?.icon === this.icon?.icon &&
other.icon?.tooltip === this.icon?.tooltip &&
JSON.stringify(other.targets) === JSON.stringify(this.targets)
);
}
override toDOM() {
return navigationButton(this.targets, markerIcon(null));
return navigationButton(this.targets, markerIcon(this.icon?.icon ?? null), this.icon?.tooltip);
}
}
const iconKey = (marker: IndexedMarker) => `${marker.pluginId}:${marker.icon}`;
/** The declared icons of the entry's markers, by plugin and declaration id. */
async function resolveIcons(entry: FileMarkers | undefined): Promise<Map<string, IndexedMarkerIcon>> {
const declared = new Map<string, [pluginId: string, id: string]>();
for (const marker of entry?.markers ?? [])
if (marker.icon !== null) declared.set(iconKey(marker), [marker.pluginId, marker.icon]);
const resolved = await Promise.all(
[...declared].map(async ([key, [pluginId, id]]) => [key, await resolveIndexedMarkerIcon(pluginId, id)] as const),
);
return new Map(resolved.filter((r): r is readonly [string, IndexedMarkerIcon] => r[1] !== undefined));
}
const setMarkers = StateEffect.define<RangeSet<GutterMarker>>();
const markersField = StateField.define<RangeSet<GutterMarker>>({
create: () => RangeSet.empty,
@@ -36,21 +58,26 @@ const markersField = StateField.define<RangeSet<GutterMarker>>({
},
});
/** One marker per line, with the targets of every marker on it. */
export function markersOf(view: EditorView, entry: FileMarkers | undefined): RangeSet<GutterMarker> {
/** One marker per line, with the targets of every marker on it and the first declared icon among them. */
export function markersOf(
view: EditorView,
entry: FileMarkers | undefined,
icons: ReadonlyMap<string, IndexedMarkerIcon> = new Map(),
): RangeSet<GutterMarker> {
if (!entry || !editorPrefs().showGutterIcons) return RangeSet.empty;
const doc = view.state.doc;
const rows = new Map<number, Location[]>();
const rows = new Map<number, { targets: Location[]; icon?: IndexedMarkerIcon }>();
for (const marker of entry.markers) {
const line = doc.lineAt(rangeToOffsets(doc, marker.range).from).from;
const targets = rows.get(line) ?? [];
const row = rows.get(line) ?? { targets: [] };
for (const target of marker.targets)
if (!targets.some((t) => t.path === target.path && JSON.stringify(t.range) === JSON.stringify(target.range)))
targets.push(target);
rows.set(line, targets);
if (!row.targets.some((t) => t.path === target.path && JSON.stringify(t.range) === JSON.stringify(target.range)))
row.targets.push(target);
row.icon ??= marker.icon === null ? undefined : icons.get(iconKey(marker));
rows.set(line, row);
}
return RangeSet.of(
[...rows].map(([pos, targets]) => new MappingMarker(targets).range(pos)),
[...rows].map(([pos, row]) => new MappingMarker(row.targets, row.icon).range(pos)),
true,
);
}
@@ -78,11 +105,17 @@ export function indexedMarkers(path: string): Extension {
last: FileMarkers | undefined;
queued = false;
disposed = false;
generation = 0;
readonly off: () => void;
readonly offIcons: () => void;
readonly unwatch: () => void;
readonly prefs: () => void;
constructor(readonly view: EditorView) {
this.off = usePluginMarkers.subscribe(() => this.schedule());
this.offIcons = onMarkerIconsChanged(() => {
this.last = undefined;
this.schedule();
});
this.unwatch = watchPluginMarkers(path);
this.prefs = useProject.subscribe((s, prev) => {
if (s.settings !== prev.settings) {
@@ -107,11 +140,17 @@ export function indexedMarkers(path: string): Extension {
const entry = usePluginMarkers.getState().entries.get(path);
if (entry && entry === this.last) return;
this.last = entry;
this.view.dispatch({ effects: setMarkers.of(markersOf(this.view, entry)) });
const doc = this.view.state.doc;
const generation = ++this.generation;
void resolveIcons(entry).then((icons) => {
if (this.disposed || generation !== this.generation || this.view.state.doc !== doc) return;
this.view.dispatch({ effects: setMarkers.of(markersOf(this.view, entry, icons)) });
});
}
destroy() {
this.disposed = true;
this.off();
this.offIcons();
this.unwatch();
this.prefs();
}
+20 -4
Переглянути файл
@@ -4,9 +4,16 @@ import { pluginIconName } from "./pluginIcon";
export interface MarkerIconDecl {
id: string;
icon: string;
tooltip?: string | null;
}
export interface IndexedMarkerIcon {
icon: string | null;
tooltip: string | null;
}
const icons = new Map<string, Promise<string | null>>();
const declared = new Map<string, { icon: Promise<string | null>; tooltip: string | null }>();
const listeners = new Set<() => void>();
export function onMarkerIconsChanged(listener: () => void): () => void {
@@ -35,13 +42,16 @@ export function projectMarkerIcon(pluginId: string, decl: MarkerIconDecl): () =>
return null;
});
const id = key(pluginId, decl.icon);
const entry = { icon: pending, tooltip: decl.tooltip ?? null };
const declId = key(pluginId, decl.id);
icons.set(id, pending);
declared.set(declId, entry);
changed();
return () => {
if (icons.get(id) === pending) {
icons.delete(id);
changed();
}
const current = icons.get(id) === pending || declared.get(declId) === entry;
if (icons.get(id) === pending) icons.delete(id);
if (declared.get(declId) === entry) declared.delete(declId);
if (current) changed();
};
}
@@ -49,3 +59,9 @@ export async function resolveMarkerIcon(pluginId: string, icon: string | null):
if (!icon) return null;
return (await icons.get(key(pluginId, icon))) ?? icon;
}
/** The icon and tooltip of the `markerIcons` declaration `id` that the host matched to an indexed marker. */
export async function resolveIndexedMarkerIcon(pluginId: string, id: string): Promise<IndexedMarkerIcon | undefined> {
const entry = declared.get(key(pluginId, id));
return entry && { icon: await entry.icon, tooltip: entry.tooltip };
}
+1 -1
Переглянути файл
@@ -43,7 +43,7 @@ it("invalidates both changed files and markers targeting them", () => {
{
path: "/p/A.xml",
version: 1,
markers: [{ range, targets: [{ path: "/p/A.java", range }] }],
markers: [{ range, targets: [{ path: "/p/A.java", range }], pluginId: "p", icon: null }],
},
],
]),
+3 -2
Переглянути файл
@@ -18,11 +18,12 @@ async function openTargets(targets: Location[], element: HTMLElement) {
});
}
/** Shared ordinary-click and keyboard navigation for interactive and indexed markers. */
export function navigationButton(targets: Location[], icon: string): HTMLButtonElement {
export function navigationButton(targets: Location[], icon: string, tooltip?: string | null): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "cm-plugin-mapping-button";
button.title = targets.map((t) => `${t.path.split("/").pop()}:${t.range.start.line + 1}`).join("\n");
const places = targets.map((t) => `${t.path.split("/").pop()}:${t.range.start.line + 1}`);
button.title = (tooltip ? [tooltip, ...places] : places).join("\n");
button.setAttribute("aria-label", tx`Go to mapping: ${button.title}`);
button.innerHTML = gutterIconMarkup(isIconName(icon) ? icon : "arrow-right") ?? "";
button.onclick = (event) => {
+8
Переглянути файл
@@ -108,6 +108,14 @@ describe("localizeContributions", () => {
expect(localizeContributions(items, { a: fr })[0]!.decl).toMatchObject({ submenu: "Actualiser" });
});
it("translates an indexed marker tooltip without changing its key prefixes", () => {
const decl = { id: "m", icon: "icons/m.svg", markers: ["Refresh"], tooltip: "Refresh" };
expect(localizeContributions([item("a", "markerIcons", decl)], { a: fr })[0]!.decl).toEqual({
...decl,
tooltip: "Actualiser",
});
});
it("gives a tools menu entry the translated title of its command", () => {
const items = [
item("a", "commands", { id: "x", title: "Refresh" }),
+1
Переглянути файл
@@ -77,6 +77,7 @@ const TEXT_KEYS: Record<string, readonly string[]> = {
commands: ["title", "category"],
menus: ["submenu"],
views: ["title"],
markerIcons: ["tooltip"],
customEditors: ["title"],
statusBar: ["text", "tooltip"],
settings: ["title"],