feat(plugins): group views and apply visibility conditions (#2019)

Closes #1949. Plugin API 1.3: views from several plugins can share one tool window (shown as tabs) and a view can declare a visibility condition (e.g. only when the project contains a file, or for the active file) so it appears only when it applies. Additive within API 1.x, with a fixture plugin, regression tests and docs.

Co-authored-by: arnaud-ritti <[email protected]>
This commit is contained in:
Arnaud Ritti authored and GitHub committed 2026-11-08 02:04:10 +08:00
1 parent 4c6e535560
commit da3d996e10
17 files changed
+1218 -82

No files matched your search

+13
View File
@@ -399,6 +399,10 @@ struct ViewDecl {
icon: Option<String>,
#[serde(default = "right")]
location: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
when: Option<String>,
/// A web page of the package (`web/index.html`) instead of content the code renders.
#[serde(default, skip_serializing_if = "Option::is_none")]
web: Option<String>,
@@ -419,6 +423,15 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: ViewDecl = decode(VIEWS, decl)?;
validate_item_id(&d.id, "view id")?;
title(VIEWS, &d.title)?;
if let Some(group) = &d.group {
pkg.manifest.needs_api("contributes.views.group", 3)?;
validate_item_id(group, "view group")?;
}
if let Some(when) = &d.when {
pkg.manifest.needs_api("contributes.views.when", 3)?;
validate_when(when).map_err(|e| format!("contributes.views: {e}"))?;
}
let files = web_or_code(pkg, d.web.as_deref(), &format!("view {:?}", d.id))?;
if !["left", "right", "bottom", SERVICES_LOCATION].contains(&d.location.as_str()) {
return Err(format!("view {:?}: `location` is left, right, bottom or services", d.id));
+80
View File
@@ -2252,6 +2252,86 @@ fn view_item_menus_name_a_declared_view() {
assert!(err.message.contains("new in plugin API 1.2"), "{}", err.message);
}
#[test]
fn conditional_and_grouped_views_need_api_1_3() {
for field in ["group = \"spring\"", "when = \"workspaceContains:pom.xml || plugin.hello.ready\""] {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let old =
hello_variant(tmp.path(), |m| m.replace("location = \"right\"", &format!("location = \"right\"\n{field}")));
let err = f.host.install_from_dir(&old, &Consent::all()).unwrap_err();
assert!(err.message.contains("new in plugin API 1.3"), "{err:?}");
}
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = hello_variant(tmp.path(), |m| {
m.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"").replace(
"location = \"right\"",
"location = \"right\"\ngroup = \"spring\"\nwhen = \"workspaceContains:pom.xml || plugin.hello.ready\"",
)
});
let info = f.install(&pkg);
assert_eq!(info.status, PluginStatus::Inactive, "declaring views must not activate code");
let view = f.host.ui_items("views").remove(0);
assert_eq!(view.decl["group"], "spring");
assert_eq!(view.decl["when"], "workspaceContains:pom.xml || plugin.hello.ready");
f.host.set_enabled("hello", false).unwrap();
assert!(f.host.ui_items("views").is_empty());
}
#[test]
fn grouped_views_fixture_renders_and_scopes_context_and_reveal() {
let f = fixture(Limits::default());
let info = f.install(&sample("grouped-views"));
assert_eq!(info.status, PluginStatus::Inactive, "views stay lazy");
assert!(info.error.is_none(), "{:?}", info.error);
let views = f.host.ui_items("views");
assert_eq!(views.len(), 3);
assert_eq!(views[0].decl["group"], "demo");
assert_eq!(views[1].decl["when"], "workspaceContains:grouped-views.txt");
f.take_events();
f.run("grouped-views", "show", None).unwrap();
assert!(f.take_events().iter().any(|(client, event)| matches!(
event,
PluginEvent::ContextChanged { plugin_id, key, value }
if client.as_deref() == Some("main") && plugin_id == "grouped-views"
&& key == "ready" && value.as_deref() == Some("true")
)));
assert!(f.host.context_keys(Some("other")).is_empty());
for view in ["overview", "files", "legacy"] {
let rendered = f.host.render_view("grouped-views", view, None, f.inv()).unwrap();
assert!(matches!(rendered.body, ide_plugin::ViewBody::Message(_)));
f.run("grouped-views", "reveal", Some(view)).unwrap();
assert!(f.take_events().iter().any(|(client, event)| matches!(
event,
PluginEvent::RevealView { plugin_id, view: target }
if client.as_deref() == Some("main") && plugin_id == "grouped-views" && target == view
)));
}
assert!(f.run("grouped-views", "reveal", Some("plugin.hello.greetings")).is_err());
f.host.set_enabled("grouped-views", false).unwrap();
assert!(f.host.ui_items("views").is_empty());
assert!(f.host.context_keys(Some("main")).is_empty());
}
#[test]
fn view_conditions_and_group_ids_are_validated() {
for (field, message) in [
("group = \"\"", "invalid view group"),
("group = \"../other\"", "invalid view group"),
("when = \"ready && (\"", "when"),
] {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = hello_variant(tmp.path(), |m| {
m.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"")
.replace("location = \"right\"", &format!("location = \"right\"\n{field}"))
});
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains(message), "{field}: {err:?}");
}
}
/// The sample plugin makes a tool window with a tree view.
/// Its menus, shortcut and status bar item are declarations, listed before
/// any code runs; the tree is rendered by the code, which loads on first show.
+333
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.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-test-grouped-views"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,20 @@
[package]
name = "openidea-test-grouped-views"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Binary file not shown.
@@ -0,0 +1 @@
1dda19524ab7711b56b67f4b6ebcf62d3c29799aa8276ce7af188b5cabe956ad
@@ -0,0 +1,37 @@
id = "grouped-views"
name = "Grouped Views"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
activation = ["on-command:*", "on-view:*"]
[[contributes.commands]]
id = "show"
title = "Grouped Views: Show"
[[contributes.commands]]
id = "hide"
title = "Grouped Views: Hide"
[[contributes.commands]]
id = "reveal"
title = "Grouped Views: Reveal"
[[contributes.views]]
id = "overview"
title = "Grouped Views"
location = "right"
group = "demo"
when = "plugin.grouped-views.ready"
[[contributes.views]]
id = "files"
title = "Project Files"
location = "right"
group = "demo"
when = "workspaceContains:grouped-views.txt"
[[contributes.views]]
id = "legacy"
title = "Ungrouped View"
location = "left"
@@ -0,0 +1,34 @@
use openidea_plugin::views::{Body, View, ViewEvent, Viewport, Views};
use openidea_plugin::{Error, Invocation, Plugin, host, register_plugin};
#[derive(Default)]
struct GroupedViews;
impl Plugin for GroupedViews {
fn run_command(&mut self, id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
"show" => host::set_context("ready", true)?,
"hide" => host::set_context("ready", false)?,
"reveal" => host::reveal_view(arg.unwrap_or("overview"))?,
_ => return Err(Error::NotFound(id.into())),
}
Ok(None)
}
}
impl Views for GroupedViews {
fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> {
match view {
"overview" => Ok(View::new(Body::Message("Context condition is true".into()))),
"files" => Ok(View::new(Body::Message("Project contains grouped-views.txt".into()))),
"legacy" => Ok(View::new(Body::Message("Ungrouped view keeps its original window id".into()))),
_ => Err(Error::NotFound(view.into())),
}
}
fn handle(&mut self, _view: &str, _event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> {
Ok(())
}
}
register_plugin!(GroupedViews, views);
+66
View File
@@ -0,0 +1,66 @@
import { expect, test } from "./fixtures";
test("plugin view groups keep their own tabs and disappear when no view applies", async ({ workbench: page }) => {
await page.evaluate(async () => {
const backendModule = "/src/features/plugins/backend.ts";
const storeModule = "/src/features/plugins/store.ts";
const { createMemoryPluginsBackend, setPluginsBackend } = await import(/* @vite-ignore */ backendModule);
const { usePlugins } = await import(/* @vite-ignore */ storeModule);
const base = createMemoryPluginsBackend();
const template = (await base.list()).plugins[0];
const plugins = ["test.views", "test.other"].map((id) => ({ ...template, id, status: "inactive" }));
setPluginsBackend({
...base,
renderView: async (_pluginId: string, id: string) => ({
toolbar: [],
body: { kind: "message", value: `${id} content` },
status: null,
}),
});
const view = (pluginId: string, id: string, title: string, when?: string, group?: string) => ({
pluginId,
point: "views",
key: id,
decl: { id, title, location: "right", when, group },
});
usePlugins.setState({
plugins,
ui: [
view("test.views", "routes", "Framework", "plugin.test.views.ready", "framework"),
view("test.views", "beans", "Beans", "plugin.test.views.beans", "framework"),
view("test.views", "legacy", "Legacy"),
view("test.other", "routes", "Other framework", undefined, "framework"),
],
});
});
const group = page.locator('[data-stripe-button="plugin.test.views.group:framework"]');
const other = page.locator('[data-stripe-button="plugin.test.other.group:framework"]');
await expect(group).toHaveCount(0);
await expect(other).toBeVisible();
await expect(page.locator('[data-stripe-button="plugin.test.views.legacy"]')).toBeVisible();
const context = async (ready: boolean, beans: boolean) =>
page.evaluate(
async ({ ready, beans }) => {
const module = "/src/platform/context.ts";
const { setContext } = await import(/* @vite-ignore */ module);
setContext("plugin.test.views.ready", ready);
setContext("plugin.test.views.beans", beans);
},
{ ready, beans },
);
await context(true, true);
await expect(group).toBeVisible();
await group.click();
const pane = page.locator('[data-toolwindow="plugin.test.views.group:framework"]');
await expect(pane.getByRole("tab")).toHaveText(["Framework", "Beans"]);
await expect(pane.getByText("routes content")).toBeVisible();
await pane.getByRole("tab", { name: "Beans", exact: true }).click();
await expect(pane.getByText("beans content")).toBeVisible();
await context(true, false);
await expect(pane.getByRole("tab", { name: "Beans", exact: true })).toHaveCount(0);
await expect(pane.getByText("routes content")).toBeVisible();
await context(false, false);
await expect(group).toHaveCount(0);
await expect(pane).toHaveCount(0);
await expect(other).toBeVisible();
});
+60 -18
View File
@@ -137,24 +137,24 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
### Contribution points
| Point | Declaration | Code needed |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`, `commit.message`), `view?`, `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
| `views` | `id`, `title`, `icon?` (an icon name or a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_) | without `web` |
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
| `commitChecks` | `id`, `title` — a before-commit check in Commit Options (see _Commits_) | yes |
| Point | Declaration | Code needed |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`, `commit.message`), `view?`, `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
| `views` | `id`, `title`, `icon?` (an icon name or a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_), `group?`, `when?` (API 1.3) | without `web` |
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
| `commitChecks` | `id`, `title` — a before-commit check in Commit Options (see _Commits_) | yes |
A `viewItem.context` menu needs `view`, the id of one of the plugin's own
views. It adds the commands to the right-click menu of the items in that
@@ -695,6 +695,48 @@ tool window `ctx.editor()` is the window's active text editor, as for a
command, so a view's buttons can act on what the user is editing (a click in
the tool window doesn't change which editor that is).
API 1.3 adds optional `group` and `when` fields to `[[contributes.views]]`;
using either requires `engines.openidea = "^1.3"`. `when` uses the same
context clauses as commands and menus, including `workspaceContains:pom.xml`
and keys set with `set_context`. A false condition removes the view from the
stripe and its group tabs; context changes, file creation/removal and project
changes evaluate it again. Hidden views are not rendered or activated.
Tool-window views (`left`, `right` or `bottom`) with the same `group`
**within one plugin** share a tool window, with
tabs in manifest declaration order. The first declaration supplies the window's
title, icon and default location, even when that view is hidden; the window
is absent when all its tabs are hidden. Group ids follow the same rules as
view ids and are scoped to the declaring plugin. Tabs load on first selection
and keep their content mounted while they apply, preserving web-view state
when switching tabs. Ungrouped views keep their
existing window ids and placement. `reveal_view` selects a visible grouped
view's tab; it does not override its condition. Services views remain providers
of the built-in Services window, and also support `when`.
```toml
[[contributes.views]]
id = "endpoints"
title = "Spring"
group = "spring"
when = "workspaceContains:pom.xml || workspaceContains:build.gradle"
[[contributes.views]]
id = "beans"
title = "Beans"
group = "spring"
when = "plugin.my-plugin.ready"
```
The `crates/ide-plugin/tests/fixtures/grouped-views` sample exercises both
fields without requesting capabilities. Build it with
`plugins/build.sh crates/ide-plugin/tests/fixtures/grouped-views`, then install
that directory. Run **Grouped Views: Show** to expose the first tab, create
`grouped-views.txt` in the project root to expose **Project Files**, and run
**Grouped Views: Hide** or remove the file to withdraw those tabs. The
**Ungrouped View** window stays separate. These fields control visibility;
they grant no access to project contents or another plugin's views.
`layout` puts a custom editor beside the text editor instead of in a tab of
its own: with `"split"` or `"preview"` the file opens in the text editor,
with your editor next to it (the layout files start in) and the _Editor Only /
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { PluginInfo, UiContribution } from "@/bindings";
import { setContext, useContextKeys } from "@/platform/context";
import { useContributions, useServiceProviders } from "@/workbench/contributions";
import { createMemoryPluginsBackend, setPluginsBackend } from "../backend";
import { clearUiContributions, syncUiContributions } from "../projection";
import { defineViewUiPoints } from "../uiPoints";
const plugins = [{ id: "spring", status: "inactive" }] as PluginInfo[];
const groupId = "plugin.spring.group:framework";
const view = (id: string, extra = {}): UiContribution => ({
pluginId: "spring",
point: "views",
key: id,
decl: { id, title: id, location: "right", group: "framework", ...extra },
});
let stop: () => void;
beforeEach(() => {
clearUiContributions();
useContextKeys.setState({}, true);
stop = defineViewUiPoints();
});
afterEach(() => {
cleanup();
clearUiContributions();
stop();
setPluginsBackend(null);
});
it("does not activate inactive plugins for declarations or hidden and unselected views", async () => {
const renderView = vi.fn(async (_pluginId: string, id: string) => ({
toolbar: [],
body: { kind: "message" as const, value: `${id} content` },
status: null,
}));
setPluginsBackend({ ...createMemoryPluginsBackend([]), renderView });
const items = [view("routes", { when: "plugin.spring.ready" }), view("beans")];
syncUiContributions(items, plugins);
expect(renderView).not.toHaveBeenCalled();
const window = useContributions.getState().toolWindows.get(groupId)!;
render(<window.component id={groupId} />);
expect(await screen.findByText("beans content")).toBeInTheDocument();
expect(renderView.mock.calls.map((c) => c[1])).toEqual(["beans"]);
act(() => setContext("plugin.spring.ready", true));
expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["routes", "beans"]);
expect(renderView.mock.calls.map((c) => c[1])).toEqual(["beans"]);
await userEvent.click(screen.getByRole("tab", { name: "routes" }));
expect(await screen.findByText("routes content")).toBeInTheDocument();
await userEvent.click(screen.getByRole("tab", { name: "beans" }));
expect(screen.getByText("beans content")).toBeVisible();
expect(renderView.mock.calls.map((c) => c[1])).toEqual(["beans", "routes"]);
act(() => syncUiContributions([], plugins));
expect(useContributions.getState().toolWindows.has(groupId)).toBe(false);
act(() => setContext("plugin.spring.ready", false));
expect(useContributions.getState().toolWindows.has(groupId)).toBe(false);
});
it("keeps declaration order when a single view changes", () => {
const items = [view("routes"), view("beans")];
syncUiContributions(items, plugins);
syncUiContributions([view("routes", { title: "Updated routes" }), items[1]!], plugins);
const window = useContributions.getState().toolWindows.get(groupId)!;
expect(window.title).toBe("Updated routes");
// The content is loaded lazily; observing tabs must not call the plugin here.
setPluginsBackend(createMemoryPluginsBackend([]));
render(<window.component id={groupId} />);
expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["Updated routes", "beans"]);
});
it("follows manifest tab reordering on reload", () => {
setPluginsBackend(createMemoryPluginsBackend([]));
const items = [view("routes"), view("beans")];
syncUiContributions(items, plugins);
syncUiContributions([...items].reverse(), plugins);
const window = useContributions.getState().toolWindows.get(groupId)!;
expect(window.title).toBe("beans");
render(<window.component id={groupId} />);
expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["beans", "routes"]);
});
it("does not re-project existing windows when another plugin adds declarations", () => {
const items = [view("routes"), view("beans")];
syncUiContributions(items, plugins);
const before = useContributions.getState().toolWindows.get(groupId);
const other = { ...view("other"), pluginId: "other" };
syncUiContributions([other, ...items], [...plugins, { ...plugins[0]!, id: "other" }]);
expect(useContributions.getState().toolWindows.get(groupId)).toBe(before);
});
it("evaluates conditions on Services providers too", () => {
syncUiContributions([view("services", { location: "services", when: "plugin.spring.ready" })], plugins);
expect(useServiceProviders.getState().providers.has("plugin.spring.services")).toBe(false);
setContext("plugin.spring.ready", true);
expect(useServiceProviders.getState().providers.has("plugin.spring.services")).toBe(true);
setContext("plugin.spring.ready", false);
expect(useServiceProviders.getState().providers.has("plugin.spring.services")).toBe(false);
expect(useContributions.getState().toolWindows.has(groupId)).toBe(false);
});
it("does not resurrect disposed views when asynchronous SVG loading finishes", async () => {
let finish!: (assets: [string, string][]) => void;
const pending = new Promise<[string, string][]>((resolve) => {
finish = resolve;
});
setPluginsBackend({ ...createMemoryPluginsBackend([]), assets: () => pending });
syncUiContributions([view("routes", { icon: "icons/routes.svg" })], plugins);
expect(useContributions.getState().toolWindows.has(groupId)).toBe(true);
clearUiContributions();
finish([["viewIcon/routes", '<svg xmlns="http://www.w3.org/2000/svg"/>']]);
await pending;
expect(useContributions.getState().toolWindows.has(groupId)).toBe(false);
});
@@ -0,0 +1,191 @@
import { useEffect } from "react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { setLocale, tx } from "@/platform/i18n";
import { setContext, useContextKeys } from "@/platform/context";
import { provideToolWindowService, useContributions } from "@/workbench/contributions";
import { registerWindowView, revealWindowView, type WindowView } from "../views/viewWindows";
const disposers: (() => void)[] = [];
const show = vi.fn();
const id = "plugin.spring.group:framework";
function add(pluginId: string, decl: Partial<WindowView> & Pick<WindowView, "id">) {
const mount = vi.fn();
const registration = registerWindowView(
pluginId,
{ title: decl.id, location: "right", ...decl },
"plugin",
function Body() {
useEffect(() => {
mount();
}, [mount]);
return <p>{decl.id} body</p>;
},
);
disposers.push(registration.dispose);
return { ...registration, mount };
}
beforeEach(() => {
useContextKeys.setState({}, true);
show.mockClear();
provideToolWindowService({ show } as never);
});
afterEach(() => {
cleanup();
disposers
.splice(0)
.reverse()
.forEach((dispose) => dispose());
});
it("collects one plugin's views in declaration order and renders only the selected tab", async () => {
const first = add("spring", { id: "routes", title: "Routes", location: "left", group: "framework" });
const second = add("spring", { id: "beans", title: "Beans", group: "framework" });
const window = useContributions.getState().toolWindows.get(id)!;
expect(window).toMatchObject({ title: "Routes", anchor: "left", order: 900 });
expect([...useContributions.getState().toolWindows.keys()].filter((k) => k.startsWith("plugin.spring"))).toEqual([
id,
]);
render(<window.component id={id} />);
expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["Routes", "Beans"]);
expect(first.mount).toHaveBeenCalledOnce();
expect(second.mount).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("tab", { name: "Beans" }));
expect(screen.getByText("beans body")).toBeInTheDocument();
expect(screen.getByText("routes body")).not.toBeVisible();
});
it("keeps other plugins and ungrouped views separate, even when their ids equal a group", () => {
add("spring", { id: "routes", group: "framework" });
add("other", { id: "routes", group: "framework" });
add("spring", { id: "framework" });
expect(useContributions.getState().toolWindows.has(id)).toBe(true);
expect(useContributions.getState().toolWindows.has("plugin.other.group:framework")).toBe(true);
expect(useContributions.getState().toolWindows.has("plugin.spring.framework")).toBe(true);
});
it("reacts to context changes, hides empty groups, and preserves group metadata and active tabs", async () => {
add("spring", { id: "routes", title: "Routes", group: "framework", when: "plugin.spring.ready" });
add("spring", { id: "beans", title: "Beans", group: "framework", when: "workspaceContains:pom.xml" });
expect(useContributions.getState().toolWindows.has(id)).toBe(false);
act(() => setContext("workspaceContains:pom.xml", true));
const window = useContributions.getState().toolWindows.get(id)!;
expect(window.title).toBe("Routes");
render(<window.component id={id} />);
expect(screen.queryByRole("tab", { name: "Routes" })).toBeNull();
expect(screen.getByText("beans body")).toBeInTheDocument();
act(() => setContext("plugin.spring.ready", true));
await userEvent.click(screen.getByRole("tab", { name: "Beans" }));
act(() => setContext("unrelated", true));
expect(useContributions.getState().toolWindows.get(id)).toBe(window);
expect(screen.getByRole("tab", { name: "Beans" })).toHaveAttribute("aria-selected", "true");
act(() => setContext("workspaceContains:pom.xml", false));
expect(screen.getByText("routes body")).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "Beans" })).toBeNull();
act(() => setContext("plugin.spring.ready", false));
expect(useContributions.getState().toolWindows.has(id)).toBe(false);
});
it("keeps legacy ungrouped window ids and removes conditional views until they apply", () => {
add("legacy", { id: "always" });
add("legacy", { id: "maybe", when: "plugin.legacy.ready" });
expect(useContributions.getState().toolWindows.has("plugin.legacy.always")).toBe(true);
expect(useContributions.getState().toolWindows.has("plugin.legacy.maybe")).toBe(false);
setContext("plugin.legacy.ready", true);
expect(useContributions.getState().toolWindows.has("plugin.legacy.maybe")).toBe(true);
setContext("plugin.legacy.ready", false);
expect(useContributions.getState().toolWindows.has("plugin.legacy.maybe")).toBe(false);
});
it("reveals the requested grouped tab and never reveals hidden or removed views", () => {
add("spring", { id: "routes", group: "framework" });
const second = add("spring", { id: "beans", group: "framework", when: "plugin.spring.ready" });
revealWindowView("spring", "beans");
expect(show).not.toHaveBeenCalled();
setContext("plugin.spring.ready", true);
const window = useContributions.getState().toolWindows.get(id)!;
render(<window.component id={id} />);
act(() => revealWindowView("spring", "beans"));
expect(screen.getByText("beans body")).toBeInTheDocument();
expect(show).toHaveBeenCalledWith(id, true);
show.mockClear();
act(() => second.dispose());
revealWindowView("spring", "beans");
expect(show).not.toHaveBeenCalled();
expect(screen.getByText("routes body")).toBeInTheDocument();
// Avoid disposing the same declaration twice.
disposers.pop();
});
it("updates SVG icons without changing tab order and withdraws registrations on disposal", () => {
const first = add("spring", { id: "routes", group: "framework" });
add("spring", { id: "beans", group: "framework" });
first.setIcon("ext:spring/view-routes");
const window = useContributions.getState().toolWindows.get(id)!;
expect(window.icon).toBe("ext:spring/view-routes");
render(<window.component id={id} />);
expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["routes", "beans"]);
cleanup();
disposers
.splice(0)
.reverse()
.forEach((dispose) => dispose());
expect(useContributions.getState().toolWindows.has(id)).toBe(false);
setContext("plugin.spring.ready", true);
expect(useContributions.getState().toolWindows.has(id)).toBe(false);
});
it("shows plugin tab labels verbatim in a translated IDE", async () => {
await act(() => setLocale("zh"));
try {
add("spring", { id: "routes", title: "Refresh", group: "framework" });
const window = useContributions.getState().toolWindows.get(id)!;
expect(window.verbatim).toBe(true);
render(<window.component id={id} />);
expect(screen.getByRole("tab", { name: "Refresh" })).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: tx("Refresh") })).toBeNull();
} finally {
await act(() => setLocale("en"));
}
});
it("retains an opened tab's local state while switching tabs", async () => {
const first = registerWindowView(
"spring",
{ id: "draft", title: "Draft", location: "right", group: "framework" },
"plugin",
() => <input aria-label="Plugin draft" />,
);
disposers.push(first.dispose);
add("spring", { id: "beans", title: "Beans", group: "framework" });
const window = useContributions.getState().toolWindows.get(id)!;
render(<window.component id={id} />);
const input = screen.getByRole("textbox", { name: "Plugin draft" });
await userEvent.type(input, "retain this draft");
await userEvent.click(screen.getByRole("tab", { name: "Beans" }));
expect(input).not.toBeVisible();
await userEvent.click(screen.getByRole("tab", { name: "Draft" }));
expect(screen.getByRole("textbox", { name: "Plugin draft" })).toBe(input);
expect(input).toHaveValue("retain this draft");
});
it("discards a conditionally hidden tab and keeps it lazy when it becomes available again", async () => {
setContext("plugin.spring.ready", true);
add("spring", { id: "routes", title: "Routes", group: "framework" });
add("spring", { id: "beans", title: "Beans", group: "framework", when: "plugin.spring.ready" });
const window = useContributions.getState().toolWindows.get(id)!;
render(<window.component id={id} />);
await userEvent.click(screen.getByRole("tab", { name: "Beans" }));
await userEvent.click(screen.getByRole("tab", { name: "Routes" }));
expect(screen.getByText("beans body")).not.toBeVisible();
act(() => setContext("plugin.spring.ready", false));
expect(screen.queryByText("beans body")).toBeNull();
act(() => setContext("plugin.spring.ready", true));
expect(screen.queryByText("beans body")).toBeNull();
await userEvent.click(screen.getByRole("tab", { name: "Beans" }));
expect(screen.getByText("beans body")).toBeVisible();
});
@@ -139,7 +139,7 @@ describe("workspaceContains", () => {
});
});
describe("startWorkspaceContains", () => {
describe.each(["menus", "views"])("startWorkspaceContains (%s)", (point) => {
const key = "workspaceContains:.devcontainer/devcontainer.json";
const setProject = (root: string) => useProject.setState({ project: { root, workspace: null } as never });
const keyIs = (value: boolean) => vi.waitFor(() => expect(getContext()[key]).toBe(value));
@@ -156,7 +156,7 @@ describe("startWorkspaceContains", () => {
ipc.files = { "/p": ["src"], "/q": [".devcontainer"], "/q/.devcontainer": ["devcontainer.json"] };
ipc.readDir.mockReset();
ipc.readDir.mockImplementation(async (dir: string) => (ipc.files[dir] ?? []).map((name) => ({ name })));
usePlugins.setState({ ui: [{ pluginId: "x", point: "menus", key: "k", decl: { when: key } }] as never });
usePlugins.setState({ ui: [{ pluginId: "x", point, key: "k", decl: { when: key } }] as never });
setProject("/p");
setContext(key, undefined);
stop = startWorkspaceContains();
+16 -4
View File
@@ -23,11 +23,15 @@ export interface UiItem<T = Record<string, unknown>> {
export interface ProjectionContext {
plugin: PluginInfo;
/** Position within this plugin's declarations of this point. */
index: number;
}
export interface UiPointDefinition<T = Record<string, unknown>> {
/** Makes one item take effect; returns its disposer. */
project?: (item: UiItem<T>, ctx: ProjectionContext) => (() => void) | void;
/** Re-project when its position in the declaration snapshot changes. */
orderSensitive?: boolean;
/** Optional section in Settings › Plugins › details for this point's items. */
Details?: ComponentType<{ plugin: PluginInfo; items: UiItem<T>[] }>;
/** Section title for the details page (defaults to the point id). */
@@ -79,8 +83,12 @@ export function syncUiContributions(items: readonly UiContribution[], plugins: r
lastItems = items;
lastPlugins = plugins;
const byId = new Map(plugins.map((p) => [p.id, p]));
const next = new Map<string, { item: UiContribution; sig: string }>();
const next = new Map<string, { item: UiContribution; sig: string; index: number }>();
const indices = new Map<string, number>();
for (const item of items) {
const scope = `${item.pluginId}\u0000${item.point}`;
const index = indices.get(scope) ?? 0;
indices.set(scope, index + 1);
if (!points.get(item.point)?.project) {
if (!points.has(item.point) && !warned.has(item.point)) {
warned.add(item.point);
@@ -89,7 +97,11 @@ export function syncUiContributions(items: readonly UiContribution[], plugins: r
continue;
}
if (!byId.has(item.pluginId)) continue;
next.set(itemKey(item), { item, sig: JSON.stringify(item.decl) });
next.set(itemKey(item), {
item,
sig: JSON.stringify(points.get(item.point)?.orderSensitive ? [item.decl, index] : item.decl),
index,
});
}
for (const [key, entry] of live) {
if (next.get(key)?.sig !== entry.sig) {
@@ -97,12 +109,12 @@ export function syncUiContributions(items: readonly UiContribution[], plugins: r
live.delete(key);
}
}
for (const [key, { item, sig }] of next) {
for (const [key, { item, sig, index }] of next) {
if (live.has(key)) continue;
const def = points.get(item.point)!;
const plugin = byId.get(item.pluginId)!;
try {
const dispose = def.project!(item as UiItem<never>, { plugin }) ?? (() => {});
const dispose = def.project!(item as UiItem<never>, { plugin, index }) ?? (() => {});
live.set(key, { sig, dispose });
} catch (e) {
// The backend validated the declaration: a failing projection is a defect.
+4 -9
View File
@@ -25,19 +25,14 @@ import { useProject } from "@/platform/project";
import { ipcPath } from "@/platform/paths";
import { shortcutLabel } from "@/platform/keymap";
import { BarButton } from "@/ui/BarButton";
import {
notifications,
maybeCommitService,
maybeEditorService,
registerStatusItem,
toolWindowService,
} from "@/workbench/contributions";
import { notifications, maybeCommitService, maybeEditorService, registerStatusItem } from "@/workbench/contributions";
import { pickItems, promptInput } from "@/workbench/inputDialogs";
import { confirm } from "@/workbench/messages";
import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
import { pluginsBackend } from "./backend";
import { usePluginT } from "./pluginText";
import { bumpView, deliverViewMessage, pluginToolWindowId } from "./views/viewStore";
import { bumpView, deliverViewMessage } from "./views/viewStore";
import { revealWindowView } from "./views/viewWindows";
import { runPluginCommand, usePlugins } from "./store";
// --- status bar items ---------------------------------------------------------
@@ -448,7 +443,7 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
void answerCommit(e.requestId, e.request);
return true;
case "revealView":
toolWindowService().show(pluginToolWindowId(e.pluginId, e.view), true);
revealWindowView(e.pluginId, e.view);
return true;
default:
return false;
+66 -49
View File
@@ -17,7 +17,7 @@
import { useMemo } from "react";
import type { PluginInfo } from "@/bindings";
import { getAction } from "@/platform/actions";
import { evaluateWhen, getContext } from "@/platform/context";
import { evaluateWhen, getContext, useContextKeys } from "@/platform/context";
import { tx } from "@/platform/i18n";
import { isMacHost, registerExtensionBindings } from "@/platform/keymap";
import {
@@ -40,7 +40,6 @@ import {
registerServiceProvider,
registerSettingsPage,
registerTextEditorWrapper,
registerToolWindow,
useSettingsForm,
type ContextMenuId,
type ContributedCommitProblem,
@@ -52,7 +51,7 @@ import { usePlugins } from "./store";
import { pluginActionId } from "./points";
import { defineUiPoint } from "./projection";
import { declareStatusItem } from "./runtimeUi";
import { pluginToolWindowId } from "./views/viewStore";
import { registerWindowView } from "./views/viewWindows";
import {
CUSTOM_EDITOR_KIND,
CustomEditor,
@@ -366,6 +365,8 @@ interface ViewDecl {
icon?: string | null;
location: ToolWindowAnchor | "services";
web?: string;
group?: string | null;
when?: string | null;
}
interface FileIconDecl {
@@ -430,56 +431,71 @@ function projectFileIcon(pluginId: string, decl: FileIconDecl): () => void {
};
}
function registerView(pluginId: string, decl: ViewDecl, icon: string): () => void {
if (decl.location === "services") {
const itemMenu = (key: string) => pluginViewItemMenuItems(pluginId, decl.id, key);
return registerServiceProvider(pluginServiceProvider(pluginId, decl, icon, itemMenu));
}
return registerToolWindow({
id: pluginToolWindowId(pluginId, decl.id),
title: decl.title,
verbatim: true,
icon,
anchor: decl.location,
order: 900,
component: () =>
decl.web ? (
<WebView pluginId={pluginId} view={decl.id} label={decl.title} />
) : (
<PluginView
pluginId={pluginId}
view={decl.id}
label={decl.title}
itemMenu={(key) => pluginViewItemMenuItems(pluginId, decl.id, key)}
/>
),
});
}
function projectView(pluginId: string, decl: ViewDecl): () => void {
if (!decl.icon?.endsWith(".svg")) return registerView(pluginId, decl, decl.icon ?? "plugin");
function projectView(pluginId: string, decl: ViewDecl, index: number): () => void {
const initialIcon = decl.icon?.endsWith(".svg") ? "plugin" : (decl.icon ?? "plugin");
let disposed = false;
let undo = () => {};
void pluginsBackend()
.assets(pluginId, `viewIcon/${decl.id}`)
.then((assets) => {
if (disposed) return;
const svg = assets.find(([id]) => id === `viewIcon/${decl.id}`)?.[1];
const name = `ext:${pluginId}/view-${decl.id}`;
const unregister = svg ? registerExternalIcons([[name, svg]]) : () => {};
const remove = registerView(pluginId, decl, svg ? name : "plugin");
undo = () => {
let unregisterIcon = () => {};
const itemMenu = (key: string) => pluginViewItemMenuItems(pluginId, decl.id, key);
let setIcon: (icon: string) => void;
let undo: () => void;
if (decl.location === "services") {
let icon = initialIcon;
let remove = () => {};
let visible = false;
const refresh = () => {
const next = evaluateWhen(decl.when ?? undefined);
if (next === visible) return;
remove();
visible = next;
remove = next ? registerServiceProvider(pluginServiceProvider(pluginId, decl, icon, itemMenu)) : () => {};
};
const unsubscribe = useContextKeys.subscribe(refresh);
refresh();
setIcon = (next) => {
icon = next;
if (visible) {
remove();
unregister();
};
})
.catch((e: unknown) => {
console.warn(`plugin ${pluginId}: view icon ${decl.id}`, e);
if (!disposed) undo = registerView(pluginId, decl, "plugin");
});
visible = false;
refresh();
}
};
undo = () => {
unsubscribe();
remove();
};
} else {
const registration = registerWindowView(
pluginId,
{ ...decl, location: decl.location },
initialIcon,
() =>
decl.web ? (
<WebView pluginId={pluginId} view={decl.id} label={decl.title} />
) : (
<PluginView pluginId={pluginId} view={decl.id} label={decl.title} itemMenu={itemMenu} />
),
index,
);
setIcon = registration.setIcon;
undo = registration.dispose;
}
if (decl.icon?.endsWith(".svg")) {
void pluginsBackend()
.assets(pluginId, `viewIcon/${decl.id}`)
.then((assets) => {
if (disposed) return;
const svg = assets.find(([id]) => id === `viewIcon/${decl.id}`)?.[1];
if (!svg) return;
const name = `ext:${pluginId}/view-${decl.id}`;
unregisterIcon = registerExternalIcons([[name, svg]]);
setIcon(name);
})
.catch((e: unknown) => console.warn(`plugin ${pluginId}: view icon ${decl.id}`, e));
}
return () => {
disposed = true;
undo();
unregisterIcon();
};
}
@@ -518,8 +534,9 @@ export function defineViewUiPoints(): () => void {
}),
}),
defineUiPoint<ViewDecl>("views", {
orderSensitive: true,
title: "Tool Windows",
project: ({ pluginId, decl }) => projectView(pluginId, decl),
project: ({ pluginId, decl }, { index }) => projectView(pluginId, decl, index),
}),
defineUiPoint<CustomEditorDecl>("customEditors", {
title: "Editors",
+179
View File
@@ -0,0 +1,179 @@
import { useEffect, useState, type FunctionComponent } from "react";
import { create, type StoreApi, type UseBoundStore } from "zustand";
import { evaluateWhen, useContextKeys } from "@/platform/context";
import { VerbatimTextContext } from "@/platform/i18n";
import { Tabs } from "@/ui";
import {
registerToolWindow,
toolWindowService,
ToolWindowShownProvider,
useToolWindowShown,
type ToolWindowAnchor,
} from "@/workbench/contributions";
import { pluginToolWindowId, viewKey } from "./viewStore";
import styles from "./PluginView.module.css";
export interface WindowView {
id: string;
title: string;
location: ToolWindowAnchor;
group?: string | null;
when?: string | null;
}
interface Member {
decl: WindowView;
icon: string;
order: number;
component: FunctionComponent;
}
interface WindowState {
members: Member[];
selected: string | null;
}
interface WindowGroup {
members: Map<string, Member>;
state: UseBoundStore<StoreApi<WindowState>>;
dispose: () => void;
unsubscribe: () => void;
signature: string;
component: FunctionComponent;
}
const groups = new Map<string, WindowGroup>();
const windowsByView = new Map<string, { window: string; group: WindowGroup }>();
function GroupView({ group, id }: { group: WindowGroup; id: string }) {
const { members, selected } = group.state();
const active = members.find((m) => m.decl.id === selected) ?? members[0];
const activeId = active?.decl.id;
const shown = useToolWindowShown();
// Like separate tool windows, opened tabs retain their local state until withdrawn.
const [visited, setVisited] = useState<ReadonlySet<string>>(() => new Set());
useEffect(() => {
setVisited((previous) => {
const next = new Set(
members.filter((m) => previous.has(m.decl.id) || m.decl.id === activeId).map((m) => m.decl.id),
);
return next.size === previous.size && [...next].every((id) => previous.has(id)) ? previous : next;
});
}, [members, activeId]);
if (!active) return null;
return (
<VerbatimTextContext.Provider value={true}>
<div className={styles.view}>
<Tabs
variant="compact"
panelIdPrefix={id}
items={members.map(({ decl }) => ({ id: decl.id, label: decl.title }))}
activeId={active.decl.id}
onSelect={(id) => group.state.setState({ selected: id })}
/>
{members
.filter((m) => visited.has(m.decl.id) || m.decl.id === activeId)
.map((member) => {
const Body = member.component;
const selected = member.decl.id === activeId;
return (
<div
key={member.decl.id}
className={styles.body}
role="tabpanel"
id={`${id}-panel-${member.decl.id}`}
aria-labelledby={`${id}-tab-${member.decl.id}`}
hidden={!selected}
>
<ToolWindowShownProvider value={shown && selected}>
<Body />
</ToolWindowShownProvider>
</div>
);
})}
</div>
</VerbatimTextContext.Provider>
);
}
function refresh(id: string, group: WindowGroup) {
const ordered = [...group.members.values()].sort((a, b) => a.order - b.order);
const first = ordered[0];
const members = ordered.filter((m) => evaluateWhen(m.decl.when ?? undefined));
const selected = group.state.getState().selected;
group.state.setState({
members,
selected: selected ?? members[0]?.decl.id ?? null,
});
const signature = first && members.length ? JSON.stringify([first.decl.title, first.decl.location, first.icon]) : "";
if (signature === group.signature) return;
group.dispose();
group.signature = signature;
group.dispose =
first && members.length
? registerToolWindow({
id,
title: first.decl.title,
verbatim: true,
anchor: first.decl.location,
icon: first.icon,
order: 900,
component: first.decl.group ? group.component : first.component,
})
: () => {};
}
/** Group names are scoped to their plugin and cannot collide with an ungrouped view id. */
export function registerWindowView(
pluginId: string,
decl: WindowView,
icon: string,
component: FunctionComponent,
order = 0,
) {
const id = decl.group ? `plugin.${pluginId}.group:${decl.group}` : pluginToolWindowId(pluginId, decl.id);
let group = groups.get(id);
if (!group) {
group = {
members: new Map(),
state: create<WindowState>(() => ({ members: [], selected: null })),
dispose: () => {},
unsubscribe: () => {},
signature: "",
component: () => null,
};
const current = group;
group.component = () => <GroupView group={current} id={id} />;
group.unsubscribe = useContextKeys.subscribe(() => refresh(id, current));
groups.set(id, group);
}
const current = group;
const member = { decl, icon, component, order };
current.members.set(decl.id, member);
windowsByView.set(viewKey(pluginId, decl.id), { window: id, group: current });
refresh(id, current);
return {
setIcon(next: string) {
member.icon = next;
refresh(id, current);
},
dispose() {
current.members.delete(decl.id);
windowsByView.delete(viewKey(pluginId, decl.id));
if (current.members.size) refresh(id, current);
else {
current.dispose();
current.unsubscribe();
groups.delete(id);
}
},
};
}
/** A hidden view stays hidden; revealing a grouped view selects its own tab. */
export function revealWindowView(pluginId: string, view: string) {
const entry = windowsByView.get(viewKey(pluginId, view));
if (!entry?.group.state.getState().members.some((m) => m.decl.id === view)) return;
entry.group.state.setState({ selected: view });
toolWindowService().show(entry.window, true);
}