diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 06e83b24d..b055c0eb4 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -1097,8 +1097,7 @@ impl PluginHost { }) } - /// The Services pane of node `key` of the plugin's `location = "services"` - /// view `view`. + /// The detail tabs of item `key` of a Services or `detail = true` view. pub fn service_detail( &self, id: &str, @@ -1106,9 +1105,9 @@ impl PluginHost { key: &str, invocation: Invocation, ) -> IdeResult> { - self.services_view(id, view)?; + self.detail_view(id, view)?; let (view, key) = (view.to_owned(), key.to_owned()); - self.code_call(id, invocation, "rendering the service's pane", move |rt, inv| { + self.code_call(id, invocation, "rendering the item's detail pane", move |rt, inv| { rt.service_detail(&view, &key, inv) }) } @@ -1164,16 +1163,16 @@ impl PluginHost { if declared { Ok(()) } else { Err(IdeError::not_found(format!("plugin {id:?} declares no view {view:?}"))) } } - fn services_view(&self, id: &str, view: &str) -> IdeResult<()> { - let services = self - .ui - .items(ui_points::VIEWS, Some(id)) - .iter() - .any(|i| i.key == view && i.decl["location"].as_str() == Some(ui_points::SERVICES_LOCATION)); - if services { + fn detail_view(&self, id: &str, view: &str) -> IdeResult<()> { + let detail = self.ui.items(ui_points::VIEWS, Some(id)).iter().any(|i| { + i.key == view + && (i.decl["location"].as_str() == Some(ui_points::SERVICES_LOCATION) + || i.decl["detail"].as_bool() == Some(true)) + }); + if detail { Ok(()) } else { - Err(IdeError::not_found(format!("plugin {id:?} declares no services view {view:?}"))) + Err(IdeError::not_found(format!("plugin {id:?} declares no detail view {view:?}"))) } } diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs index 151e9ec58..597eb8b35 100644 --- a/crates/ide-host/src/plugins/ui_points.rs +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -404,6 +404,9 @@ struct ViewDecl { group: Option, #[serde(default, skip_serializing_if = "Option::is_none")] when: Option, + /// Show the selected tree/list item's detail-provider tabs beside it. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + detail: bool, /// 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, @@ -443,6 +446,12 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result { return Err(format!("view {:?}: a services view is rendered by the plugin's code (no `web`)", d.id)); } } + if d.detail { + pkg.manifest.needs_api(&format!("view {:?}: `detail`", d.id), 3)?; + if d.web.is_some() { + return Err(format!("view {:?}: `detail` needs a tree or list rendered by the plugin (no `web`)", d.id)); + } + } if d.icon.as_ref().is_some_and(|i| i.len() > 64) { return Err(format!("view {:?}: icon names are up to 64 bytes", d.id)); } diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 73c32a825..1d5991252 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -3676,7 +3676,80 @@ fn services_views_have_a_detail_pane() { let f = fixture(Limits::default()); f.install(&sample("hello")); let err = f.host.service_detail("hello", "greetings", "x", f.inv()).unwrap_err(); - assert!(err.message.contains("declares no services view"), "a right view has no pane: {err:?}"); + assert!(err.message.contains("declares no detail view"), "a right view has no pane: {err:?}"); +} + +#[test] +fn detail_views_work_in_every_location_and_require_api_1_3_code() { + for location in ["left", "right", "bottom", "services"] { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = hello_variant(tmp.path(), |t| { + t.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"") + .replace("location = \"right\"", &format!("location = {location:?}\ndetail = true")) + }); + assert!(f.install(&pkg).error.is_none()); + assert_eq!(f.host.ui_items("views")[0].decl["detail"], true); + let tabs = f.host.service_detail("hello", "greetings", "item:😀", f.inv()).unwrap(); + assert_eq!(tabs[1].body, ide_plugin::ServicePane::Text("greetings: item:😀".into())); + assert!(f.host.service_detail("hello", "missing", "x", f.inv()).is_err()); + } + for (api, web, message) in + [("^1.0", false, "new in plugin API 1.3"), ("^1.3", true, "`detail` needs a tree or list")] + { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = hello_variant(tmp.path(), |t| { + t.replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = {api:?}")).replace( + "location = \"right\"", + if web { + "location = \"right\"\ndetail = true\nweb = \"web/index.html\"" + } else { + "location = \"right\"\ndetail = true" + }, + ) + }); + if web { + std::fs::create_dir_all(pkg.join("web")).unwrap(); + std::fs::write(pkg.join("web/index.html"), "

x

").unwrap(); + } + let error = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err(); + assert!(error.message.contains(message), "{error:?}"); + } +} + +#[test] +fn detail_fixture_tracks_selection_and_refresh_for_tree_list_and_services() { + let f = fixture(Limits::default()); + assert!(f.install(&sample("detail-views")).error.is_none()); + for view in ["tree", "list", "services"] { + assert!(matches!( + f.host.render_view("test.detail-views", view, None, f.inv()).unwrap().body, + ide_plugin::ViewBody::Tree(_) | ide_plugin::ViewBody::List(_) + )); + f.host + .view_event("test.detail-views", view, ide_plugin::ViewEvent::Select { key: "beta".into() }, f.inv()) + .unwrap(); + let tabs = f.host.service_detail("test.detail-views", view, "beta", f.inv()).unwrap(); + let ide_plugin::ServicePane::Text(text) = &tabs[0].body else { panic!("text detail") }; + assert!(text.starts_with(&format!("{view}: beta"))); + f.host + .view_event("test.detail-views", view, ide_plugin::ViewEvent::Button { id: "refresh".into() }, f.inv()) + .unwrap(); + let refreshed = f.host.service_detail("test.detail-views", view, "beta", f.inv()).unwrap(); + assert_ne!(tabs, refreshed); + } + let selections = [("tree", "alpha"), ("list", "beta"), ("services", "alpha")]; + for (view, key) in selections { + f.host + .view_event("test.detail-views", view, ide_plugin::ViewEvent::Select { key: key.into() }, f.inv()) + .unwrap(); + } + for (view, key) in selections { + let tabs = f.host.service_detail("test.detail-views", view, key, f.inv()).unwrap(); + let ide_plugin::ServicePane::Text(text) = &tabs[0].body else { panic!("text detail") }; + assert!(text.starts_with(&format!("{view}: {key}"))); + } } /// `hello` as a services view that may run `sh`, granted or not. @@ -3758,6 +3831,30 @@ fn service_logs_need_the_grant_and_a_log_tab() { assert!(!marker.exists(), "a cancelled request starts nothing"); } +#[cfg(unix)] +#[test] +fn ordinary_detail_views_reuse_log_grants_and_streaming() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = hello_variant(tmp.path(), |t| { + services_variant(t).replace("location = \"services\"", "location = \"right\"\ndetail = true").replace( + "required = [\"editor.read\", \"editor.write\"]", + "required = [\"editor.read\", \"editor.write\"]\noptional = [\"process:sh\"]", + ) + }); + f.install(&pkg); + f.host.set_capability("hello", "process:sh", false).unwrap(); + assert_eq!( + stream(&f, "echo allowed", &[], &Default::default(), |_| {}).unwrap_err().kind, + ErrorKind::PermissionDenied + ); + f.host.set_capability("hello", "process:sh", true).unwrap(); + f.host.set_program_path(Arc::new(|| Some(PathBuf::from("/bin").into_os_string()))); + let lines = Mutex::new(Vec::new()); + assert_eq!(stream(&f, "echo allowed", &[], &Default::default(), |l| lines.lock().push(l.text)).unwrap(), Some(0)); + assert_eq!(lines.into_inner(), ["allowed"]); +} + #[cfg(unix)] #[test] fn service_log_cancel_kills_the_child() { diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm index 87f0133bf..ef2855849 100644 Binary files a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources index 59aeb5604..6ec88cb35 100644 --- a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources @@ -1 +1 @@ -3f444f1cc3b7f495f26378594fd9f98418146cd8b2275310e53d4905d0bb5c30 +2652fb97735511f78576d5a16f685871bd7ab12df94795f14984bc819fe0b593 diff --git a/crates/ide-plugin/tests/fixtures/detail-views/Cargo.lock b/crates/ide-plugin/tests/fixtures/detail-views/Cargo.lock new file mode 100644 index 000000000..a4edc6e9e --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/detail-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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[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-plugin-detail-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/detail-views/Cargo.toml b/crates/ide-plugin/tests/fixtures/detail-views/Cargo.toml new file mode 100644 index 000000000..92ccf31cc --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/detail-views/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openidea-plugin-detail-views" +version = "0.1.0" +edition = "2024" +publish = false + +# Standalone: not a member of the IDE workspace (different target). +[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/detail-views/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm new file mode 100644 index 000000000..101301198 Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources new file mode 100644 index 000000000..dc9bf4212 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources @@ -0,0 +1 @@ +41cf712d387210fea3e102f3f590b05c69b56814b742504a40c53f160874e926 diff --git a/crates/ide-plugin/tests/fixtures/detail-views/plugin.toml b/crates/ide-plugin/tests/fixtures/detail-views/plugin.toml new file mode 100644 index 000000000..d7c347ea1 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/detail-views/plugin.toml @@ -0,0 +1,24 @@ +id = "test.detail-views" +name = "Detail views" +version = "0.1.0" +engines.openidea = "^1.3" +wasm = "dist/plugin.wasm" +activation = ["on-view:tree", "on-view:list", "on-view:services"] + +[[contributes.views]] +id = "tree" +title = "Detail Tree" +location = "left" +detail = true + +[[contributes.views]] +id = "list" +title = "Detail List" +location = "bottom" +detail = true + +[[contributes.views]] +id = "services" +title = "Detail Services" +location = "services" +detail = true diff --git a/crates/ide-plugin/tests/fixtures/detail-views/src/lib.rs b/crates/ide-plugin/tests/fixtures/detail-views/src/lib.rs new file mode 100644 index 000000000..c93de8afb --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/detail-views/src/lib.rs @@ -0,0 +1,64 @@ +//! A tree/list detail-provider fixture for host and native UI verification. +use std::collections::BTreeMap; + +use openidea_plugin::details::{Details, Pane, Tab}; +use openidea_plugin::views::{Body, Button, ListItem, TreeItem, View, ViewEvent, Viewport, Views}; +use openidea_plugin::{Error, Invocation, Plugin, host, register_plugin}; + +#[derive(Default)] +struct DetailViews { + selected: BTreeMap, + removed: bool, + revision: u32, +} + +impl Plugin for DetailViews { + fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result, Error> { + Err(Error::NotFound(id.into())) + } +} + +impl Views for DetailViews { + fn render(&mut self, view: &str, _viewport: Option, _ctx: &Invocation) -> Result { + let keys: &[&str] = if self.removed { &["beta"] } else { &["alpha", "beta"] }; + let body = if view == "list" { + Body::List(keys.iter().map(|key| ListItem::new(key, key)).collect()) + } else { + Body::Tree(keys.iter().map(|key| TreeItem::new(key, key)).collect()) + }; + Ok(View::new(body) + .button(Button::new("refresh", "Refresh details")) + .button(Button::new("remove", "Remove alpha"))) + } + + fn handle(&mut self, view: &str, event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> { + match event { + ViewEvent::Select(key) => { + self.selected.insert(view.to_owned(), key); + } + ViewEvent::Button(id) => { + if id == "remove" { + self.removed = true; + } + self.revision += 1; + host::refresh_view(view); + } + _ => {} + } + Ok(()) + } +} + +impl Details for DetailViews { + fn detail(&mut self, view: &str, key: &str, _ctx: &Invocation) -> Result, Error> { + if self.selected.get(view).map(String::as_str) != Some(key) { + return Err(Error::Failed("select must finish before detail".into())); + } + Ok(vec![ + Tab::new("info", "Info", Pane::Text(format!("{view}: {key}\nRevision {}", self.revision))), + Tab::new("message", "Summary", Pane::Message(format!("Selected {key}"))), + ]) + } +} + +register_plugin!(DetailViews, views, details); diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm index 4e4f1e858..7692e5b06 100644 Binary files a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources index 2a197f645..52a837bcc 100644 --- a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources @@ -1 +1 @@ -26ae74bb9df6f88bca87a31d20fb00d52cd9464047df3525d0e47b5bf6b2b2ca +cdd58d817f507452ed192e3a19071bd56740efc70c4a965b0d48e5b224fff1d4 diff --git a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm index ca73c072e..892fa6b6e 100644 Binary files a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources index 436aa5dc3..6d3cc157b 100644 --- a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources @@ -1 +1 @@ -24b3c81c74355174aa2fd484677bbac38020ecd119aff419d0479a942e2e4dbd +44da806d4e0d4d370534d108bd90406bcc475bac17cf69d0d323d341b79deb9a diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm index a679243d7..112bfdbd8 100644 Binary files a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm 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 index 768e2239e..fedec7ba4 100644 --- a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources @@ -1 +1 @@ -0bb069928979ba22a2d6bd977108d905ff870ef981aad6ef4930834ad5c35c35 +6d61b436c76c26b7edf444c89c8e71091ca7c5bccfba9ae79e770f70f8b9013d diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index 3e720c89d..612cf46bb 100644 Binary files a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index 42768ddd6..2d8db8996 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -da7c5e58d2d26b7613a8ba5b50d4ec7788438f186b4abcfda90a9bf0c34ab591 +b4fedecefcc934cd6ddc49c3ffe948468a7603930a47c3594be85f5f01758fab diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm index b6e97341e..baee49a89 100644 Binary files a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources index 59d8676a5..242254d86 100644 --- a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources @@ -1 +1 @@ -65583ded79c9fb4de0ed856d04aa08e58c69368d3a9235a57afa50ea757eb45f +e593cd53d3757c3a738121ec2fcc3840a9d1c0dcc6ea1e17134c9d53ef695ebc diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm index fb9ca9d87..0238937a4 100644 Binary files a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index 7b1eece32..4165bb01d 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -0c0e4f3611971307268cc8e2cc6fb96734923f10dbccbd8950639d86036c682c +3f49e3bb7f523103f70a39087e96500b5cd806ab60de60fceb480ca54ad4aa1d diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm index 365ad9036..943a63cef 100644 Binary files a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources index a951825a6..8c1c7456b 100644 --- a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources @@ -1 +1 @@ -c09efb8c9d0be262b7a25099639213e304fbfd5b0b91ad3d80672fb8148c0e4a +f3290c6ef1e794c95d3e8993aacecd58566e2ba690f966f52613a2dbdfa21dd5 diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 261df6af6..8bbe87f6d 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index 174a21ba1..ef1c5a1f9 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -7c728e98b1e1d4ea3d03831c263298fc4792599a81138e40dfd4c48ce6c4a47e +7e95ef2102982c48740d664cf050c86d2f7015d438e30376c35c5a97c2fb8c1b diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index 0bc51a89c..2c433f90d 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -d8626558aba036e4d476df9929c6e16089dd0a1e43101b48dd978946889c7616 +58bcfb533bf386c0f0179e6775be834aa2d30f1b5e88e7ca00ad662bdd35699e diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm index afd0a867f..84a342d63 100644 Binary files a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources index 5da119f95..3af778b65 100644 --- a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources @@ -1 +1 @@ -512b7d41868034322fccc15f100c85e9f53335c22aec4d7492a14723b54cd874 +12e92a08e3493a282b811592d1f044f293d6296b36b4c8e3b25ef80c5aca022f diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm index bf350cf23..ccbdb011f 100644 Binary files a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources index c4f8084f6..2b0fb51d6 100644 --- a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -1 +1 @@ -de7a217d3931c62387718b231a2c895438a210cc2e2b2cbb374312a1cf86a68d +eb481d1624b5d96488925f15dfa773a74c38cde490bc0c43b9a8caf6631168ce diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm index b76eea9cc..eaaa37edb 100644 Binary files a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources index 21294bdc2..05e8a7da9 100644 --- a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources @@ -1 +1 @@ -f76dd5cbd131af57c371c2f44146274426731a3e9609cc6f5aaabbacaecb17a7 +99e6543b156c2ea1a916f597224416db55f10bdce37af8943e2297a397fad0c9 diff --git a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm index 6d2c5e8bc..255d942b8 100644 Binary files a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources index 88a446fad..4173f7d66 100644 --- a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources @@ -1 +1 @@ -ffe6e931db4d0232005de934fce7090ae9098e9cdfd1f9e8b7f6243abe3d3ae9 +be318451a7ea7653bfd40a5624aa0951b4fb814c8c39a494ed3066c7ac8ef35e diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm index 84391bcc6..a25719642 100644 Binary files a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources index 33c5cbdbd..1ee2e8c2d 100644 --- a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources @@ -1 +1 @@ -8d42a25cd250041ceb6aaafed66ac0bd38ff1717cc450d6175517cd3d01738c9 +9b4b83d13abbfaf88b04dff3ecc1beca7f12805fa651b53f1087b6e4ff2f0e38 diff --git a/e2e/pluginDetails.fixture.tsx b/e2e/pluginDetails.fixture.tsx new file mode 100644 index 000000000..6841320f3 --- /dev/null +++ b/e2e/pluginDetails.fixture.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import { createRoot } from "react-dom/client"; +import type { ProjectInfo, RemoteSession, View } from "../src/bindings"; +import { createMemoryPluginsBackend, setPluginsBackend } from "../src/features/plugins/backend"; +import { PluginView } from "../src/features/plugins/views/PluginView"; +import { bumpView } from "../src/features/plugins/views/viewStore"; +import { useProject } from "../src/platform/project"; +import { useRemote } from "../src/platform/remote"; +import "../src/styles/tokens.css"; + +const remote = (config: string): RemoteSession => ({ + target: { kind: "ssh", config }, + root: "/same", + host: config, + os: "linux", + commands: [], +}); +useProject.setState({ project: { root: "/same" } as ProjectInfo }); +useRemote.setState({ session: remote("one") }); + +let removed = false; +let revision = 0; +let selected = ""; +setPluginsBackend({ + ...createMemoryPluginsBackend([]), + renderView: async (_p, view): Promise => ({ + toolbar: [ + { id: "remove", label: "Remove alpha", icon: null, enabled: true }, + { id: "refresh", label: "Refresh details", icon: null, enabled: true }, + ], + status: null, + body: { + kind: view === "tree" ? "tree" : "list", + value: (removed ? ["beta"] : ["alpha", "beta"]).map((key) => ({ + key, + label: key, + icon: null, + detail: null, + parent: null, + expanded: false, + expandable: false, + })), + }, + }), + viewEvent: async (_p, view, event) => { + if (event.kind === "select") selected = event.key; + if (event.kind === "button") { + if (event.id === "remove") removed = true; + revision++; + bumpView("fixture", view); + } + }, + serviceDetail: async (_p, view, key) => { + if (selected !== key) throw new Error("select was not handled"); + return [ + { id: "info", label: "Info", body: { kind: "text", value: `${view}: ${key}\nRevision ${revision}` } }, + { id: "summary", label: "Summary", body: { kind: "message", value: `Selected ${key}` } }, + ]; + }, +}); +function Fixture() { + const kind = new URLSearchParams(location.search).get("kind") === "tree" ? "tree" : "list"; + const [shown, setShown] = useState(true); + const [host, setHost] = useState("one"); + return ( + <> + + + {host} +
{shown && }
+ + ); +} +createRoot(document.getElementById("root")!).render(); diff --git a/e2e/pluginDetails.html b/e2e/pluginDetails.html new file mode 100644 index 000000000..c7a3a6c0c --- /dev/null +++ b/e2e/pluginDetails.html @@ -0,0 +1,11 @@ + + + + + Plugin details regression + + +
+ + + diff --git a/e2e/pluginDetails.spec.ts b/e2e/pluginDetails.spec.ts new file mode 100644 index 000000000..b40d3c44f --- /dev/null +++ b/e2e/pluginDetails.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test"; + +test.use({ video: "on" }); + +for (const kind of ["tree", "list"]) { + test(`${kind} detail follows selection, refresh and removal and remembers a dragged divider`, async ({ page }) => { + await page.goto(`/e2e/pluginDetails.html?kind=${kind}`); + await page.getByText("alpha", { exact: true }).click(); + await expect(page.locator("#root").getByRole("document")).toHaveText(`${kind}: alpha\nRevision 0`); + await page.getByRole("tab", { name: "Summary" }).click(); + await expect(page.getByText("Selected alpha", { exact: true })).toBeVisible(); + await page.getByText("beta", { exact: true }).click(); + await expect(page.locator("#root").getByRole("document")).toContainText(`${kind}: beta`); + const handle = page.getByRole("separator"); + const before = await handle.boundingBox(); + expect(before).not.toBeNull(); + await page.mouse.move(before!.x + before!.width / 2, before!.y + 50); + await page.mouse.down(); + await page.mouse.move(before!.x + 150, before!.y + 50, { steps: 10 }); + await page.mouse.up(); + await expect.poll(async () => (await handle.boundingBox())!.x).toBeGreaterThan(before!.x + 100); + const dragged = (await handle.boundingBox())!.x; + await page.getByRole("button", { name: "Toggle view" }).click(); + await page.getByRole("button", { name: "Toggle view" }).click(); + await expect(handle).toBeVisible(); + await expect.poll(async () => Math.abs((await handle.boundingBox())!.x - dragged)).toBeLessThan(2); + await page.getByText("alpha", { exact: true }).click(); + await page.getByRole("button", { name: "Refresh details" }).click(); + await expect(page.locator("#root").getByRole("document")).toContainText("Revision 1"); + await page.getByRole("button", { name: "Switch remote host" }).click(); + await expect(page.getByLabel("Remote host")).toHaveText("two"); + await expect(page.getByText("Select an item to view details")).toBeVisible(); + await expect(page.locator("#root").getByRole("document")).toHaveCount(0); + await page.getByText("alpha", { exact: true }).click(); + await expect(page.locator("#root").getByRole("document")).toContainText(`${kind}: alpha`); + await page.getByRole("button", { name: "Remove alpha" }).click(); + await expect(page.getByText("Select an item to view details")).toBeVisible(); + await expect(page.locator("#root").getByRole("document")).toHaveCount(0); + await page.reload(); + await expect(handle).toBeVisible(); + await expect.poll(async () => Math.abs((await handle.boundingBox())!.x - dragged)).toBeLessThan(2); + }); +} diff --git a/plugins/README.md b/plugins/README.md index a9b2c0ca0..4695c798c 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -851,6 +851,28 @@ Scripts get the same as `openidea.theme` (`{ dark, vars }`) and `openidea.onThemeChange(fn)`, for drawing on a canvas, say. Variables are only ever added. +#### List and detail views + +With API 1.3 (`engines.openidea = "^1.3"`), a rendered `tree` or `list` view +in any location can declare `detail = true`: + +```toml +[[contributes.views]] +id = "endpoints" +title = "Endpoints" +location = "bottom" +detail = true +``` + +Implement the same `details::Details` export shown below. The host requests +the selected item's tabs after its `select` event finishes, and again after +`host::refresh_view(view)`. The list and tabs appear side by side with a +resizable divider remembered per plugin/view. Changing or removing the selected +item closes its pane and stops its log. Changing projects or remote hosts clears +selection; hiding the window stops its log. Other body kinds render as before; +`detail` cannot be combined with `web`. Services uses its existing single +list/detail split, so this declaration never adds a nested split. + #### Services views `location = "services"` (API 1.3: `engines.openidea = "^1.3"`) puts a view's diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index c31940bed..a628301c0 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index dd5363f3b..b33b8927d 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -977835f3364613876b3f563877d7f0cf36d668226c5c6ac2280473c73d44117d +f5d442002dd3315f14ed7ebe873a7d1c858fa31a2840aebc7876366269d5fd6c diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index 77b26d71a..c3a69de28 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index ae1a5e838..cb4c66f56 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -55e3306fc2ff9456d79f02b63712c7f83b1fe4e75fec051f5fb96e7fb766066f +0bebf24cc46dd2dac9c51fffcd4b9186f7e575c2d552e8d600c02fa5fe8fa9b0 diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index dedb8a9b2..5fdecfbed 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index 354594309..b4b5ddbaf 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -f582b640e980e4b69e292ec425d0ed8113527119d18d0fa1ea73e7c34f1f89a1 +a4179ed839602e1d1697d738689d8936d22889b0d4e09ff953d6354a6d91f368 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index 6ab24411e..16d6d4449 100644 Binary files a/plugins/official/git-toolbox/dist/plugin.wasm and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index cb43fe6a6..cdb84bef7 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -842de029af0767864b56dc71199a823ca0ea04aec5ece8928dac69340ff7ab7a +eb5f3a257f2551a29c3ad62b222b0641b07b7b9422780e1ffecafa7e39b5032d diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index 41c2e4a27..2fc3e6e28 100644 Binary files a/plugins/official/graphviz/dist/plugin.wasm and b/plugins/official/graphviz/dist/plugin.wasm differ diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index 2f09b562d..a75fb7800 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -1dd92a9e139458813f513479cbc0f8187eff217f5dac2b39441537e3039e9dca +74df219fa8bfbb491bc2f3c021e3df2a8daa9bed03606e8d558624918d9d7104 diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index ad22fcdbf..89cf26099 100644 Binary files a/plugins/official/key-promoter/dist/plugin.wasm and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 696c79edd..f1891d7a6 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -5ce56abe0082fc610deec13f6907cdfd8be7346ce9a1c43c59e1828957bc3274 +19c90c5f90d6a26be8f4762d42cbe2f9281e311acc61467bd104da7ec25e5545 diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index 3f845c15d..a73f82c97 100644 Binary files a/plugins/official/license-header/dist/plugin.wasm and b/plugins/official/license-header/dist/plugin.wasm differ diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index 8fbbf7217..6b9d7a3ea 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -be6dd0b46a0f407f354ae2b67c6304948c71749bc3587dcf81b39dd245afdb48 +a9bf8c6074ffb5d5335f07d6e5f1af6108faec0ca6e282a85684b991995baefc diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index 1a4045534..56953a376 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 8d0252556..9fb0a4cd6 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -4fe7aec43c3009cd0567e93f2e80c135091269aa0a2fcea3ba00f43a24c5ec56 +6519aa645fe62c9f963d1baf7cd0a36dd75787b37da46a7d1218f3983f828e79 diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index fdf752807..c741af6be 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index b89dabe08..d5ee8b74c 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -7be4ce274fef38b5d4741646de970afd803fbf5ccffaa49236af1d15c8c870a9 +a95e6ff3977bf25cf106f6641cd8333124bb8615a8c327baefa068e36dd3d2ee diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index 028fcbd86..0cfff4ffa 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index ac053bcd8..956a79eb4 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -0a02c2ec0444e5eccb68b2bfabeaaf7752d1bc2b22bc0f26883a2a35eeac7df7 +674e164012c93a106a0c3977b1066d5a49524f8f8426fb09404e3b7a7340a629 diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 5e022a9e2..68c504165 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index 1599b4632..45439d0f3 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -10c214d40a6b43bb8b850bfe6d97c1a360ed748b5e95645354b3317806d7a538 +f253276316839b418223b8292683e313d311ce41c9701c04c9c3fcc85e0638e5 diff --git a/sdk/rust/openidea-plugin/src/details.rs b/sdk/rust/openidea-plugin/src/details.rs index 5a9349309..bf6806ec9 100644 --- a/sdk/rust/openidea-plugin/src/details.rs +++ b/sdk/rust/openidea-plugin/src/details.rs @@ -1,7 +1,8 @@ -//! The pane beside the Services tree for the nodes of the plugin's -//! `location = "services"` views (world `plugin-details`). The nodes, the +//! The selected item's pane for `location = "services"` views and tree/list +//! views declaring `detail = true` (API 1.3, world `plugin-details`). The items, //! toolbar and their events come from [`crate::views::Views`] as in any view; -//! register both: `register_plugin!(MyPlugin, views, details)`. +//! register both: `register_plugin!(MyPlugin, views, details)`. The host remembers +//! the resizable divider and requests details after the select event finishes. use crate::{Error, Invocation}; @@ -64,7 +65,7 @@ impl Tab { } } -/// The detail pane of the nodes of the plugin's services views. +/// The selected item's detail pane in Services or a `detail = true` tree/list view. pub trait Details { /// The tabs of node `key` of view `view` (at most 8), asked when the node is /// selected and again after [`crate::host::refresh_view`] of the view. diff --git a/src/features/plugins/__tests__/detailViews.test.tsx b/src/features/plugins/__tests__/detailViews.test.tsx new file mode 100644 index 000000000..af0de32d3 --- /dev/null +++ b/src/features/plugins/__tests__/detailViews.test.tsx @@ -0,0 +1,290 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ProjectInfo, ServiceTab, View } from "@/bindings"; +import { createMemoryPluginsBackend, setPluginsBackend } from "../backend"; +import { ToolWindowShownProvider, useContributions } from "@/workbench/contributions"; +import { useProject } from "@/platform/project"; +import { useRemote } from "@/platform/remote"; +import { PluginView } from "../views/PluginView"; +import { ServicePane } from "../views/ServicePane"; +import { defineBuiltinUiPoints } from "../points"; +import { clearUiContributions, syncUiContributions } from "../projection"; +import { bumpView } from "../views/viewStore"; + +const originalProject = useProject.getState().project; +const originalRemote = useRemote.getState().session; +afterEach(() => { + setPluginsBackend(null); + act(() => { + useProject.setState({ project: originalProject }); + useRemote.setState({ session: originalRemote }); + }); +}); +const select = (key: string) => { + const row = screen.getByText(key); + fireEvent.mouseDown(row, { button: 0 }); + fireEvent.click(row); +}; +const tabs = (key: string): ServiceTab[] => [ + { id: "info", label: "Info", body: { kind: "text", value: `Details ${key}` } }, +]; +const item = (key: string) => ({ + key, + label: key, + icon: null, + detail: null, + parent: null, + expandable: false, + expanded: false, +}); + +function setup(kind: "tree" | "list") { + let keys = ["a", "b"]; + const b = { + ...createMemoryPluginsBackend([]), + renderView: vi.fn(async (): Promise => ({ + toolbar: [], + status: null, + body: { kind, value: keys.map(item) }, + })), + viewEvent: vi.fn(async () => {}), + serviceDetail: vi.fn(async (_p: string, _v: string, key: string) => tabs(key)), + }; + setPluginsBackend(b); + return { + b, + remove: () => { + keys = ["b"]; + bumpView("p", kind); + }, + }; +} + +describe("detail views", () => { + it.each(["tree", "list"] as const)("follows %s selection and clears removed items", async (kind) => { + const { b, remove } = setup(kind); + render(); + await screen.findByText("a"); + select("a"); + expect(await screen.findByRole("document")).toHaveTextContent("Details a"); + select("b"); + await waitFor(() => expect(screen.getByRole("document")).toHaveTextContent("Details b")); + expect(b.serviceDetail).toHaveBeenLastCalledWith("p", kind, "b"); + select("a"); + await waitFor(() => expect(screen.getByRole("document")).toHaveTextContent("Details a")); + act(remove); + expect(await screen.findByText("Select an item to view details")).toBeTruthy(); + expect(screen.queryByRole("document")).toBeNull(); + }); + + it("waits for the select handler before requesting details", async () => { + const { b } = setup("list"); + let finish = () => {}; + b.viewEvent.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + render(); + await screen.findByText("a"); + select("a"); + await waitFor(() => expect(b.viewEvent).toHaveBeenCalled()); + expect(b.serviceDetail).not.toHaveBeenCalled(); + await act(async () => finish()); + expect(await screen.findByRole("document")).toHaveTextContent("Details a"); + }); + + it.each(["tree", "list"] as const)("updates %s details on keyboard selection", async (kind) => { + const { b } = setup(kind); + render(); + await screen.findByText("a"); + select("a"); + await screen.findByRole("document"); + const row = screen.getByText("a").closest('[role="treeitem"], [role="option"]')!; + fireEvent.keyDown(row, { key: "ArrowDown" }); + await waitFor(() => expect(screen.getByRole("document")).toHaveTextContent("Details b")); + expect(b.serviceDetail).toHaveBeenLastCalledWith("p", kind, "b"); + }); + + it("keeps ordinary bodies unchanged and does not request details without opt-in", async () => { + const { b } = setup("list"); + render(); + await screen.findByText("a"); + select("a"); + await waitFor(() => expect(b.viewEvent).toHaveBeenCalled()); + expect(screen.queryByRole("separator")).toBeNull(); + expect(b.serviceDetail).not.toHaveBeenCalled(); + }); + + it("ignores late detail responses after the selected item changes", async () => { + let resolveA = (_tabs: ServiceTab[]) => {}; + const b = { + ...createMemoryPluginsBackend([]), + serviceDetail: vi.fn(async (_p: string, _v: string, key: string) => + key === "a" + ? new Promise((resolve) => { + resolveA = resolve; + }) + : tabs(key), + ), + }; + setPluginsBackend(b); + const ui = render(); + await waitFor(() => expect(b.serviceDetail).toHaveBeenCalled()); + ui.rerender(); + expect(await screen.findByRole("document")).toHaveTextContent("Details b"); + await act(async () => resolveA(tabs("a"))); + expect(screen.getByRole("document")).toHaveTextContent("Details b"); + }); + + it("stops the previous item's log immediately on selection change", async () => { + const stop = vi.fn(); + setPluginsBackend({ + ...createMemoryPluginsBackend([]), + serviceDetail: async (_p, _v, key) => + key === "a" + ? [{ id: "log", label: "Log", body: { kind: "log", value: { program: "sh", args: [], toggles: [] } } }] + : new Promise(() => {}), + serviceLog: () => stop, + }); + const ui = render(); + await screen.findByRole("tab", { name: "Log" }); + ui.rerender(); + expect(stop).toHaveBeenCalledOnce(); + expect(screen.queryByRole("tab")).toBeNull(); + }); +}); + +it("retries details after an error and refresh", async () => { + let fail = true; + setPluginsBackend({ + ...createMemoryPluginsBackend([]), + serviceDetail: async () => { + if (fail) throw new Error("offline"); + return tabs("a"); + }, + }); + render(); + await screen.findByText("offline"); + fail = false; + act(() => bumpView("p", "retry")); + expect(await screen.findByRole("document")).toHaveTextContent("Details a"); +}); + +it("clears selection and cancels a log when the project changes", async () => { + const { b } = setup("list"); + const stop = vi.fn(); + setPluginsBackend({ + ...b, + serviceDetail: async () => [ + { id: "log", label: "Log", body: { kind: "log", value: { program: "sh", args: [], toggles: [] } } }, + ], + serviceLog: () => stop, + }); + render(); + await screen.findByText("a"); + select("a"); + await screen.findByRole("tab", { name: "Log" }); + act(() => useProject.setState({ project: { root: "/other" } as ProjectInfo })); + expect(stop).toHaveBeenCalledOnce(); + expect(await screen.findByText("Select an item to view details")).toBeTruthy(); + expect(screen.queryByRole("tab")).toBeNull(); +}); + +it("stops logs while a kept-alive tool window is hidden and reloads on show", async () => { + const stop = vi.fn(); + const serviceDetail = vi.fn(async (): Promise => [ + { id: "log", label: "Log", body: { kind: "log", value: { program: "sh", args: [], toggles: [] } } }, + ]); + setPluginsBackend({ ...createMemoryPluginsBackend([]), serviceDetail, serviceLog: () => stop }); + const view = (shown: boolean) => ( + + + + ); + const ui = render(view(true)); + await screen.findByRole("tab", { name: "Log" }); + ui.rerender(view(false)); + expect(stop).toHaveBeenCalledOnce(); + expect(screen.queryByRole("tab")).toBeNull(); + ui.rerender(view(true)); + await screen.findByRole("tab", { name: "Log" }); + expect(serviceDetail).toHaveBeenCalledTimes(2); +}); + +it.each(["left", "right", "bottom"].flatMap((location) => [null, "items"].map((group) => ({ location, group }))))( + "projects detail declarations in $location with group=$group", + async ({ location, group }) => { + const undefine = defineBuiltinUiPoints(); + const { b } = setup("list"); + try { + syncUiContributions( + [ + { + pluginId: "p", + point: "views", + key: "list", + decl: { id: "list", title: "Items", location, group, detail: true }, + }, + ], + [{ id: "p", name: "P", enabled: true, status: "active" } as Parameters[1][number]], + ); + const contribution = useContributions + .getState() + .toolWindows.get(group ? `plugin.p.group:${group}` : "plugin.p.list")!; + expect(contribution.anchor).toBe(location); + const Component = contribution.component; + render(); + await screen.findByText("a"); + select("a"); + expect(await screen.findByRole("document")).toHaveTextContent("Details a"); + expect(b.serviceDetail).toHaveBeenCalledWith("p", "list", "a"); + } finally { + clearUiContributions(); + undefine(); + } + }, +); + +it("drops queued events when their project/view has been left", async () => { + const { b } = setup("list"); + let finish = () => {}; + b.viewEvent.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + render(); + await screen.findByText("a"); + select("a"); + await waitFor(() => expect(b.viewEvent).toHaveBeenCalledOnce()); + select("b"); + act(() => useProject.setState({ project: { root: "/next" } as ProjectInfo })); + await act(async () => finish()); + expect(b.viewEvent).toHaveBeenCalledOnce(); + expect(b.serviceDetail).not.toHaveBeenCalled(); +}); + +it("clears detail when remote hosts use the same project path", async () => { + setup("list"); + const session = (config: string) => ({ + target: { kind: "ssh" as const, config }, + root: "/same", + host: config, + os: "linux", + commands: [], + }); + act(() => { + useProject.setState({ project: { root: "/same" } as ProjectInfo }); + useRemote.setState({ session: session("one") }); + }); + render(); + await screen.findByText("a"); + select("a"); + await screen.findByRole("document"); + act(() => useRemote.setState({ session: session("two") })); + expect(await screen.findByText("Select an item to view details")).toBeTruthy(); + expect(screen.queryByRole("document")).toBeNull(); +}); diff --git a/src/features/plugins/__tests__/serviceProvider.test.tsx b/src/features/plugins/__tests__/serviceProvider.test.tsx index 2780c4b83..d8f4cb9c5 100644 --- a/src/features/plugins/__tests__/serviceProvider.test.tsx +++ b/src/features/plugins/__tests__/serviceProvider.test.tsx @@ -1,13 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { render, renderHook, waitFor } from "@testing-library/react"; -import type { View, ViewEvent, ViewTreeItem } from "@/bindings"; +import { act, render, renderHook, waitFor } from "@testing-library/react"; +import type { ProjectInfo, View, ViewEvent, ViewTreeItem } from "@/bindings"; import { serviceProviders, type ServiceTreeItem } from "@/workbench/contributions"; import { createMemoryPluginsBackend, setPluginsBackend, type PluginsBackend } from "../backend"; import { defineBuiltinUiPoints } from "../points"; import { clearUiContributions, syncUiContributions } from "../projection"; +import { useProject } from "@/platform/project"; +import { useRemote } from "@/platform/remote"; import { pluginServiceProvider } from "../views/serviceProvider"; let undefinePoints: () => void = () => {}; +const originalProject = useProject.getState().project; +const originalRemote = useRemote.getState().session; beforeEach(() => { undefinePoints = defineBuiltinUiPoints(); @@ -17,6 +21,10 @@ afterEach(() => { clearUiContributions(); undefinePoints(); setPluginsBackend(null); + act(() => { + useProject.setState({ project: originalProject }); + useRemote.setState({ session: originalRemote }); + }); }); const item = (key: string, label: string, parent: string | null = null): ViewTreeItem => ({ @@ -73,6 +81,17 @@ describe("plugin service providers", () => { expect(nodes[0]?.label).toBe("minikube"); }); + it("maps a list to Services nodes without another split", async () => { + backend({ + toolbar: [], + status: null, + body: { kind: "list", value: [{ key: "a", label: "Alpha", icon: null, detail: "ready" }] }, + }); + const { roots: nodes } = await roots("p"); + expect(nodes).toEqual([expect.objectContaining({ key: "plugin:p/kube/a", label: "Alpha", detail: "ready" })]); + expect(nodes[0]?.children).toBeUndefined(); + }); + it("routes events with the raw key", async () => { const { events } = backend(); const { provider, roots: nodes } = await roots("p"); @@ -130,3 +149,70 @@ describe("plugin service providers", () => { expect(serviceProviders().map((p) => p.id)).not.toContain("plugin.p.kube"); }); }); + +const remote = (config: string) => ({ + target: { kind: "ssh" as const, config }, + root: "/same", + host: config, + os: "linux", + commands: [], +}); + +it("reloads Services roots when remote hosts share a project path", async () => { + const { b } = backend(); + act(() => { + useProject.setState({ project: { root: "/same" } as ProjectInfo }); + useRemote.setState({ session: remote("one") }); + }); + const provider = pluginServiceProvider("p", decl, "plugin", noMenu); + renderHook(() => provider.useRoots()); + await waitFor(() => expect(b.renderView).toHaveBeenCalledTimes(1)); + act(() => useRemote.setState({ session: remote("two") })); + await waitFor(() => expect(b.renderView).toHaveBeenCalledTimes(2)); +}); + +it("drops queued Services selections after a remote host switch", async () => { + const { b } = backend(); + let finish = () => {}; + const viewEvent = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + setPluginsBackend({ ...b, viewEvent }); + act(() => { + useProject.setState({ project: { root: "/same" } as ProjectInfo }); + useRemote.setState({ session: remote("one") }); + }); + const provider = pluginServiceProvider("p", decl, "plugin", noMenu); + provider.select?.({ key: "plugin:p/kube/a", label: "a", icon: "folder" }); + await waitFor(() => expect(viewEvent).toHaveBeenCalledOnce()); + provider.select?.({ key: "plugin:p/kube/b", label: "b", icon: "folder" }); + act(() => useRemote.setState({ session: remote("two") })); + finish(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(viewEvent).toHaveBeenCalledOnce(); +}); + +it("waits for the Services selection handler before requesting its detail", async () => { + const { b } = backend(); + let finish = () => {}; + const viewEvent = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const serviceDetail = vi.fn(async () => []); + setPluginsBackend({ ...b, viewEvent, serviceDetail }); + const provider = pluginServiceProvider("p", decl, "plugin", noMenu); + const node = { key: "plugin:p/kube/a", label: "a", icon: "folder" }; + provider.select?.(node); + const Detail = provider.detail!; + render(); + await waitFor(() => expect(viewEvent).toHaveBeenCalledOnce()); + expect(serviceDetail).not.toHaveBeenCalled(); + await act(async () => finish()); + await waitFor(() => expect(serviceDetail).toHaveBeenCalledWith("p", "kube", "a")); +}); diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index 154b3d588..d443aae17 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -100,7 +100,7 @@ export interface PluginsBackend { /** Content of a plugin view (`doc`: the document of a custom editor). */ renderView(pluginId: string, view: string, viewport: Viewport | null, doc: number | null): Promise; viewEvent(pluginId: string, view: string, event: ViewEvent, doc: number | null): Promise; - /** The Services pane of node `key` of a plugin's services view. */ + /** The detail tabs of item `key` in a Services or `detail = true` view. */ serviceDetail(pluginId: string, view: string, key: string): Promise; /** The HTTP routes a plugin reports for this window's project (`[[contributes.endpoints]]`). */ endpoints(pluginId: string): Promise; diff --git a/src/features/plugins/uiPoints.tsx b/src/features/plugins/uiPoints.tsx index 75d91a74a..25ca7b6fe 100644 --- a/src/features/plugins/uiPoints.tsx +++ b/src/features/plugins/uiPoints.tsx @@ -365,6 +365,7 @@ interface ViewDecl { title: string; icon?: string | null; location: ToolWindowAnchor | "services"; + detail?: boolean; web?: string; group?: string | null; when?: string | null; @@ -475,7 +476,7 @@ function projectView(pluginId: string, decl: ViewDecl, index: number): () => voi decl.web ? ( ) : ( - + ), index, ); diff --git a/src/features/plugins/views/PluginView.module.css b/src/features/plugins/views/PluginView.module.css index b8f4c443a..d90af139f 100644 --- a/src/features/plugins/views/PluginView.module.css +++ b/src/features/plugins/views/PluginView.module.css @@ -80,6 +80,15 @@ outline: none; } +.listItem[aria-selected="true"] { + background: var(--bg-tree-selected); +} + +.master { + height: 100%; + overflow: auto; +} + .detail { color: var(--fg-muted); } diff --git a/src/features/plugins/views/PluginView.tsx b/src/features/plugins/views/PluginView.tsx index 669b2cb36..e6df815a3 100644 --- a/src/features/plugins/views/PluginView.tsx +++ b/src/features/plugins/views/PluginView.tsx @@ -27,6 +27,9 @@ import { Input, Select, Spinner, + SplitGroup, + SplitHandle, + SplitPanel, Tree, showContextMenu, type MenuItem, @@ -35,7 +38,8 @@ import { pluginsBackend } from "../backend"; import { subscribeTheme, themeKey } from "@/platform/themeWatch"; import { FRAME_TAG, frameDocument, frameTheme, frameThemeMessage } from "./frame"; import { localizeView, usePluginT } from "../pluginText"; -import { onViewMessage, useViewRevision } from "./viewStore"; +import { currentViewLocation, onViewMessage, useViewLocation, useViewRevision } from "./viewStore"; +import { ServicePane } from "./ServicePane"; import styles from "./PluginView.module.css"; export interface PluginViewProps { @@ -49,6 +53,8 @@ export interface PluginViewProps { label?: string; /** Context menu entries by item key (tables use the absolute row index). */ itemMenu?: (key: string) => MenuItem[]; + /** Show detail-provider tabs beside a tree or list. */ + detail?: boolean; } /** Table rows asked for beyond the visible ones (each side). */ @@ -57,14 +63,23 @@ const ROW_HEIGHT = 24; /** Plugin text is shown as written: the plugin translates it through its own catalog. */ export function PluginView(props: PluginViewProps) { + const location = useViewLocation(); return ( - + ); } -function PluginViewContent({ pluginId, view, doc = null, revision = 0, label, itemMenu }: PluginViewProps) { +function PluginViewContent({ + pluginId, + view, + doc = null, + revision = 0, + label, + itemMenu, + detail = false, +}: PluginViewProps) { const [rendered, setContent] = useState(null); const t = usePluginT(pluginId); const content = useMemo(() => rendered && localizeView(rendered, t), [rendered, t]); @@ -72,6 +87,24 @@ function PluginViewContent({ pluginId, view, doc = null, revision = 0, label, it const [viewport, setViewport] = useState(null); const pluginRevision = useViewRevision(pluginId, view); const seq = useRef(0); + const mounted = useRef(true); + const queue = useRef(Promise.resolve()); + const [selection, setSelection] = useState<{ key: string; after: Promise } | null>(null); + const items = content?.body.kind === "tree" || content?.body.kind === "list" ? content.body.value : null; + const hasDetail = detail && items !== null; + const selected = hasDetail && items.some((i) => i.key === selection?.key) ? selection : null; + const splitId = `plugin-detail-${encodeURIComponent(JSON.stringify([pluginId, view]))}`; + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + useEffect(() => { + if (!selected) setSelection(null); + }, [selected]); useEffect(() => { const mine = ++seq.current; @@ -88,13 +121,23 @@ function PluginViewContent({ pluginId, view, doc = null, revision = 0, label, it setError(e instanceof Error ? e.message : String(e)); }, ); + return () => { + ++seq.current; + }; }, [pluginId, view, doc, viewport, revision, pluginRevision]); const send = useCallback( (event: ViewEvent) => { - pluginsBackend() - .viewEvent(pluginId, view, event, doc) - .catch((e: unknown) => setError(e instanceof Error ? e.message : String(e))); + const location = currentViewLocation(); + const pending = queue.current.then(() => + mounted.current && location === currentViewLocation() + ? pluginsBackend().viewEvent(pluginId, view, event, doc) + : undefined, + ); + queue.current = pending.catch((e: unknown) => { + if (mounted.current) setError(e instanceof Error ? e.message : String(e)); + }); + if (event.kind === "select") setSelection({ key: event.key, after: pending }); }, [pluginId, view, doc], ); @@ -143,14 +186,43 @@ function PluginViewContent({ pluginId, view, doc = null, revision = 0, label, it )}
- + {hasDetail ? ( + + +
+ +
+
+ + + {selected ? ( + + ) : ( + + )} + +
+ ) : ( + + )}
{content.status &&
{content.status}
} @@ -164,15 +236,16 @@ interface BodyProps { view: string; onViewport: (v: Viewport) => void; itemMenu?: (key: string) => MenuItem[]; + selectedKey?: string | null; } -function Body({ content, send, pluginId, view, onViewport, itemMenu }: BodyProps) { +function Body({ content, send, pluginId, view, onViewport, itemMenu, selectedKey }: BodyProps) { const body = content.body; switch (body.kind) { case "message": return ; case "tree": - return ; + return ; case "list": return (
    @@ -180,12 +253,32 @@ function Body({ content, send, pluginId, view, onViewport, itemMenu }: BodyProps
  • send({ kind: "select", key: i.key })} onDoubleClick={() => send({ kind: "activate", key: i.key })} - onKeyDown={(e) => e.key === "Enter" && send({ kind: "activate", key: i.key })} + onKeyDown={(e) => { + if (selectedKey === undefined) { + if (e.key === "Enter") send({ kind: "activate", key: i.key }); + return; + } + if (e.key === " ") { + e.preventDefault(); + send({ kind: "select", key: i.key }); + } else if (e.key === "Enter") { + send({ kind: "select", key: i.key }); + send({ kind: "activate", key: i.key }); + } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + const index = body.value.indexOf(i) + (e.key === "ArrowDown" ? 1 : -1); + const next = body.value[index]; + if (next) { + (e.currentTarget.parentElement?.children[index] as HTMLElement | undefined)?.focus(); + send({ kind: "select", key: next.key }); + } + } + }} onContextMenu={(e) => openItemMenu(e, itemMenu, i.key)} > {i.icon && } @@ -245,9 +338,10 @@ interface TreeBodyProps { items: ViewTreeItem[]; send: (e: ViewEvent) => void; itemMenu?: (key: string) => MenuItem[]; + selectedKey?: string | null; } -function TreeBody({ items, send, itemMenu }: TreeBodyProps) { +function TreeBody({ items, send, itemMenu, selectedKey }: TreeBodyProps) { const roots = useMemo(() => treeNodes(items), [items]); const expanded = useMemo(() => new Set(items.filter((i) => i.expanded).map((i) => i.key)), [items]); return ( @@ -260,6 +354,7 @@ function TreeBody({ items, send, itemMenu }: TreeBodyProps) { getIcon={(n) => n.item.icon ?? undefined} getDetail={(n) => n.item.detail ?? undefined} expanded={expanded} + selected={selectedKey === undefined ? undefined : new Set(selectedKey === null ? [] : [selectedKey])} onExpandedChange={(_, node, isExpanded) => send(isExpanded ? { kind: "expand", key: node.item.key } : { kind: "collapse", key: node.item.key }) } diff --git a/src/features/plugins/views/ServicePane.tsx b/src/features/plugins/views/ServicePane.tsx index 0044264ad..a9b23cd4a 100644 --- a/src/features/plugins/views/ServicePane.tsx +++ b/src/features/plugins/views/ServicePane.tsx @@ -1,6 +1,5 @@ /** - * The Services pane of a node of a plugin's `location = "services"` view: the - * tabs its `service-provider` returns, switched here without calling the plugin. + * The detail-provider tabs of a selected item, shared by Services and tool windows. */ import { useEffect, useState } from "react"; import type { ServiceTab } from "@/bindings"; @@ -8,25 +7,30 @@ import { tx, VerbatimTextContext } from "@/platform/i18n"; import { EmptyState, LogPane, Spinner, Tabs } from "@/ui"; import { copySelectionOnKeyDown } from "@/ui/selectableText"; import { pluginsBackend } from "../backend"; -import { useViewRevision } from "./viewStore"; +import { useToolWindowShown } from "@/workbench/contributions"; +import { useViewLocation, useViewRevision } from "./viewStore"; import styles from "./ServicePane.module.css"; interface ServicePaneProps { pluginId: string; view: string; itemKey: string; + after?: Promise; } /** Plugin text is shown as written: the plugin translates it through its own catalog. */ export function ServicePane(props: ServicePaneProps) { + const location = useViewLocation(); + const shown = useToolWindowShown(); + if (!shown) return null; return ( - + ); } -function ServicePaneContent({ pluginId, view, itemKey }: ServicePaneProps) { +function ServicePaneContent({ pluginId, view, itemKey, after }: ServicePaneProps) { // `views.refresh()` asks for the pane again. const revision = useViewRevision(pluginId, view); const [tabs, setTabs] = useState(null); @@ -35,8 +39,8 @@ function ServicePaneContent({ pluginId, view, itemKey }: ServicePaneProps) { useEffect(() => { let alive = true; - pluginsBackend() - .serviceDetail(pluginId, view, itemKey) + Promise.resolve(after) + .then(() => (alive ? pluginsBackend().serviceDetail(pluginId, view, itemKey) : [])) .then( (t) => { if (!alive) return; @@ -48,7 +52,7 @@ function ServicePaneContent({ pluginId, view, itemKey }: ServicePaneProps) { return () => { alive = false; }; - }, [pluginId, view, itemKey, revision]); + }, [pluginId, view, itemKey, revision, after]); if (error) return ; if (!tabs) return ; diff --git a/src/features/plugins/views/serviceProvider.tsx b/src/features/plugins/views/serviceProvider.tsx index 9c301a00d..464be153e 100644 --- a/src/features/plugins/views/serviceProvider.tsx +++ b/src/features/plugins/views/serviceProvider.tsx @@ -2,7 +2,7 @@ * A plugin view declared with `location = "services"`: its tree's items are * nodes of the Services tool window, its toolbar the Services toolbar for one * selected node, `viewItem.context` its nodes' right-click menu, and the - * plugin's `service-provider` the selected node's pane. + * plugin's `detail-provider` the selected node's pane. */ import { useEffect, useMemo, useState } from "react"; import type { View, ViewEvent } from "@/bindings"; @@ -11,7 +11,7 @@ import type { ServiceProviderContribution, ServiceTreeItem } from "@/workbench/c import { pluginsBackend } from "../backend"; import { treeNodes, type TreeNode } from "./PluginView"; import { ServicePane } from "./ServicePane"; -import { useViewRevision } from "./viewStore"; +import { currentViewLocation, useViewLocation, useViewRevision } from "./viewStore"; interface ServicesViewDecl { id: string; @@ -30,8 +30,11 @@ export function pluginServiceProvider( // Events of one node stay in order (a toolbar button after the select it acts on). let queue = Promise.resolve(); const send = (event: ViewEvent) => { + const location = currentViewLocation(); queue = queue - .then(() => pluginsBackend().viewEvent(pluginId, decl.id, event, null)) + .then(() => + location === currentViewLocation() ? pluginsBackend().viewEvent(pluginId, decl.id, event, null) : undefined, + ) .catch((e: unknown) => console.warn(`plugin ${pluginId}: view ${decl.id}`, e)); }; const toItem = (n: TreeNode): ServiceTreeItem => ({ @@ -49,7 +52,9 @@ export function pluginServiceProvider( order: 900, useRoots() { const revision = useViewRevision(pluginId, decl.id); + const location = useViewLocation(); const [view, setView] = useState(null); + const [renderedLocation, setRenderedLocation] = useState(location); useEffect(() => { let alive = true; pluginsBackend() @@ -59,14 +64,23 @@ export function pluginServiceProvider( if (!alive) return; toolbar = v.toolbar; setView(v); + setRenderedLocation(location); }, (e: unknown) => console.warn(`plugin ${pluginId}: view ${decl.id}`, e), ); return () => { alive = false; }; - }, [revision]); - return useMemo(() => (view?.body.kind === "tree" ? treeNodes(view.body.value).map(toItem) : []), [view]); + }, [revision, location]); + return useMemo(() => { + if (renderedLocation !== location) return []; + if (view?.body.kind === "tree") return treeNodes(view.body.value).map(toItem); + if (view?.body.kind === "list") + return view.body.value.map((item) => + toItem({ item: { ...item, parent: null, expandable: false, expanded: false }, children: [] }), + ); + return []; + }, [view, location, renderedLocation]); }, // Ids per node: the Services toolbar offers an action of several selected // nodes only when they share its id, and a plugin's buttons act on one. @@ -89,6 +103,6 @@ export function pluginServiceProvider( }; function Detail({ item }: { item: ServiceTreeItem }) { - return ; + return ; } } diff --git a/src/features/plugins/views/viewStore.ts b/src/features/plugins/views/viewStore.ts index 433f636d7..fa0b25326 100644 --- a/src/features/plugins/views/viewStore.ts +++ b/src/features/plugins/views/viewStore.ts @@ -4,6 +4,18 @@ * sends to the script of an `html` body (`viewMessage`). */ import { create } from "zustand"; +import { useProject } from "@/platform/project"; +import { projectLocation, useRemote } from "@/platform/remote"; + +export function currentViewLocation(): string | null { + return projectLocation(useProject.getState().project?.root); +} + +export function useViewLocation(): string | null { + const root = useProject((s) => s.project?.root); + useRemote((s) => s.session); + return projectLocation(root); +} export const viewKey = (pluginId: string, view: string) => `${pluginId}/${view}`; diff --git a/src/features/services/ServicesTree.tsx b/src/features/services/ServicesTree.tsx index 839247649..faa67ba9b 100644 --- a/src/features/services/ServicesTree.tsx +++ b/src/features/services/ServicesTree.tsx @@ -205,8 +205,8 @@ export function ServicesTreeView() { ) : ( - - + +
    key={location} @@ -258,7 +258,7 @@ export function ServicesTreeView() {
    - + {selected && Detail && shown ? ( ) : ( diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index fc2b480fc..a17b7f7d2 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -313,5 +313,7 @@ "Reopen in Container": "Reopen in Container", "Builds or starts the project's dev container (devcontainer.json) with the Dev Containers CLI and opens the project in it.": "Builds or starts the project's dev container (devcontainer.json) with the Dev Containers CLI and opens the project in it.", "devcontainer up exited with {status}": "devcontainer up exited with {status}", + "Select an item to view details": "Select an item to view details", + "Nothing to show": "Nothing to show", "IDE-managed Tomcat runtime directory must not overlap Tomcat home or base directory": "IDE-managed Tomcat runtime directory must not overlap Tomcat home or base directory" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index b256a1a96..9f497af11 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6680,5 +6680,6 @@ "Builds or starts the project's dev container (devcontainer.json) with the Dev Containers CLI and opens the project in it.": "使用 Dev Containers CLI 构建或启动项目的开发容器(devcontainer.json),并在其中打开项目。", "Read-only: the table schema could not be identified in the result database.": "只读:无法确定结果所在数据库中的表架构。", "devcontainer up exited with {status}": "devcontainer up 已退出,状态为 {status}", + "Select an item to view details": "选择条目以查看详情", "IDE-managed Tomcat runtime directory must not overlap Tomcat home or base directory": "IDE 托管的 Tomcat 运行目录不得与 Tomcat 主目录或基目录重叠" } diff --git a/src/ui/Splitter.tsx b/src/ui/Splitter.tsx index 8c71a47f9..d4448641f 100644 --- a/src/ui/Splitter.tsx +++ b/src/ui/Splitter.tsx @@ -4,10 +4,55 @@ */ import { Group, Panel, Separator, type GroupProps, type PanelProps, type SeparatorProps } from "react-resizable-panels"; import clsx from "clsx"; +import { useState } from "react"; import styles from "./Splitter.module.css"; -export function SplitGroup({ className, ...rest }: GroupProps) { - return ; +export function SplitGroup({ + className, + storageKey, + defaultLayout, + onLayoutChanged, + ...rest +}: GroupProps & { storageKey?: string }) { + const [saved] = useState(() => { + try { + const value: unknown = storageKey ? JSON.parse(localStorage.getItem(storageKey) ?? "null") : null; + return value && + typeof value === "object" && + !Array.isArray(value) && + Object.values(value).every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 100) + ? (value as Record) + : undefined; + } catch { + return undefined; + } + }); + if (!storageKey) + return ( + + ); + return ( + { + if (storageKey && meta.isUserInteraction) { + try { + localStorage.setItem(storageKey, JSON.stringify(meta.requestedLayout ?? layout)); + } catch { + /* Storage may be unavailable. */ + } + } + onLayoutChanged?.(layout, meta); + }} + /> + ); } export function SplitPanel({ className, ...rest }: PanelProps) {