feat(plugins): services views in the Services tool window (#1760)

A plugin view declared with `location = "services"` (plugin API 1.3, `engines.openidea ^1.3`) shows its tree in the Services tool window, next to Docker and the run configurations. The nodes, the toolbar (acting on one selected node) and their events come from `view-provider`; `viewItem.context` menus give the nodes a right-click menu. The pane beside the tree comes from a new optional export, `service-provider.detail(view, key)` (worlds `plugin-services` / `services`), which returns typed tabs: a message, read-only text or a process log. The IDE switches tabs without calling the plugin.

For a log, the UI names only the node and tab (`plugin_service_log`); the host asks the plugin for the pane again and runs its program with the plugin's `process:` grant in the project root, with the arguments of the toggles that are on, streaming its output into the log pane Docker uses until it exits (the exit code is shown), the pane closes or the Services window is hidden. A cancelled request starts nothing, lines are cut at 16 KiB, and revoking the grant or cancelling kills the program and what it started. The log pane moves to `src/ui/LogPane` and drops output that arrives after its source was stopped. The Rust SDK gains `openidea_plugin::services` (`Services`, `Tab`, `Pane`, `ProcessLog`). All components are rebuilt for the WIT change.

Closes #1736

Co-authored-by: arnaud-ritti <[email protected]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-07 09:39:18 +08:00
1 джерело 809bc1cc2f
коміт 39f6162c12
72 files changed
+1936 -178

No files matched your search

+81
Переглянути файл
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ServiceTab } from "@/bindings";
import { createMemoryPluginsBackend, setPluginsBackend, type PluginsBackend } from "../backend";
import { ServicePane } from "../views/ServicePane";
import { bumpView } from "../views/viewStore";
afterEach(() => setPluginsBackend(null));
const log = {
program: "kubectl",
args: ["logs", "x"],
toggles: [{ id: "ts", label: "Timestamps", args: ["--timestamps"] }],
};
const tabs: ServiceTab[] = [
{ id: "log", label: "Log", body: { kind: "log", value: log } },
{ id: "inspect", label: "Inspect", body: { kind: "text", value: "a: 1" } },
];
function backend(detail: () => ServiceTab[] = () => tabs) {
const stops: ReturnType<typeof vi.fn>[] = [];
const b = {
...createMemoryPluginsBackend([]),
serviceDetail: vi.fn(async () => detail()),
serviceLog: vi.fn<PluginsBackend["serviceLog"]>(() => {
const stop = vi.fn();
stops.push(stop);
return stop;
}),
};
setPluginsBackend(b);
return { b, stops };
}
describe("ServicePane", () => {
it("switches tabs without calling the plugin", async () => {
const { b } = backend();
const user = userEvent.setup();
render(<ServicePane pluginId="kube" view="pods" itemKey="pod:a" />);
await user.click(await screen.findByRole("tab", { name: "Inspect" }));
expect(screen.getByRole("document").textContent).toBe("a: 1");
expect(b.serviceDetail).toHaveBeenCalledTimes(1);
});
it("follows a log tab by node and tab, and restarts it on a toggle", async () => {
const { b, stops } = backend();
const user = userEvent.setup();
const { unmount } = render(<ServicePane pluginId="kube" view="pods" itemKey="pod:a" />);
await waitFor(() => expect(b.serviceLog).toHaveBeenCalledTimes(1));
expect(b.serviceLog.mock.calls[0]?.slice(0, 5)).toEqual(["kube", "pods", "pod:a", "log", new Set()]);
await user.click(screen.getByRole("switch", { name: "Timestamps" }));
expect(b.serviceLog).toHaveBeenCalledTimes(2);
expect(b.serviceLog.mock.calls[1]?.[4]).toEqual(new Set(["ts"]));
expect(stops.at(0)).toHaveBeenCalled();
unmount();
expect(stops.at(1)).toHaveBeenCalled();
});
it("asks again on a refresh and restarts a log whose program changed", async () => {
let args = ["logs", "x"];
const { b, stops } = backend(() => [{ id: "log", label: "Log", body: { kind: "log", value: { ...log, args } } }]);
render(<ServicePane pluginId="kube" view="pods" itemKey="pod:a" />);
await waitFor(() => expect(b.serviceLog).toHaveBeenCalledTimes(1));
args = ["logs", "y"];
bumpView("kube", "pods");
await waitFor(() => expect(b.serviceLog).toHaveBeenCalledTimes(2));
expect(b.serviceDetail).toHaveBeenCalledTimes(2);
expect(stops.at(0)).toHaveBeenCalled();
});
it("shows the plugin's error", async () => {
const b = {
...createMemoryPluginsBackend([]),
serviceDetail: vi.fn(async () => Promise.reject(new Error("no cluster"))),
};
setPluginsBackend(b);
render(<ServicePane pluginId="kube" view="pods" itemKey="pod:a" />);
expect(await screen.findByText("no cluster")).toBeTruthy();
});
});
+132
Переглянути файл
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, renderHook, waitFor } from "@testing-library/react";
import type { View, ViewEvent, ViewTreeItem } from "@/bindings";
import { serviceProviders, type ServiceTreeItem } from "@/workbench/contributions";
import { createMemoryPluginsBackend, setPluginsBackend, type PluginsBackend } from "../backend";
import { defineBuiltinUiPoints } from "../points";
import { clearUiContributions, syncUiContributions } from "../projection";
import { pluginServiceProvider } from "../views/serviceProvider";
let undefinePoints: () => void = () => {};
beforeEach(() => {
undefinePoints = defineBuiltinUiPoints();
});
afterEach(() => {
clearUiContributions();
undefinePoints();
setPluginsBackend(null);
});
const item = (key: string, label: string, parent: string | null = null): ViewTreeItem => ({
key,
parent,
label,
icon: "folder",
detail: null,
expandable: false,
expanded: false,
});
const pods: View = {
toolbar: [{ id: "delete", label: "Delete", icon: "trash", enabled: true }],
status: null,
body: {
kind: "tree",
value: [item("ctx", "minikube"), item("ns", "default", "ctx"), item("pod:default/nginx", "nginx", "ns")],
},
};
function backend(view: View = pods) {
const events: [string, string, ViewEvent][] = [];
const b: PluginsBackend = {
...createMemoryPluginsBackend([]),
renderView: vi.fn(async () => view),
viewEvent: vi.fn(async (pluginId: string, v: string, e: ViewEvent) => void events.push([pluginId, v, e])),
};
setPluginsBackend(b);
return { b, events };
}
const decl = { id: "kube", title: "Kubernetes", location: "services" as const };
const noMenu = () => [];
async function roots(pluginId: string) {
const provider = pluginServiceProvider(pluginId, decl, "plugin", noMenu);
const { result } = renderHook(() => provider.useRoots());
await waitFor(() => expect(result.current).not.toHaveLength(0));
return { provider, roots: result.current };
}
const keys = (items: ServiceTreeItem[]): unknown =>
items.map((i) => (i.children ? { [i.key]: keys(i.children) } : i.key));
describe("plugin service providers", () => {
it("maps the plugin tree to service nodes", async () => {
backend();
const { roots: nodes } = await roots("p");
expect(keys(nodes)).toEqual([
{ "plugin:p/kube/ctx": [{ "plugin:p/kube/ns": ["plugin:p/kube/pod:default/nginx"] }] },
]);
expect(nodes[0]?.label).toBe("minikube");
});
it("routes events with the raw key", async () => {
const { events } = backend();
const { provider, roots: nodes } = await roots("p");
const nginx = nodes[0]!.children![0]!.children![0]!;
provider.select?.(nginx);
await waitFor(() => expect(events).toContainEqual(["p", "kube", { kind: "select", key: "pod:default/nginx" }]));
provider.activate?.(nginx);
await waitFor(() => expect(events).toContainEqual(["p", "kube", { kind: "activate", key: "pod:default/nginx" }]));
const actions = provider.actions?.(nginx) ?? [];
expect(actions[0]?.id).not.toBe(provider.actions?.(nodes[0]!)[0]?.id);
actions[0]?.run();
await waitFor(() => expect(events).toContainEqual(["p", "kube", { kind: "button", id: "delete" }]));
const select = events.findIndex(([, , e]) => e.kind === "select" && e.key === "pod:default/nginx");
const button = events.findIndex(([, , e]) => e.kind === "button");
expect(select).toBeGreaterThanOrEqual(0);
expect(select).toBeLessThan(button);
});
it("keeps two plugins' nodes apart when they use the same keys", async () => {
backend();
const a = await roots("a");
const b = await roots("b");
expect(a.roots[0]?.key).not.toBe(b.roots[0]?.key);
});
it("asks the plugin's service provider for the selected node's pane", async () => {
const { b } = backend();
const serviceDetail = vi.fn(async () => [
{ id: "info", label: "Info", body: { kind: "text" as const, value: "Name: nginx" } },
]);
setPluginsBackend({ ...b, serviceDetail });
const provider = pluginServiceProvider("p", decl, "plugin", noMenu);
const Detail = provider.detail!;
const node: ServiceTreeItem = { key: "plugin:p/kube/pod:default/nginx", label: "nginx", icon: "folder" };
render(<Detail item={node} />);
await waitFor(() => expect(serviceDetail).toHaveBeenCalledWith("p", "kube", "pod:default/nginx"));
});
it("offers the view's item menu on its nodes", async () => {
backend();
const itemMenu = vi.fn((key: string) => [{ label: key, run: () => {} }]);
const provider = pluginServiceProvider("p", decl, "plugin", itemMenu);
const node: ServiceTreeItem = { key: "plugin:p/kube/pod:default/nginx", label: "nginx", icon: "folder" };
expect(provider.menu?.(node)).toEqual([{ label: "pod:default/nginx", run: expect.any(Function) }]);
});
it("registers services views as providers and drops them with the plugin", () => {
backend();
const plugin = { id: "p", name: "P", status: "active", enabled: true } as unknown as Parameters<
typeof syncUiContributions
>[1][number];
syncUiContributions([{ pluginId: "p", point: "views", key: "kube", decl }], [plugin]);
expect(serviceProviders().map((p) => p.id)).toContain("plugin.p.kube");
syncUiContributions([], [plugin]);
expect(serviceProviders().map((p) => p.id)).not.toContain("plugin.p.kube");
});
});