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();
+16 -4
Переглянути файл
@@ -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.
+4 -9
Переглянути файл
@@ -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;
+66 -49
Переглянути файл
@@ -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",
+179
Переглянути файл
@@ -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);
}