дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +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();
|
||||
|
||||
@@ -23,11 +23,15 @@ export interface UiItem<T = Record<string, unknown>> {
|
||||
|
||||
export interface ProjectionContext {
|
||||
plugin: PluginInfo;
|
||||
/** Position within this plugin's declarations of this point. */
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface UiPointDefinition<T = Record<string, unknown>> {
|
||||
/** Makes one item take effect; returns its disposer. */
|
||||
project?: (item: UiItem<T>, ctx: ProjectionContext) => (() => void) | void;
|
||||
/** Re-project when its position in the declaration snapshot changes. */
|
||||
orderSensitive?: boolean;
|
||||
/** Optional section in Settings › Plugins › details for this point's items. */
|
||||
Details?: ComponentType<{ plugin: PluginInfo; items: UiItem<T>[] }>;
|
||||
/** Section title for the details page (defaults to the point id). */
|
||||
@@ -79,8 +83,12 @@ export function syncUiContributions(items: readonly UiContribution[], plugins: r
|
||||
lastItems = items;
|
||||
lastPlugins = plugins;
|
||||
const byId = new Map(plugins.map((p) => [p.id, p]));
|
||||
const next = new Map<string, { item: UiContribution; sig: string }>();
|
||||
const next = new Map<string, { item: UiContribution; sig: string; index: number }>();
|
||||
const indices = new Map<string, number>();
|
||||
for (const item of items) {
|
||||
const scope = `${item.pluginId}\u0000${item.point}`;
|
||||
const index = indices.get(scope) ?? 0;
|
||||
indices.set(scope, index + 1);
|
||||
if (!points.get(item.point)?.project) {
|
||||
if (!points.has(item.point) && !warned.has(item.point)) {
|
||||
warned.add(item.point);
|
||||
@@ -89,7 +97,11 @@ export function syncUiContributions(items: readonly UiContribution[], plugins: r
|
||||
continue;
|
||||
}
|
||||
if (!byId.has(item.pluginId)) continue;
|
||||
next.set(itemKey(item), { item, sig: JSON.stringify(item.decl) });
|
||||
next.set(itemKey(item), {
|
||||
item,
|
||||
sig: JSON.stringify(points.get(item.point)?.orderSensitive ? [item.decl, index] : item.decl),
|
||||
index,
|
||||
});
|
||||
}
|
||||
for (const [key, entry] of live) {
|
||||
if (next.get(key)?.sig !== entry.sig) {
|
||||
@@ -97,12 +109,12 @@ export function syncUiContributions(items: readonly UiContribution[], plugins: r
|
||||
live.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, { item, sig }] of next) {
|
||||
for (const [key, { item, sig, index }] of next) {
|
||||
if (live.has(key)) continue;
|
||||
const def = points.get(item.point)!;
|
||||
const plugin = byId.get(item.pluginId)!;
|
||||
try {
|
||||
const dispose = def.project!(item as UiItem<never>, { plugin }) ?? (() => {});
|
||||
const dispose = def.project!(item as UiItem<never>, { plugin, index }) ?? (() => {});
|
||||
live.set(key, { sig, dispose });
|
||||
} catch (e) {
|
||||
// The backend validated the declaration: a failing projection is a defect.
|
||||
|
||||
@@ -25,19 +25,14 @@ import { useProject } from "@/platform/project";
|
||||
import { ipcPath } from "@/platform/paths";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { BarButton } from "@/ui/BarButton";
|
||||
import {
|
||||
notifications,
|
||||
maybeCommitService,
|
||||
maybeEditorService,
|
||||
registerStatusItem,
|
||||
toolWindowService,
|
||||
} from "@/workbench/contributions";
|
||||
import { notifications, maybeCommitService, maybeEditorService, registerStatusItem } from "@/workbench/contributions";
|
||||
import { pickItems, promptInput } from "@/workbench/inputDialogs";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import { usePluginT } from "./pluginText";
|
||||
import { bumpView, deliverViewMessage, pluginToolWindowId } from "./views/viewStore";
|
||||
import { bumpView, deliverViewMessage } from "./views/viewStore";
|
||||
import { revealWindowView } from "./views/viewWindows";
|
||||
import { runPluginCommand, usePlugins } from "./store";
|
||||
|
||||
// --- status bar items ---------------------------------------------------------
|
||||
@@ -448,7 +443,7 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
|
||||
void answerCommit(e.requestId, e.request);
|
||||
return true;
|
||||
case "revealView":
|
||||
toolWindowService().show(pluginToolWindowId(e.pluginId, e.view), true);
|
||||
revealWindowView(e.pluginId, e.view);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { useMemo } from "react";
|
||||
import type { PluginInfo } from "@/bindings";
|
||||
import { getAction } from "@/platform/actions";
|
||||
import { evaluateWhen, getContext } from "@/platform/context";
|
||||
import { evaluateWhen, getContext, useContextKeys } from "@/platform/context";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { isMacHost, registerExtensionBindings } from "@/platform/keymap";
|
||||
import {
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
registerServiceProvider,
|
||||
registerSettingsPage,
|
||||
registerTextEditorWrapper,
|
||||
registerToolWindow,
|
||||
useSettingsForm,
|
||||
type ContextMenuId,
|
||||
type ContributedCommitProblem,
|
||||
@@ -52,7 +51,7 @@ import { usePlugins } from "./store";
|
||||
import { pluginActionId } from "./points";
|
||||
import { defineUiPoint } from "./projection";
|
||||
import { declareStatusItem } from "./runtimeUi";
|
||||
import { pluginToolWindowId } from "./views/viewStore";
|
||||
import { registerWindowView } from "./views/viewWindows";
|
||||
import {
|
||||
CUSTOM_EDITOR_KIND,
|
||||
CustomEditor,
|
||||
@@ -366,6 +365,8 @@ interface ViewDecl {
|
||||
icon?: string | null;
|
||||
location: ToolWindowAnchor | "services";
|
||||
web?: string;
|
||||
group?: string | null;
|
||||
when?: string | null;
|
||||
}
|
||||
|
||||
interface FileIconDecl {
|
||||
@@ -430,56 +431,71 @@ function projectFileIcon(pluginId: string, decl: FileIconDecl): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
function registerView(pluginId: string, decl: ViewDecl, icon: string): () => void {
|
||||
if (decl.location === "services") {
|
||||
const itemMenu = (key: string) => pluginViewItemMenuItems(pluginId, decl.id, key);
|
||||
return registerServiceProvider(pluginServiceProvider(pluginId, decl, icon, itemMenu));
|
||||
}
|
||||
return registerToolWindow({
|
||||
id: pluginToolWindowId(pluginId, decl.id),
|
||||
title: decl.title,
|
||||
verbatim: true,
|
||||
icon,
|
||||
anchor: decl.location,
|
||||
order: 900,
|
||||
component: () =>
|
||||
decl.web ? (
|
||||
<WebView pluginId={pluginId} view={decl.id} label={decl.title} />
|
||||
) : (
|
||||
<PluginView
|
||||
pluginId={pluginId}
|
||||
view={decl.id}
|
||||
label={decl.title}
|
||||
itemMenu={(key) => pluginViewItemMenuItems(pluginId, decl.id, key)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function projectView(pluginId: string, decl: ViewDecl): () => void {
|
||||
if (!decl.icon?.endsWith(".svg")) return registerView(pluginId, decl, decl.icon ?? "plugin");
|
||||
function projectView(pluginId: string, decl: ViewDecl, index: number): () => void {
|
||||
const initialIcon = decl.icon?.endsWith(".svg") ? "plugin" : (decl.icon ?? "plugin");
|
||||
let disposed = false;
|
||||
let undo = () => {};
|
||||
void pluginsBackend()
|
||||
.assets(pluginId, `viewIcon/${decl.id}`)
|
||||
.then((assets) => {
|
||||
if (disposed) return;
|
||||
const svg = assets.find(([id]) => id === `viewIcon/${decl.id}`)?.[1];
|
||||
const name = `ext:${pluginId}/view-${decl.id}`;
|
||||
const unregister = svg ? registerExternalIcons([[name, svg]]) : () => {};
|
||||
const remove = registerView(pluginId, decl, svg ? name : "plugin");
|
||||
undo = () => {
|
||||
let unregisterIcon = () => {};
|
||||
const itemMenu = (key: string) => pluginViewItemMenuItems(pluginId, decl.id, key);
|
||||
let setIcon: (icon: string) => void;
|
||||
let undo: () => void;
|
||||
if (decl.location === "services") {
|
||||
let icon = initialIcon;
|
||||
let remove = () => {};
|
||||
let visible = false;
|
||||
const refresh = () => {
|
||||
const next = evaluateWhen(decl.when ?? undefined);
|
||||
if (next === visible) return;
|
||||
remove();
|
||||
visible = next;
|
||||
remove = next ? registerServiceProvider(pluginServiceProvider(pluginId, decl, icon, itemMenu)) : () => {};
|
||||
};
|
||||
const unsubscribe = useContextKeys.subscribe(refresh);
|
||||
refresh();
|
||||
setIcon = (next) => {
|
||||
icon = next;
|
||||
if (visible) {
|
||||
remove();
|
||||
unregister();
|
||||
};
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
console.warn(`plugin ${pluginId}: view icon ${decl.id}`, e);
|
||||
if (!disposed) undo = registerView(pluginId, decl, "plugin");
|
||||
});
|
||||
visible = false;
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
undo = () => {
|
||||
unsubscribe();
|
||||
remove();
|
||||
};
|
||||
} else {
|
||||
const registration = registerWindowView(
|
||||
pluginId,
|
||||
{ ...decl, location: decl.location },
|
||||
initialIcon,
|
||||
() =>
|
||||
decl.web ? (
|
||||
<WebView pluginId={pluginId} view={decl.id} label={decl.title} />
|
||||
) : (
|
||||
<PluginView pluginId={pluginId} view={decl.id} label={decl.title} itemMenu={itemMenu} />
|
||||
),
|
||||
index,
|
||||
);
|
||||
setIcon = registration.setIcon;
|
||||
undo = registration.dispose;
|
||||
}
|
||||
if (decl.icon?.endsWith(".svg")) {
|
||||
void pluginsBackend()
|
||||
.assets(pluginId, `viewIcon/${decl.id}`)
|
||||
.then((assets) => {
|
||||
if (disposed) return;
|
||||
const svg = assets.find(([id]) => id === `viewIcon/${decl.id}`)?.[1];
|
||||
if (!svg) return;
|
||||
const name = `ext:${pluginId}/view-${decl.id}`;
|
||||
unregisterIcon = registerExternalIcons([[name, svg]]);
|
||||
setIcon(name);
|
||||
})
|
||||
.catch((e: unknown) => console.warn(`plugin ${pluginId}: view icon ${decl.id}`, e));
|
||||
}
|
||||
return () => {
|
||||
disposed = true;
|
||||
undo();
|
||||
unregisterIcon();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -518,8 +534,9 @@ export function defineViewUiPoints(): () => void {
|
||||
}),
|
||||
}),
|
||||
defineUiPoint<ViewDecl>("views", {
|
||||
orderSensitive: true,
|
||||
title: "Tool Windows",
|
||||
project: ({ pluginId, decl }) => projectView(pluginId, decl),
|
||||
project: ({ pluginId, decl }, { index }) => projectView(pluginId, decl, index),
|
||||
}),
|
||||
defineUiPoint<CustomEditorDecl>("customEditors", {
|
||||
title: "Editors",
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useState, type FunctionComponent } from "react";
|
||||
import { create, type StoreApi, type UseBoundStore } from "zustand";
|
||||
import { evaluateWhen, useContextKeys } from "@/platform/context";
|
||||
import { VerbatimTextContext } from "@/platform/i18n";
|
||||
import { Tabs } from "@/ui";
|
||||
import {
|
||||
registerToolWindow,
|
||||
toolWindowService,
|
||||
ToolWindowShownProvider,
|
||||
useToolWindowShown,
|
||||
type ToolWindowAnchor,
|
||||
} from "@/workbench/contributions";
|
||||
import { pluginToolWindowId, viewKey } from "./viewStore";
|
||||
import styles from "./PluginView.module.css";
|
||||
|
||||
export interface WindowView {
|
||||
id: string;
|
||||
title: string;
|
||||
location: ToolWindowAnchor;
|
||||
group?: string | null;
|
||||
when?: string | null;
|
||||
}
|
||||
|
||||
interface Member {
|
||||
decl: WindowView;
|
||||
icon: string;
|
||||
order: number;
|
||||
component: FunctionComponent;
|
||||
}
|
||||
|
||||
interface WindowState {
|
||||
members: Member[];
|
||||
selected: string | null;
|
||||
}
|
||||
|
||||
interface WindowGroup {
|
||||
members: Map<string, Member>;
|
||||
state: UseBoundStore<StoreApi<WindowState>>;
|
||||
dispose: () => void;
|
||||
unsubscribe: () => void;
|
||||
signature: string;
|
||||
component: FunctionComponent;
|
||||
}
|
||||
|
||||
const groups = new Map<string, WindowGroup>();
|
||||
const windowsByView = new Map<string, { window: string; group: WindowGroup }>();
|
||||
|
||||
function GroupView({ group, id }: { group: WindowGroup; id: string }) {
|
||||
const { members, selected } = group.state();
|
||||
const active = members.find((m) => m.decl.id === selected) ?? members[0];
|
||||
const activeId = active?.decl.id;
|
||||
const shown = useToolWindowShown();
|
||||
// Like separate tool windows, opened tabs retain their local state until withdrawn.
|
||||
const [visited, setVisited] = useState<ReadonlySet<string>>(() => new Set());
|
||||
useEffect(() => {
|
||||
setVisited((previous) => {
|
||||
const next = new Set(
|
||||
members.filter((m) => previous.has(m.decl.id) || m.decl.id === activeId).map((m) => m.decl.id),
|
||||
);
|
||||
return next.size === previous.size && [...next].every((id) => previous.has(id)) ? previous : next;
|
||||
});
|
||||
}, [members, activeId]);
|
||||
if (!active) return null;
|
||||
return (
|
||||
<VerbatimTextContext.Provider value={true}>
|
||||
<div className={styles.view}>
|
||||
<Tabs
|
||||
variant="compact"
|
||||
panelIdPrefix={id}
|
||||
items={members.map(({ decl }) => ({ id: decl.id, label: decl.title }))}
|
||||
activeId={active.decl.id}
|
||||
onSelect={(id) => group.state.setState({ selected: id })}
|
||||
/>
|
||||
{members
|
||||
.filter((m) => visited.has(m.decl.id) || m.decl.id === activeId)
|
||||
.map((member) => {
|
||||
const Body = member.component;
|
||||
const selected = member.decl.id === activeId;
|
||||
return (
|
||||
<div
|
||||
key={member.decl.id}
|
||||
className={styles.body}
|
||||
role="tabpanel"
|
||||
id={`${id}-panel-${member.decl.id}`}
|
||||
aria-labelledby={`${id}-tab-${member.decl.id}`}
|
||||
hidden={!selected}
|
||||
>
|
||||
<ToolWindowShownProvider value={shown && selected}>
|
||||
<Body />
|
||||
</ToolWindowShownProvider>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</VerbatimTextContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function refresh(id: string, group: WindowGroup) {
|
||||
const ordered = [...group.members.values()].sort((a, b) => a.order - b.order);
|
||||
const first = ordered[0];
|
||||
const members = ordered.filter((m) => evaluateWhen(m.decl.when ?? undefined));
|
||||
const selected = group.state.getState().selected;
|
||||
group.state.setState({
|
||||
members,
|
||||
selected: selected ?? members[0]?.decl.id ?? null,
|
||||
});
|
||||
const signature = first && members.length ? JSON.stringify([first.decl.title, first.decl.location, first.icon]) : "";
|
||||
if (signature === group.signature) return;
|
||||
group.dispose();
|
||||
group.signature = signature;
|
||||
group.dispose =
|
||||
first && members.length
|
||||
? registerToolWindow({
|
||||
id,
|
||||
title: first.decl.title,
|
||||
verbatim: true,
|
||||
anchor: first.decl.location,
|
||||
icon: first.icon,
|
||||
order: 900,
|
||||
component: first.decl.group ? group.component : first.component,
|
||||
})
|
||||
: () => {};
|
||||
}
|
||||
|
||||
/** Group names are scoped to their plugin and cannot collide with an ungrouped view id. */
|
||||
export function registerWindowView(
|
||||
pluginId: string,
|
||||
decl: WindowView,
|
||||
icon: string,
|
||||
component: FunctionComponent,
|
||||
order = 0,
|
||||
) {
|
||||
const id = decl.group ? `plugin.${pluginId}.group:${decl.group}` : pluginToolWindowId(pluginId, decl.id);
|
||||
let group = groups.get(id);
|
||||
if (!group) {
|
||||
group = {
|
||||
members: new Map(),
|
||||
state: create<WindowState>(() => ({ members: [], selected: null })),
|
||||
dispose: () => {},
|
||||
unsubscribe: () => {},
|
||||
signature: "",
|
||||
component: () => null,
|
||||
};
|
||||
const current = group;
|
||||
group.component = () => <GroupView group={current} id={id} />;
|
||||
group.unsubscribe = useContextKeys.subscribe(() => refresh(id, current));
|
||||
groups.set(id, group);
|
||||
}
|
||||
const current = group;
|
||||
const member = { decl, icon, component, order };
|
||||
current.members.set(decl.id, member);
|
||||
windowsByView.set(viewKey(pluginId, decl.id), { window: id, group: current });
|
||||
refresh(id, current);
|
||||
return {
|
||||
setIcon(next: string) {
|
||||
member.icon = next;
|
||||
refresh(id, current);
|
||||
},
|
||||
dispose() {
|
||||
current.members.delete(decl.id);
|
||||
windowsByView.delete(viewKey(pluginId, decl.id));
|
||||
if (current.members.size) refresh(id, current);
|
||||
else {
|
||||
current.dispose();
|
||||
current.unsubscribe();
|
||||
groups.delete(id);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A hidden view stays hidden; revealing a grouped view selects its own tab. */
|
||||
export function revealWindowView(pluginId: string, view: string) {
|
||||
const entry = windowsByView.get(viewKey(pluginId, view));
|
||||
if (!entry?.group.state.getState().members.some((m) => m.decl.id === view)) return;
|
||||
entry.group.state.setState({ selected: view });
|
||||
toolWindowService().show(entry.window, true);
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача