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]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-08 02:04:10 +08:00
1 джерело 4c6e535560
коміт da3d996e10
17 files changed
+1218 -82

No files matched your search

+116
Переглянути файл
@@ -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);
});
+191
Переглянути файл
@@ -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();
});
+2 -2
Переглянути файл
@@ -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();