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