feat(plugins): support detail panes for tree and list views (#2033)

Closes #1950

Plugins can declare `detail = true` on a tree or list view (any tool-window side, grouped or not) to show the selected item's detail-provider tabs beside the list. The pane follows selection and refresh, clears stale content when the item, project or remote host changes, shows an empty state when nothing is selected, and remembers its resizable divider. Services keeps its single split. Released WIT and IPC shapes are unchanged. Verified on macOS in light and dark themes.

Co-authored-by: arnaud-ritti <[email protected]>
This commit is contained in:
Arnaud Ritti authored and GitHub committed 2026-11-08 03:18:44 +08:00
1 parent 7cedf0b9be
commit 4a78310f76
78 files changed
+1351 -85

No files matched your search

+11 -12
View File
@@ -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<Vec<ide_plugin::ServiceTab>> {
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:?}")))
}
}
+9
View File
@@ -404,6 +404,9 @@ struct ViewDecl {
group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
when: Option<String>,
/// 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<String>,
@@ -443,6 +446,12 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
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));
}
+98 -1
View File
@@ -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"), "<p>x</p>").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() {
Binary file not shown.
@@ -1 +1 @@
3f444f1cc3b7f495f26378594fd9f98418146cd8b2275310e53d4905d0bb5c30
2652fb97735511f78576d5a16f685871bd7ab12df94795f14984bc819fe0b593
+333
View File
@@ -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"
@@ -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"
Binary file not shown.
@@ -0,0 +1 @@
41cf712d387210fea3e102f3f590b05c69b56814b742504a40c53f160874e926
@@ -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
@@ -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<String, String>,
removed: bool,
revision: u32,
}
impl Plugin for DetailViews {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
Err(Error::NotFound(id.into()))
}
}
impl Views for DetailViews {
fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> {
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<Vec<Tab>, 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);
Binary file not shown.
@@ -1 +1 @@
26ae74bb9df6f88bca87a31d20fb00d52cd9464047df3525d0e47b5bf6b2b2ca
cdd58d817f507452ed192e3a19071bd56740efc70c4a965b0d48e5b224fff1d4
Binary file not shown.
@@ -1 +1 @@
24b3c81c74355174aa2fd484677bbac38020ecd119aff419d0479a942e2e4dbd
44da806d4e0d4d370534d108bd90406bcc475bac17cf69d0d323d341b79deb9a
Binary file not shown.
@@ -1 +1 @@
0bb069928979ba22a2d6bd977108d905ff870ef981aad6ef4930834ad5c35c35
6d61b436c76c26b7edf444c89c8e71091ca7c5bccfba9ae79e770f70f8b9013d
Binary file not shown.
@@ -1 +1 @@
da7c5e58d2d26b7613a8ba5b50d4ec7788438f186b4abcfda90a9bf0c34ab591
b4fedecefcc934cd6ddc49c3ffe948468a7603930a47c3594be85f5f01758fab
Binary file not shown.
@@ -1 +1 @@
65583ded79c9fb4de0ed856d04aa08e58c69368d3a9235a57afa50ea757eb45f
e593cd53d3757c3a738121ec2fcc3840a9d1c0dcc6ea1e17134c9d53ef695ebc
Binary file not shown.
@@ -1 +1 @@
0c0e4f3611971307268cc8e2cc6fb96734923f10dbccbd8950639d86036c682c
3f49e3bb7f523103f70a39087e96500b5cd806ab60de60fceb480ca54ad4aa1d
Binary file not shown.
@@ -1 +1 @@
c09efb8c9d0be262b7a25099639213e304fbfd5b0b91ad3d80672fb8148c0e4a
f3290c6ef1e794c95d3e8993aacecd58566e2ba690f966f52613a2dbdfa21dd5
Binary file not shown.
@@ -1 +1 @@
7c728e98b1e1d4ea3d03831c263298fc4792599a81138e40dfd4c48ce6c4a47e
7e95ef2102982c48740d664cf050c86d2f7015d438e30376c35c5a97c2fb8c1b
@@ -1 +1 @@
d8626558aba036e4d476df9929c6e16089dd0a1e43101b48dd978946889c7616
58bcfb533bf386c0f0179e6775be834aa2d30f1b5e88e7ca00ad662bdd35699e
Binary file not shown.
@@ -1 +1 @@
512b7d41868034322fccc15f100c85e9f53335c22aec4d7492a14723b54cd874
12e92a08e3493a282b811592d1f044f293d6296b36b4c8e3b25ef80c5aca022f
Binary file not shown.
@@ -1 +1 @@
de7a217d3931c62387718b231a2c895438a210cc2e2b2cbb374312a1cf86a68d
eb481d1624b5d96488925f15dfa773a74c38cde490bc0c43b9a8caf6631168ce
Binary file not shown.
@@ -1 +1 @@
f76dd5cbd131af57c371c2f44146274426731a3e9609cc6f5aaabbacaecb17a7
99e6543b156c2ea1a916f597224416db55f10bdce37af8943e2297a397fad0c9
Binary file not shown.
@@ -1 +1 @@
ffe6e931db4d0232005de934fce7090ae9098e9cdfd1f9e8b7f6243abe3d3ae9
be318451a7ea7653bfd40a5624aa0951b4fb814c8c39a494ed3066c7ac8ef35e
Binary file not shown.
@@ -1 +1 @@
8d42a25cd250041ceb6aaafed66ac0bd38ff1717cc450d6175517cd3d01738c9
9b4b83d13abbfaf88b04dff3ecc1beca7f12805fa651b53f1087b6e4ff2f0e38
+82
View File
@@ -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<View> => ({
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 (
<>
<button onClick={() => setShown((value) => !value)}>Toggle view</button>
<button
onClick={() => {
const next = host === "one" ? "two" : "one";
useRemote.setState({ session: remote(next) });
setHost(next);
}}
>
Switch remote host
</button>
<output aria-label="Remote host">{host}</output>
<div style={{ height: 400, width: 900 }}>{shown && <PluginView pluginId="fixture" view={kind} detail />}</div>
</>
);
}
createRoot(document.getElementById("root")!).render(<Fixture />);
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Plugin details regression</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/e2e/pluginDetails.fixture.tsx"></script>
</body>
</html>
+43
View File
@@ -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);
});
}
+22
View File
@@ -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
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
977835f3364613876b3f563877d7f0cf36d668226c5c6ac2280473c73d44117d
f5d442002dd3315f14ed7ebe873a7d1c858fa31a2840aebc7876366269d5fd6c
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
55e3306fc2ff9456d79f02b63712c7f83b1fe4e75fec051f5fb96e7fb766066f
0bebf24cc46dd2dac9c51fffcd4b9186f7e575c2d552e8d600c02fa5fe8fa9b0
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
f582b640e980e4b69e292ec425d0ed8113527119d18d0fa1ea73e7c34f1f89a1
a4179ed839602e1d1697d738689d8936d22889b0d4e09ff953d6354a6d91f368
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
842de029af0767864b56dc71199a823ca0ea04aec5ece8928dac69340ff7ab7a
eb5f3a257f2551a29c3ad62b222b0641b07b7b9422780e1ffecafa7e39b5032d
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
1dd92a9e139458813f513479cbc0f8187eff217f5dac2b39441537e3039e9dca
74df219fa8bfbb491bc2f3c021e3df2a8daa9bed03606e8d558624918d9d7104
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
5ce56abe0082fc610deec13f6907cdfd8be7346ce9a1c43c59e1828957bc3274
19c90c5f90d6a26be8f4762d42cbe2f9281e311acc61467bd104da7ec25e5545
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
be6dd0b46a0f407f354ae2b67c6304948c71749bc3587dcf81b39dd245afdb48
a9bf8c6074ffb5d5335f07d6e5f1af6108faec0ca6e282a85684b991995baefc
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
4fe7aec43c3009cd0567e93f2e80c135091269aa0a2fcea3ba00f43a24c5ec56
6519aa645fe62c9f963d1baf7cd0a36dd75787b37da46a7d1218f3983f828e79
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
7be4ce274fef38b5d4741646de970afd803fbf5ccffaa49236af1d15c8c870a9
a95e6ff3977bf25cf106f6641cd8333124bb8615a8c327baefa068e36dd3d2ee
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
0a02c2ec0444e5eccb68b2bfabeaaf7752d1bc2b22bc0f26883a2a35eeac7df7
674e164012c93a106a0c3977b1066d5a49524f8f8426fb09404e3b7a7340a629
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
10c214d40a6b43bb8b850bfe6d97c1a360ed748b5e95645354b3317806d7a538
f253276316839b418223b8292683e313d311ce41c9701c04c9c3fcc85e0638e5
+5 -4
View File
@@ -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.
@@ -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<View> => ({
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(<PluginView pluginId="p" view={kind} detail />);
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<void>((resolve) => {
finish = resolve;
}),
);
render(<PluginView pluginId="p" view="list" detail />);
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(<PluginView pluginId="p" view={kind} detail />);
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(<PluginView pluginId="p" view="plain" />);
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<ServiceTab[]>((resolve) => {
resolveA = resolve;
})
: tabs(key),
),
};
setPluginsBackend(b);
const ui = render(<ServicePane pluginId="p" view="v" itemKey="a" />);
await waitFor(() => expect(b.serviceDetail).toHaveBeenCalled());
ui.rerender(<ServicePane pluginId="p" view="v" itemKey="b" />);
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(<ServicePane pluginId="p" view="v" itemKey="a" />);
await screen.findByRole("tab", { name: "Log" });
ui.rerender(<ServicePane pluginId="p" view="v" itemKey="b" />);
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(<ServicePane pluginId="p" view="retry" itemKey="a" />);
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(<PluginView pluginId="p" view="list" detail />);
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<ServiceTab[]> => [
{ id: "log", label: "Log", body: { kind: "log", value: { program: "sh", args: [], toggles: [] } } },
]);
setPluginsBackend({ ...createMemoryPluginsBackend([]), serviceDetail, serviceLog: () => stop });
const view = (shown: boolean) => (
<ToolWindowShownProvider value={shown}>
<ServicePane pluginId="p" view="hidden" itemKey="a" />
</ToolWindowShownProvider>
);
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<typeof syncUiContributions>[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(<Component id={contribution.id} />);
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<void>((resolve) => {
finish = resolve;
}),
);
render(<PluginView pluginId="p" view="list" detail />);
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(<PluginView pluginId="p" view="list" detail />);
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();
});
@@ -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<PluginsBackend["viewEvent"]>(
() =>
new Promise<void>((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<PluginsBackend["viewEvent"]>(
() =>
new Promise<void>((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(<Detail item={node} />);
await waitFor(() => expect(viewEvent).toHaveBeenCalledOnce());
expect(serviceDetail).not.toHaveBeenCalled();
await act(async () => finish());
await waitFor(() => expect(serviceDetail).toHaveBeenCalledWith("p", "kube", "a"));
});
+1 -1
View File
@@ -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<View>;
viewEvent(pluginId: string, view: string, event: ViewEvent, doc: number | null): Promise<void>;
/** 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<ServiceTab[]>;
/** The HTTP routes a plugin reports for this window's project (`[[contributes.endpoints]]`). */
endpoints(pluginId: string): Promise<PluginEndpoint[]>;
+2 -1
View File
@@ -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 ? (
<WebView pluginId={pluginId} view={decl.id} label={decl.title} />
) : (
<PluginView pluginId={pluginId} view={decl.id} label={decl.title} itemMenu={itemMenu} />
<PluginView pluginId={pluginId} view={decl.id} detail={decl.detail} label={decl.title} itemMenu={itemMenu} />
),
index,
);
@@ -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);
}
+114 -19
View File
@@ -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 (
<VerbatimTextContext.Provider value={true}>
<PluginViewContent {...props} />
<PluginViewContent key={JSON.stringify([props.pluginId, props.view, props.doc, location])} {...props} />
</VerbatimTextContext.Provider>
);
}
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<View | null>(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<Viewport | null>(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<unknown> } | 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
</div>
)}
<div className={styles.body}>
<Body
content={content}
send={send}
pluginId={pluginId}
view={view}
onViewport={setViewport}
itemMenu={itemMenu}
/>
{hasDetail ? (
<SplitGroup
orientation="horizontal"
storageKey={`openidea.plugins.detailSplit.${JSON.stringify([pluginId, view])}`}
>
<SplitPanel id={`${splitId}-items`} defaultSize="40%" minSize="100px">
<div className={styles.master}>
<Body
content={content}
send={send}
pluginId={pluginId}
view={view}
onViewport={setViewport}
itemMenu={itemMenu}
selectedKey={selected?.key ?? null}
/>
</div>
</SplitPanel>
<SplitHandle />
<SplitPanel id={`${splitId}-detail`} minSize="100px">
{selected ? (
<ServicePane pluginId={pluginId} view={view} itemKey={selected.key} after={selected.after} />
) : (
<EmptyState title={tx("Select an item to view details")} />
)}
</SplitPanel>
</SplitGroup>
) : (
<Body
content={content}
send={send}
pluginId={pluginId}
view={view}
onViewport={setViewport}
itemMenu={itemMenu}
/>
)}
</div>
{content.status && <div className={styles.status}>{content.status}</div>}
</div>
@@ -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 <EmptyState className={styles.empty} title={body.value} />;
case "tree":
return <TreeBody items={body.value} send={send} itemMenu={itemMenu} />;
return <TreeBody items={body.value} send={send} itemMenu={itemMenu} selectedKey={selectedKey} />;
case "list":
return (
<ul className={styles.list} role="listbox">
@@ -180,12 +253,32 @@ function Body({ content, send, pluginId, view, onViewport, itemMenu }: BodyProps
<li
key={i.key}
role="option"
aria-selected={false}
aria-selected={i.key === selectedKey}
tabIndex={0}
className={styles.listItem}
onClick={() => 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 && <Icon name={i.icon} size={14} />}
@@ -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 })
}
+12 -8
View File
@@ -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<unknown>;
}
/** 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 (
<VerbatimTextContext.Provider value={true}>
<ServicePaneContent {...props} />
<ServicePaneContent key={JSON.stringify([props.pluginId, props.view, props.itemKey, location])} {...props} />
</VerbatimTextContext.Provider>
);
}
function ServicePaneContent({ pluginId, view, itemKey }: ServicePaneProps) {
function ServicePaneContent({ pluginId, view, itemKey, after }: ServicePaneProps) {
// `views.refresh(<view>)` asks for the pane again.
const revision = useViewRevision(pluginId, view);
const [tabs, setTabs] = useState<ServiceTab[] | null>(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 <EmptyState title={error} />;
if (!tabs) return <Spinner />;
+20 -6
View File
@@ -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<View | null>(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 <ServicePane pluginId={pluginId} view={decl.id} itemKey={rawKey(item)} />;
return <ServicePane pluginId={pluginId} view={decl.id} itemKey={rawKey(item)} after={queue} />;
}
}
+12
View File
@@ -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}`;
+3 -3
View File
@@ -205,8 +205,8 @@ export function ServicesTreeView() {
</button>
</div>
) : (
<SplitGroup orientation="horizontal" className={styles.split}>
<SplitPanel defaultSize="40%" minSize="160px">
<SplitGroup orientation="horizontal" className={styles.split} storageKey="openidea.services.detailSplit">
<SplitPanel id="services-items" defaultSize="40%" minSize="160px">
<div className={styles.tree}>
<Tree<ServiceTreeItem>
key={location}
@@ -258,7 +258,7 @@ export function ServicesTreeView() {
</div>
</SplitPanel>
<SplitHandle />
<SplitPanel minSize="200px">
<SplitPanel id="services-detail" minSize="200px">
{selected && Detail && shown ? (
<Detail key={selected.item.key} item={selected.item} />
) : (
+2
View File
@@ -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"
}
+1
View File
@@ -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 主目录或基目录重叠"
}
+47 -2
View File
@@ -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 <Group className={clsx(styles.group, className)} {...rest} />;
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<string, number>)
: undefined;
} catch {
return undefined;
}
});
if (!storageKey)
return (
<Group
className={clsx(styles.group, className)}
{...rest}
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
/>
);
return (
<Group
className={clsx(styles.group, className)}
{...rest}
defaultLayout={saved ?? defaultLayout}
onLayoutChanged={(layout, meta) => {
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) {