дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +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
@@ -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")
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Заблокувати користувача