mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
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:
1 parent
7cedf0b9be
commit
4a78310f76
78 files changed
+1351
-85
No files matched your search
@@ -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:?}")))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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 />);
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
977835f3364613876b3f563877d7f0cf36d668226c5c6ac2280473c73d44117d
|
||||
f5d442002dd3315f14ed7ebe873a7d1c858fa31a2840aebc7876366269d5fd6c
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
55e3306fc2ff9456d79f02b63712c7f83b1fe4e75fec051f5fb96e7fb766066f
|
||||
0bebf24cc46dd2dac9c51fffcd4b9186f7e575c2d552e8d600c02fa5fe8fa9b0
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
f582b640e980e4b69e292ec425d0ed8113527119d18d0fa1ea73e7c34f1f89a1
|
||||
a4179ed839602e1d1697d738689d8936d22889b0d4e09ff953d6354a6d91f368
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
842de029af0767864b56dc71199a823ca0ea04aec5ece8928dac69340ff7ab7a
|
||||
eb5f3a257f2551a29c3ad62b222b0641b07b7b9422780e1ffecafa7e39b5032d
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
1dd92a9e139458813f513479cbc0f8187eff217f5dac2b39441537e3039e9dca
|
||||
74df219fa8bfbb491bc2f3c021e3df2a8daa9bed03606e8d558624918d9d7104
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
5ce56abe0082fc610deec13f6907cdfd8be7346ce9a1c43c59e1828957bc3274
|
||||
19c90c5f90d6a26be8f4762d42cbe2f9281e311acc61467bd104da7ec25e5545
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
be6dd0b46a0f407f354ae2b67c6304948c71749bc3587dcf81b39dd245afdb48
|
||||
a9bf8c6074ffb5d5335f07d6e5f1af6108faec0ca6e282a85684b991995baefc
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
4fe7aec43c3009cd0567e93f2e80c135091269aa0a2fcea3ba00f43a24c5ec56
|
||||
6519aa645fe62c9f963d1baf7cd0a36dd75787b37da46a7d1218f3983f828e79
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
7be4ce274fef38b5d4741646de970afd803fbf5ccffaa49236af1d15c8c870a9
|
||||
a95e6ff3977bf25cf106f6641cd8333124bb8615a8c327baefa068e36dd3d2ee
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
0a02c2ec0444e5eccb68b2bfabeaaf7752d1bc2b22bc0f26883a2a35eeac7df7
|
||||
674e164012c93a106a0c3977b1066d5a49524f8f8426fb09404e3b7a7340a629
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
10c214d40a6b43bb8b850bfe6d97c1a360ed748b5e95645354b3317806d7a538
|
||||
f253276316839b418223b8292683e313d311ce41c9701c04c9c3fcc85e0638e5
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user