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");
});
});
+34 -1
Переглянути файл
@@ -18,9 +18,11 @@ import type {
PluginEditor,
PluginEvent,
PluginInfo,
PluginOutputLine,
PluginsSnapshot,
PluginSecretState,
PluginStatusItem,
ServiceTab,
UiContribution,
View,
ViewEvent,
@@ -28,7 +30,7 @@ import type {
WorkbenchEvent,
} from "@/bindings";
import { tx } from "@/platform/i18n";
import { call, commands, IpcError, isTauri } from "@/platform/ipc";
import { call, commands, IpcError, isCancelled, isTauri, nextRequestId } from "@/platform/ipc";
import { editorContext } from "./editorContext";
export interface PluginsBackend {
@@ -89,6 +91,22 @@ export interface PluginsBackend {
/** Content of a plugin view (`doc`: the document of a custom editor). */
renderView(pluginId: string, view: string, viewport: Viewport | null, doc: number | null): Promise<View>;
viewEvent(pluginId: string, view: string, event: ViewEvent, doc: number | null): Promise<void>;
/** The Services pane of node `key` of a plugin's services view. */
serviceDetail(pluginId: string, view: string, key: string): Promise<ServiceTab[]>;
/**
* Runs the program of node `key`'s log tab `tab` (with the toggles in `on`) and streams its
* lines until it exits (`onExit`) or the returned stop function is called.
*/
serviceLog(
pluginId: string,
view: string,
key: string,
tab: string,
on: ReadonlySet<string>,
onLine: (line: PluginOutputLine) => void,
onError: (message: string) => void,
onExit: (code: number | null) => void,
): () => void;
/** The URL of a web view's page (`web = …`), under the `openidea-plugin` scheme. */
webPageUrl(pluginId: string, view: string): Promise<string>;
/** Save dialog for a web view's `openidea.saveFile`; the path written, or `null` when cancelled. */
@@ -204,6 +222,16 @@ export const tauriPluginsBackend: PluginsBackend = {
viewEvent: async (pluginId, view, event, doc) => {
await call(commands.pluginViewEvent(pluginId, view, event, doc, doc == null ? editorContext() : null));
},
serviceDetail: (pluginId, view, key) => call(commands.pluginServiceDetail(pluginId, view, key)),
serviceLog: (pluginId, view, key, tab, on, onLine, onError, onExit) => {
const request = nextRequestId();
const channel = new Channel<PluginOutputLine>();
channel.onmessage = onLine;
call(commands.pluginServiceLog(request, pluginId, view, key, tab, [...on], channel)).then(onExit, (e: unknown) => {
if (!isCancelled(e)) onError(e instanceof Error ? e.message : String(e));
});
return () => void commands.cancelRequest(request);
},
webPageUrl: async (pluginId, view) => {
const page = await call(commands.pluginWebPage(pluginId, view));
return webSchemeUrl(page.path);
@@ -540,6 +568,11 @@ export function createMemoryPluginsBackend(
status: null,
}),
viewEvent: async () => {},
serviceDetail: async () => [],
serviceLog: (pluginId, view, key, _tab, _on, _onLine, onError) => {
onError(`${pluginId} · ${view} · ${key}: logs need the desktop app`);
return () => {};
},
webPageUrl: async (pluginId, view) => {
throw new IpcError({ kind: "unavailable", message: `${pluginId} · ${view}: web views need the desktop app` });
},
+7 -1
Переглянути файл
@@ -34,6 +34,7 @@ import {
registerFileEditorProvider,
registerMenuGroup,
registerMenuSlotItem,
registerServiceProvider,
registerSettingsPage,
registerTextEditorWrapper,
registerToolWindow,
@@ -58,6 +59,7 @@ import {
} from "./views/customEditor";
import { layoutActions, layoutWrapper } from "./views/editorLayout";
import { PluginView } from "./views/PluginView";
import { pluginServiceProvider } from "./views/serviceProvider";
import { WebView } from "./views/WebView";
import styles from "./PluginsPage.module.css";
@@ -242,7 +244,7 @@ interface ViewDecl {
id: string;
title: string;
icon?: string | null;
location: ToolWindowAnchor;
location: ToolWindowAnchor | "services";
web?: string;
}
@@ -309,6 +311,10 @@ 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,
+7 -7
Переглянути файл
@@ -199,17 +199,17 @@ function Body({ content, send, pluginId, view, onViewport, itemMenu }: BodyProps
// --- tree -------------------------------------------------------------------------
interface Node {
export interface TreeNode {
item: ViewTreeItem;
children: Node[];
children: TreeNode[];
}
/** The flat item list as nodes (items naming an unknown parent are roots). */
export function treeNodes(items: readonly ViewTreeItem[]): Node[] {
const byKey = new Map<string, Node>();
const roots: Node[] = [];
export function treeNodes(items: readonly ViewTreeItem[]): TreeNode[] {
const byKey = new Map<string, TreeNode>();
const roots: TreeNode[] = [];
for (const item of items) {
const node: Node = { item, children: [] };
const node: TreeNode = { item, children: [] };
if (!byKey.has(item.key)) byKey.set(item.key, node);
const parent = item.parent != null ? byKey.get(item.parent) : undefined;
(parent ? parent.children : roots).push(node);
@@ -238,7 +238,7 @@ function TreeBody({ items, send, itemMenu }: TreeBodyProps) {
const roots = useMemo(() => treeNodes(items), [items]);
const expanded = useMemo(() => new Set(items.filter((i) => i.expanded).map((i) => i.key)), [items]);
return (
<Tree<Node>
<Tree<TreeNode>
roots={roots}
getKey={(n) => n.item.key}
getLabel={(n) => n.item.label}
+29
Переглянути файл
@@ -0,0 +1,29 @@
.pane {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.text {
flex: 1;
box-sizing: border-box;
margin: 0;
padding: 6px 10px;
overflow: auto;
font-family: var(--font-mono);
font-size: var(--fs-code);
line-height: var(--lh-code);
color: var(--fg-editor);
background: var(--bg-editor);
white-space: pre;
-webkit-user-select: text;
user-select: text;
}
+87
Переглянути файл
@@ -0,0 +1,87 @@
/**
* The Services pane of a node of a plugin's `location = "services"` view: the
* tabs its `service-provider` returns, switched here without calling the plugin.
*/
import { useEffect, useState } from "react";
import type { ServiceTab } from "@/bindings";
import { EmptyState, LogPane, Spinner, Tabs } from "@/ui";
import { copySelectionOnKeyDown } from "@/ui/selectableText";
import { pluginsBackend } from "../backend";
import { useViewRevision } from "./viewStore";
import styles from "./ServicePane.module.css";
interface ServicePaneProps {
pluginId: string;
view: string;
itemKey: string;
}
export function ServicePane({ pluginId, view, itemKey }: ServicePaneProps) {
// `views.refresh(<view>)` asks for the pane again.
const revision = useViewRevision(pluginId, view);
const [tabs, setTabs] = useState<ServiceTab[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [picked, setPicked] = useState<string | null>(null);
useEffect(() => {
let alive = true;
pluginsBackend()
.serviceDetail(pluginId, view, itemKey)
.then(
(t) => {
if (!alive) return;
setTabs(t);
setError(null);
},
(e: unknown) => alive && setError(e instanceof Error ? e.message : String(e)),
);
return () => {
alive = false;
};
}, [pluginId, view, itemKey, revision]);
if (error) return <EmptyState title={error} />;
if (!tabs) return <Spinner />;
const active = tabs.find((t) => t.id === picked) ?? tabs[0];
if (!active) return <EmptyState title="Nothing to show" />;
return (
<div className={styles.pane}>
<Tabs
variant="compact"
activeId={active.id}
onSelect={setPicked}
items={tabs.map((t) => ({ id: t.id, label: t.label }))}
/>
<div className={styles.body}>
<TabBody key={active.id} tab={active} pluginId={pluginId} view={view} itemKey={itemKey} />
</div>
</div>
);
}
function TabBody({ tab, pluginId, view, itemKey }: ServicePaneProps & { tab: ServiceTab }) {
const body = tab.body;
switch (body.kind) {
case "message":
return <EmptyState title={body.value} />;
case "text":
return (
<pre role="document" tabIndex={0} className={styles.text} onKeyDown={copySelectionOnKeyDown}>
{body.value}
</pre>
);
case "log":
return (
<LogPane
// A refresh that changes what runs starts it again.
sourceKey={JSON.stringify([pluginId, view, itemKey, tab.id, body.value])}
live
toggles={body.value.toggles}
ariaLabel={tab.label}
start={(onLine, onError, on, onExit) =>
pluginsBackend().serviceLog(pluginId, view, itemKey, tab.id, on, onLine, onError, onExit)
}
/>
);
}
}
+94
Переглянути файл
@@ -0,0 +1,94 @@
/**
* A plugin view declared with `location = "services"`: its tree's items are
* nodes of the Services tool window, its toolbar the Services toolbar for one
* selected node, `viewItem.context` its nodes' right-click menu, and the
* plugin's `service-provider` the selected node's pane.
*/
import { useEffect, useMemo, useState } from "react";
import type { View, ViewEvent } from "@/bindings";
import type { MenuItem } from "@/ui";
import type { ServiceProviderContribution, ServiceTreeItem } from "@/workbench/contributions";
import { pluginsBackend } from "../backend";
import { treeNodes, type TreeNode } from "./PluginView";
import { ServicePane } from "./ServicePane";
import { useViewRevision } from "./viewStore";
interface ServicesViewDecl {
id: string;
title: string;
}
export function pluginServiceProvider(
pluginId: string,
decl: ServicesViewDecl,
icon: string,
itemMenu: (key: string) => MenuItem[],
): ServiceProviderContribution {
const prefix = `plugin:${pluginId}/${decl.id}/`;
const rawKey = (item: ServiceTreeItem) => item.key.slice(prefix.length);
let toolbar: View["toolbar"] = [];
// Events of one node stay in order (a toolbar button after the select it acts on).
let queue = Promise.resolve();
const send = (event: ViewEvent) => {
queue = queue
.then(() => pluginsBackend().viewEvent(pluginId, decl.id, event, null))
.catch((e: unknown) => console.warn(`plugin ${pluginId}: view ${decl.id}`, e));
};
const toItem = (n: TreeNode): ServiceTreeItem => ({
key: prefix + n.item.key,
label: n.item.label,
icon: n.item.icon ?? icon,
detail: n.item.detail ?? undefined,
expanded: n.item.expanded,
expandable: n.item.expandable,
children: n.children.length || n.item.expandable ? n.children.map(toItem) : undefined,
});
return {
id: `plugin.${pluginId}.${decl.id}`,
order: 900,
useRoots() {
const revision = useViewRevision(pluginId, decl.id);
const [view, setView] = useState<View | null>(null);
useEffect(() => {
let alive = true;
pluginsBackend()
.renderView(pluginId, decl.id, null, null)
.then(
(v) => {
if (!alive) return;
toolbar = v.toolbar;
setView(v);
},
(e: unknown) => console.warn(`plugin ${pluginId}: view ${decl.id}`, e),
);
return () => {
alive = false;
};
}, [revision]);
return useMemo(() => (view?.body.kind === "tree" ? treeNodes(view.body.value).map(toItem) : []), [view]);
},
// Ids per node: the Services toolbar offers an action of several selected
// nodes only when they share its id, and a plugin's buttons act on one.
actions: (item) =>
toolbar.map((b) => ({
id: `${item.key}\0${b.id}`,
title: b.label,
icon: b.icon ?? "plugin",
disabled: !b.enabled,
run: () => {
send({ kind: "select", key: rawKey(item) });
send({ kind: "button", id: b.id });
},
})),
menu: (item) => itemMenu(rawKey(item)),
select: (item) => send({ kind: "select", key: rawKey(item) }),
activate: (item) => send({ kind: "activate", key: rawKey(item) }),
expand: (item, expanded) => send({ kind: expanded ? "expand" : "collapse", key: rawKey(item) }),
detail: Detail,
};
function Detail({ item }: { item: ServiceTreeItem }) {
return <ServicePane pluginId={pluginId} view={decl.id} itemKey={rawKey(item)} />;
}
}
+1 -14
Переглянути файл
@@ -4,8 +4,7 @@
flex-direction: column;
min-height: 0;
}
.toolbar,
.subToolbar {
.toolbar {
height: 32px;
flex: none;
display: flex;
@@ -13,20 +12,12 @@
gap: 2px;
padding: 0 6px;
}
.subToolbar {
gap: 12px;
padding: 0 10px;
border-bottom: 1px solid var(--border);
}
.sep {
width: 1px;
height: 16px;
margin: 0 4px;
background: var(--border);
}
.flex {
flex: 1;
}
.banner {
flex: none;
display: flex;
@@ -133,10 +124,6 @@
.log > div {
cursor: text;
}
.wrap {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.json {
white-space: pre;
}
+42 -1
Переглянути файл
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { useServiceProviders, type ServiceTreeItem } from "@/workbench/contributions";
import { ToolWindowShownProvider, useServiceProviders, type ServiceTreeItem } from "@/workbench/contributions";
import { ServicesTreeView } from "./ServicesTree";
import { useServicesTree } from "./tree";
import { selectedServices, selectionActions, selectionMenu } from "./selection";
@@ -12,6 +12,7 @@ const roots: ServiceTreeItem[] = ["one", "two", "three"].map((key) => ({ key, la
const run = vi.fn();
const stop = vi.fn();
const remove = vi.fn();
const select = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
@@ -32,6 +33,7 @@ beforeEach(() => {
{ label: "Remove from Services", run: () => remove(item.key) },
],
detail: ({ item }) => <div data-testid="detail">{item.label}</div>,
select: (item) => select(item.key),
},
],
]),
@@ -67,6 +69,45 @@ describe("Services selection → actions", () => {
expect(screen.queryByTestId("detail")).not.toBeInTheDocument();
});
it("tells the provider which node became the lead selection", () => {
const { row } = setup();
fireEvent.mouseDown(row("one"));
fireEvent.mouseDown(row("two"));
expect(select.mock.calls).toEqual([["one"], ["two"]]);
});
it("drops the detail pane (and what it streams) while the Services window is hidden", () => {
const view = (shown: boolean) => (
<ToolWindowShownProvider value={shown}>
<ServicesTreeView />
</ToolWindowShownProvider>
);
const { rerender } = render(view(true));
fireEvent.mouseDown(within(screen.getByRole("tree", { name: "Services" })).getByText("one"));
expect(screen.getByTestId("detail")).toHaveTextContent("one");
rerender(view(false));
expect(screen.queryByTestId("detail")).not.toBeInTheDocument();
rerender(view(true));
expect(screen.getByTestId("detail")).toHaveTextContent("one");
});
it("Expand All and Collapse All reach lazy nodes and tell their provider", () => {
const expand = vi.fn();
const lazy: ServiceTreeItem[] = [{ key: "lazy", label: "lazy", icon: "run", expandable: true, children: [] }];
useServiceProviders.setState({
providers: new Map([["lazy", { id: "lazy", useRoots: () => lazy, expand: (i, open) => expand(i.key, open) }]]),
});
render(<ServicesTreeView />);
fireEvent.click(screen.getByRole("button", { name: "Expand All" }));
expect(useServicesTree.getState().expanded.has("lazy")).toBe(true);
expect(expand.mock.calls).toEqual([["lazy", true]]);
fireEvent.click(screen.getByRole("button", { name: "Collapse All" }));
expect(expand.mock.calls).toEqual([
["lazy", true],
["lazy", false],
]);
});
it("Shift click selects a range; Stop skips disabled services", () => {
const { row, selected, toolbar } = setup();
fireEvent.mouseDown(row("one"));
+27 -8
Переглянути файл
@@ -25,6 +25,7 @@ import {
} from "@/ui";
import {
useServiceProviders,
useToolWindowShown,
serviceProviders,
type ServiceProviderContribution,
type ServiceTreeItem,
@@ -134,7 +135,19 @@ export function ServicesTreeView() {
...(selected && selectedKeys.size === 1 ? moveActions(selected.item) : []),
];
/** Expands / collapses many nodes at once, telling their providers what changed. */
const setExpanded = (next: Set<string>) => {
useServicesTree.setState({ expanded: next });
for (const key of new Set([...expanded, ...next])) {
const at = index.get(key);
if (!at || expanded.has(key) === next.has(key)) continue;
providers.find((p) => p.id === at.provider)?.expand?.(at.item, next.has(key));
}
};
const Detail = owner?.detail;
// A hidden tool window stays mounted: drop the pane so a log it follows stops.
const shown = useToolWindowShown();
const add = shortcutLabel("services.addService");
return (
@@ -159,7 +172,7 @@ export function ServicesTreeView() {
iconSize={16}
title="Expand All"
disabled={!roots.length}
onClick={() => useServicesTree.setState({ expanded: new Set(expandableKeys(roots)) })}
onClick={() => setExpanded(new Set(expandableKeys(roots)))}
/>
<IconButton
icon="collapse-all"
@@ -167,7 +180,7 @@ export function ServicesTreeView() {
iconSize={16}
title="Collapse All"
disabled={!roots.length}
onClick={() => useServicesTree.setState({ expanded: new Set() })}
onClick={() => setExpanded(new Set())}
/>
{actions.length > 0 && <span className={styles.sep} />}
{actions.map((a) => (
@@ -209,13 +222,19 @@ export function ServicesTreeView() {
getKey={(n) => n.key}
getLabel={(n) => n.label}
getChildren={(n) => n.children}
isExpandable={(n) => !!n.children?.length}
isExpandable={(n) => !!n.children?.length || !!n.expandable}
expanded={expanded}
onExpandedChange={(next) => useServicesTree.setState({ expanded: next })}
onExpandedChange={(next, node, open) => {
useServicesTree.setState({ expanded: next });
providers.find((p) => p.id === index.get(node.key)?.provider)?.expand?.(node, open);
}}
selected={selectedKeys}
onSelectionChange={(keys, lead) =>
useServicesTree.setState({ selectedKeys: keys, selectedKey: keys.size ? (lead?.key ?? null) : null })
}
onSelectionChange={(keys, lead) => {
const selectedKey = keys.size ? (lead?.key ?? null) : null;
const changed = selectedKey !== useServicesTree.getState().selectedKey;
useServicesTree.setState({ selectedKeys: keys, selectedKey });
if (lead && changed) providers.find((p) => p.id === index.get(lead.key)?.provider)?.select?.(lead);
}}
renderNode={(n, state) => <Row item={n} state={state} />}
onActivate={(n) => {
const p = index.get(n.key);
@@ -246,7 +265,7 @@ export function ServicesTreeView() {
</SplitPanel>
<SplitHandle />
<SplitPanel minSize="200px">
{selected && Detail ? (
{selected && Detail && shown ? (
<Detail key={selected.item.key} item={selected.item} />
) : (
<EmptyState title="Select service to view details" />
+18 -97
Переглянути файл
@@ -4,7 +4,7 @@
* log / inspect of the selection in the detail pane.
*/
import { copySelectionOnKeyDown } from "@/ui/selectableText";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import clsx from "clsx";
import { Channel } from "@tauri-apps/api/core";
import type {
@@ -17,7 +17,7 @@ import type {
} from "@/platform/ipc";
import { tx, useTx, useTxTemplate } from "@/platform/i18n";
import { call, commands, isCancelled, nextRequestId } from "@/platform/ipc";
import { Button, EmptyState, IconButton, Spinner, Tabs, Toggle, T } from "@/ui";
import { Button, EmptyState, IconButton, LogPane, Spinner, Tabs, T } from "@/ui";
import type { MenuItem } from "@/ui";
import {
notifications,
@@ -274,107 +274,28 @@ export function DockerTaskBanner() {
);
}
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g; // eslint-disable-line no-control-regex
const MAX_LINES = 5000;
/** Follows a container's log while shown. */
function LogView({ conn, id, running }: { conn: string; id: string; running: boolean }) {
const [timestamps, setTimestamps] = useState(false);
const [wrap, setWrap] = useState(true);
// A new stream (and buffer) per option set.
return (
<LogStream
key={`${running}:${timestamps}`}
endpoint={endpointOf(conn)}
id={id}
running={running}
timestamps={timestamps}
setTimestamps={setTimestamps}
wrap={wrap}
setWrap={setWrap}
/>
);
}
function LogStream(props: {
endpoint: DockerEndpoint;
id: string;
running: boolean;
timestamps: boolean;
setTimestamps: (v: boolean) => void;
wrap: boolean;
setWrap: (v: boolean) => void;
}) {
const tx = useTx();
const { endpoint, id, running, timestamps, setTimestamps, wrap, setWrap } = props;
const [lines, setLines] = useState<DockerLogLine[]>([]);
const [error, setError] = useState<string | null>(null);
const scroller = useRef<HTMLDivElement>(null);
const stick = useRef(true);
const ep = useRef(endpoint);
useEffect(() => {
const request = nextRequestId();
const channel = new Channel<DockerLogLine>();
let buffer: DockerLogLine[] = [];
let frame = 0;
const flush = () => {
frame = 0;
const add = buffer;
buffer = [];
setLines((prev) => {
const next = prev.concat(add);
return next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next;
});
};
channel.onmessage = (line) => {
buffer.push({ stderr: line.stderr, text: line.text.replace(ANSI, "") });
frame ||= requestAnimationFrame(flush);
};
call(commands.dockerLogs(request, ep.current, id, 1000, running, timestamps, channel)).catch((e) => {
if (!isCancelled(e)) setError(errorMessage(e));
});
return () => {
cancelAnimationFrame(frame);
void commands.cancelRequest(request);
};
}, [id, running, timestamps]);
useEffect(() => {
const el = scroller.current;
if (el && stick.current) el.scrollTop = el.scrollHeight;
}, [lines]);
return (
<div className={styles.detailBody}>
<div className={styles.subToolbar}>
<Toggle checked={timestamps} onChange={setTimestamps} label="Timestamps" />
<Toggle checked={wrap} onChange={setWrap} label="Soft-wrap" />
<span className={styles.flex} />
<IconButton icon="trash" size="small" title="Clear" onClick={() => setLines([])} />
</div>
<div
ref={scroller}
className={clsx(styles.log, wrap && styles.wrap)}
tabIndex={0}
role="log"
onKeyDown={copySelectionOnKeyDown}
aria-label={tx("Container log")}
onScroll={(e) => {
const el = e.currentTarget;
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
<LogPane
sourceKey={`${conn}:${id}:${running}`}
live={running}
toggles={[{ id: "timestamps", label: "Timestamps" }]}
ariaLabel={tx("Container log")}
start={(onLine, onError, on) => {
const request = nextRequestId();
const channel = new Channel<DockerLogLine>();
channel.onmessage = onLine;
call(commands.dockerLogs(request, endpointOf(conn), id, 1000, running, on.has("timestamps"), channel)).catch(
(e) => {
if (!isCancelled(e)) onError(errorMessage(e));
},
);
return () => void commands.cancelRequest(request);
}}
>
{lines.map((l, i) => (
<div key={i} className={clsx(l.stderr && styles.stderr)}>
{l.text || " "}
</div>
))}
{error && <div className={styles.stderr}>{error}</div>}
{!lines.length && !error && (
<div className={styles.dim}>{tx(running ? "Waiting for output…" : "No output")}</div>
)}
</div>
/>
</div>
);
}
+2 -2
Переглянути файл
@@ -82,9 +82,9 @@ export function orderedRoots(
export function expandableKeys(items: readonly ServiceTreeItem[]): string[] {
const out: string[] = [];
const visit = (i: ServiceTreeItem) => {
if (i.children?.length) {
if (i.children?.length || i.expandable) {
out.push(i.key);
i.children.forEach(visit);
i.children?.forEach(visit);
}
};
items.forEach(visit);