From da3d996e108213e28ba82aea8da787145cafe95f Mon Sep 17 00:00:00 2001 From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:04:10 +0100 Subject: [PATCH] feat(plugins): group views and apply visibility conditions (#2019) Closes #1949. Plugin API 1.3: views from several plugins can share one tool window (shown as tabs) and a view can declare a visibility condition (e.g. only when the project contains a file, or for the active file) so it appears only when it applies. Additive within API 1.x, with a fixture plugin, regression tests and docs. Co-authored-by: arnaud-ritti <77437157+arnaud-ritti@users.noreply.github.com> --- crates/ide-host/src/plugins/ui_points.rs | 13 + crates/ide-host/tests/plugins.rs | 80 +++++ .../tests/fixtures/grouped-views/Cargo.lock | 333 ++++++++++++++++++ .../tests/fixtures/grouped-views/Cargo.toml | 20 ++ .../fixtures/grouped-views/dist/plugin.wasm | Bin 0 -> 89160 bytes .../grouped-views/dist/plugin.wasm.sources | 1 + .../tests/fixtures/grouped-views/plugin.toml | 37 ++ .../tests/fixtures/grouped-views/src/lib.rs | 34 ++ e2e/pluginViews.spec.ts | 66 ++++ plugins/README.md | 78 +++- .../plugins/__tests__/viewProjection.test.tsx | 116 ++++++ .../plugins/__tests__/viewWindows.test.tsx | 191 ++++++++++ .../__tests__/workspaceContains.test.ts | 4 +- src/features/plugins/projection.ts | 20 +- src/features/plugins/runtimeUi.tsx | 13 +- src/features/plugins/uiPoints.tsx | 115 +++--- src/features/plugins/views/viewWindows.tsx | 179 ++++++++++ 17 files changed, 1218 insertions(+), 82 deletions(-) create mode 100644 crates/ide-plugin/tests/fixtures/grouped-views/Cargo.lock create mode 100644 crates/ide-plugin/tests/fixtures/grouped-views/Cargo.toml create mode 100644 crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm create mode 100644 crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources create mode 100644 crates/ide-plugin/tests/fixtures/grouped-views/plugin.toml create mode 100644 crates/ide-plugin/tests/fixtures/grouped-views/src/lib.rs create mode 100644 e2e/pluginViews.spec.ts create mode 100644 src/features/plugins/__tests__/viewProjection.test.tsx create mode 100644 src/features/plugins/__tests__/viewWindows.test.tsx create mode 100644 src/features/plugins/views/viewWindows.tsx diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs index b98f220c2..1b74aedec 100644 --- a/crates/ide-host/src/plugins/ui_points.rs +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -399,6 +399,10 @@ struct ViewDecl { icon: Option, #[serde(default = "right")] location: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + group: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + when: Option, /// A web page of the package (`web/index.html`) instead of content the code renders. #[serde(default, skip_serializing_if = "Option::is_none")] web: Option, @@ -419,6 +423,15 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result { let d: ViewDecl = decode(VIEWS, decl)?; validate_item_id(&d.id, "view id")?; title(VIEWS, &d.title)?; + if let Some(group) = &d.group { + pkg.manifest.needs_api("contributes.views.group", 3)?; + validate_item_id(group, "view group")?; + } + if let Some(when) = &d.when { + pkg.manifest.needs_api("contributes.views.when", 3)?; + validate_when(when).map_err(|e| format!("contributes.views: {e}"))?; + } + let files = web_or_code(pkg, d.web.as_deref(), &format!("view {:?}", d.id))?; if !["left", "right", "bottom", SERVICES_LOCATION].contains(&d.location.as_str()) { return Err(format!("view {:?}: `location` is left, right, bottom or services", d.id)); diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index ddb5c8d88..41ba72535 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -2252,6 +2252,86 @@ fn view_item_menus_name_a_declared_view() { assert!(err.message.contains("new in plugin API 1.2"), "{}", err.message); } +#[test] +fn conditional_and_grouped_views_need_api_1_3() { + for field in ["group = \"spring\"", "when = \"workspaceContains:pom.xml || plugin.hello.ready\""] { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let old = + hello_variant(tmp.path(), |m| m.replace("location = \"right\"", &format!("location = \"right\"\n{field}"))); + let err = f.host.install_from_dir(&old, &Consent::all()).unwrap_err(); + assert!(err.message.contains("new in plugin API 1.3"), "{err:?}"); + } + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = hello_variant(tmp.path(), |m| { + m.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"").replace( + "location = \"right\"", + "location = \"right\"\ngroup = \"spring\"\nwhen = \"workspaceContains:pom.xml || plugin.hello.ready\"", + ) + }); + let info = f.install(&pkg); + assert_eq!(info.status, PluginStatus::Inactive, "declaring views must not activate code"); + let view = f.host.ui_items("views").remove(0); + assert_eq!(view.decl["group"], "spring"); + assert_eq!(view.decl["when"], "workspaceContains:pom.xml || plugin.hello.ready"); + f.host.set_enabled("hello", false).unwrap(); + assert!(f.host.ui_items("views").is_empty()); +} + +#[test] +fn grouped_views_fixture_renders_and_scopes_context_and_reveal() { + let f = fixture(Limits::default()); + let info = f.install(&sample("grouped-views")); + assert_eq!(info.status, PluginStatus::Inactive, "views stay lazy"); + assert!(info.error.is_none(), "{:?}", info.error); + let views = f.host.ui_items("views"); + assert_eq!(views.len(), 3); + assert_eq!(views[0].decl["group"], "demo"); + assert_eq!(views[1].decl["when"], "workspaceContains:grouped-views.txt"); + f.take_events(); + f.run("grouped-views", "show", None).unwrap(); + assert!(f.take_events().iter().any(|(client, event)| matches!( + event, + PluginEvent::ContextChanged { plugin_id, key, value } + if client.as_deref() == Some("main") && plugin_id == "grouped-views" + && key == "ready" && value.as_deref() == Some("true") + ))); + assert!(f.host.context_keys(Some("other")).is_empty()); + for view in ["overview", "files", "legacy"] { + let rendered = f.host.render_view("grouped-views", view, None, f.inv()).unwrap(); + assert!(matches!(rendered.body, ide_plugin::ViewBody::Message(_))); + f.run("grouped-views", "reveal", Some(view)).unwrap(); + assert!(f.take_events().iter().any(|(client, event)| matches!( + event, + PluginEvent::RevealView { plugin_id, view: target } + if client.as_deref() == Some("main") && plugin_id == "grouped-views" && target == view + ))); + } + assert!(f.run("grouped-views", "reveal", Some("plugin.hello.greetings")).is_err()); + f.host.set_enabled("grouped-views", false).unwrap(); + assert!(f.host.ui_items("views").is_empty()); + assert!(f.host.context_keys(Some("main")).is_empty()); +} + +#[test] +fn view_conditions_and_group_ids_are_validated() { + for (field, message) in [ + ("group = \"\"", "invalid view group"), + ("group = \"../other\"", "invalid view group"), + ("when = \"ready && (\"", "when"), + ] { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = hello_variant(tmp.path(), |m| { + m.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"") + .replace("location = \"right\"", &format!("location = \"right\"\n{field}")) + }); + let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err(); + assert!(err.message.contains(message), "{field}: {err:?}"); + } +} + /// The sample plugin makes a tool window with a tree view. /// Its menus, shortcut and status bar item are declarations, listed before /// any code runs; the tree is rendered by the code, which loads on first show. diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/Cargo.lock b/crates/ide-plugin/tests/fixtures/grouped-views/Cargo.lock new file mode 100644 index 000000000..bff656dfd --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/grouped-views/Cargo.lock @@ -0,0 +1,333 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "openidea-test-grouped-views" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/Cargo.toml b/crates/ide-plugin/tests/fixtures/grouped-views/Cargo.toml new file mode 100644 index 000000000..1584d60c7 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/grouped-views/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "openidea-test-grouped-views" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm new file mode 100644 index 0000000000000000000000000000000000000000..55dd80d41fec09c9c0077836cbdf1e6a80cee684 GIT binary patch literal 89160 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 literal 0 HcmV?d00001 diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources new file mode 100644 index 000000000..4957164bc --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources @@ -0,0 +1 @@ +1dda19524ab7711b56b67f4b6ebcf62d3c29799aa8276ce7af188b5cabe956ad diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/plugin.toml b/crates/ide-plugin/tests/fixtures/grouped-views/plugin.toml new file mode 100644 index 000000000..852aabca5 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/grouped-views/plugin.toml @@ -0,0 +1,37 @@ +id = "grouped-views" +name = "Grouped Views" +version = "0.1.0" +engines.openidea = "^1.3" +wasm = "dist/plugin.wasm" +activation = ["on-command:*", "on-view:*"] + +[[contributes.commands]] +id = "show" +title = "Grouped Views: Show" + +[[contributes.commands]] +id = "hide" +title = "Grouped Views: Hide" + +[[contributes.commands]] +id = "reveal" +title = "Grouped Views: Reveal" + +[[contributes.views]] +id = "overview" +title = "Grouped Views" +location = "right" +group = "demo" +when = "plugin.grouped-views.ready" + +[[contributes.views]] +id = "files" +title = "Project Files" +location = "right" +group = "demo" +when = "workspaceContains:grouped-views.txt" + +[[contributes.views]] +id = "legacy" +title = "Ungrouped View" +location = "left" diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/src/lib.rs b/crates/ide-plugin/tests/fixtures/grouped-views/src/lib.rs new file mode 100644 index 000000000..adb64b504 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/grouped-views/src/lib.rs @@ -0,0 +1,34 @@ +use openidea_plugin::views::{Body, View, ViewEvent, Viewport, Views}; +use openidea_plugin::{Error, Invocation, Plugin, host, register_plugin}; + +#[derive(Default)] +struct GroupedViews; + +impl Plugin for GroupedViews { + fn run_command(&mut self, id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result, Error> { + match id { + "show" => host::set_context("ready", true)?, + "hide" => host::set_context("ready", false)?, + "reveal" => host::reveal_view(arg.unwrap_or("overview"))?, + _ => return Err(Error::NotFound(id.into())), + } + Ok(None) + } +} + +impl Views for GroupedViews { + fn render(&mut self, view: &str, _viewport: Option, _ctx: &Invocation) -> Result { + match view { + "overview" => Ok(View::new(Body::Message("Context condition is true".into()))), + "files" => Ok(View::new(Body::Message("Project contains grouped-views.txt".into()))), + "legacy" => Ok(View::new(Body::Message("Ungrouped view keeps its original window id".into()))), + _ => Err(Error::NotFound(view.into())), + } + } + + fn handle(&mut self, _view: &str, _event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> { + Ok(()) + } +} + +register_plugin!(GroupedViews, views); diff --git a/e2e/pluginViews.spec.ts b/e2e/pluginViews.spec.ts new file mode 100644 index 000000000..0b306cae7 --- /dev/null +++ b/e2e/pluginViews.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from "./fixtures"; + +test("plugin view groups keep their own tabs and disappear when no view applies", async ({ workbench: page }) => { + await page.evaluate(async () => { + const backendModule = "/src/features/plugins/backend.ts"; + const storeModule = "/src/features/plugins/store.ts"; + const { createMemoryPluginsBackend, setPluginsBackend } = await import(/* @vite-ignore */ backendModule); + const { usePlugins } = await import(/* @vite-ignore */ storeModule); + const base = createMemoryPluginsBackend(); + const template = (await base.list()).plugins[0]; + const plugins = ["test.views", "test.other"].map((id) => ({ ...template, id, status: "inactive" })); + setPluginsBackend({ + ...base, + renderView: async (_pluginId: string, id: string) => ({ + toolbar: [], + body: { kind: "message", value: `${id} content` }, + status: null, + }), + }); + const view = (pluginId: string, id: string, title: string, when?: string, group?: string) => ({ + pluginId, + point: "views", + key: id, + decl: { id, title, location: "right", when, group }, + }); + usePlugins.setState({ + plugins, + ui: [ + view("test.views", "routes", "Framework", "plugin.test.views.ready", "framework"), + view("test.views", "beans", "Beans", "plugin.test.views.beans", "framework"), + view("test.views", "legacy", "Legacy"), + view("test.other", "routes", "Other framework", undefined, "framework"), + ], + }); + }); + const group = page.locator('[data-stripe-button="plugin.test.views.group:framework"]'); + const other = page.locator('[data-stripe-button="plugin.test.other.group:framework"]'); + await expect(group).toHaveCount(0); + await expect(other).toBeVisible(); + await expect(page.locator('[data-stripe-button="plugin.test.views.legacy"]')).toBeVisible(); + const context = async (ready: boolean, beans: boolean) => + page.evaluate( + async ({ ready, beans }) => { + const module = "/src/platform/context.ts"; + const { setContext } = await import(/* @vite-ignore */ module); + setContext("plugin.test.views.ready", ready); + setContext("plugin.test.views.beans", beans); + }, + { ready, beans }, + ); + await context(true, true); + await expect(group).toBeVisible(); + await group.click(); + const pane = page.locator('[data-toolwindow="plugin.test.views.group:framework"]'); + await expect(pane.getByRole("tab")).toHaveText(["Framework", "Beans"]); + await expect(pane.getByText("routes content")).toBeVisible(); + await pane.getByRole("tab", { name: "Beans", exact: true }).click(); + await expect(pane.getByText("beans content")).toBeVisible(); + await context(true, false); + await expect(pane.getByRole("tab", { name: "Beans", exact: true })).toHaveCount(0); + await expect(pane.getByText("routes content")).toBeVisible(); + await context(false, false); + await expect(group).toHaveCount(0); + await expect(pane).toHaveCount(0); + await expect(other).toBeVisible(); +}); diff --git a/plugins/README.md b/plugins/README.md index 91cfaa3b1..e96b57845 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -137,24 +137,24 @@ 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/.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`, `viewItem.context`, `tools`, `commit.message`), `view?`, `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 a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_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 | -| `commitChecks` | `id`, `title` — a before-commit check in Commit Options (see _Commits_) | yes | +| 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`, `viewItem.context`, `tools`, `commit.message`), `view?`, `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 a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_), `group?`, `when?` (API 1.3) | 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 | +| `commitChecks` | `id`, `title` — a before-commit check in Commit Options (see _Commits_) | yes | A `viewItem.context` menu needs `view`, the id of one of the plugin's own views. It adds the commands to the right-click menu of the items in that @@ -695,6 +695,48 @@ tool window `ctx.editor()` is the window's active text editor, as for a command, so a view's buttons can act on what the user is editing (a click in the tool window doesn't change which editor that is). +API 1.3 adds optional `group` and `when` fields to `[[contributes.views]]`; +using either requires `engines.openidea = "^1.3"`. `when` uses the same +context clauses as commands and menus, including `workspaceContains:pom.xml` +and keys set with `set_context`. A false condition removes the view from the +stripe and its group tabs; context changes, file creation/removal and project +changes evaluate it again. Hidden views are not rendered or activated. + +Tool-window views (`left`, `right` or `bottom`) with the same `group` +**within one plugin** share a tool window, with +tabs in manifest declaration order. The first declaration supplies the window's +title, icon and default location, even when that view is hidden; the window +is absent when all its tabs are hidden. Group ids follow the same rules as +view ids and are scoped to the declaring plugin. Tabs load on first selection +and keep their content mounted while they apply, preserving web-view state +when switching tabs. Ungrouped views keep their +existing window ids and placement. `reveal_view` selects a visible grouped +view's tab; it does not override its condition. Services views remain providers +of the built-in Services window, and also support `when`. + +```toml +[[contributes.views]] +id = "endpoints" +title = "Spring" +group = "spring" +when = "workspaceContains:pom.xml || workspaceContains:build.gradle" + +[[contributes.views]] +id = "beans" +title = "Beans" +group = "spring" +when = "plugin.my-plugin.ready" +``` + +The `crates/ide-plugin/tests/fixtures/grouped-views` sample exercises both +fields without requesting capabilities. Build it with +`plugins/build.sh crates/ide-plugin/tests/fixtures/grouped-views`, then install +that directory. Run **Grouped Views: Show** to expose the first tab, create +`grouped-views.txt` in the project root to expose **Project Files**, and run +**Grouped Views: Hide** or remove the file to withdraw those tabs. The +**Ungrouped View** window stays separate. These fields control visibility; +they grant no access to project contents or another plugin's views. + `layout` puts a custom editor beside the text editor instead of in a tab of its own: with `"split"` or `"preview"` the file opens in the text editor, with your editor next to it (the layout files start in) and the _Editor Only / diff --git a/src/features/plugins/__tests__/viewProjection.test.tsx b/src/features/plugins/__tests__/viewProjection.test.tsx new file mode 100644 index 000000000..48e260c09 --- /dev/null +++ b/src/features/plugins/__tests__/viewProjection.test.tsx @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { PluginInfo, UiContribution } from "@/bindings"; +import { setContext, useContextKeys } from "@/platform/context"; +import { useContributions, useServiceProviders } from "@/workbench/contributions"; +import { createMemoryPluginsBackend, setPluginsBackend } from "../backend"; +import { clearUiContributions, syncUiContributions } from "../projection"; +import { defineViewUiPoints } from "../uiPoints"; + +const plugins = [{ id: "spring", status: "inactive" }] as PluginInfo[]; +const groupId = "plugin.spring.group:framework"; +const view = (id: string, extra = {}): UiContribution => ({ + pluginId: "spring", + point: "views", + key: id, + decl: { id, title: id, location: "right", group: "framework", ...extra }, +}); +let stop: () => void; + +beforeEach(() => { + clearUiContributions(); + useContextKeys.setState({}, true); + stop = defineViewUiPoints(); +}); + +afterEach(() => { + cleanup(); + clearUiContributions(); + stop(); + setPluginsBackend(null); +}); + +it("does not activate inactive plugins for declarations or hidden and unselected views", async () => { + const renderView = vi.fn(async (_pluginId: string, id: string) => ({ + toolbar: [], + body: { kind: "message" as const, value: `${id} content` }, + status: null, + })); + setPluginsBackend({ ...createMemoryPluginsBackend([]), renderView }); + const items = [view("routes", { when: "plugin.spring.ready" }), view("beans")]; + syncUiContributions(items, plugins); + expect(renderView).not.toHaveBeenCalled(); + const window = useContributions.getState().toolWindows.get(groupId)!; + render(); + expect(await screen.findByText("beans content")).toBeInTheDocument(); + expect(renderView.mock.calls.map((c) => c[1])).toEqual(["beans"]); + act(() => setContext("plugin.spring.ready", true)); + expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["routes", "beans"]); + expect(renderView.mock.calls.map((c) => c[1])).toEqual(["beans"]); + await userEvent.click(screen.getByRole("tab", { name: "routes" })); + expect(await screen.findByText("routes content")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("tab", { name: "beans" })); + expect(screen.getByText("beans content")).toBeVisible(); + expect(renderView.mock.calls.map((c) => c[1])).toEqual(["beans", "routes"]); + act(() => syncUiContributions([], plugins)); + expect(useContributions.getState().toolWindows.has(groupId)).toBe(false); + act(() => setContext("plugin.spring.ready", false)); + expect(useContributions.getState().toolWindows.has(groupId)).toBe(false); +}); + +it("keeps declaration order when a single view changes", () => { + const items = [view("routes"), view("beans")]; + syncUiContributions(items, plugins); + syncUiContributions([view("routes", { title: "Updated routes" }), items[1]!], plugins); + const window = useContributions.getState().toolWindows.get(groupId)!; + expect(window.title).toBe("Updated routes"); + // The content is loaded lazily; observing tabs must not call the plugin here. + setPluginsBackend(createMemoryPluginsBackend([])); + render(); + expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["Updated routes", "beans"]); +}); + +it("follows manifest tab reordering on reload", () => { + setPluginsBackend(createMemoryPluginsBackend([])); + const items = [view("routes"), view("beans")]; + syncUiContributions(items, plugins); + syncUiContributions([...items].reverse(), plugins); + const window = useContributions.getState().toolWindows.get(groupId)!; + expect(window.title).toBe("beans"); + render(); + expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["beans", "routes"]); +}); + +it("does not re-project existing windows when another plugin adds declarations", () => { + const items = [view("routes"), view("beans")]; + syncUiContributions(items, plugins); + const before = useContributions.getState().toolWindows.get(groupId); + const other = { ...view("other"), pluginId: "other" }; + syncUiContributions([other, ...items], [...plugins, { ...plugins[0]!, id: "other" }]); + expect(useContributions.getState().toolWindows.get(groupId)).toBe(before); +}); + +it("evaluates conditions on Services providers too", () => { + syncUiContributions([view("services", { location: "services", when: "plugin.spring.ready" })], plugins); + expect(useServiceProviders.getState().providers.has("plugin.spring.services")).toBe(false); + setContext("plugin.spring.ready", true); + expect(useServiceProviders.getState().providers.has("plugin.spring.services")).toBe(true); + setContext("plugin.spring.ready", false); + expect(useServiceProviders.getState().providers.has("plugin.spring.services")).toBe(false); + expect(useContributions.getState().toolWindows.has(groupId)).toBe(false); +}); + +it("does not resurrect disposed views when asynchronous SVG loading finishes", async () => { + let finish!: (assets: [string, string][]) => void; + const pending = new Promise<[string, string][]>((resolve) => { + finish = resolve; + }); + setPluginsBackend({ ...createMemoryPluginsBackend([]), assets: () => pending }); + syncUiContributions([view("routes", { icon: "icons/routes.svg" })], plugins); + expect(useContributions.getState().toolWindows.has(groupId)).toBe(true); + clearUiContributions(); + finish([["viewIcon/routes", '']]); + await pending; + expect(useContributions.getState().toolWindows.has(groupId)).toBe(false); +}); diff --git a/src/features/plugins/__tests__/viewWindows.test.tsx b/src/features/plugins/__tests__/viewWindows.test.tsx new file mode 100644 index 000000000..4566ecc8d --- /dev/null +++ b/src/features/plugins/__tests__/viewWindows.test.tsx @@ -0,0 +1,191 @@ +import { useEffect } from "react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { setLocale, tx } from "@/platform/i18n"; +import { setContext, useContextKeys } from "@/platform/context"; +import { provideToolWindowService, useContributions } from "@/workbench/contributions"; +import { registerWindowView, revealWindowView, type WindowView } from "../views/viewWindows"; + +const disposers: (() => void)[] = []; +const show = vi.fn(); +const id = "plugin.spring.group:framework"; + +function add(pluginId: string, decl: Partial & Pick) { + const mount = vi.fn(); + const registration = registerWindowView( + pluginId, + { title: decl.id, location: "right", ...decl }, + "plugin", + function Body() { + useEffect(() => { + mount(); + }, [mount]); + return

{decl.id} body

; + }, + ); + disposers.push(registration.dispose); + return { ...registration, mount }; +} + +beforeEach(() => { + useContextKeys.setState({}, true); + show.mockClear(); + provideToolWindowService({ show } as never); +}); + +afterEach(() => { + cleanup(); + disposers + .splice(0) + .reverse() + .forEach((dispose) => dispose()); +}); + +it("collects one plugin's views in declaration order and renders only the selected tab", async () => { + const first = add("spring", { id: "routes", title: "Routes", location: "left", group: "framework" }); + const second = add("spring", { id: "beans", title: "Beans", group: "framework" }); + const window = useContributions.getState().toolWindows.get(id)!; + expect(window).toMatchObject({ title: "Routes", anchor: "left", order: 900 }); + expect([...useContributions.getState().toolWindows.keys()].filter((k) => k.startsWith("plugin.spring"))).toEqual([ + id, + ]); + render(); + expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["Routes", "Beans"]); + expect(first.mount).toHaveBeenCalledOnce(); + expect(second.mount).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole("tab", { name: "Beans" })); + expect(screen.getByText("beans body")).toBeInTheDocument(); + expect(screen.getByText("routes body")).not.toBeVisible(); +}); + +it("keeps other plugins and ungrouped views separate, even when their ids equal a group", () => { + add("spring", { id: "routes", group: "framework" }); + add("other", { id: "routes", group: "framework" }); + add("spring", { id: "framework" }); + expect(useContributions.getState().toolWindows.has(id)).toBe(true); + expect(useContributions.getState().toolWindows.has("plugin.other.group:framework")).toBe(true); + expect(useContributions.getState().toolWindows.has("plugin.spring.framework")).toBe(true); +}); + +it("reacts to context changes, hides empty groups, and preserves group metadata and active tabs", async () => { + add("spring", { id: "routes", title: "Routes", group: "framework", when: "plugin.spring.ready" }); + add("spring", { id: "beans", title: "Beans", group: "framework", when: "workspaceContains:pom.xml" }); + expect(useContributions.getState().toolWindows.has(id)).toBe(false); + act(() => setContext("workspaceContains:pom.xml", true)); + const window = useContributions.getState().toolWindows.get(id)!; + expect(window.title).toBe("Routes"); + render(); + expect(screen.queryByRole("tab", { name: "Routes" })).toBeNull(); + expect(screen.getByText("beans body")).toBeInTheDocument(); + act(() => setContext("plugin.spring.ready", true)); + await userEvent.click(screen.getByRole("tab", { name: "Beans" })); + act(() => setContext("unrelated", true)); + expect(useContributions.getState().toolWindows.get(id)).toBe(window); + expect(screen.getByRole("tab", { name: "Beans" })).toHaveAttribute("aria-selected", "true"); + act(() => setContext("workspaceContains:pom.xml", false)); + expect(screen.getByText("routes body")).toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Beans" })).toBeNull(); + act(() => setContext("plugin.spring.ready", false)); + expect(useContributions.getState().toolWindows.has(id)).toBe(false); +}); + +it("keeps legacy ungrouped window ids and removes conditional views until they apply", () => { + add("legacy", { id: "always" }); + add("legacy", { id: "maybe", when: "plugin.legacy.ready" }); + expect(useContributions.getState().toolWindows.has("plugin.legacy.always")).toBe(true); + expect(useContributions.getState().toolWindows.has("plugin.legacy.maybe")).toBe(false); + setContext("plugin.legacy.ready", true); + expect(useContributions.getState().toolWindows.has("plugin.legacy.maybe")).toBe(true); + setContext("plugin.legacy.ready", false); + expect(useContributions.getState().toolWindows.has("plugin.legacy.maybe")).toBe(false); +}); + +it("reveals the requested grouped tab and never reveals hidden or removed views", () => { + add("spring", { id: "routes", group: "framework" }); + const second = add("spring", { id: "beans", group: "framework", when: "plugin.spring.ready" }); + revealWindowView("spring", "beans"); + expect(show).not.toHaveBeenCalled(); + setContext("plugin.spring.ready", true); + const window = useContributions.getState().toolWindows.get(id)!; + render(); + act(() => revealWindowView("spring", "beans")); + expect(screen.getByText("beans body")).toBeInTheDocument(); + expect(show).toHaveBeenCalledWith(id, true); + show.mockClear(); + act(() => second.dispose()); + revealWindowView("spring", "beans"); + expect(show).not.toHaveBeenCalled(); + expect(screen.getByText("routes body")).toBeInTheDocument(); + // Avoid disposing the same declaration twice. + disposers.pop(); +}); + +it("updates SVG icons without changing tab order and withdraws registrations on disposal", () => { + const first = add("spring", { id: "routes", group: "framework" }); + add("spring", { id: "beans", group: "framework" }); + first.setIcon("ext:spring/view-routes"); + const window = useContributions.getState().toolWindows.get(id)!; + expect(window.icon).toBe("ext:spring/view-routes"); + render(); + expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["routes", "beans"]); + cleanup(); + disposers + .splice(0) + .reverse() + .forEach((dispose) => dispose()); + expect(useContributions.getState().toolWindows.has(id)).toBe(false); + setContext("plugin.spring.ready", true); + expect(useContributions.getState().toolWindows.has(id)).toBe(false); +}); + +it("shows plugin tab labels verbatim in a translated IDE", async () => { + await act(() => setLocale("zh")); + try { + add("spring", { id: "routes", title: "Refresh", group: "framework" }); + const window = useContributions.getState().toolWindows.get(id)!; + expect(window.verbatim).toBe(true); + render(); + expect(screen.getByRole("tab", { name: "Refresh" })).toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: tx("Refresh") })).toBeNull(); + } finally { + await act(() => setLocale("en")); + } +}); + +it("retains an opened tab's local state while switching tabs", async () => { + const first = registerWindowView( + "spring", + { id: "draft", title: "Draft", location: "right", group: "framework" }, + "plugin", + () => , + ); + disposers.push(first.dispose); + add("spring", { id: "beans", title: "Beans", group: "framework" }); + const window = useContributions.getState().toolWindows.get(id)!; + render(); + const input = screen.getByRole("textbox", { name: "Plugin draft" }); + await userEvent.type(input, "retain this draft"); + await userEvent.click(screen.getByRole("tab", { name: "Beans" })); + expect(input).not.toBeVisible(); + await userEvent.click(screen.getByRole("tab", { name: "Draft" })); + expect(screen.getByRole("textbox", { name: "Plugin draft" })).toBe(input); + expect(input).toHaveValue("retain this draft"); +}); + +it("discards a conditionally hidden tab and keeps it lazy when it becomes available again", async () => { + setContext("plugin.spring.ready", true); + add("spring", { id: "routes", title: "Routes", group: "framework" }); + add("spring", { id: "beans", title: "Beans", group: "framework", when: "plugin.spring.ready" }); + const window = useContributions.getState().toolWindows.get(id)!; + render(); + await userEvent.click(screen.getByRole("tab", { name: "Beans" })); + await userEvent.click(screen.getByRole("tab", { name: "Routes" })); + expect(screen.getByText("beans body")).not.toBeVisible(); + act(() => setContext("plugin.spring.ready", false)); + expect(screen.queryByText("beans body")).toBeNull(); + act(() => setContext("plugin.spring.ready", true)); + expect(screen.queryByText("beans body")).toBeNull(); + await userEvent.click(screen.getByRole("tab", { name: "Beans" })); + expect(screen.getByText("beans body")).toBeVisible(); +}); diff --git a/src/features/plugins/__tests__/workspaceContains.test.ts b/src/features/plugins/__tests__/workspaceContains.test.ts index 72b13b1ab..6db16b102 100644 --- a/src/features/plugins/__tests__/workspaceContains.test.ts +++ b/src/features/plugins/__tests__/workspaceContains.test.ts @@ -139,7 +139,7 @@ describe("workspaceContains", () => { }); }); -describe("startWorkspaceContains", () => { +describe.each(["menus", "views"])("startWorkspaceContains (%s)", (point) => { const key = "workspaceContains:.devcontainer/devcontainer.json"; const setProject = (root: string) => useProject.setState({ project: { root, workspace: null } as never }); const keyIs = (value: boolean) => vi.waitFor(() => expect(getContext()[key]).toBe(value)); @@ -156,7 +156,7 @@ describe("startWorkspaceContains", () => { ipc.files = { "/p": ["src"], "/q": [".devcontainer"], "/q/.devcontainer": ["devcontainer.json"] }; ipc.readDir.mockReset(); ipc.readDir.mockImplementation(async (dir: string) => (ipc.files[dir] ?? []).map((name) => ({ name }))); - usePlugins.setState({ ui: [{ pluginId: "x", point: "menus", key: "k", decl: { when: key } }] as never }); + usePlugins.setState({ ui: [{ pluginId: "x", point, key: "k", decl: { when: key } }] as never }); setProject("/p"); setContext(key, undefined); stop = startWorkspaceContains(); diff --git a/src/features/plugins/projection.ts b/src/features/plugins/projection.ts index d2a286015..02a4305fe 100644 --- a/src/features/plugins/projection.ts +++ b/src/features/plugins/projection.ts @@ -23,11 +23,15 @@ export interface UiItem> { export interface ProjectionContext { plugin: PluginInfo; + /** Position within this plugin's declarations of this point. */ + index: number; } export interface UiPointDefinition> { /** Makes one item take effect; returns its disposer. */ project?: (item: UiItem, ctx: ProjectionContext) => (() => void) | void; + /** Re-project when its position in the declaration snapshot changes. */ + orderSensitive?: boolean; /** Optional section in Settings › Plugins › details for this point's items. */ Details?: ComponentType<{ plugin: PluginInfo; items: UiItem[] }>; /** Section title for the details page (defaults to the point id). */ @@ -79,8 +83,12 @@ export function syncUiContributions(items: readonly UiContribution[], plugins: r lastItems = items; lastPlugins = plugins; const byId = new Map(plugins.map((p) => [p.id, p])); - const next = new Map(); + const next = new Map(); + const indices = new Map(); for (const item of items) { + const scope = `${item.pluginId}\u0000${item.point}`; + const index = indices.get(scope) ?? 0; + indices.set(scope, index + 1); if (!points.get(item.point)?.project) { if (!points.has(item.point) && !warned.has(item.point)) { warned.add(item.point); @@ -89,7 +97,11 @@ export function syncUiContributions(items: readonly UiContribution[], plugins: r continue; } if (!byId.has(item.pluginId)) continue; - next.set(itemKey(item), { item, sig: JSON.stringify(item.decl) }); + next.set(itemKey(item), { + item, + sig: JSON.stringify(points.get(item.point)?.orderSensitive ? [item.decl, index] : item.decl), + index, + }); } for (const [key, entry] of live) { if (next.get(key)?.sig !== entry.sig) { @@ -97,12 +109,12 @@ export function syncUiContributions(items: readonly UiContribution[], plugins: r live.delete(key); } } - for (const [key, { item, sig }] of next) { + for (const [key, { item, sig, index }] of next) { if (live.has(key)) continue; const def = points.get(item.point)!; const plugin = byId.get(item.pluginId)!; try { - const dispose = def.project!(item as UiItem, { plugin }) ?? (() => {}); + const dispose = def.project!(item as UiItem, { plugin, index }) ?? (() => {}); live.set(key, { sig, dispose }); } catch (e) { // The backend validated the declaration: a failing projection is a defect. diff --git a/src/features/plugins/runtimeUi.tsx b/src/features/plugins/runtimeUi.tsx index 21cd570cc..f2b2edc89 100644 --- a/src/features/plugins/runtimeUi.tsx +++ b/src/features/plugins/runtimeUi.tsx @@ -25,19 +25,14 @@ import { useProject } from "@/platform/project"; import { ipcPath } from "@/platform/paths"; import { shortcutLabel } from "@/platform/keymap"; import { BarButton } from "@/ui/BarButton"; -import { - notifications, - maybeCommitService, - maybeEditorService, - registerStatusItem, - toolWindowService, -} from "@/workbench/contributions"; +import { notifications, maybeCommitService, maybeEditorService, registerStatusItem } from "@/workbench/contributions"; import { pickItems, promptInput } from "@/workbench/inputDialogs"; import { confirm } from "@/workbench/messages"; import { removeTask, upsertTask, useTasks } from "@/workbench/tasks"; import { pluginsBackend } from "./backend"; import { usePluginT } from "./pluginText"; -import { bumpView, deliverViewMessage, pluginToolWindowId } from "./views/viewStore"; +import { bumpView, deliverViewMessage } from "./views/viewStore"; +import { revealWindowView } from "./views/viewWindows"; import { runPluginCommand, usePlugins } from "./store"; // --- status bar items --------------------------------------------------------- @@ -448,7 +443,7 @@ export function handleRuntimeEvent(e: PluginEvent): boolean { void answerCommit(e.requestId, e.request); return true; case "revealView": - toolWindowService().show(pluginToolWindowId(e.pluginId, e.view), true); + revealWindowView(e.pluginId, e.view); return true; default: return false; diff --git a/src/features/plugins/uiPoints.tsx b/src/features/plugins/uiPoints.tsx index e97f1e086..b8331a926 100644 --- a/src/features/plugins/uiPoints.tsx +++ b/src/features/plugins/uiPoints.tsx @@ -17,7 +17,7 @@ import { useMemo } from "react"; import type { PluginInfo } from "@/bindings"; import { getAction } from "@/platform/actions"; -import { evaluateWhen, getContext } from "@/platform/context"; +import { evaluateWhen, getContext, useContextKeys } from "@/platform/context"; import { tx } from "@/platform/i18n"; import { isMacHost, registerExtensionBindings } from "@/platform/keymap"; import { @@ -40,7 +40,6 @@ import { registerServiceProvider, registerSettingsPage, registerTextEditorWrapper, - registerToolWindow, useSettingsForm, type ContextMenuId, type ContributedCommitProblem, @@ -52,7 +51,7 @@ import { usePlugins } from "./store"; import { pluginActionId } from "./points"; import { defineUiPoint } from "./projection"; import { declareStatusItem } from "./runtimeUi"; -import { pluginToolWindowId } from "./views/viewStore"; +import { registerWindowView } from "./views/viewWindows"; import { CUSTOM_EDITOR_KIND, CustomEditor, @@ -366,6 +365,8 @@ interface ViewDecl { icon?: string | null; location: ToolWindowAnchor | "services"; web?: string; + group?: string | null; + when?: string | null; } interface FileIconDecl { @@ -430,56 +431,71 @@ function projectFileIcon(pluginId: string, decl: FileIconDecl): () => void { }; } -function registerView(pluginId: string, decl: ViewDecl, icon: string): () => void { - if (decl.location === "services") { - const itemMenu = (key: string) => pluginViewItemMenuItems(pluginId, decl.id, key); - return registerServiceProvider(pluginServiceProvider(pluginId, decl, icon, itemMenu)); - } - return registerToolWindow({ - id: pluginToolWindowId(pluginId, decl.id), - title: decl.title, - verbatim: true, - icon, - anchor: decl.location, - order: 900, - component: () => - decl.web ? ( - - ) : ( - pluginViewItemMenuItems(pluginId, decl.id, key)} - /> - ), - }); -} - -function projectView(pluginId: string, decl: ViewDecl): () => void { - if (!decl.icon?.endsWith(".svg")) return registerView(pluginId, decl, decl.icon ?? "plugin"); +function projectView(pluginId: string, decl: ViewDecl, index: number): () => void { + const initialIcon = decl.icon?.endsWith(".svg") ? "plugin" : (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 = () => { + let unregisterIcon = () => {}; + const itemMenu = (key: string) => pluginViewItemMenuItems(pluginId, decl.id, key); + let setIcon: (icon: string) => void; + let undo: () => void; + if (decl.location === "services") { + let icon = initialIcon; + let remove = () => {}; + let visible = false; + const refresh = () => { + const next = evaluateWhen(decl.when ?? undefined); + if (next === visible) return; + remove(); + visible = next; + remove = next ? registerServiceProvider(pluginServiceProvider(pluginId, decl, icon, itemMenu)) : () => {}; + }; + const unsubscribe = useContextKeys.subscribe(refresh); + refresh(); + setIcon = (next) => { + icon = next; + if (visible) { remove(); - unregister(); - }; - }) - .catch((e: unknown) => { - console.warn(`plugin ${pluginId}: view icon ${decl.id}`, e); - if (!disposed) undo = registerView(pluginId, decl, "plugin"); - }); + visible = false; + refresh(); + } + }; + undo = () => { + unsubscribe(); + remove(); + }; + } else { + const registration = registerWindowView( + pluginId, + { ...decl, location: decl.location }, + initialIcon, + () => + decl.web ? ( + + ) : ( + + ), + index, + ); + setIcon = registration.setIcon; + undo = registration.dispose; + } + if (decl.icon?.endsWith(".svg")) { + void pluginsBackend() + .assets(pluginId, `viewIcon/${decl.id}`) + .then((assets) => { + if (disposed) return; + const svg = assets.find(([id]) => id === `viewIcon/${decl.id}`)?.[1]; + if (!svg) return; + const name = `ext:${pluginId}/view-${decl.id}`; + unregisterIcon = registerExternalIcons([[name, svg]]); + setIcon(name); + }) + .catch((e: unknown) => console.warn(`plugin ${pluginId}: view icon ${decl.id}`, e)); + } return () => { disposed = true; undo(); + unregisterIcon(); }; } @@ -518,8 +534,9 @@ export function defineViewUiPoints(): () => void { }), }), defineUiPoint("views", { + orderSensitive: true, title: "Tool Windows", - project: ({ pluginId, decl }) => projectView(pluginId, decl), + project: ({ pluginId, decl }, { index }) => projectView(pluginId, decl, index), }), defineUiPoint("customEditors", { title: "Editors", diff --git a/src/features/plugins/views/viewWindows.tsx b/src/features/plugins/views/viewWindows.tsx new file mode 100644 index 000000000..6f3c05afb --- /dev/null +++ b/src/features/plugins/views/viewWindows.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState, type FunctionComponent } from "react"; +import { create, type StoreApi, type UseBoundStore } from "zustand"; +import { evaluateWhen, useContextKeys } from "@/platform/context"; +import { VerbatimTextContext } from "@/platform/i18n"; +import { Tabs } from "@/ui"; +import { + registerToolWindow, + toolWindowService, + ToolWindowShownProvider, + useToolWindowShown, + type ToolWindowAnchor, +} from "@/workbench/contributions"; +import { pluginToolWindowId, viewKey } from "./viewStore"; +import styles from "./PluginView.module.css"; + +export interface WindowView { + id: string; + title: string; + location: ToolWindowAnchor; + group?: string | null; + when?: string | null; +} + +interface Member { + decl: WindowView; + icon: string; + order: number; + component: FunctionComponent; +} + +interface WindowState { + members: Member[]; + selected: string | null; +} + +interface WindowGroup { + members: Map; + state: UseBoundStore>; + dispose: () => void; + unsubscribe: () => void; + signature: string; + component: FunctionComponent; +} + +const groups = new Map(); +const windowsByView = new Map(); + +function GroupView({ group, id }: { group: WindowGroup; id: string }) { + const { members, selected } = group.state(); + const active = members.find((m) => m.decl.id === selected) ?? members[0]; + const activeId = active?.decl.id; + const shown = useToolWindowShown(); + // Like separate tool windows, opened tabs retain their local state until withdrawn. + const [visited, setVisited] = useState>(() => new Set()); + useEffect(() => { + setVisited((previous) => { + const next = new Set( + members.filter((m) => previous.has(m.decl.id) || m.decl.id === activeId).map((m) => m.decl.id), + ); + return next.size === previous.size && [...next].every((id) => previous.has(id)) ? previous : next; + }); + }, [members, activeId]); + if (!active) return null; + return ( + +
+ ({ id: decl.id, label: decl.title }))} + activeId={active.decl.id} + onSelect={(id) => group.state.setState({ selected: id })} + /> + {members + .filter((m) => visited.has(m.decl.id) || m.decl.id === activeId) + .map((member) => { + const Body = member.component; + const selected = member.decl.id === activeId; + return ( + + ); + })} +
+
+ ); +} + +function refresh(id: string, group: WindowGroup) { + const ordered = [...group.members.values()].sort((a, b) => a.order - b.order); + const first = ordered[0]; + const members = ordered.filter((m) => evaluateWhen(m.decl.when ?? undefined)); + const selected = group.state.getState().selected; + group.state.setState({ + members, + selected: selected ?? members[0]?.decl.id ?? null, + }); + const signature = first && members.length ? JSON.stringify([first.decl.title, first.decl.location, first.icon]) : ""; + if (signature === group.signature) return; + group.dispose(); + group.signature = signature; + group.dispose = + first && members.length + ? registerToolWindow({ + id, + title: first.decl.title, + verbatim: true, + anchor: first.decl.location, + icon: first.icon, + order: 900, + component: first.decl.group ? group.component : first.component, + }) + : () => {}; +} + +/** Group names are scoped to their plugin and cannot collide with an ungrouped view id. */ +export function registerWindowView( + pluginId: string, + decl: WindowView, + icon: string, + component: FunctionComponent, + order = 0, +) { + const id = decl.group ? `plugin.${pluginId}.group:${decl.group}` : pluginToolWindowId(pluginId, decl.id); + let group = groups.get(id); + if (!group) { + group = { + members: new Map(), + state: create(() => ({ members: [], selected: null })), + dispose: () => {}, + unsubscribe: () => {}, + signature: "", + component: () => null, + }; + const current = group; + group.component = () => ; + group.unsubscribe = useContextKeys.subscribe(() => refresh(id, current)); + groups.set(id, group); + } + const current = group; + const member = { decl, icon, component, order }; + current.members.set(decl.id, member); + windowsByView.set(viewKey(pluginId, decl.id), { window: id, group: current }); + refresh(id, current); + return { + setIcon(next: string) { + member.icon = next; + refresh(id, current); + }, + dispose() { + current.members.delete(decl.id); + windowsByView.delete(viewKey(pluginId, decl.id)); + if (current.members.size) refresh(id, current); + else { + current.dispose(); + current.unsubscribe(); + groups.delete(id); + } + }, + }; +} + +/** A hidden view stays hidden; revealing a grouped view selects its own tab. */ +export function revealWindowView(pluginId: string, view: string) { + const entry = windowsByView.get(viewKey(pluginId, view)); + if (!entry?.group.state.getState().members.some((m) => m.decl.id === view)) return; + entry.group.state.setState({ selected: view }); + toolWindowService().show(entry.window, true); +}