feat(plugins): let plugin code open streaming log tabs (#2082)

views.open-log-tab(key, title, process-log) (SDK host::open_log_tab) opens the read-only log tab a web view opens with open-log-tab, from a command or event of a window, reusing the Services pane's process-log.

The host checks the process grant and limits when called and remembers what each key runs in that window; the tab starts it by key (plugin_log_tab_spawn) through the web-view PTY checks: pinned program, trusted project, session cap, lifecycle and grant revocation. Remote windows report that these tabs aren't available there yet.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-08 10:01:35 +08:00
1 джерело ec171c3188
коміт 2bf412ff0d
92 files changed
+496 -50

No files matched your search

+16 -5
Переглянути файл
@@ -9,11 +9,11 @@ import { usePlugins } from "../store";
import { startViewUi } from "../uiPoints";
import { registerLogActions } from "../views/logActions";
import { PluginLogEditor } from "../views/logEditor";
import { boundedLog, logTabData } from "../views/logTab";
import { webPtyBackend } from "../views/ptyBackend";
import { boundedLog, logTabData, type PluginLogEditorData } from "../views/logTab";
import { logTabPtyBackend, webPtyBackend } from "../views/ptyBackend";
import type { PtyBackend } from "../views/ptyBridge";
vi.mock("../views/ptyBackend", () => ({ webPtyBackend: vi.fn() }));
vi.mock("../views/ptyBackend", () => ({ webPtyBackend: vi.fn(), logTabPtyBackend: vi.fn() }));
const plugin = { id: "probe", enabled: true, capabilities: [{ id: "process:bash", granted: true }] } as PluginInfo;
const spec = {
@@ -36,7 +36,7 @@ afterEach(() => {
vi.restoreAllMocks();
});
function setup() {
function setup(extra: Partial<PluginLogEditorData> = {}) {
usePlugins.setState({ plugins: [plugin] });
let output: (text: string) => void = () => {};
let exit: Parameters<PtyBackend["onExit"]>[0] = () => {};
@@ -54,7 +54,8 @@ function setup() {
},
};
vi.mocked(webPtyBackend).mockReturnValue(backend);
const data = logTabData("probe", "logs", spec, 100);
vi.mocked(logTabPtyBackend).mockReturnValue(backend);
const data = { ...logTabData("probe", "logs", spec, 100), ...extra };
const input = { kind: "pluginLog", key: "test-log", data };
const rendered = render(<PluginLogEditor input={input} />);
const content = () => rendered.container.querySelector(".cm-content")!;
@@ -122,6 +123,16 @@ describe("plugin streaming log tabs", () => {
expect(screen.getByRole("textbox", { name: "Find" })).toBeTruthy();
});
it("starts a tab opened by plugin code by its key, with the toggles that are on", async () => {
const f = setup({ view: "", key: "code", origin: "code" });
expect(webPtyBackend).not.toHaveBeenCalled();
expect(logTabPtyBackend).toHaveBeenLastCalledWith("probe", "code", []);
await act(async () => {});
fireEvent.click(screen.getByRole("switch", { name: "Verbose" }));
await waitFor(() => expect(f.backend.kill).toHaveBeenCalledWith(42));
expect(logTabPtyBackend).toHaveBeenLastCalledWith("probe", "code", ["verbose"]);
});
it("kills the previous process on toggle and every lifecycle boundary", async () => {
for (const boundary of ["unmount", "disable", "project", "window"] as const) {
const f = setup();
+18
Переглянути файл
@@ -31,6 +31,8 @@ import { confirm } from "@/workbench/messages";
import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
import { pluginsBackend } from "./backend";
import { usePluginT } from "./pluginText";
import { PLUGIN_LOG_EDITOR_KIND } from "./views/logEditor";
import type { PluginLogEditorData } from "./views/logTab";
import { bumpView, deliverViewMessage } from "./views/viewStore";
import { revealWindowView } from "./views/viewWindows";
import { runPluginCommand, usePlugins } from "./store";
@@ -420,6 +422,22 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
// The program was approved (`process:`) and verified by the host.
void executeAction("terminal.runCommand", { program: e.program, args: e.args, name: e.title, cwd: e.cwd });
return true;
case "openLogTab":
void maybeEditorService()?.openInput({
kind: PLUGIN_LOG_EDITOR_KIND,
key: `pluginLog:${JSON.stringify([e.pluginId, "", e.key])}`,
data: {
pluginId: e.pluginId,
view: "",
key: e.key,
title: e.title,
program: e.log.program,
args: e.log.args,
toggles: e.log.toggles,
origin: "code",
} satisfies PluginLogEditorData,
});
return true;
case "openLocation":
void maybeEditorService()?.openFile(e.path, { selection: { line: e.line, col: e.column }, focus: true });
return true;
+6 -2
Переглянути файл
@@ -23,7 +23,7 @@ import type { EditorInput } from "@/workbench/contributions";
import { usePlugins } from "../store";
import { bindLogControls } from "./logActions";
import { boundedLog, type PluginLogEditorData } from "./logTab";
import { webPtyBackend } from "./ptyBackend";
import { logTabPtyBackend, webPtyBackend } from "./ptyBackend";
import { ptyBridge } from "./ptyBridge";
export const PLUGIN_LOG_EDITOR_KIND = "pluginLog";
@@ -371,7 +371,11 @@ function PluginLogEditorBody({ input, data }: { input: EditorInput; data: Plugin
sequence = undefined;
bridge.handle(JSON.stringify({ op: "pty-ack", sessionId: "log", sequence: consumed }));
};
const bridge = ptyBridge(data.pluginId, webPtyBackend(data.pluginId, data.view), (message) => {
const backend =
data.origin === "code"
? logTabPtyBackend(data.pluginId, data.key ?? "", [...on])
: webPtyBackend(data.pluginId, data.view);
const bridge = ptyBridge(data.pluginId, backend, (message) => {
const m = message as Record<string, unknown>;
if (stopped) return;
if (m.op === "pty-data") {
+2
Переглянути файл
@@ -15,6 +15,8 @@ export interface PluginLogEditorData {
program: string;
args: string[];
toggles: PluginLogToggleSpec[];
/** Opened by the plugin's code (`views.open-log-tab`): the host starts what it approved for `key`. */
origin?: "code";
}
const text = (v: unknown, max: number): v is string =>
+24 -1
Переглянути файл
@@ -1,11 +1,34 @@
import { Channel } from "@tauri-apps/api/core";
import { tx } from "@/platform/i18n";
import type { TerminalInfo, WebPtyCommand } from "@/bindings";
import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { isRemoteWindow } from "@/platform/remote";
import { usePlugins } from "../store";
import type { PtyBackend } from "./ptyBridge";
/** Both frame terminals and log tabs use the backend's exact, pinned process grant. */
export function webPtyBackend(pluginId: string, view: string): PtyBackend {
return pluginPtyBackend(pluginId, (command, channel) =>
call(commands.pluginWebPtySpawn(pluginId, view, command, channel)),
);
}
/**
* A log tab the plugin's code opened: the host runs what it approved for `key`,
* with the toggles in `on`; the command the tab builds only lends its size.
*/
export function logTabPtyBackend(pluginId: string, key: string, on: string[]): PtyBackend {
return pluginPtyBackend(pluginId, (command, channel) => {
if (isRemoteWindow())
throw new Error(tx("Log tabs opened by plugin code are not available in remote projects yet."));
return call(commands.pluginLogTabSpawn(pluginId, key, on, command.cols, command.rows, channel));
});
}
function pluginPtyBackend(
pluginId: string,
start: (command: WebPtyCommand, channel: Channel<string>) => Promise<TerminalInfo>,
): PtyBackend {
return {
spawn: async (command, output) => {
const current = usePlugins.getState().plugins.find((p) => p.id === pluginId);
@@ -17,7 +40,7 @@ export function webPtyBackend(pluginId: string, view: string): PtyBackend {
throw new Error(tx`Cannot start PTY: process:${command.program} capability not granted.`);
const channel = new Channel<string>();
channel.onmessage = output;
return call(commands.pluginWebPtySpawn(pluginId, view, command, channel));
return start(command, channel);
},
write: (id, data) => call(commands.termWrite(id, data)),
resize: (id, cols, rows) => call(commands.termResize(id, cols, rows)),