дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
067feac89c
коміт
3dae4ef729
5 files changed
+115
-4
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Заблокувати користувача