diff --git a/crates/ide-host/src/plugins/navigation.rs b/crates/ide-host/src/plugins/navigation.rs index 7b1cc340f..070c1f7d6 100644 --- a/crates/ide-host/src/plugins/navigation.rs +++ b/crates/ide-host/src/plugins/navigation.rs @@ -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, } +#[derive(Deserialize)] +struct MarkerIconPrefixes { + id: String, + #[serde(default)] + markers: Vec, +} +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::(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 { p.manifest() .declarations("documentIndexers") diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs index 3bc19146f..9a1edc4e0 100644 --- a/crates/ide-host/src/plugins/ui_points.rs +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -744,6 +744,12 @@ struct MarkerIconDecl { icon: PathBuf, #[serde(default)] icon_dark: Option, + /// Key prefixes of document-indexer markers drawn with this icon. + #[serde(default)] + markers: Vec, + /// Shown above the targets of those markers. + #[serde(default)] + tooltip: Option, } pub(super) fn marker_icon(pkg: &Package, decl: toml::Value) -> Result { @@ -753,7 +759,25 @@ pub(super) fn marker_icon(pkg: &Package, decl: toml::Value) -> Result 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)?; diff --git a/crates/ide-host/tests/plugin_indexing.rs b/crates/ide-host/tests/plugin_indexing.rs index 22cd83849..63dd97dad 100644 --- a/crates/ide-host/tests/plugin_indexing.rs +++ b/crates/ide-host/tests/plugin_indexing.rs @@ -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, 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#""#, + ) + .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::>() + }; + 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(); diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index bbfe79b91..9702d3f99 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -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#""#) + .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::>().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()); diff --git a/crates/ide-plugin/src/indexing.rs b/crates/ide-plugin/src/indexing.rs index fe0ef3e99..bcf159449 100644 --- a/crates/ide-plugin/src/indexing.rs +++ b/crates/ide-plugin/src/indexing.rs @@ -35,7 +35,17 @@ pub struct Edges { pub struct FileMarkers { pub path: PathBuf, pub version: Option, - pub markers: Vec, + pub markers: Vec, +} + +#[derive(Clone, Debug, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct IndexedMarker { + pub range: Range, + pub targets: Vec, + pub plugin_id: String, + /// The `[[contributes.markerIcons]]` id whose `markers` prefix matches the marker's key. + pub icon: Option, } #[derive(Clone, Debug, Serialize, Deserialize, Type)] diff --git a/docs/plugins/indexing.md b/docs/plugins/indexing.md index 5262637bd..a9eb60676 100644 --- a/docs/plugins/indexing.md +++ b/docs/plugins/indexing.md @@ -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 diff --git a/plugins/README.md b/plugins/README.md index 776622683..333cd1df4 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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 diff --git a/src/bindings.ts b/src/bindings.ts index 1da620525..75503b244 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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, diff --git a/src/features/plugins/indexedMarkers.test.ts b/src/features/plugins/indexedMarkers.test.ts index 724e9f2e4..5092389c3 100644 --- a/src/features/plugins/indexedMarkers.test.ts +++ b/src/features/plugins/indexedMarkers.test.ts @@ -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", ''], + ]); + 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", ''], + ]); + 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"); +}); diff --git a/src/features/plugins/indexedMarkers.ts b/src/features/plugins/indexedMarkers.ts index 559db2796..94ec9fae8 100644 --- a/src/features/plugins/indexedMarkers.ts +++ b/src/features/plugins/indexedMarkers.ts @@ -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> { + const declared = new Map(); + 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>(); const markersField = StateField.define>({ create: () => RangeSet.empty, @@ -36,21 +58,26 @@ const markersField = StateField.define>({ }, }); -/** One marker per line, with the targets of every marker on it. */ -export function markersOf(view: EditorView, entry: FileMarkers | undefined): RangeSet { +/** 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 = new Map(), +): RangeSet { if (!entry || !editorPrefs().showGutterIcons) return RangeSet.empty; const doc = view.state.doc; - const rows = new Map(); + const rows = new Map(); 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(); } diff --git a/src/features/plugins/markerIcons.ts b/src/features/plugins/markerIcons.ts index 13f23bd7a..b45d197bf 100644 --- a/src/features/plugins/markerIcons.ts +++ b/src/features/plugins/markerIcons.ts @@ -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>(); +const declared = new Map; 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 { + const entry = declared.get(key(pluginId, id)); + return entry && { icon: await entry.icon, tooltip: entry.tooltip }; +} diff --git a/src/features/plugins/markerStore.test.ts b/src/features/plugins/markerStore.test.ts index 5c953c86c..437110964 100644 --- a/src/features/plugins/markerStore.test.ts +++ b/src/features/plugins/markerStore.test.ts @@ -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 }], }, ], ]), diff --git a/src/features/plugins/navigationButton.ts b/src/features/plugins/navigationButton.ts index b7e1ebf2c..d02ea0f42 100644 --- a/src/features/plugins/navigationButton.ts +++ b/src/features/plugins/navigationButton.ts @@ -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) => { diff --git a/src/features/plugins/pluginText.test.ts b/src/features/plugins/pluginText.test.ts index 3eaaefa08..ae0c45e8e 100644 --- a/src/features/plugins/pluginText.test.ts +++ b/src/features/plugins/pluginText.test.ts @@ -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" }), diff --git a/src/features/plugins/pluginText.ts b/src/features/plugins/pluginText.ts index b0ee6711c..8f9b1291c 100644 --- a/src/features/plugins/pluginText.ts +++ b/src/features/plugins/pluginText.ts @@ -77,6 +77,7 @@ const TEXT_KEYS: Record = { commands: ["title", "category"], menus: ["submenu"], views: ["title"], + markerIcons: ["tooltip"], customEditors: ["title"], statusBar: ["text", "tooltip"], settings: ["title"],