diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs index 3b5e3d14..135ddd7d 100644 --- a/crates/ide-host/src/plugins/ui_points.rs +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -378,9 +378,19 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result { } 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 { diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index d303fd52..e4f925bf 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -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##""##; + let package = |dir: &str, icon: &str, svg: &str| { + let pkg = tmp.path().join(dir); + for (path, text) in [("panel/index.html", "

panel

"), ("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("alert(1).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/.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 diff --git a/src/features/plugins/uiPoints.tsx b/src/features/plugins/uiPoints.tsx index 4fd675cf..d00ed2d2 100644 --- a/src/features/plugins/uiPoints.tsx +++ b/src/features/plugins/uiPoints.tsx @@ -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 ? ( + + ) : ( + + ), + }); +} + +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("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 ? ( - - ) : ( - - ), - }), + project: ({ pluginId, decl }) => projectView(pluginId, decl), }), defineUiPoint("customEditors", { title: "Editors",