дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
809bc1cc2f
коміт
39f6162c12
72 files changed
+1936
-178
No files matched your search
@@ -1090,6 +1090,14 @@ export const commands = {
|
||||
language: string,
|
||||
selections: Range[],
|
||||
} | null) => typedError<null, IdeError>(__TAURI_INVOKE("plugin_view_event", { pluginId, view, event, doc, editor })),
|
||||
/** The Services pane of node `key` of a plugin's `location = "services"` view. */
|
||||
pluginServiceDetail: (pluginId: string, view: string, key: string) => typedError<ServiceTab[], IdeError>(__TAURI_INVOKE("plugin_service_detail", { pluginId, view, key })),
|
||||
/**
|
||||
* Runs the program of the log tab `tab` of node `key`'s Services pane (with
|
||||
* the toggles in `on`), streaming its output until it exits or `request` is
|
||||
* cancelled; returns the exit code.
|
||||
*/
|
||||
pluginServiceLog: (request: RequestId, pluginId: string, view: string, key: string, tab: string, on: string[], onLine: Channel<PluginOutputLine>) => typedError<number | null, IdeError>(__TAURI_INVOKE("plugin_service_log", { request, pluginId, view, key, tab, on, onLine })),
|
||||
/** The page of a plugin's web view or custom editor (`web = …`). */
|
||||
pluginWebPage: (pluginId: string, view: string) => typedError<WebPage, IdeError>(__TAURI_INVOKE("plugin_web_page", { pluginId, view })),
|
||||
/**
|
||||
@@ -5870,6 +5878,12 @@ export type PluginInfo = {
|
||||
previousVersion: string | null,
|
||||
};
|
||||
|
||||
/** A line a view's `process` body printed. */
|
||||
export type PluginOutputLine = {
|
||||
stderr: boolean,
|
||||
text: string,
|
||||
};
|
||||
|
||||
/** A secret a plugin declares, and whether the user stored it. */
|
||||
export type PluginSecretState = {
|
||||
name: string,
|
||||
@@ -6956,6 +6970,32 @@ export type ServerStatus = "starting" | "ready" | "busy" | "crashed" | "stopped"
|
||||
*/
|
||||
"misconfigured";
|
||||
|
||||
export type ServiceLog = {
|
||||
program: string,
|
||||
args: string[],
|
||||
toggles: ServiceLogToggle[],
|
||||
};
|
||||
|
||||
/** A switch above the log; when on, `args` are appended and the program restarts. */
|
||||
export type ServiceLogToggle = {
|
||||
id: string,
|
||||
label: string,
|
||||
args: string[],
|
||||
};
|
||||
|
||||
export type ServicePane = { kind: "message"; value: string } |
|
||||
/** Read-only text that can be selected and copied. */
|
||||
{ kind: "text"; value: string } |
|
||||
/** A program the IDE runs (with the plugin's `process:` grant), followed in a log. */
|
||||
{ kind: "log"; value: ServiceLog };
|
||||
|
||||
/** A tab of the Services pane of a plugin's node (`service-provider.detail`). */
|
||||
export type ServiceTab = {
|
||||
id: string,
|
||||
label: string,
|
||||
body: ServicePane,
|
||||
};
|
||||
|
||||
/** Exception filters of one session (see [`DebugManager::exception_filters`]). */
|
||||
export type SessionExceptionFilters = {
|
||||
session: DebugSessionId,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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` });
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)} />;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
.pane {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.toolbar {
|
||||
height: 32px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.flex {
|
||||
flex: 1;
|
||||
}
|
||||
.log {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
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;
|
||||
cursor: default;
|
||||
}
|
||||
.log > div {
|
||||
cursor: text;
|
||||
}
|
||||
.wrap {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.stderr {
|
||||
color: var(--danger);
|
||||
}
|
||||
.dim {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { LogPane, type LogLine } from "./LogPane";
|
||||
|
||||
type Push = (l: LogLine) => void;
|
||||
|
||||
function source() {
|
||||
const pushes: Push[] = [];
|
||||
const exits: ((code: number | null) => void)[] = [];
|
||||
const stops: ReturnType<typeof vi.fn>[] = [];
|
||||
const start = vi.fn(
|
||||
(
|
||||
onLine: Push,
|
||||
_onError: (message: string) => void,
|
||||
_on: ReadonlySet<string>,
|
||||
onExit: (code: number | null) => void,
|
||||
) => {
|
||||
pushes.push(onLine);
|
||||
exits.push(onExit);
|
||||
const stop = vi.fn();
|
||||
stops.push(stop);
|
||||
return stop;
|
||||
},
|
||||
);
|
||||
const push = (text: string, stderr = false) => pushes.at(-1)!({ text, stderr });
|
||||
const exit = (code: number | null) => act(() => exits.at(-1)!(code));
|
||||
return { start, stops, push, exit };
|
||||
}
|
||||
|
||||
let frames: FrameRequestCallback[] = [];
|
||||
const runFrames = () =>
|
||||
act(() => {
|
||||
const run = frames;
|
||||
frames = [];
|
||||
run.forEach((f) => f(0));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
frames = [];
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((fn) => frames.push(fn));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("LogPane", () => {
|
||||
it("drops output that arrives after the source was stopped", () => {
|
||||
const s = source();
|
||||
const { unmount } = render(<LogPane start={s.start} sourceKey="a" live ariaLabel="Log" />);
|
||||
const late = s.start.mock.calls.at(-1)![0];
|
||||
unmount();
|
||||
expect(() => late({ text: "late", stderr: false })).not.toThrow();
|
||||
expect(frames).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("says when the program finished, after its last lines", () => {
|
||||
const s = source();
|
||||
render(<LogPane start={s.start} sourceKey="a" live ariaLabel="Log" />);
|
||||
s.push("done");
|
||||
s.exit(3);
|
||||
expect(screen.getByRole("log").textContent).toBe("doneProcess finished with exit code 3");
|
||||
});
|
||||
|
||||
it("appends lines and strips ANSI", () => {
|
||||
const s = source();
|
||||
render(<LogPane start={s.start} sourceKey="a" live ariaLabel="Log" />);
|
||||
s.push("\x1b[31mred\x1b[0m");
|
||||
runFrames();
|
||||
expect(screen.getByRole("log").textContent).toBe("red");
|
||||
});
|
||||
|
||||
it("keeps the last 5000 lines", () => {
|
||||
const s = source();
|
||||
render(<LogPane start={s.start} sourceKey="a" live ariaLabel="Log" />);
|
||||
for (let i = 0; i <= 5000; i++) s.push(`line ${i}`);
|
||||
runFrames();
|
||||
const lines = screen.getByRole("log").children;
|
||||
expect(lines).toHaveLength(5000);
|
||||
expect(lines[0]?.textContent).toBe("line 1");
|
||||
});
|
||||
|
||||
it("restarts the source when a toggle flips", () => {
|
||||
const s = source();
|
||||
render(
|
||||
<LogPane
|
||||
start={s.start}
|
||||
sourceKey="a"
|
||||
live
|
||||
ariaLabel="Log"
|
||||
toggles={[{ id: "timestamps", label: "Timestamps" }]}
|
||||
/>,
|
||||
);
|
||||
expect(s.start).toHaveBeenCalledTimes(1);
|
||||
expect(s.start.mock.calls[0]?.[2]).toEqual(new Set());
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Timestamps" }));
|
||||
expect(s.start).toHaveBeenCalledTimes(2);
|
||||
expect(s.start.mock.calls[1]?.[2]).toEqual(new Set(["timestamps"]));
|
||||
expect(s.stops.at(0)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops the source on unmount", () => {
|
||||
const s = source();
|
||||
const { unmount } = render(<LogPane start={s.start} sourceKey="a" live ariaLabel="Log" />);
|
||||
unmount();
|
||||
expect(s.stops.at(0)).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { IconButton } from "./Button";
|
||||
import { Toggle } from "./Checkbox";
|
||||
import { copySelectionOnKeyDown } from "./selectableText";
|
||||
import styles from "./LogPane.module.css";
|
||||
|
||||
export interface LogLine {
|
||||
stderr: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface LogToggle {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface LogPaneProps {
|
||||
/**
|
||||
* Starts the source and returns its stop function; called again whenever the toggles or `sourceKey` change.
|
||||
* A program's source reports its exit code with `onExit`.
|
||||
*/
|
||||
start(
|
||||
onLine: (l: LogLine) => void,
|
||||
onError: (message: string) => void,
|
||||
on: ReadonlySet<string>,
|
||||
onExit: (code: number | null) => void,
|
||||
): () => void;
|
||||
sourceKey: string;
|
||||
toggles?: LogToggle[];
|
||||
/** "Waiting for output…" rather than "No output" while empty. */
|
||||
live: boolean;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g; // eslint-disable-line no-control-regex
|
||||
const MAX_LINES = 5000;
|
||||
|
||||
/** A followed log (container, program output) with toggles, Soft-wrap and Clear. */
|
||||
export function LogPane({ sourceKey, ...props }: LogPaneProps) {
|
||||
const [on, setOn] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [wrap, setWrap] = useState(true);
|
||||
const onKey = [...on].sort().join("\0");
|
||||
// A new source (and buffer) per option set.
|
||||
return <LogStream key={`${sourceKey}\0${onKey}`} {...props} on={on} setOn={setOn} wrap={wrap} setWrap={setWrap} />;
|
||||
}
|
||||
|
||||
function LogStream(
|
||||
props: Omit<LogPaneProps, "sourceKey"> & {
|
||||
on: ReadonlySet<string>;
|
||||
setOn: (on: ReadonlySet<string>) => void;
|
||||
wrap: boolean;
|
||||
setWrap: (wrap: boolean) => void;
|
||||
},
|
||||
) {
|
||||
const tx = useTx();
|
||||
const { toggles = [], live, ariaLabel, on, setOn, wrap, setWrap } = props;
|
||||
const [lines, setLines] = useState<LogLine[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [exit, setExit] = useState<number | null | undefined>(undefined);
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const stick = useRef(true);
|
||||
const source = useRef({ start: props.start, on });
|
||||
|
||||
useEffect(() => {
|
||||
let buffer: LogLine[] = [];
|
||||
let frame = 0;
|
||||
// Output that arrives after the stop (a source that already ran) is dropped.
|
||||
let stopped = false;
|
||||
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;
|
||||
});
|
||||
};
|
||||
const stop = source.current.start(
|
||||
(line) => {
|
||||
if (stopped) return;
|
||||
buffer.push({ stderr: line.stderr, text: line.text.replace(ANSI, "") });
|
||||
frame ||= requestAnimationFrame(flush);
|
||||
},
|
||||
(message) => !stopped && setError(message),
|
||||
source.current.on,
|
||||
(code) => {
|
||||
if (stopped) return;
|
||||
flush();
|
||||
setExit(code);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
stopped = true;
|
||||
cancelAnimationFrame(frame);
|
||||
stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scroller.current;
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
}, [lines]);
|
||||
|
||||
const toggle = (id: string, checked: boolean) => {
|
||||
const next = new Set(on);
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
setOn(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.pane}>
|
||||
<div className={styles.toolbar}>
|
||||
{toggles.map((t) => (
|
||||
<Toggle key={t.id} checked={on.has(t.id)} onChange={(checked) => toggle(t.id, checked)} label={t.label} />
|
||||
))}
|
||||
<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={ariaLabel}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
||||
}}
|
||||
>
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} className={clsx(l.stderr && styles.stderr)}>
|
||||
{l.text || " "}
|
||||
</div>
|
||||
))}
|
||||
{error && <div className={styles.stderr}>{error}</div>}
|
||||
{exit !== undefined && (
|
||||
<div className={styles.dim}>
|
||||
{exit === null ? "Process finished" : `Process finished with exit code ${exit}`}
|
||||
</div>
|
||||
)}
|
||||
{!lines.length && !error && exit === undefined && (
|
||||
<div className={styles.dim}>{tx(live ? "Waiting for output…" : "No output")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,3 +38,4 @@ export { selectableText, copyMenuFor, copyText } from "./selectableText";
|
||||
export { useSelectionBoundary } from "./useSelectionBoundary";
|
||||
export { useSanitizedField } from "./useSanitizedField";
|
||||
export * from "./rowSelection";
|
||||
export * from "./LogPane";
|
||||
@@ -208,6 +208,8 @@ export interface ServiceTreeItem {
|
||||
dim?: boolean;
|
||||
/** Expanded the first time the node appears. */
|
||||
expanded?: boolean;
|
||||
/** Shows an expander before its children are known (the provider's `expand` loads them). */
|
||||
expandable?: boolean;
|
||||
/** Undefined for leaves. */
|
||||
children?: ServiceTreeItem[];
|
||||
}
|
||||
@@ -239,6 +241,10 @@ export interface ServiceProviderContribution {
|
||||
menu?(item: ServiceTreeItem): MenuItem[];
|
||||
/** Double click / Enter. */
|
||||
activate?(item: ServiceTreeItem): void;
|
||||
/** A node of this provider became the lead selection. */
|
||||
select?(item: ServiceTreeItem): void;
|
||||
/** A node of this provider was expanded or collapsed. */
|
||||
expand?(item: ServiceTreeItem, expanded: boolean): void;
|
||||
/** Right pane for a selected node. */
|
||||
detail?: ComponentType<{ item: ServiceTreeItem }>;
|
||||
/** Status line above the tree (a long operation in progress…); renders null when idle. */
|
||||
@@ -340,6 +346,11 @@ export const EditorShownProvider = EditorShown.Provider;
|
||||
/** Whether this editor tab is the one its pane shows (a `keepAlive` tab stays mounted when it isn't). */
|
||||
export const useEditorShown = () => useContext(EditorShown);
|
||||
|
||||
const ToolWindowShown = createContext(true);
|
||||
export const ToolWindowShownProvider = ToolWindowShown.Provider;
|
||||
/** Whether this tool window is showing (one shown once stays mounted while hidden). */
|
||||
export const useToolWindowShown = () => useContext(ToolWindowShown);
|
||||
|
||||
export const registerEditorKind = (c: EditorKindContribution) => register("editorKinds", c.kind, c);
|
||||
export const registerTitleItem = (c: TitleItemContribution) => register("titleItems", c.id, c);
|
||||
export const registerServiceView = (c: ServiceViewContribution) => register("serviceViews", c.id, c);
|
||||
|
||||
@@ -20,7 +20,12 @@ import { useProject } from "@/platform/project";
|
||||
import { dragRequiresAlt, Icon, IconButton, Menu, showContextMenu, tooltipAttrs, type MenuItem, T } from "@/ui";
|
||||
import { FOCUSABLE } from "@/ui/popupState";
|
||||
import { TabStripRegistry } from "@/ui/Tabs";
|
||||
import { useContributions, type ToolWindowAnchor, type ToolWindowContribution } from "../contributions";
|
||||
import {
|
||||
ToolWindowShownProvider,
|
||||
useContributions,
|
||||
type ToolWindowAnchor,
|
||||
type ToolWindowContribution,
|
||||
} from "../contributions";
|
||||
import { useDistractionFree } from "../distractionFree";
|
||||
import {
|
||||
hideToolWindow,
|
||||
@@ -800,7 +805,9 @@ function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: bo
|
||||
)}
|
||||
<div className={styles.body} data-toolwindow-body="">
|
||||
<ToolWindowFrame.Provider value={frame}>
|
||||
<Content id={tw.id} />
|
||||
<ToolWindowShownProvider value={!hidden}>
|
||||
<Content id={tw.id} />
|
||||
</ToolWindowShownProvider>
|
||||
</ToolWindowFrame.Provider>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in new issue
Заблокувати користувача