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 <[email protected]>
Цей коміт міститься в:
Deng Liuтаdengliu authored and GitHub committed 2026-11-09 03:35:57 +08:00
1 джерело 067feac89c
коміт 3dae4ef729
5 files changed
+115 -4

No files matched your search

+10
Переглянути файл
@@ -549,6 +549,10 @@ struct ViewDecl {
icon: Option<String>,
#[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<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -598,6 +602,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.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() {
+48
Переглянути файл
@@ -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:?}");
}
}
+8 -3
Переглянути файл
@@ -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
+43
Переглянути файл
@@ -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" });
+6 -1
Переглянути файл
@@ -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,