дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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]>
Цей коміт міститься в:
1 джерело
4c6e535560
коміт
da3d996e10
17 files changed
+1218
-82
No files matched your search
@@ -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();
|
||||
|
||||
Reference in new issue
Заблокувати користувача