From 3dae4ef729353cd4e39e5a7d50876efb991ac346 Mon Sep 17 00:00:00 2001 From: Deng Liu Date: Sun, 8 Nov 2026 11:35:57 -0800 Subject: [PATCH] feat(plugins): support secondary split on [[contributes.views]] (#2185) Plugin tool windows could only target the primary split of their dock, because the view was never registered as secondary. An optional secondary = true on [[contributes.views]], gated to plugin API 1.3, places a view in Left Bottom, Right Bottom or Bottom Right. Existing plugins stay where they are, and a user's saved layout wins. Co-authored-by: dengliu <5108773+dengliu@users.noreply.github.com> --- crates/ide-host/src/plugins/ui_points.rs | 10 ++++ crates/ide-host/tests/plugins.rs | 48 +++++++++++++++++++ plugins/README.md | 11 +++-- .../plugins/__tests__/viewWindows.test.tsx | 43 +++++++++++++++++ src/features/plugins/views/viewWindows.tsx | 7 ++- 5 files changed, 115 insertions(+), 4 deletions(-) diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs index 522602631..3bc19146f 100644 --- a/crates/ide-host/src/plugins/ui_points.rs +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -549,6 +549,10 @@ struct ViewDecl { icon: Option, #[serde(default = "right")] location: String, + /// Place the tool window in the secondary split of its dock (`left` → Left Bottom, + /// `right` → Right Bottom, `bottom` → Bottom Right). + #[serde(default, skip_serializing_if = "Option::is_none")] + secondary: Option, #[serde(default, skip_serializing_if = "Option::is_none")] group: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -598,6 +602,12 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result { return Err(format!("view {:?}: a services view is rendered by the plugin's code (no `web`)", d.id)); } } + if d.secondary.is_some() { + pkg.manifest.needs_api("contributes.views.secondary", 3)?; + if d.location == SERVICES_LOCATION { + return Err(format!("view {:?}: `secondary` does not apply to a services view", d.id)); + } + } if d.detail { pkg.manifest.needs_api(&format!("view {:?}: `detail`", d.id), 3)?; if d.web.is_some() { diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 432737d6f..bbfe79b91 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -5544,4 +5544,52 @@ fn view_header_none_needs_api_1_3_and_an_ungrouped_web_view() { let info = f.install(&pkg); assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error); assert_eq!(f.host.ui_items("views")[0].decl["header"], "none"); + + std::fs::write(pkg.join("plugin.toml"), manifest("^1.2", "web = \"web/index.html\"\nsecondary = true")).unwrap(); + let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err().message; + assert!(err.contains("new in plugin API 1.3"), "{err}"); + + std::fs::write( + pkg.join("plugin.toml"), + "id = \"headerless\"\nname = \"Headerless\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.3\"\n[[contributes.views]]\nid = \"panel\"\ntitle = \"Panel\"\nlocation = \"right\"\nsecondary = true\nweb = \"web/index.html\"\nheader = \"none\"\n", + ) + .unwrap(); + let info = f.install(&pkg); + assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error); + assert_eq!(f.host.ui_items("views")[0].decl["secondary"], true); +} + +#[test] +fn view_secondary_requires_api_1_3_and_a_boolean_tool_window_placement() { + let f = fixture(Limits::default()); + for location in ["left", "right", "bottom"] { + for fields in ["", "secondary = false", "secondary = true", "group = \"g\"\nsecondary = true"] { + 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:?}\n{fields}")) + }); + assert!(f.install(&pkg).error.is_none()); + let decl = &f.host.ui_items("views")[0].decl; + assert_eq!(decl["location"], location); + let expected = if fields.is_empty() { None } else { Some(!fields.contains("false")) }; + assert_eq!(decl.get("secondary").and_then(|v| v.as_bool()), expected); + } + } + for (api, location, value, message) in [ + ("^1.0", "right", "true", "new in plugin API 1.3"), + ("^1.2", "right", "false", "new in plugin API 1.3"), + ("^1.3", "services", "true", "does not apply to a services view"), + ("^1.3", "services", "false", "does not apply to a services view"), + ("^1.3", "bottom", "\"yes\"", "boolean"), + ("^1.3", "left", "1", "boolean"), + ] { + 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\"", &format!("location = {location:?}\nsecondary = {value}")) + }); + let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err(); + assert!(err.message.contains(message), "{api}, {location}, {value}: {err:?}"); + } } diff --git a/plugins/README.md b/plugins/README.md index 158a7de05..776622683 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -871,8 +871,13 @@ 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 +API 1.3 adds optional `group`, `when` and `secondary` fields to `[[contributes.views]]`; +using any of them requires `engines.openidea = "^1.3"`. `secondary = true` +places a `left`, `right` or `bottom` tool window in the secondary split of its +dock (**Left Bottom**, **Right Bottom** or **Bottom Right**). It defaults to +`false` for every view, including views with `header = "none"`. A saved user +placement takes precedence. Older IDEs that do not recognize `secondary` reject +the manifest at install; update the IDE to use it. `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 @@ -881,7 +886,7 @@ 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 +title, icon, default location and `secondary`, 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 diff --git a/src/features/plugins/__tests__/viewWindows.test.tsx b/src/features/plugins/__tests__/viewWindows.test.tsx index 4566ecc8d..39e8640b4 100644 --- a/src/features/plugins/__tests__/viewWindows.test.tsx +++ b/src/features/plugins/__tests__/viewWindows.test.tsx @@ -5,6 +5,7 @@ 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 { loadToolWindowLayout, moveToolWindow, resetToolWindowLayout, slotOfId } from "@/workbench/toolwindows/store"; import { registerWindowView, revealWindowView, type WindowView } from "../views/viewWindows"; const disposers: (() => void)[] = []; @@ -173,6 +174,48 @@ it("retains an opened tab's local state while switching tabs", async () => { expect(input).toHaveValue("retain this draft"); }); +it("keeps views primary unless secondary is explicitly true in every dock", () => { + for (const location of ["left", "right", "bottom"] as const) { + for (const secondary of [undefined, false, true]) { + for (const header of [undefined, "none"] as const) { + const id = `${location}-${secondary}-${header}`; + add("split", { id, location, secondary, header }); + expect(useContributions.getState().toolWindows.get(`plugin.split.${id}`)?.secondary).toBe(secondary === true); + } + } + } +}); + +it("uses the first group member's split even when hidden", () => { + add("spring", { id: "routes", group: "framework", secondary: true, when: "plugin.spring.ready" }); + add("spring", { id: "beans", group: "framework", secondary: false }); + expect(useContributions.getState().toolWindows.get(id)?.secondary).toBe(true); + setContext("plugin.spring.ready", true); + expect(useContributions.getState().toolWindows.get(id)?.secondary).toBe(true); +}); + +it("preserves a user's moved placement across registration and layout reload", () => { + localStorage.clear(); + resetToolWindowLayout(); + loadToolWindowLayout("/plugin-split-test"); + try { + const view = add("split", { id: "panel", location: "right", secondary: true }); + const id = "plugin.split.panel"; + expect(slotOfId(id)).toBe("right-split"); + moveToolWindow(id, "right"); + view.setIcon("folder"); + loadToolWindowLayout("/plugin-split-test"); + expect(slotOfId(id)).toBe("right"); + moveToolWindow(id, "bottom-split"); + view.setIcon("plugin"); + loadToolWindowLayout("/plugin-split-test"); + expect(slotOfId(id)).toBe("bottom-split"); + } finally { + resetToolWindowLayout(); + localStorage.clear(); + } +}); + 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" }); diff --git a/src/features/plugins/views/viewWindows.tsx b/src/features/plugins/views/viewWindows.tsx index 6f3c05afb..df4eeeb52 100644 --- a/src/features/plugins/views/viewWindows.tsx +++ b/src/features/plugins/views/viewWindows.tsx @@ -17,6 +17,8 @@ export interface WindowView { id: string; title: string; location: ToolWindowAnchor; + secondary?: boolean; + header?: "default" | "none" | null; group?: string | null; when?: string | null; } @@ -105,7 +107,9 @@ function refresh(id: string, group: WindowGroup) { members, selected: selected ?? members[0]?.decl.id ?? null, }); - const signature = first && members.length ? JSON.stringify([first.decl.title, first.decl.location, first.icon]) : ""; + const secondary = first?.decl.secondary === true; + const signature = + first && members.length ? JSON.stringify([first.decl.title, first.decl.location, secondary, first.icon]) : ""; if (signature === group.signature) return; group.dispose(); group.signature = signature; @@ -116,6 +120,7 @@ function refresh(id: string, group: WindowGroup) { title: first.decl.title, verbatim: true, anchor: first.decl.location, + secondary, icon: first.icon, order: 900, component: first.decl.group ? group.component : first.component,