mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
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:
1 parent
3efec1c848
commit
0bee9b7aab
21 files changed
+499
-12
No files matched your search
@@ -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>"##,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)))?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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,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 必须大于零",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user