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 ea3a85795..e36735729 100644 Binary files a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm differ 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; } // ---------------------------------------------------------------------------