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

+28 -1
Переглянути файл
@@ -452,6 +452,7 @@ impl PluginHost {
return vec![];
};
let mut result = vec![];
let mut icons = HashMap::new();
for path in paths {
for (id, graph) in graphs {
let Some(plugin) = plugins.get(id) else { continue };
@@ -462,6 +463,7 @@ impl PluginHost {
if version_of(path).is_some_and(|v| file.version != Some(v)) {
continue;
}
let prefixes = icons.entry(id).or_insert_with(|| marker_icons(plugin));
let mut markers = vec![];
for anchor in &file.edges.markers {
let candidates = graph.targets(&anchor.key);
@@ -481,7 +483,15 @@ impl PluginHost {
}
}
if !targets.is_empty() {
markers.push(NavigationResult { range: anchor.range, targets });
markers.push(IndexedMarker {
range: anchor.range,
targets,
plugin_id: id.clone(),
icon: prefixes
.iter()
.find(|(prefix, _)| anchor.key.starts_with(prefix.as_str()))
.map(|(_, icon)| icon.clone()),
});
}
}
if !markers.is_empty() {
@@ -516,6 +526,23 @@ pub(crate) fn snapshot_hash(text: &str) -> blake3::Hash {
struct Decl {
extensions: Vec<String>,
}
#[derive(Deserialize)]
struct MarkerIconPrefixes {
id: String,
#[serde(default)]
markers: Vec<String>,
}
fn marker_icons(p: &super::LoadedPlugin) -> Vec<(String, String)> {
let mut prefixes: Vec<_> = p
.manifest()
.declarations(super::ui_points::MARKER_ICONS)
.into_iter()
.filter_map(|d| decode::<MarkerIconPrefixes>(super::ui_points::MARKER_ICONS, d).ok())
.flat_map(|d| d.markers.into_iter().map(move |prefix| (prefix, d.id.clone())))
.collect();
prefixes.sort_by_key(|(prefix, _)| std::cmp::Reverse(prefix.len()));
prefixes
}
fn declaration(p: &super::LoadedPlugin) -> Option<Decl> {
p.manifest()
.declarations("documentIndexers")
+25 -1
Переглянути файл
@@ -744,6 +744,12 @@ struct MarkerIconDecl {
icon: PathBuf,
#[serde(default)]
icon_dark: Option<PathBuf>,
/// Key prefixes of document-indexer markers drawn with this icon.
#[serde(default)]
markers: Vec<String>,
/// Shown above the targets of those markers.
#[serde(default)]
tooltip: Option<String>,
}
pub(super) fn marker_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
@@ -753,7 +759,25 @@ pub(super) fn marker_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, St
if d.icon.to_string_lossy().len() > 256 {
return Err("marker icon paths are up to 256 bytes".into());
}
let mut out = UiDecl::new(d.id.clone(), d.id.clone(), serde_json::json!({ "id": d.id, "icon": d.icon }));
if d.markers.len() > MAX_PATTERNS || d.markers.iter().any(|m| m.is_empty() || m.len() > 256) {
return Err(format!(
"marker icon {:?}: `markers` lists up to {MAX_PATTERNS} key prefixes of 1–256 bytes",
d.id
));
}
if let Some(tooltip) = &d.tooltip {
if d.markers.is_empty() {
return Err(format!("marker icon {:?}: `tooltip` applies to indexed markers; set `markers`", d.id));
}
if tooltip.trim().is_empty() || tooltip.len() > MAX_TITLE {
return Err(format!("marker icon {:?}: tooltips are 1–{MAX_TITLE} bytes", d.id));
}
}
let mut out = UiDecl::new(
d.id.clone(),
d.id.clone(),
serde_json::json!({ "id": d.id, "icon": d.icon, "markers": d.markers, "tooltip": d.tooltip }),
);
for (path, variant) in [(Some(&d.icon), "light"), (d.icon_dark.as_ref(), "dark")] {
let Some(path) = path else { continue };
let svg = super::icons::read_icon(pkg, path, super::icons::MAX_SVG)?;
+53 -5
Переглянути файл
@@ -16,15 +16,22 @@ struct Fixture {
}
impl Fixture {
fn new() -> Self {
Self::with_manifest(|_| {})
}
fn with_manifest(edit: impl FnOnce(&Path)) -> Self {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let host = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
host.set_project_policy(Arc::new(|_| true));
host.install_from_dir(
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer"),
&Consent::all(),
)
.unwrap();
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer");
let package = tempfile::tempdir().unwrap();
let pkg = package.path();
std::fs::create_dir_all(pkg.join("dist")).unwrap();
for file in ["plugin.toml", "dist/plugin.wasm"] {
std::fs::copy(fixture.join(file), pkg.join(file)).unwrap();
}
edit(pkg);
host.install_from_dir(pkg, &Consent::all()).unwrap();
Self { _tmp: tmp, root, host }
}
fn index(&self, path: &str, text: &str, symbols: &[SymbolTag], version: Option<u32>, scope: &str) -> PathBuf {
@@ -237,6 +244,47 @@ fn markers_and_disable_follow_graph_state() {
assert!(f.host.navigation_markers(&f.root, &[source], None, |_| None).is_empty());
}
#[test]
fn marker_icons_map_indexed_markers_by_longest_key_prefix() {
let f = Fixture::with_manifest(|pkg| {
let manifest = std::fs::read_to_string(pkg.join("plugin.toml")).unwrap().replace("^1.2", "^1.3");
std::fs::create_dir_all(pkg.join("icons")).unwrap();
std::fs::write(
pkg.join("icons/marker.svg"),
r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#,
)
.unwrap();
std::fs::write(
pkg.join("plugin.toml"),
format!(
r#"{manifest}
[[contributes.markerIcons]]
id = "mapping"
icon = "icons/marker.svg"
markers = ["mapping:"]
[[contributes.markerIcons]]
id = "mapping-item"
icon = "icons/marker.svg"
markers = ["other:", "mapping:item"]
tooltip = "Mapped item"
"#
),
)
.unwrap();
});
let source = f.index("source.idx", "def source:item\nref mapping:item\nref mapping:other\n", &[], Some(1), "app");
let mapping = f.index("mapping.idx", "def mapping:item\ndef mapping:other\nref source:item\n", &[], Some(1), "app");
let entries = f.host.navigation_markers(&f.root, &[source.clone(), mapping.clone()], None, |_| None);
let icons = |path: &PathBuf| {
let entry = entries.iter().find(|e| &e.path == path).unwrap();
assert!(entry.markers.iter().all(|m| m.plugin_id == "test.indexer"));
entry.markers.iter().map(|m| m.icon.as_deref()).collect::<Vec<_>>()
};
assert_eq!(icons(&source), [Some("mapping-item"), Some("mapping")]);
assert_eq!(icons(&mapping), [None]);
}
#[test]
fn invalid_ranges_and_oversized_documents_never_publish() {
let f = Fixture::new();
+49
Переглянути файл
@@ -5314,6 +5314,55 @@ iconDark = "{dark}"
assert!(f.host.assets("marker-icons", "markerIcon/resource/").is_err());
}
#[test]
fn marker_icons_validate_indexed_marker_prefixes_and_tooltips() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("plugin");
std::fs::create_dir_all(pkg.join("icons")).unwrap();
std::fs::write(pkg.join("icons/usages.svg"), r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#)
.unwrap();
let manifest = |engine: &str, fields: &str| {
format!(
r#"
id = "indexed-marker-icons"
name = "Indexed Marker Icons"
version = "1.0.0"
engines.openidea = "{engine}"
[[contributes.markerIcons]]
id = "usages"
icon = "icons/usages.svg"
{fields}
"#
)
};
let long = "k".repeat(257);
let many = format!("[{}]", (0..17).map(|i| format!("\"k{i}#\"")).collect::<Vec<_>>().join(", "));
for (engine, fields) in [
("^1.0", r#"markers = ["usages#"]"#.to_owned()),
("^1.3", r#"markers = [""]"#.to_owned()),
("^1.3", format!(r#"markers = ["{long}"]"#)),
("^1.3", format!("markers = {many}")),
("^1.3", r#"markers = "usages#""#.to_owned()),
("^1.3", r#"tooltip = "Usages""#.to_owned()),
("^1.3", "markers = [\"usages#\"]\ntooltip = \" \"".to_owned()),
("^1.3", format!("markers = [\"usages#\"]\ntooltip = \"{long}\"")),
] {
std::fs::write(pkg.join("plugin.toml"), manifest(engine, &fields)).unwrap();
assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err(), "accepted {fields}");
}
std::fs::write(
pkg.join("plugin.toml"),
manifest("^1.3", "markers = [\"usages#\", \"scope#\"]\ntooltip = \"Usages\""),
)
.unwrap();
let info = f.install(&pkg);
assert!(info.error.is_none(), "{:?}", info.error);
let decl = &f.host.ui_items("markerIcons")[0].decl;
assert_eq!(decl["markers"], serde_json::json!(["usages#", "scope#"]));
assert_eq!(decl["tooltip"], "Usages");
}
#[test]
fn marker_providers_preserve_names_and_package_paths() {
let f = fixture(Limits::default());
+11 -1
Переглянути файл
@@ -35,7 +35,17 @@ pub struct Edges {
pub struct FileMarkers {
pub path: PathBuf,
pub version: Option<u32>,
pub markers: Vec<NavigationResult>,
pub markers: Vec<IndexedMarker>,
}
#[derive(Clone, Debug, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct IndexedMarker {
pub range: Range,
pub targets: Vec<Location>,
pub plugin_id: String,
/// The `[[contributes.markerIcons]]` id whose `markers` prefix matches the marker's key.
pub icon: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
+16
Переглянути файл
@@ -31,6 +31,22 @@ Keys are the plugin's own strings and ranges are UTF-16. When a key has
definitions in the same detected module, other modules' definitions are left
out. Several targets open a chooser.
A marker's tooltip lists its targets. With API 1.3, a `[[contributes.markerIcons]]`
entry can brand markers by key prefix; the longest matching prefix wins, and
other markers keep the navigation glyph:
```toml
[[contributes.markerIcons]]
id = "usages"
icon = "icons/usages.svg"
iconDark = "icons/usages_dark.svg"
markers = ["usages#"]
tooltip = "Usages of this class" # shown above the targets
```
A marker published as `Anchor { key: "usages#User".into(), range }` then shows
that icon. See `plugins/README.md` for the limits.
Initial discovery reuses the host's project index. The editor schedules
cancellable batches of at most 16 files; edits and file changes invalidate the
affected entries before they are indexed again. Reloading, disabling or a
+17
Переглянути файл
@@ -634,6 +634,23 @@ several cross-file targets, publish document-index mappings; the existing
chooser lists every matching definition. Command clicks are deferred; use a
code action on the same line.
Document-indexer markers (`Edges.markers`) use a declared icon when their key
starts with one of its `markers` prefixes; the longest matching prefix wins,
then declaration order. An optional `tooltip` (1–256 bytes, translated by the
plugin's catalog) is shown above the marker's targets and needs `markers`:
```toml
[[contributes.markerIcons]]
id = "usages"
icon = "icons/usages.svg"
markers = ["usages#"] # up to 16 key prefixes of 1–256 bytes
tooltip = "Usages of this class"
```
Keys without a matching prefix keep the navigation glyph. Older IDEs that do
not recognize `markers` or `tooltip` reject the manifest at install; update the
IDE to use them.
Then `register_plugin!(MyPlugin, providers)` and implement
`openidea_plugin::providers::Providers` for it: `completion`,
`inline_completion`, `code_actions`, `definition`, `line_markers`. Each gets
+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"],