mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
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 <[email protected]>
This commit is contained in:
1 parent
4c6e535560
commit
da3d996e10
17 files changed
+1218
-82
No files matched your search
@@ -399,6 +399,10 @@ struct ViewDecl {
|
||||
icon: Option<String>,
|
||||
#[serde(default = "right")]
|
||||
location: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
group: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
when: Option<String>,
|
||||
/// 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<String>,
|
||||
@@ -419,6 +423,15 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
+333
@@ -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"
|
||||
@@ -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"
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
1dda19524ab7711b56b67f4b6ebcf62d3c29799aa8276ce7af188b5cabe956ad
|
||||
@@ -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"
|
||||
@@ -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<Option<String>, 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<Viewport>, _ctx: &Invocation) -> Result<View, Error> {
|
||||
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);
|
||||
@@ -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();
|
||||
});
|
||||
+60
-18
@@ -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/<id>.toml` format, see §6) | no |
|
||||
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
|
||||
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
|
||||
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
|
||||
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `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/<id>.toml` format, see §6) | no |
|
||||
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
|
||||
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
|
||||
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
|
||||
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `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 /
|
||||
|
||||
@@ -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(<window.component id={groupId} />);
|
||||
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(<window.component id={groupId} />);
|
||||
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(<window.component id={groupId} />);
|
||||
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", '<svg xmlns="http://www.w3.org/2000/svg"/>']]);
|
||||
await pending;
|
||||
expect(useContributions.getState().toolWindows.has(groupId)).toBe(false);
|
||||
});
|
||||
@@ -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<WindowView> & Pick<WindowView, "id">) {
|
||||
const mount = vi.fn();
|
||||
const registration = registerWindowView(
|
||||
pluginId,
|
||||
{ title: decl.id, location: "right", ...decl },
|
||||
"plugin",
|
||||
function Body() {
|
||||
useEffect(() => {
|
||||
mount();
|
||||
}, [mount]);
|
||||
return <p>{decl.id} body</p>;
|
||||
},
|
||||
);
|
||||
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(<window.component id={id} />);
|
||||
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(<window.component id={id} />);
|
||||
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(<window.component id={id} />);
|
||||
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(<window.component id={id} />);
|
||||
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(<window.component id={id} />);
|
||||
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",
|
||||
() => <input aria-label="Plugin draft" />,
|
||||
);
|
||||
disposers.push(first.dispose);
|
||||
add("spring", { id: "beans", title: "Beans", group: "framework" });
|
||||
const window = useContributions.getState().toolWindows.get(id)!;
|
||||
render(<window.component id={id} />);
|
||||
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(<window.component id={id} />);
|
||||
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();
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -23,11 +23,15 @@ export interface UiItem<T = Record<string, unknown>> {
|
||||
|
||||
export interface ProjectionContext {
|
||||
plugin: PluginInfo;
|
||||
/** Position within this plugin's declarations of this point. */
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface UiPointDefinition<T = Record<string, unknown>> {
|
||||
/** Makes one item take effect; returns its disposer. */
|
||||
project?: (item: UiItem<T>, 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<T>[] }>;
|
||||
/** 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<string, { item: UiContribution; sig: string }>();
|
||||
const next = new Map<string, { item: UiContribution; sig: string; index: number }>();
|
||||
const indices = new Map<string, number>();
|
||||
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<never>, { plugin }) ?? (() => {});
|
||||
const dispose = def.project!(item as UiItem<never>, { plugin, index }) ?? (() => {});
|
||||
live.set(key, { sig, dispose });
|
||||
} catch (e) {
|
||||
// The backend validated the declaration: a failing projection is a defect.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? (
|
||||
<WebView pluginId={pluginId} view={decl.id} label={decl.title} />
|
||||
) : (
|
||||
<PluginView
|
||||
pluginId={pluginId}
|
||||
view={decl.id}
|
||||
label={decl.title}
|
||||
itemMenu={(key) => 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 ? (
|
||||
<WebView pluginId={pluginId} view={decl.id} label={decl.title} />
|
||||
) : (
|
||||
<PluginView pluginId={pluginId} view={decl.id} label={decl.title} itemMenu={itemMenu} />
|
||||
),
|
||||
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<ViewDecl>("views", {
|
||||
orderSensitive: true,
|
||||
title: "Tool Windows",
|
||||
project: ({ pluginId, decl }) => projectView(pluginId, decl),
|
||||
project: ({ pluginId, decl }, { index }) => projectView(pluginId, decl, index),
|
||||
}),
|
||||
defineUiPoint<CustomEditorDecl>("customEditors", {
|
||||
title: "Editors",
|
||||
|
||||
@@ -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<string, Member>;
|
||||
state: UseBoundStore<StoreApi<WindowState>>;
|
||||
dispose: () => void;
|
||||
unsubscribe: () => void;
|
||||
signature: string;
|
||||
component: FunctionComponent;
|
||||
}
|
||||
|
||||
const groups = new Map<string, WindowGroup>();
|
||||
const windowsByView = new Map<string, { window: string; group: WindowGroup }>();
|
||||
|
||||
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<ReadonlySet<string>>(() => 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 (
|
||||
<VerbatimTextContext.Provider value={true}>
|
||||
<div className={styles.view}>
|
||||
<Tabs
|
||||
variant="compact"
|
||||
panelIdPrefix={id}
|
||||
items={members.map(({ decl }) => ({ 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 (
|
||||
<div
|
||||
key={member.decl.id}
|
||||
className={styles.body}
|
||||
role="tabpanel"
|
||||
id={`${id}-panel-${member.decl.id}`}
|
||||
aria-labelledby={`${id}-tab-${member.decl.id}`}
|
||||
hidden={!selected}
|
||||
>
|
||||
<ToolWindowShownProvider value={shown && selected}>
|
||||
<Body />
|
||||
</ToolWindowShownProvider>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</VerbatimTextContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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<WindowState>(() => ({ members: [], selected: null })),
|
||||
dispose: () => {},
|
||||
unsubscribe: () => {},
|
||||
signature: "",
|
||||
component: () => null,
|
||||
};
|
||||
const current = group;
|
||||
group.component = () => <GroupView group={current} id={id} />;
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user