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 <[email protected]>
This commit is contained in:
Arnaud Ritti authored and GitHub committed 2026-11-08 03:12:01 +08:00
1 parent 3efec1c848
commit 0bee9b7aab
21 files changed
+499 -12

No files matched your search

+6
View File
@@ -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##"<svg><use xlink:href="data:image/svg+xml,..."/></svg>"##,
r##"<svg><rect style="fill:url(https://evil)"/></svg>"##,
r##"<svg><rect fill="url( 'http://x' )"/></svg>"##,
r##"<svg><rect style="background-image:image-set('https://evil/x.png' 1x)"/></svg>"##,
r##"<svg><rect style="background-image:-webkit-image-set('https://evil/x.png' 1x)"/></svg>"##,
r##"<svg><rect style="background-image:image('https://evil/x.png')"/></svg>"##,
r##"<svg><rect style="fill:src('https://evil/x.svg')"/></svg>"##,
r##"<!DOCTYPE svg [<!ENTITY x "y">]><svg/>"##,
r##"<svg><![CDATA[x]]></svg>"##,
r##"<svg><a href="#x"/></svg>"##,
+2 -1
View File
@@ -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();
+1
View File
@@ -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)))?;
+30
View File
@@ -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<UiDecl, Stri
Ok(out)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct MarkerIconDecl {
id: String,
icon: PathBuf,
#[serde(default)]
icon_dark: Option<PathBuf>,
}
pub(super) fn marker_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
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 {
+109
View File
@@ -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#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#;
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![
"<svg><script>alert(1)</script></svg>".to_owned(),
"<svg onload=\"alert(1)\"/>".to_owned(),
"<svg><foreignObject/></svg>".to_owned(),
"<svg><rect style=\"fill:url(https://evil/x)\"/></svg>".to_owned(),
"<svg><rect style=\"background-image:image-set('https://evil/x' 1x)\"/></svg>".to_owned(),
format!("<svg>{}</svg>", " ".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!("<svg><use {attr}=\"{scheme}\"/></svg>"));
}
}
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("<rect")));
assert!(assets.iter().any(|(id, svg)| id.ends_with("/dark") && svg.contains("<circle")));
let revision = f.host.ui_items("markerIcons")[0].decl["revision"].clone();
std::fs::write(pkg.join("icons/resource.svg"), clean.replace("width=\"16\"", "width=\"14\"")).unwrap();
let updated = f.install(&pkg);
assert!(updated.error.is_none(), "{:?}", updated.error);
assert_ne!(f.host.ui_items("markerIcons")[0].decl["revision"], revision);
let assets = f.host.assets("marker-icons", "markerIcon/resource/").unwrap();
assert!(assets.iter().any(|(id, svg)| id.ends_with("/light") && svg.contains("width=\"14\"")));
std::fs::remove_dir_all(pkg).unwrap();
assert_eq!(f.host.assets("marker-icons", "markerIcon/resource/").unwrap(), assets);
f.host.set_enabled("marker-icons", false).unwrap();
assert!(f.host.assets("marker-icons", "markerIcon/resource/").is_err());
}
#[test]
fn marker_providers_preserve_names_and_package_paths() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("plugin");
copy_dir(&misbehave_pkg(), &pkg);
let manifest = std::fs::read_to_string(pkg.join("plugin.toml")).unwrap();
std::fs::write(
pkg.join("plugin.toml"),
format!("{manifest}\n[[contributes.providers]]\nkinds = [\"line-markers\"]\nlanguages = [\"text\"]\n"),
)
.unwrap();
f.install(&pkg);
let doc = ide_plugin::ProviderDocument {
path: f.project.join("markers.txt"),
language: "text".into(),
version: 1,
text: "marker-icons\nordinary\ncustom\nlegacy".into(),
};
assert!(eventually(|| f.host.provide_line_markers(&doc).len() == 4));
let markers = f.host.provide_line_markers(&doc);
assert_eq!(
markers.iter().map(|(_, m)| m.icon.as_deref().unwrap()).collect::<Vec<_>>(),
["run", "database", "icons/very-long-resource-marker-icon.svg", "overriding"]
);
}
+1 -1
View File
@@ -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),
})
Binary file not shown.
@@ -1 +1 @@
0ec5c31f6b9b221b0b7be58f73d5c94703e8c14ada610d8ad12f84f63bf5fe97
a3b813b172441c5fad462b249c69dadda2b8fb1e2b387bda2487f7df3f729338
+14 -2
View File
@@ -358,8 +358,20 @@ impl providers::Guest for Misbehave {
Vec::new()
}
fn line_markers(_doc: Document) -> Vec<LineMarker> {
Vec::new()
fn line_markers(doc: Document) -> Vec<LineMarker> {
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()
}
}
+102
View File
@@ -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",
'<svg viewBox="0 0 16 16"><rect width="12" height="12" x="2" y="2" fill="#087faa"/></svg>',
],
["markerIcon/resource/dark", '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" fill="#5bd1e8"/></svg>'],
],
});
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");
});
+21
View File
@@ -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
@@ -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");
@@ -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: '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/></svg>',
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", '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/></svg>'],
]);
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();
}
});
+22 -5
View File
@@ -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<string, string> = {
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<unknown>[] = [setMarkers.of(placed)];
if (placed.length > 0 !== this.shown) {
+58
View File
@@ -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) => `<svg viewBox="0 0 16 16">${shape}</svg>`;
it("resolves declared package paths within their plugin and uses the dark SVG", async () => {
mocks.assets.mockResolvedValue([
["markerIcon/resource/light", svg('<rect width="16" height="16"/>')],
["markerIcon/resource/dark", svg('<circle cx="8" cy="8" r="7"/>')],
]);
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("<circle");
expect(await resolveMarkerIcon("other", "icons/resource.svg")).toBe("icons/resource.svg");
} finally {
remove();
}
expect(await resolveMarkerIcon("p", "icons/resource.svg")).toBe("icons/resource.svg");
});
it("retains built-in names, legacy aliases and unknown-icon fallback", async () => {
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('<rect width="16" height="16"/>')]]);
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();
}
});
+51
View File
@@ -0,0 +1,51 @@
import { pluginsBackend } from "./backend";
import { pluginIconName } from "./pluginIcon";
export interface MarkerIconDecl {
id: string;
icon: string;
}
const icons = new Map<string, Promise<string | null>>();
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<string | null> {
if (!icon) return null;
return (await icons.get(key(pluginId, icon))) ?? icon;
}
+5
View File
@@ -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<MarkerIconDecl>("markerIcons", {
title: "Marker Icons",
project: ({ pluginId, decl }) => projectMarkerIcon(pluginId, decl),
}),
defineUiPoint<FileIconDecl>("fileIcons", {
title: "File Icons",
project: ({ pluginId, decl }) => projectFileIcon(pluginId, decl),
+1
View File
@@ -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",
+1
View File
@@ -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 必须大于零",
+9
View File
@@ -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(/^<svg/);
});
it("rejects inherited object keys as icon names", () => {
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 <Icon> for unknown names, without React", () => {
const { container } = render(<Icon name="symbol-macro" />);
const withoutData = (html: string) => html.replace(/ data-icon(?:-[a-z]+)?="[^"]*"/g, "");
+5 -2
View File
@@ -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<string, string>)[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<string, string>)[name] : undefined;
}
// ---------------------------------------------------------------------------