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 000000000..55dd80d41 Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm differ 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); +}