From 0bee9b7aab9df309262a618466187d59759d3b86 Mon Sep 17 00:00:00 2001 From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:12:01 +0100 Subject: [PATCH] feat(plugins): allow custom gutter marker icons (#2044) Closes #1951 Gutter markers only recognised a short alias list and the runtime truncated icon references at 32 bytes, so ordinary icon names and package paths were dropped. Markers now accept any built-in icon name, and API 1.3 `contributes.markerIcons` declares package SVGs (with an optional dark variant) that are size-limited, sanitised and rendered through the icon registry. The shared SVG sanitiser now also rejects URL strings in any CSS function (e.g. image-set), not only url(). Verified on macOS in light and dark themes. Co-authored-by: arnaud-ritti <77437157+arnaud-ritti@users.noreply.github.com> --- crates/ide-host/src/plugins/icons.rs | 6 + crates/ide-host/src/plugins/mod.rs | 3 +- crates/ide-host/src/plugins/points.rs | 1 + crates/ide-host/src/plugins/ui_points.rs | 30 +++++ crates/ide-host/tests/plugins.rs | 109 ++++++++++++++++++ crates/ide-plugin/src/runtime.rs | 2 +- .../tests/fixtures/misbehave/dist/plugin.wasm | Bin 136764 -> 137885 bytes .../misbehave/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/misbehave/src/lib.rs | 16 ++- e2e/pluginMarkerIcons.spec.ts | 102 ++++++++++++++++ plugins/README.md | 21 ++++ .../plugins/__tests__/plugins.test.tsx | 1 + src/features/plugins/lineMarkerGutter.test.ts | 59 ++++++++++ src/features/plugins/lineMarkerGutter.ts | 27 ++++- src/features/plugins/markerIcons.test.ts | 58 ++++++++++ src/features/plugins/markerIcons.ts | 51 ++++++++ src/features/plugins/uiPoints.tsx | 5 + src/platform/locales/text/en.json | 1 + src/platform/locales/text/zh.json | 1 + src/ui/icons.test.tsx | 9 ++ src/ui/icons.tsx | 7 +- 21 files changed, 499 insertions(+), 12 deletions(-) create mode 100644 e2e/pluginMarkerIcons.spec.ts create mode 100644 src/features/plugins/markerIcons.test.ts create mode 100644 src/features/plugins/markerIcons.ts diff --git a/crates/ide-host/src/plugins/icons.rs b/crates/ide-host/src/plugins/icons.rs index 99dfe3d11..031af23f5 100644 --- a/crates/ide-host/src/plugins/icons.rs +++ b/crates/ide-host/src/plugins/icons.rs @@ -510,6 +510,8 @@ fn check_value(key: &str, value: &str) -> Result<(), String> { if lower.contains("javascript:") || lower.contains("expression(") || lower.contains("@import") + // These CSS image/URL functions accept bare strings, bypassing url(). + || ["image(", "image-set(", "src("].iter().any(|function| lower.contains(function)) || value.contains('\\') { return Err("unsafe value".into()); @@ -607,6 +609,10 @@ mod tests { r##""##, r##""##, r##""##, + r##""##, + r##""##, + r##""##, + r##""##, r##"]>"##, r##"x"##, r##""##, diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 5d9b2dd60..06e83b24d 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -382,7 +382,8 @@ impl PluginHost { let ui = UiStore::new(); let index = Arc::new(ContributionIndex::new( plugins_dir.join(".cache").join("contributions"), - env!("CARGO_PKG_VERSION"), + // Revalidate SVGs accepted before URL-valued CSS functions were rejected. + concat!(env!("CARGO_PKG_VERSION"), ":svg-2"), )); ui.set_index(Some(index.clone())); let languages = PluginLanguages::new(); diff --git a/crates/ide-host/src/plugins/points.rs b/crates/ide-host/src/plugins/points.rs index 3fcb09b84..13ed6d29f 100644 --- a/crates/ide-host/src/plugins/points.rs +++ b/crates/ide-host/src/plugins/points.rs @@ -67,6 +67,7 @@ pub fn register_builtin_points( registry.register(Arc::new(UiPoint::new(p::SETTINGS, ui.clone(), p::settings)))?; registry.register(Arc::new(UiPoint::new(p::VIEWS, ui.clone(), p::view)))?; registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?; + registry.register(Arc::new(UiPoint::new(p::MARKER_ICONS, ui.clone(), p::marker_icon)))?; registry.register(Arc::new(UiPoint::new(p::FILE_ICONS, ui.clone(), p::file_icon)))?; registry.register(Arc::new(UiPoint::new(p::FILE_TEMPLATES, ui.clone(), p::file_template)))?; registry.register(Arc::new(UiPoint::new(p::COMMIT_CHECKS, ui.clone(), p::commit_check)))?; diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs index a38dc44d8..151e9ec58 100644 --- a/crates/ide-host/src/plugins/ui_points.rs +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -69,6 +69,7 @@ pub const SETTINGS: &str = "settings"; pub const VIEWS: &str = "views"; pub const CUSTOM_EDITORS: &str = "customEditors"; pub const FILE_ICONS: &str = "fileIcons"; +pub const MARKER_ICONS: &str = "markerIcons"; pub const FILE_TEMPLATES: &str = "fileTemplates"; pub const COMMIT_CHECKS: &str = "commitChecks"; @@ -553,6 +554,35 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result, +} + +pub(super) fn marker_icon(pkg: &Package, decl: toml::Value) -> Result { + pkg.manifest.needs_api("contributes.markerIcons", 3)?; + let d: MarkerIconDecl = decode(MARKER_ICONS, decl)?; + validate_item_id(&d.id, "marker icon id")?; + 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 })); + 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)?; + out.assets.push((format!("markerIcon/{}/{variant}", d.id), svg)); + out.files.push(path.clone()); + } + // A changed SVG must reproject even when its declared path stays the same. + let bytes = serde_json::to_vec(&out.assets).map_err(|e| e.to_string())?; + out.json["revision"] = Value::String(blake3::hash(&bytes).to_hex().to_string()); + Ok(out) +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct FileTemplateDecl { diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 295effc30..73c32a825 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -4336,3 +4336,112 @@ fn view_messages_accept_multi_megabyte_snapshots_and_cap_at_8_mib() { f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Message { text: too_large }, f.inv()).unwrap_err(); assert!(err.message.contains("limited to"), "{}", err.message); } + +#[test] +fn marker_icons_ship_sanitized_light_and_dark_assets() { + 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(); + let manifest = |engine: &str, icon: &str, dark: &str| { + format!( + r#" +id = "marker-icons" +name = "Marker Icons" +version = "1.0.0" +engines.openidea = "{engine}" +[[contributes.markerIcons]] +id = "resource" +icon = "{icon}" +iconDark = "{dark}" +"# + ) + }; + let clean = r#""#; + std::fs::write(pkg.join("icons/resource.svg"), clean).unwrap(); + std::fs::write(pkg.join("icons/resource_dark.svg"), clean.replace("rect", "circle")).unwrap(); + for (engine, icon, dark) in [ + ("^1.0", "icons/resource.svg", "icons/resource_dark.svg"), + ("^1.3", "../outside.svg", "icons/resource_dark.svg"), + ("^1.3", "icons/missing.svg", "icons/resource_dark.svg"), + ("^1.3", "icons/resource.svg", "icons/missing.svg"), + ] { + std::fs::write(pkg.join("plugin.toml"), manifest(engine, icon, dark)).unwrap(); + assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err()); + } + std::fs::write(pkg.join("plugin.toml"), manifest("^1.3", "icons/resource.svg", "icons/resource_dark.svg")).unwrap(); + #[cfg(unix)] + { + let outside = tmp.path().join("outside.svg"); + std::fs::write(&outside, clean).unwrap(); + std::fs::remove_file(pkg.join("icons/resource_dark.svg")).unwrap(); + std::os::unix::fs::symlink(outside, pkg.join("icons/resource_dark.svg")).unwrap(); + assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err()); + std::fs::remove_file(pkg.join("icons/resource_dark.svg")).unwrap(); + } + let mut unsafe_svgs = vec![ + "".to_owned(), + "".to_owned(), + "".to_owned(), + "".to_owned(), + "".to_owned(), + format!("{}", " ".repeat(64 << 10)), + ]; + for attr in ["href", "xlink:href"] { + for scheme in ["http://evil/x", "data:image/svg+xml,x", "javascript:alert(1)"] { + unsafe_svgs.push(format!("")); + } + } + for path in ["icons/resource.svg", "icons/resource_dark.svg"] { + for svg in &unsafe_svgs { + std::fs::write(pkg.join(path), svg).unwrap(); + assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err(), "accepted {path}: {svg}"); + } + std::fs::write(pkg.join(path), clean).unwrap(); + } + std::fs::write(pkg.join("icons/resource_dark.svg"), clean.replace("rect", "circle")).unwrap(); + let info = f.install(&pkg); + assert!(info.error.is_none(), "{:?}", info.error); + let assets = f.host.assets("marker-icons", "markerIcon/resource/").unwrap(); + assert_eq!(assets.len(), 2); + assert!(assets.iter().any(|(id, svg)| id.ends_with("/light") && svg.contains(">(), + ["run", "database", "icons/very-long-resource-marker-icon.svg", "overriding"] + ); +} diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 8206c78d8..baa2b4c53 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -1027,7 +1027,7 @@ impl Runtime { .take(MAX_PROVIDER_ITEMS) .map(|m| LineMarker { line: m.line, - icon: m.icon.map(|i| clip(i, 32)), + icon: m.icon.map(|i| clip(i, 256)), tooltip: clip(m.tooltip, 1024), target: m.target.map(location_from_wit), }) diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm index ea3a85795bdb1fc6d8ac86f2ed9eee0ef31d91d2..e36735729dddb0c06ec29f27f24e11c21c4d65a8 100644 GIT binary patch delta 9613 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 Gx&H={A}CA% diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index fb352bce8..b05ba59a6 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -0ec5c31f6b9b221b0b7be58f73d5c94703e8c14ada610d8ad12f84f63bf5fe97 +a3b813b172441c5fad462b249c69dadda2b8fb1e2b387bda2487f7df3f729338 diff --git a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs index e274a24f6..eabe199ab 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs @@ -358,8 +358,20 @@ impl providers::Guest for Misbehave { Vec::new() } - fn line_markers(_doc: Document) -> Vec { - Vec::new() + fn line_markers(doc: Document) -> Vec { + if !doc.text.starts_with("marker-icons") { + return Vec::new(); + } + ["run", "database", "icons/very-long-resource-marker-icon.svg", "overriding"] + .into_iter() + .enumerate() + .map(|(line, icon)| LineMarker { + line: line as u32, + icon: Some(icon.into()), + tooltip: format!("Marker: {icon}"), + target: None, + }) + .collect() } } diff --git a/e2e/pluginMarkerIcons.spec.ts b/e2e/pluginMarkerIcons.spec.ts new file mode 100644 index 000000000..8e67692fe --- /dev/null +++ b/e2e/pluginMarkerIcons.spec.ts @@ -0,0 +1,102 @@ +import { expect, stubDesktop, test } from "./fixtures"; + +test("plugin markers render names and package SVG variants and keep the indexed target chooser", async ({ + workbench: page, +}) => { + await stubDesktop(page); + await page.evaluate(async () => { + const backendPath = "/src/editor/core/backend.ts"; + const contributionsPath = "/src/workbench/contributions.ts"; + const pluginsPath = "/src/features/plugins/backend.ts"; + const storePath = "/src/features/plugins/store.ts"; + const projectionPath = "/src/features/plugins/projection.ts"; + const { docBackend } = await import(backendPath); + const { editorService } = await import(contributionsPath); + const { pluginsBackend, setPluginsBackend } = await import(pluginsPath); + const { usePlugins } = await import(storePath); + const { syncUiContributions } = await import(projectionPath); + const source = "/dev/acme-api/markers.txt"; + const files = docBackend().files; + files.set(source, "run marker\nbuiltin marker\npackage marker\n😀 mapping key\n"); + files.set("/dev/acme-api/target-a.txt", "first target\n"); + files.set("/dev/acme-api/target-b.txt", "second target\n"); + const range = { start: { line: 0, col: 0 }, end: { line: 0, col: 5 } }; + window.__ipc.handlers.plugin_line_markers = () => [ + { pluginId: "hello", line: 0, icon: "run", tooltip: "Run marker", target: null }, + { pluginId: "hello", line: 1, icon: "database", tooltip: "Database marker", target: null }, + { pluginId: "hello", line: 2, icon: "icons/resource.svg", tooltip: "Package marker", target: null }, + ]; + window.__ipc.handlers.plugin_navigation_markers = () => [ + { + path: source, + version: 0, + markers: [ + { + range: { start: { line: 3, col: 3 }, end: { line: 3, col: 10 } }, + targets: ["target-a.txt", "target-b.txt"].map((name) => ({ path: `/dev/acme-api/${name}`, range })), + }, + ], + }, + ]; + window.__ipc.handlers.fs_read_text = (args) => files.get(String(args.path)) ?? ""; + window.__ipc.handlers.fs_probe = () => ({ size: 64, binary: false, tooLarge: false }); + window.__ipc.handlers.window_is_fullscreen = () => false; + const base = pluginsBackend(); + setPluginsBackend({ + ...base, + assets: async () => [ + [ + "markerIcon/resource/light", + '', + ], + ["markerIcon/resource/dark", ''], + ], + }); + const { ui, plugins } = usePlugins.getState(); + syncUiContributions( + [ + ...ui, + { + pluginId: "hello", + point: "markerIcons", + key: "resource", + decl: { id: "resource", icon: "icons/resource.svg" }, + }, + ], + plugins, + ); + await editorService().openFile(source); + }); + const markers = page.locator(".cm-openidea-gutter-pluginMarkers"); + await expect(markers.locator('[title="Run marker"] svg')).toHaveAttribute("data-icon", "gutter-run"); + await expect(markers.locator('[title="Database marker"] svg')).toHaveAttribute("data-icon", "database"); + const custom = markers.locator('[title="Package marker"] svg'); + await expect(custom).toHaveAttribute("data-icon", /^ext:pluginIcon\/hello\//); + await page.evaluate(async () => { + const path = "/src/workbench/theme.ts"; + await (await import(path)).setTheme("dark"); + }); + await expect(custom.locator("circle")).toBeVisible(); + await expect(custom.locator("rect")).toBeHidden(); + await page.screenshot({ path: test.info().outputPath("markers-dark.png"), animations: "disabled" }); + await page.evaluate(async () => { + const path = "/src/workbench/theme.ts"; + await (await import(path)).setTheme("light"); + }); + await expect(custom.locator("rect")).toBeVisible(); + await expect(custom.locator("circle")).toBeHidden(); + await page.screenshot({ path: test.info().outputPath("markers-light.png"), animations: "disabled" }); + await page.locator(".cm-plugin-mapping-gutter button").click(); + const chooser = page.getByRole("dialog", { name: "Choose Mapping Target" }); + await expect(chooser.getByRole("option")).toHaveCount(2); + await page.screenshot({ path: test.info().outputPath("markers-chooser.png"), animations: "disabled" }); + await chooser.getByRole("option").filter({ hasText: "target-a.txt" }).click(); + await expect + .poll(() => + page.evaluate(async () => { + const path = "/src/workbench/contributions.ts"; + return (await import(path)).editorService().activePath(); + }), + ) + .toBe("/dev/acme-api/target-a.txt"); +}); diff --git a/plugins/README.md b/plugins/README.md index bb1854036..b929d38a1 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -555,6 +555,27 @@ kinds = ["definition", "line-markers", "completion"] # also "inline-completion" languages = ["java", "xml"] # editor language ids; "*" for any ``` +Line marker `icon` accepts an icon name (including registered plugin icons), +the aliases `navigate`, `implemented`, `implementing`, `overridden`, `overriding` +and `run`, or a package SVG path. Declare package marker icons so installation +includes and validates the SVGs (`engines.openidea = "^1.3"`): + +```toml +[[contributes.markerIcons]] +id = "resource" +icon = "icons/resource.svg" +iconDark = "icons/resource_dark.svg" # optional dark-theme variant +``` + +Return `icon = Some("icons/resource.svg".into())` from `line_markers` to use it. +The referenced icon path is package-relative, up to 256 bytes; both SVGs use the same sandbox, +64 KiB size limit and sanitization as view icons. Active content and external SVG or CSS +references are rejected during installation. Missing or unknown icons retain the +navigation glyph. A marker's optional target still navigates on click. For +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. + 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/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index 9e21553ab..e00827d27 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -779,6 +779,7 @@ describe("plugin line markers", () => { const doc = EditorState.create({ doc: "a\nbb\nccc" }).doc; const marker = (line: number, icon: string | null) => ({ pluginId: "m", line, icon, tooltip: "t", target: null }); expect(placeMarkers(doc, [marker(1, "run"), marker(7, null)]).map((m) => m.pos)).toEqual([2]); + expect(markerIcon("database")).toBe("database"); expect(markerIcon("run")).toBe("gutter-run"); expect(markerIcon("implemented")).toBe("gutter-implemented"); expect(markerIcon(null)).toBe("gutter-implementing"); diff --git a/src/features/plugins/lineMarkerGutter.test.ts b/src/features/plugins/lineMarkerGutter.test.ts index 61180e6d5..7aa1bf81c 100644 --- a/src/features/plugins/lineMarkerGutter.test.ts +++ b/src/features/plugins/lineMarkerGutter.test.ts @@ -4,8 +4,11 @@ import { EditorView } from "@codemirror/view"; import type { EditorDocContext } from "@/editor/api"; import type { PluginEvent } from "@/bindings"; import { pluginLineMarkerGutter } from "./lineMarkerGutter"; +import { pluginIconName } from "./pluginIcon"; +import { projectMarkerIcon } from "./markerIcons"; const mock = vi.hoisted(() => ({ markers: vi.fn(), + assets: vi.fn(), event: undefined as ((event: PluginEvent) => void) | undefined, off: vi.fn(), })); @@ -15,6 +18,7 @@ vi.mock(import("@/platform/ipc"), async (original) => { }); vi.mock("./backend", () => ({ pluginsBackend: () => ({ + assets: mock.assets, onEvent: (fn: (event: PluginEvent) => void) => { mock.event = fn; return mock.off; @@ -54,3 +58,58 @@ it("withdraws old interactive markers on reload and does not refresh on idle sta view = undefined; expect(mock.off).toHaveBeenCalledOnce(); }); + +it("renders ordinary and custom registered icons alongside legacy marker aliases", async () => { + vi.useFakeTimers(); + const icon = pluginIconName("markers", { + light: '', + dark: null, + }); + mock.markers.mockResolvedValue({ + status: "ok", + data: ["run", "database", icon].map((icon, line) => ({ + pluginId: "markers", + line, + icon, + tooltip: `marker ${line}`, + target: null, + })), + }); + view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc: "legacy\nordinary\ncustom", + extensions: pluginLineMarkerGutter({ doc: 1 } as EditorDocContext), + }), + }); + await vi.advanceTimersByTimeAsync(450); + const markers = view.dom.querySelectorAll(".cm-plugin-mapping-button"); + expect(markers).toHaveLength(3); + expect([...markers].map((el) => el.getAttribute("aria-label"))).toEqual(["marker 0", "marker 1", "marker 2"]); + expect(markers[0]!.innerHTML).not.toBe(markers[1]!.innerHTML); + expect(markers[2]!.querySelector("circle")).not.toBeNull(); +}); + +it("refreshes a fallback when the package icon declaration arrives after its markers", async () => { + vi.useFakeTimers(); + mock.markers.mockResolvedValue({ + status: "ok", + data: [{ pluginId: "late", line: 0, icon: "icons/late.svg", tooltip: "late icon", target: null }], + }); + view = new EditorView({ + parent: document.body, + state: EditorState.create({ doc: "late", extensions: pluginLineMarkerGutter({ doc: 1 } as EditorDocContext) }), + }); + await vi.advanceTimersByTimeAsync(450); + expect(view.dom.querySelector(".cm-plugin-mapping-button circle")).toBeNull(); + mock.assets.mockResolvedValue([ + ["markerIcon/late/light", ''], + ]); + const remove = projectMarkerIcon("late", { id: "late", icon: "icons/late.svg" }); + try { + await vi.advanceTimersByTimeAsync(450); + expect(view.dom.querySelector(".cm-plugin-mapping-button circle")).not.toBeNull(); + } finally { + remove(); + } +}); diff --git a/src/features/plugins/lineMarkerGutter.ts b/src/features/plugins/lineMarkerGutter.ts index 4fa34cb2c..6f611ed5c 100644 --- a/src/features/plugins/lineMarkerGutter.ts +++ b/src/features/plugins/lineMarkerGutter.ts @@ -10,7 +10,8 @@ import type { EditorDocContext } from "@/editor/api"; import { navigationButton } from "./navigationButton"; import { pluginsBackend } from "./backend"; import { commands, type LineMarkerInfo } from "@/platform/ipc"; -import { gutterIconMarkup } from "@/ui"; +import { gutterIconMarkup, isIconName } from "@/ui/icons"; +import { onMarkerIconsChanged, resolveMarkerIcon } from "./markerIcons"; import styles from "@/editor/navigation/LineMarkers.module.css"; const DEBOUNCE_MS = 400; @@ -24,9 +25,10 @@ const ICONS: Record = { navigate: "gutter-implementing", }; -/** Icon name for a plugin marker (`navigate` when unknown). */ +/** Legacy aliases and ordinary registered icon names; navigate when unknown. */ export function markerIcon(icon: string | null): string { - return ICONS[icon ?? "navigate"] ?? ICONS.navigate!; + const name = icon ?? "navigate"; + return Object.hasOwn(ICONS, name) ? ICONS[name]! : isIconName(name) ? name : ICONS.navigate!; } interface Placed { @@ -85,13 +87,21 @@ class MarkersPlugin { private doc: number, private column: Compartment, ) { - this.off = pluginsBackend().onEvent((event) => { + const offBackend = pluginsBackend().onEvent((event) => { if (event.type !== "documentIndexesChanged") return; this.generation++; this.shown = false; this.view.dispatch({ effects: [setMarkers.of([]), this.column.reconfigure([])] }); this.schedule(); }); + const offIcons = onMarkerIconsChanged(() => { + this.generation++; + this.schedule(); + }); + this.off = () => { + offBackend(); + offIcons(); + }; this.schedule(); } @@ -112,7 +122,14 @@ class MarkersPlugin { const markers = await commands.pluginLineMarkers(this.doc); if (gen !== this.generation || this.view.state.doc !== text || !this.view.dom.isConnected) return; if (markers.status !== "ok") return; - const placed = placeMarkers(text, markers.data); + const resolved = await Promise.all( + markers.data.map(async (marker) => ({ + ...marker, + icon: await resolveMarkerIcon(marker.pluginId, marker.icon), + })), + ); + if (gen !== this.generation || this.view.state.doc !== text || !this.view.dom.isConnected) return; + const placed = placeMarkers(text, resolved); if (!placed.length && !this.view.state.field(markersField).length) return; const effects: StateEffect[] = [setMarkers.of(placed)]; if (placed.length > 0 !== this.shown) { diff --git a/src/features/plugins/markerIcons.test.ts b/src/features/plugins/markerIcons.test.ts new file mode 100644 index 000000000..02fd74a15 --- /dev/null +++ b/src/features/plugins/markerIcons.test.ts @@ -0,0 +1,58 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { gutterIconMarkup } from "@/ui/icons"; +import { projectMarkerIcon, resolveMarkerIcon } from "./markerIcons"; +import { markerIcon } from "./lineMarkerGutter"; + +const mocks = vi.hoisted(() => ({ assets: vi.fn() })); +vi.mock("./backend", () => ({ pluginsBackend: () => ({ assets: mocks.assets }) })); +afterEach(() => vi.clearAllMocks()); +const svg = (shape: string) => `${shape}`; + +it("resolves declared package paths within their plugin and uses the dark SVG", async () => { + mocks.assets.mockResolvedValue([ + ["markerIcon/resource/light", svg('')], + ["markerIcon/resource/dark", svg('')], + ]); + const remove = projectMarkerIcon("p", { id: "resource", icon: "icons/resource.svg" }); + try { + const name = (await resolveMarkerIcon("p", "icons/resource.svg"))!; + expect(mocks.assets).toHaveBeenCalledWith("p", "markerIcon/resource/"); + expect(markerIcon(name)).toBe(name); + expect(gutterIconMarkup(name)).toContain(" { + expect(markerIcon(await resolveMarkerIcon("p", "database"))).toBe("database"); + expect(markerIcon("overriding")).toBe("gutter-overriding"); + expect(markerIcon("not-an-icon")).toBe("gutter-implementing"); + expect(markerIcon("__proto__")).toBe("gutter-implementing"); + expect(markerIcon("constructor")).toBe("gutter-implementing"); + expect(markerIcon(null)).toBe("gutter-implementing"); + expect(mocks.assets).not.toHaveBeenCalled(); +}); + +it("keeps a replacement when an older declaration is disposed", async () => { + mocks.assets.mockResolvedValue([["markerIcon/resource/light", svg('')]]); + const removeOld = projectMarkerIcon("p", { id: "resource", icon: "icons/resource.svg" }); + const removeNew = projectMarkerIcon("p", { id: "resource", icon: "icons/resource.svg" }); + removeOld(); + expect(await resolveMarkerIcon("p", "icons/resource.svg")).toMatch(/^ext:/); + removeNew(); +}); + +it("falls back when an asset fails to load", async () => { + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + mocks.assets.mockRejectedValue(new Error("disabled")); + const remove = projectMarkerIcon("p", { id: "resource", icon: "icons/resource.svg" }); + try { + expect(markerIcon(await resolveMarkerIcon("p", "icons/resource.svg"))).toBe("gutter-implementing"); + } finally { + remove(); + warning.mockRestore(); + } +}); diff --git a/src/features/plugins/markerIcons.ts b/src/features/plugins/markerIcons.ts new file mode 100644 index 000000000..13f23bd7a --- /dev/null +++ b/src/features/plugins/markerIcons.ts @@ -0,0 +1,51 @@ +import { pluginsBackend } from "./backend"; +import { pluginIconName } from "./pluginIcon"; + +export interface MarkerIconDecl { + id: string; + icon: string; +} + +const icons = new Map>(); +const listeners = new Set<() => void>(); + +export function onMarkerIconsChanged(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function changed() { + for (const listener of listeners) listener(); +} +const key = (pluginId: string, path: string) => `${pluginId}\0${path}`; + +/** Package paths are resolved only through declarations validated by the host. */ +export function projectMarkerIcon(pluginId: string, decl: MarkerIconDecl): () => void { + const pending = pluginsBackend() + .assets(pluginId, `markerIcon/${decl.id}/`) + .then((assets) => { + const svg = (variant: string) => assets.find(([id]) => id === `markerIcon/${decl.id}/${variant}`)?.[1]; + const light = svg("light"); + return light ? pluginIconName(pluginId, { light, dark: svg("dark") ?? null }) : null; + }) + .catch((e: unknown) => { + console.warn(`plugin ${pluginId}: marker icon ${decl.id}`, e); + return null; + }); + const id = key(pluginId, decl.icon); + icons.set(id, pending); + changed(); + return () => { + if (icons.get(id) === pending) { + icons.delete(id); + changed(); + } + }; +} + +export async function resolveMarkerIcon(pluginId: string, icon: string | null): Promise { + if (!icon) return null; + return (await icons.get(key(pluginId, icon))) ?? icon; +} diff --git a/src/features/plugins/uiPoints.tsx b/src/features/plugins/uiPoints.tsx index a01af14cb..75d91a74a 100644 --- a/src/features/plugins/uiPoints.tsx +++ b/src/features/plugins/uiPoints.tsx @@ -47,6 +47,7 @@ import { } from "@/workbench/contributions"; import { pluginsBackend } from "./backend"; import { pluginIconName } from "./pluginIcon"; +import { projectMarkerIcon, type MarkerIconDecl } from "./markerIcons"; import { usePlugins } from "./store"; import { pluginActionId } from "./points"; import { defineUiPoint } from "./projection"; @@ -568,6 +569,10 @@ export function defineViewUiPoints(): () => void { }; }, }), + defineUiPoint("markerIcons", { + title: "Marker Icons", + project: ({ pluginId, decl }) => projectMarkerIcon(pluginId, decl), + }), defineUiPoint("fileIcons", { title: "File Icons", project: ({ pluginId, decl }) => projectFileIcon(pluginId, decl), diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 14c3e11f1..fc2b480fc 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1,4 +1,5 @@ { + "Marker Icons": "Marker Icons", "this data source does not list databases": "this data source does not list databases", "commit or roll back before clearing the selected database": "commit or roll back before clearing the selected database", "connect_timeout must be greater than zero": "connect_timeout must be greater than zero", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 44afe6d94..b256a1a96 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,5 @@ { + "Marker Icons": "行标记图标", "this data source does not list databases": "此数据源不支持列出数据库", "commit or roll back before clearing the selected database": "清除所选数据库前,请先提交或回滚事务", "connect_timeout must be greater than zero": "connect_timeout 必须大于零", diff --git a/src/ui/icons.test.tsx b/src/ui/icons.test.tsx index 8179d2a41..285ee8293 100644 --- a/src/ui/icons.test.tsx +++ b/src/ui/icons.test.tsx @@ -11,6 +11,7 @@ import { fileIconName, hasIcon, iconMarkup, + isIconName, iconMarkupOrFallback, registerExternalIcons, setFileIconTheme, @@ -54,6 +55,14 @@ describe("Icon", () => { for (const name of ICON_NAMES) expect(iconMarkup(name), name).toMatch(/^ { + for (const name of ["__proto__", "constructor", "toString", "symbol-__proto__", "symbol-toString"]) { + expect(isIconName(name), name).toBe(false); + expect(iconMarkup(name), name).toBeNull(); + } + expect(isIconName("symbol-constructor")).toBe(true); + }); + it("falls back like for unknown names, without React", () => { const { container } = render(); const withoutData = (html: string) => html.replace(/ data-icon(?:-[a-z]+)?="[^"]*"/g, ""); diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 648e31090..2ab1e4af2 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -567,8 +567,11 @@ function iconPath(name: string): string | undefined { const themed = fileIconTheme?.icon?.(name); if (themed && EXTERNAL.has(themed)) return themed; if (name.startsWith(EXTERNAL_PREFIX)) return EXTERNAL.has(name) ? name : undefined; - if (name.startsWith("symbol-")) return SYMBOL_ICONS[name.slice(7) as SymbolKind]; - return (ICONS as Record)[name]; + if (name.startsWith("symbol-")) { + const kind = name.slice(7); + return Object.hasOwn(SYMBOL_ICONS, kind) ? SYMBOL_ICONS[kind as SymbolKind] : undefined; + } + return Object.hasOwn(ICONS, name) ? (ICONS as Record)[name] : undefined; } // ---------------------------------------------------------------------------