feat(plugins): let plugin tool windows use an SVG icon from the package (#1363)

A view's `icon` could only be a built-in icon name, so a plugin's tool
window button couldn't show the plugin's own mark. Like `fileIcons`, an
`icon` ending in `.svg` is now a path in the package: the host reads and
checks it at install time (a missing file or an SVG with `<script>` refuses
the install), serves it as the `viewIcon/<id>` asset, and the tool window
registers it as an external icon, falling back to the plugin icon if it
can't be loaded. Icon names work as before.

Closes #1335

Co-authored-by: yzPeedro <54516747+yzPeedro@users.noreply.github.com>
This commit is contained in:
Pedro Pessoa
2026-11-05 07:48:39 +08:00
committed by GitHub
parent ab2b53ff80
commit 7ffaa0b5da
4 changed files with 116 additions and 31 deletions
+10
View File
@@ -378,9 +378,19 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
}
let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?);
out.files = files;
if let Some(icon) = d.icon.as_deref().filter(|i| i.ends_with(".svg")) {
let path = std::path::PathBuf::from(icon);
let svg = super::icons::read_icon(pkg, &path)?;
out.assets.push((view_icon_asset(&d.id), svg));
out.files.push(path);
}
Ok(out)
}
pub fn view_icon_asset(id: &str) -> String {
format!("viewIcon/{id}")
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct CustomEditorDecl {
+45
View File
@@ -2045,6 +2045,51 @@ fn pinned_programs_fill_views_and_open_terminals() {
assert_eq!(args, ["follow", "build", "--lines=5"]);
}
#[test]
fn views_can_use_an_svg_icon_from_the_package() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let clean = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" fill="#08f"/></svg>"##;
let package = |dir: &str, icon: &str, svg: &str| {
let pkg = tmp.path().join(dir);
for (path, text) in [("panel/index.html", "<p>panel</p>"), ("icons/panel.svg", svg)] {
std::fs::create_dir_all(pkg.join(path).parent().unwrap()).unwrap();
std::fs::write(pkg.join(path), text).unwrap();
}
std::fs::write(
pkg.join("plugin.toml"),
format!(
r#"id = "panel-icon"
name = "Panel Icon"
version = "1.0.0"
engines.openidea = "^1.0"
[[contributes.views]]
id = "panel"
title = "Panel"
icon = "{icon}"
location = "bottom"
web = "panel/index.html"
"#
),
)
.unwrap();
pkg
};
assert!(f.host.install_from_dir(&package("missing", "icons/missing.svg", clean), &Consent::all()).is_err());
let scripted = clean.replace("<rect", "<script>alert(1)</script><rect");
assert!(f.host.install_from_dir(&package("scripted", "icons/panel.svg", &scripted), &Consent::all()).is_err());
let info = f.install(&package("clean", "icons/panel.svg", clean));
assert!(info.error.is_none(), "{:?}", info.error);
let view = f.host.ui_items("views").into_iter().find(|v| v.key == "panel").expect("the view");
assert_eq!(view.decl["icon"].as_str(), Some("icons/panel.svg"));
let assets = f.host.assets("panel-icon", "viewIcon/panel").unwrap();
assert_eq!(assets.len(), 1);
assert!(assets[0].1.contains("<rect"));
}
/// A plugin with web views and no code: its pages and their files are
/// installed with it and served only from the declared directories, while
/// it is enabled and only for the installed version; file icons and
+17 -17
View File
@@ -125,23 +125,23 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
### Contribution points
| Point | Declaration | Code needed |
| --------------- | -------------------------------------------------------------------------------------------------- | ------------- |
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
| `views` | `id`, `title`, `icon?`, `location` (`left`, `right`, `bottom`), `web?` (see _Web views_) | without `web` |
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
| Point | Declaration | Code needed |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
| `views` | `id`, `title`, `icon?` (an icon name, or an SVG of the package), `location` (`left`, `right`, `bottom`), `web?` (see _Web views_) | without `web` |
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
Declared contributions are live as soon as the plugin is enabled — commands
show in Find Action, menus and the status bar, shortcuts work, themes and icon
+44 -14
View File
@@ -290,6 +290,49 @@ function projectFileIcon(pluginId: string, decl: FileIconDecl): () => void {
export const pluginToolWindowId = (pluginId: string, view: string) => `plugin.${pluginId}.${view}`;
function registerView(pluginId: string, decl: ViewDecl, icon: string): () => void {
return registerToolWindow({
id: pluginToolWindowId(pluginId, decl.id),
title: decl.title,
icon,
anchor: decl.location,
order: 900,
component: () =>
decl.web ? (
<WebView pluginId={pluginId} view={decl.id} label={decl.title} />
) : (
<PluginView pluginId={pluginId} view={decl.id} label={decl.title} />
),
});
}
function projectView(pluginId: string, decl: ViewDecl): () => void {
if (!decl.icon?.endsWith(".svg")) return registerView(pluginId, decl, decl.icon ?? "plugin");
let disposed = false;
let undo = () => {};
void pluginsBackend()
.assets(pluginId, `viewIcon/${decl.id}`)
.then((assets) => {
if (disposed) return;
const svg = assets.find(([id]) => id === `viewIcon/${decl.id}`)?.[1];
const name = `ext:${pluginId}/view-${decl.id}`;
const unregister = svg ? registerExternalIcons([[name, svg]]) : () => {};
const remove = registerView(pluginId, decl, svg ? name : "plugin");
undo = () => {
remove();
unregister();
};
})
.catch((e: unknown) => {
console.warn(`plugin ${pluginId}: view icon ${decl.id}`, e);
if (!disposed) undo = registerView(pluginId, decl, "plugin");
});
return () => {
disposed = true;
undo();
};
}
/** Registers the client side of the P7 points; returns their disposer. */
export function defineViewUiPoints(): () => void {
const disposers = [
@@ -326,20 +369,7 @@ export function defineViewUiPoints(): () => void {
}),
defineUiPoint<ViewDecl>("views", {
title: "Tool Windows",
project: ({ pluginId, decl }) =>
registerToolWindow({
id: pluginToolWindowId(pluginId, decl.id),
title: decl.title,
icon: decl.icon ?? "plugin",
anchor: decl.location,
order: 900,
component: () =>
decl.web ? (
<WebView pluginId={pluginId} view={decl.id} label={decl.title} />
) : (
<PluginView pluginId={pluginId} view={decl.id} label={decl.title} />
),
}),
project: ({ pluginId, decl }) => projectView(pluginId, decl),
}),
defineUiPoint<CustomEditorDecl>("customEditors", {
title: "Editors",