feat(plugins): generic streaming log editor tab for plugin web views (#2043)

A plugin web view can open a read-only editor tab that streams a process's output, with plugin-defined toggles, Find, wrap, zoom and Clear. The process is authorised in the backend against the exact granted `process:<program>` and its pinned executable, output is bounded, and the stream survives tab switches but ends when the tab closes, the plugin is disabled or the window closes. Adds a web-log test fixture and docs. Verified on macOS; Windows/Linux not run.

Co-authored-by: dengliu <[email protected]>
Цей коміт міститься в:
Deng Liuтаdengliu authored and GitHub committed 2026-11-08 04:06:04 +08:00
1 джерело 292ddc4d2a
коміт fbf57bed23
24 files changed
+1509 -27

No files matched your search

+172
Переглянути файл
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { PluginInfo, TerminalInfo } from "@/bindings";
import { useProject } from "@/platform/project";
import { useContributions } from "@/workbench/contributions";
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 type { PtyBackend } from "../views/ptyBridge";
vi.mock("../views/ptyBackend", () => ({ webPtyBackend: vi.fn() }));
const plugin = { id: "probe", enabled: true, capabilities: [{ id: "process:bash", granted: true }] } as PluginInfo;
const spec = {
program: "bash",
title: "Probe logs",
baseArgs: ["--noprofile", "--norc"],
toggles: [{ id: "verbose", label: "Verbose", args: ["-v"] }],
};
let stopActions: () => void;
beforeEach(() => {
stopActions = registerLogActions();
});
const projectState = useProject.getState();
afterEach(() => {
cleanup();
stopActions();
usePlugins.setState({ plugins: [] });
useProject.setState(projectState, true);
vi.restoreAllMocks();
});
function setup() {
usePlugins.setState({ plugins: [plugin] });
let output: (text: string) => void = () => {};
let exit: Parameters<PtyBackend["onExit"]>[0] = () => {};
const backend: PtyBackend = {
spawn: vi.fn(async (_command, listener) => {
output = listener;
return { id: 42 } as TerminalInfo;
}),
write: vi.fn(async () => {}),
resize: vi.fn(async () => {}),
kill: vi.fn(async () => {}),
onExit: (listener) => {
exit = listener;
return vi.fn();
},
};
vi.mocked(webPtyBackend).mockReturnValue(backend);
const data = logTabData("probe", "logs", spec, 100);
const input = { kind: "pluginLog", key: "test-log", data };
const rendered = render(<PluginLogEditor input={input} />);
const content = () => rendered.container.querySelector(".cm-content")!;
const view = () => EditorView.findFromDOM(content() as HTMLElement)!;
return {
backend,
rendered,
input,
view,
output: (text: string) => act(() => output(text)),
exit: () => act(() => exit(42, 0)),
};
}
describe("plugin streaming log tabs", () => {
it("rejects malformed requests and exact-grant failures instead of rewriting commands", () => {
usePlugins.setState({ plugins: [plugin] });
for (const bad of [
{ program: "sh" },
{ program: "/bin/bash" },
{ program: " bash" },
{ baseArgs: [123] },
{ baseArgs: ["\0"] },
{ baseArgs: ["x".repeat(4097)] },
{ cwd: "/" },
{ env: { PATH: "/tmp" } },
{ title: "x".repeat(257) },
{ toggles: [spec.toggles[0], spec.toggles[0]] },
{ toggles: [{ id: "v", label: "V", args: [false] }] },
{ key: "x".repeat(129) },
])
expect(() => logTabData("probe", "logs", { ...spec, ...bad }, 100)).toThrow();
expect(() => logTabData("probe", "logs", spec, (2 << 20) + 1)).toThrow();
usePlugins.setState({ plugins: [{ ...plugin, enabled: false }] });
expect(() => logTabData("probe", "logs", spec, 100)).toThrow();
});
it("bounds newline-free output and retained lines", () => {
expect(boundedLog("x".repeat(2 << 20))).toHaveLength(1 << 20);
expect(boundedLog("😀" + "x".repeat((1 << 20) - 1))).toBe("x".repeat((1 << 20) - 1));
expect(boundedLog(Array.from({ length: 6000 }, (_, i) => `${i}`).join("\n")).split("\n")).toHaveLength(5000);
});
it("renders partial output read-only, handles early exit, clears, wraps and finds", async () => {
const f = setup();
expect(webPtyBackend).toHaveBeenCalledWith("probe", "logs");
expect(f.backend.spawn).toHaveBeenCalledWith(
expect.objectContaining({ program: "bash", env: {}, args: spec.baseArgs }),
expect.any(Function),
);
expect(f.view().state.facet(EditorState.readOnly)).toBe(true);
expect(f.view().contentDOM.getAttribute("contenteditable")).toBe("false");
f.output("INFO partial");
f.exit();
await waitFor(() => expect(f.view().state.doc.toString()).toContain("[Process finished with exit code 0]"));
expect(f.view().state.doc.toString()).toContain("INFO partial");
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
expect(f.view().state.doc.length).toBe(0);
const wasWrapped = f.view().contentDOM.classList.contains("cm-lineWrapping");
fireEvent.click(screen.getByRole("switch", { name: "Soft-wrap" }));
expect(screen.getByRole("switch", { name: "Soft-wrap" }).getAttribute("aria-checked")).toBe(String(!wasWrapped));
expect(f.view().contentDOM.classList.contains("cm-lineWrapping")).toBe(!wasWrapped);
fireEvent.click(screen.getByRole("button", { name: "Find in Logs" }));
expect(screen.getByRole("textbox", { name: "Find" })).toBeTruthy();
});
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();
await act(async () => {});
fireEvent.click(screen.getByRole("switch", { name: "Verbose" }));
await waitFor(() => expect(f.backend.kill).toHaveBeenCalledWith(42));
expect(f.backend.spawn).toHaveBeenLastCalledWith(
expect.objectContaining({ args: [...spec.baseArgs, "-v"] }),
expect.any(Function),
);
vi.mocked(f.backend.kill).mockClear();
act(() => {
if (boundary === "unmount") f.rendered.unmount();
if (boundary === "disable") usePlugins.setState({ plugins: [{ ...plugin, enabled: false }] });
if (boundary === "project") useProject.setState({ project: { root: "/other" } as never });
if (boundary === "window") window.dispatchEvent(new Event("pagehide"));
});
expect(f.backend.kill).toHaveBeenCalledWith(42);
f.rendered.unmount();
}
});
it("kills a spawn that resolves after its tab closes", async () => {
const f = setup();
let resolve!: (info: TerminalInfo) => void;
vi.mocked(f.backend.spawn).mockImplementation(
() =>
new Promise((r) => {
resolve = r;
}),
);
fireEvent.click(screen.getByRole("switch", { name: "Verbose" }));
f.rendered.unmount();
await act(async () => resolve({ id: 99 } as TerminalInfo));
expect(f.backend.kill).toHaveBeenCalledWith(99);
});
it("registers a transient tab that stays mounted when switching editors and has no dirty document", () => {
const dispose = startViewUi(() => {});
try {
const kind = useContributions.getState().editorKinds.get("pluginLog")!;
expect(kind.keepAlive).toBe(true);
expect(kind.restorable).not.toBe(true);
expect(kind.document).toBeUndefined();
} finally {
dispose();
}
});
});
+44
Переглянути файл
@@ -0,0 +1,44 @@
import { afterEach, expect, it, vi } from "vitest";
import type { PluginInfo } from "@/bindings";
import { commands } from "@/platform/ipc";
import { usePlugins } from "../store";
import { webPtyBackend } from "../views/ptyBackend";
vi.mock("@tauri-apps/api/core", () => ({
Channel: class {
onmessage?: (data: string) => void;
},
}));
vi.mock("@/platform/ipc", async (original) => ({
...(await original<object>()),
isTauri: () => true,
commands: { pluginWebPtySpawn: vi.fn(async () => ({ status: "ok", data: { id: 42 } })) },
}));
afterEach(() => {
usePlugins.setState({ plugins: [] });
vi.clearAllMocks();
});
it("uses the plugin-scoped backend for both log and frame streams, rechecking the current grant", async () => {
const plugin = { id: "probe", enabled: true, capabilities: [{ id: "process:bash", granted: true }] } as PluginInfo;
const command = { program: "bash", args: [], env: {}, cols: 240, rows: 40 };
const backend = webPtyBackend("probe", "logs");
usePlugins.setState({ plugins: [plugin] });
const output = vi.fn();
await expect(backend.spawn(command, output)).resolves.toEqual({ id: 42 });
expect(commands.pluginWebPtySpawn).toHaveBeenCalledWith(
"probe",
"logs",
command,
expect.objectContaining({ onmessage: output }),
);
for (const p of [
{ ...plugin, capabilities: [] },
{ ...plugin, enabled: false },
]) {
usePlugins.setState({ plugins: [p] });
await expect(backend.spawn(command, output)).rejects.toThrow("capability not granted");
}
expect(commands.pluginWebPtySpawn).toHaveBeenCalledOnce();
});
+58
Переглянути файл
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { act, render, waitFor } from "@testing-library/react";
import type { PluginInfo, ViewEvent } from "@/bindings";
import { addFileIconProvider, fileIconName, registerExternalIcons } from "@/ui";
import { provideEditorService, type EditorInput, type EditorService } from "@/workbench/contributions";
import {
addContributedFileTemplate,
fileTemplates,
@@ -253,6 +254,63 @@ describe("WebView", () => {
}
});
it("opens a streaming log editor tab only when process:<program> capability is granted", async () => {
const opened: EditorInput[] = [];
provideEditorService({
openFile: async () => {},
openVirtual: () => {},
openInput: async (input: EditorInput) => void opened.push(input),
openDiff: () => {},
openMerge: () => {},
closeFile: () => {},
activeFile: () => null,
} as unknown as EditorService);
const { view, events } = setup(true);
const { send } = await frameOf(view.container);
const payload = {
op: "open-log-tab",
title: "App Logs",
key: "shared",
program: "tail",
baseArgs: ["-f", "app.log"],
toggles: [{ id: "verbose", label: "Verbose", args: ["-v"] }],
};
// Denied when process:tail is not granted.
send({ text: JSON.stringify(payload) });
await new Promise((r) => setTimeout(r, 0));
expect(opened).toHaveLength(0);
expect(events).toHaveLength(0);
// Granted when process:tail is granted.
usePlugins.setState({
plugins: [
{
id: "acme.draw",
enabled: true,
hasCode: true,
capabilities: [{ id: "process:tail", granted: true }],
} as unknown as PluginInfo,
],
});
send({ text: JSON.stringify(payload) });
await waitFor(() => expect(opened).toHaveLength(1));
expect(opened[0]).toMatchObject({
kind: "pluginLog",
key: `pluginLog:${JSON.stringify(["acme.draw", "draw", "shared"])}`,
data: {
view: "draw",
pluginId: "acme.draw",
title: "App Logs",
program: "tail",
baseArgs: ["-f", "app.log"],
toggles: [{ id: "verbose", label: "Verbose", args: ["-v"] }],
},
});
view.unmount();
});
it("closes frame PTYs on plugin changes, navigation, window teardown and unmount", async () => {
const original = ptyModule.ptyBridge;
const closes: ReturnType<typeof vi.fn>[] = [];
+10
Переглянути файл
@@ -63,9 +63,11 @@ import {
type CustomEditorDecl,
} from "./views/customEditor";
import { layoutActions, layoutWrapper } from "./views/editorLayout";
import { PLUGIN_LOG_EDITOR_KIND, PluginLogEditor, pluginLogEditorTitle } from "./views/logEditor";
import { PluginView } from "./views/PluginView";
import { pluginServiceProvider } from "./views/serviceProvider";
import { WebView } from "./views/WebView";
import { registerLogActions } from "./views/logActions";
import styles from "./PluginsPage.module.css";
// --- menus ------------------------------------------------------------------------
@@ -607,6 +609,7 @@ const openWith = new Map<string, { pluginId: string; decl: CustomEditorDecl; mat
/** One-time registrations behind the points: the editor kind and the context menu group. */
export function startViewUi(openInput: (input: ReturnType<typeof customEditorInput>) => void): () => void {
const disposers = [
registerLogActions(),
registerEditorKind({
kind: CUSTOM_EDITOR_KIND,
component: CustomEditor,
@@ -615,6 +618,13 @@ export function startViewUi(openInput: (input: ReturnType<typeof customEditorInp
restorable: true,
document: customEditorDocument,
}),
registerEditorKind({
kind: PLUGIN_LOG_EDITOR_KIND,
component: PluginLogEditor,
keepAlive: true,
title: pluginLogEditorTitle,
icon: () => "file-log",
}),
registerMenuGroup({
id: "plugins",
menus: ["editor", "projectView", "editorTab"],
+26 -24
Переглянути файл
@@ -16,15 +16,17 @@
* - passes on IDE shortcuts it doesn't handle itself (Save, Close…).
*/
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { Channel } from "@tauri-apps/api/core";
import { tx } from "@/platform/i18n";
import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
import { EmptyState, Spinner } from "@/ui";
import { editorService } from "@/workbench/contributions";
import { pluginsBackend, type FileFilter } from "../backend";
import { errorText, usePlugins } from "../store";
import { FRAME_TAG, frameTheme, frameThemeMessage } from "./frame";
import { PLUGIN_LOG_EDITOR_KIND } from "./logEditor";
import { logTabData } from "./logTab";
import { webPtyBackend } from "./ptyBackend";
import { onViewMessage } from "./viewStore";
import { ptyBridge } from "./ptyBridge";
import styles from "./PluginView.module.css";
@@ -156,29 +158,28 @@ export function WebView({ pluginId, view, label, document = null, doc = null }:
useEffect(() => {
let dialog = false;
const handleLogTabMessage = (rawText: string): boolean => {
let parsed: unknown;
try {
parsed = JSON.parse(rawText);
} catch {
return false;
}
if (!parsed || typeof parsed !== "object" || !("op" in parsed) || parsed.op !== "open-log-tab") return false;
try {
const data = logTabData(pluginId, view, parsed, rawText.length);
void editorService().openInput({
kind: PLUGIN_LOG_EDITOR_KIND,
key: `pluginLog:${JSON.stringify([pluginId, view, data.key ?? data.title])}`,
data,
});
} catch (err) {
post({ text: JSON.stringify({ op: "log-error", error: errorText(err) }) });
}
return true;
};
const createPtys = () =>
ptyBridge(
pluginId,
{
spawn: async (command, output) => {
const current = usePlugins.getState().plugins.find((p) => p.id === pluginId);
if (
!isTauri() ||
!current?.enabled ||
!current.capabilities.some((c) => c.id === `process:${command.program}` && c.granted)
)
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));
},
write: (id, data) => call(commands.termWrite(id, data)),
resize: (id, cols, rows) => call(commands.termResize(id, cols, rows)),
kill: (id) => call(commands.termKill(id)),
onExit: (listener) => onIdeEvent("terminalExited", (ev) => listener(ev.terminal, ev.code)),
},
(message) => post({ text: JSON.stringify(message) }),
);
ptyBridge(pluginId, webPtyBackend(pluginId, view), (message) => post({ text: JSON.stringify(message) }));
let ptys = createPtys();
const resetPtys = () => {
ptys.close();
@@ -272,6 +273,7 @@ export function WebView({ pluginId, view, label, document = null, doc = null }:
(err: unknown) => post({ reply: id, error: errorText(err) }),
);
} else if (typeof m.text === "string") {
if (handleLogTabMessage(m.text)) return;
if (ptys.handle(m.text)) return;
if (hasCode) {
pluginsBackend()
+53
Переглянути файл
@@ -0,0 +1,53 @@
import { registerActions } from "@/platform/actions";
import { editorService } from "@/workbench/contributions";
export interface LogControls {
key: string;
find(): void;
clear(): void;
wrap(): void;
toggle(id: string, checked: boolean): void;
}
const controls = new Map<string, LogControls>();
export function bindLogControls(id: string, value: LogControls): () => void {
controls.set(id, value);
return () => {
controls.delete(id);
};
}
/** Toolbar actions address their own tab; user keybindings address the active input. */
export function registerLogActions(): () => void {
const target = (arg: unknown) => {
if (typeof arg === "string") return controls.get(arg);
const input = editorService().activeInput?.();
return input?.kind === "pluginLog" ? [...controls.values()].find((c) => c.key === input.key) : undefined;
};
return registerActions([
...(
[
["find", "Find in Logs"],
["clear", "Clear"],
["wrap", "Soft-wrap"],
] as const
).map(([operation, title]) => ({
id: `plugins.log.${operation}`,
title,
category: "Plugins",
hidden: true,
run: ({ arg }: { arg?: unknown }) => target(arg)?.[operation](),
})),
{
id: "plugins.log.toggleArgument",
title: "Toggle Log Option",
category: "Plugins",
hidden: true,
run: ({ arg }) => {
const m = arg as { target?: string; id?: string; checked?: boolean } | undefined;
if (m && typeof m.id === "string" && typeof m.checked === "boolean") target(m.target)?.toggle(m.id, m.checked);
},
},
]);
}
+494
Переглянути файл
@@ -0,0 +1,494 @@
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { Compartment, EditorState, Prec, type StateEffect } from "@codemirror/state";
import { EditorView, highlightSpecialChars, keymap } from "@codemirror/view";
import { defaultKeymap } from "@codemirror/commands";
import { LanguageSupport, syntaxHighlighting } from "@codemirror/language";
import { caretRowHighlight } from "@/editor/core/cm/caretRow";
import { findExtension, findResultLines, findScope, type FindOptions } from "@/editor/core/cm/find";
import { openideaHighlightStyle } from "@/editor/core/cm/highlight";
import { overviewLane } from "@/editor/core/cm/lanes";
import { logLanguage } from "@/editor/core/cm/log";
import { caretExtension, fontZoom, setFontZoom, softWrapActive, stepFontZoom } from "@/editor/core/cm/prefs";
import { fontExtension, heavyExtension, lineNumbersExtension, prefsOf } from "@/editor/core/cm/setup";
import { openideaTheme } from "@/editor/core/cm/theme";
import { FindPanel, searchAgain, seedFind, type FindTarget } from "@/editor/core/view/FindBar";
import { executeAction } from "@/platform/actions";
import { editorPrefs } from "@/platform/editorSettings";
import { tx } from "@/platform/i18n";
import { bindingsFor, normalizeBinding, registerKeyClaim, strokeOf } from "@/platform/keymap";
import { useProject } from "@/platform/project";
import { EmptyState, IconButton, Toggle, selectableText } from "@/ui";
import logStyles from "@/ui/LogPane.module.css";
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 { ptyBridge } from "./ptyBridge";
export const PLUGIN_LOG_EDITOR_KIND = "pluginLog";
export function pluginLogEditorTitle(input: EditorInput): string {
const data = input.data as Partial<PluginLogEditorData> | undefined;
return data?.title || input.key;
}
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g; // eslint-disable-line no-control-regex
function isActionStroke(stroke: string | null, actionId: string): boolean {
return !!stroke && bindingsFor(actionId).some((b) => normalizeBinding(b) === stroke);
}
const wheelZoom = EditorView.domEventHandlers({
wheel(e, view) {
if (!e.metaKey && !e.ctrlKey) return false;
e.preventDefault();
stepFontZoom(view, e.deltaY < 0 ? 1 : -1);
return true;
},
});
export function PluginLogEditor({ input }: { input: EditorInput }) {
const data = input.data as PluginLogEditorData | undefined;
if (!data || !data.program) {
return <EmptyState title={tx("Invalid log stream configuration")} />;
}
return <PluginLogEditorBody input={input} data={data} />;
}
function PluginLogEditorBody({ input, data }: { input: EditorInput; data: PluginLogEditorData }) {
const initialWrap = useMemo(() => {
const s = useProject.getState().settings?.editor;
return softWrapActive(s?.softWrap ?? true, prefsOf(s), "stream.log");
}, []);
const [on, setOn] = useState<ReadonlySet<string>>(() => new Set());
const [wrap, setWrap] = useState<boolean>(initialWrap);
const [find, setFind] = useState<{ seq: number } | null>(null);
const [viewGen, setViewGen] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const wrapSlotRef = useRef(new Compartment());
const stickRef = useRef(true);
const receivedRef = useRef(false);
const findOpenRef = useRef(false);
const findWatchersRef = useRef(new Set<(u: { docChanged: boolean; selectionSet: boolean }) => void>());
const findEscapeRef = useRef<() => boolean>(() => false);
const findId = useId();
const findTarget = useMemo<FindTarget>(
() => ({
id: `pluginLog:${findId}:${input.key}`,
view: () => viewRef.current,
watch(fn) {
findWatchersRef.current.add(fn);
return () => void findWatchersRef.current.delete(fn);
},
isOpen: () => findOpenRef.current,
results(options: FindOptions) {
const view = viewRef.current;
if (!view) return null;
const state = view.state;
const scope = state.field(findScope, false) ?? { from: 0, to: state.doc.length };
return {
title: tx`Occurrences of '${options.search}'`,
query: {
pattern: options.search,
isRegex: options.regexp,
caseSensitive: options.caseSensitive,
wholeWord: options.wholeWord,
context: options.context,
include: [],
exclude: [],
scope: { kind: "files", paths: [data.title] },
maxMatches: 0,
},
read: () => ({
path: data.title,
relPath: data.title,
matches: findResultLines(
viewRef.current?.state ?? state,
options,
state.field(findScope, false)
? ((viewRef.current?.state ?? state).field(findScope, false) ?? scope)
: { from: 0, to: (viewRef.current?.state ?? state).doc.length },
),
}),
};
},
}),
[findId, input.key, data.title],
);
const openFind = () => {
if (!viewRef.current) return;
findOpenRef.current = true;
seedFind(findTarget, false);
setFind((f) => ({ seq: (f?.seq ?? 0) + 1 }));
};
const closeFind = () => {
findOpenRef.current = false;
setFind(null);
viewRef.current?.focus();
};
findEscapeRef.current = () => {
if (!findOpenRef.current) return false;
closeFind();
return true;
};
// Mount CodeMirror EditorView with regular editor's theme, font, color scheme, line numbers, find & zoom.
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const fontSlot = new Compartment();
const caretSlot = new Compartment();
const lineNumbersSlot = new Compartment();
const heavySlot = new Compartment();
const wrapSlot = wrapSlotRef.current;
const editorSettings = () => useProject.getState().settings?.editor;
const view = new EditorView({
parent: host,
state: EditorState.create({
doc: tx("Waiting for output…"),
extensions: [
fontSlot.of(fontExtension(editorSettings())),
caretSlot.of(caretExtension(editorPrefs())),
lineNumbersSlot.of(lineNumbersExtension(editorSettings())),
wrapSlot.of(wrap ? EditorView.lineWrapping : []),
heavySlot.of(heavyExtension(0, editorSettings())),
fontZoom,
wheelZoom,
caretRowHighlight,
openideaTheme,
highlightSpecialChars(),
syntaxHighlighting(openideaHighlightStyle),
new LanguageSupport(logLanguage),
findExtension(),
overviewLane,
Prec.high(keymap.of([{ key: "Escape", run: () => findEscapeRef.current() }])),
keymap.of(defaultKeymap),
EditorState.readOnly.of(true),
EditorView.editable.of(false),
EditorView.contentAttributes.of({
"aria-label": data.title,
class: selectableText,
tabindex: "0",
}),
EditorView.updateListener.of((u) => {
if (u.docChanged || u.selectionSet) {
findWatchersRef.current.forEach((fn) => fn(u));
}
}),
],
}),
});
viewRef.current = view;
setViewGen((g) => g + 1);
const onScroll = () => {
const el = view.scrollDOM;
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 32;
};
view.scrollDOM.addEventListener("scroll", onScroll, { passive: true });
let prevAppearance = editorPrefs().appearance;
let prevEditor = editorSettings();
const offSettings = useProject.subscribe(() => {
const effects: StateEffect<unknown>[] = [];
const nextPrefs = editorPrefs();
const nextEditor = editorSettings();
if (JSON.stringify(nextPrefs.appearance) !== JSON.stringify(prevAppearance)) {
prevAppearance = nextPrefs.appearance;
effects.push(caretSlot.reconfigure(caretExtension(nextPrefs)));
}
if (nextEditor !== prevEditor) {
prevEditor = nextEditor;
effects.push(
fontSlot.reconfigure(fontExtension(nextEditor)),
lineNumbersSlot.reconfigure(lineNumbersExtension(nextEditor)),
heavySlot.reconfigure(heavyExtension(view.state.doc.length, nextEditor)),
);
}
if (effects.length) view.dispatch({ effects });
});
return () => {
offSettings();
view.scrollDOM.removeEventListener("scroll", onScroll);
view.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.title]);
// Reconfigure soft wrap when toggled.
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: wrapSlotRef.current.reconfigure(wrap ? EditorView.lineWrapping : []),
});
}, [wrap]);
// Claim Find (⌘F / ⌘G / ⇧⌘G) and Font Zoom (⌘= / ⌘+ / ⌘- / ⌘0 / keymap bindings) while focused.
useEffect(() => {
return registerKeyClaim((e, stroke) => {
if (!rootRef.current?.contains(e.target as Node)) return false;
const view = viewRef.current;
if (!view) return false;
if (isActionStroke(stroke, "find.inFile") || stroke === "Meta+F" || stroke === "Ctrl+F") {
e.preventDefault();
e.stopPropagation();
openFind();
return true;
}
if (isActionStroke(stroke, "find.next") || isActionStroke(stroke, "find.previous")) {
e.preventDefault();
e.stopPropagation();
if (!searchAgain(findTarget, isActionStroke(stroke, "find.next"))) openFind();
return true;
}
if (
isActionStroke(stroke, "editor.increaseFontSize") ||
stroke === "Meta+=" ||
stroke === "Meta+Shift+=" ||
stroke === "Ctrl+=" ||
stroke === "Ctrl+Shift+=" ||
stroke === "Meta+NumpadAdd" ||
stroke === "Ctrl+NumpadAdd"
) {
e.preventDefault();
e.stopPropagation();
stepFontZoom(view, 1);
return true;
}
if (
isActionStroke(stroke, "editor.decreaseFontSize") ||
stroke === "Meta+-" ||
stroke === "Meta+Shift+-" ||
stroke === "Ctrl+-" ||
stroke === "Ctrl+Shift+-" ||
stroke === "Meta+NumpadSubtract" ||
stroke === "Ctrl+NumpadSubtract"
) {
e.preventDefault();
e.stopPropagation();
stepFontZoom(view, -1);
return true;
}
if (isActionStroke(stroke, "editor.resetFontSize") || stroke === "Meta+0" || stroke === "Ctrl+0") {
e.preventDefault();
e.stopPropagation();
view.dispatch({ effects: setFontZoom.of(0) });
return true;
}
if (stroke === "Escape" && findOpenRef.current) {
e.preventDefault();
e.stopPropagation();
closeFind();
return true;
}
return false;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [findTarget]);
const onKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
const stroke = strokeOf(e.nativeEvent);
const view = viewRef.current;
if (!view || !stroke) return;
if (isActionStroke(stroke, "find.inFile") || stroke === "Meta+F" || stroke === "Ctrl+F") {
openFind();
} else if (isActionStroke(stroke, "find.next") || isActionStroke(stroke, "find.previous")) {
if (!searchAgain(findTarget, isActionStroke(stroke, "find.next"))) openFind();
} else if (
isActionStroke(stroke, "editor.increaseFontSize") ||
stroke === "Meta+=" ||
stroke === "Meta+Shift+=" ||
stroke === "Ctrl+="
) {
stepFontZoom(view, 1);
} else if (
isActionStroke(stroke, "editor.decreaseFontSize") ||
stroke === "Meta+-" ||
stroke === "Meta+Shift+-" ||
stroke === "Ctrl+-"
) {
stepFontZoom(view, -1);
} else if (isActionStroke(stroke, "editor.resetFontSize") || stroke === "Meta+0" || stroke === "Ctrl+0") {
view.dispatch({ effects: setFontZoom.of(0) });
} else if (e.key === "Escape" && findOpenRef.current) {
closeFind();
} else {
return;
}
e.preventDefault();
e.stopPropagation();
};
// One bounded PTY bridge per mounted tab; toggling replaces the owned process.
useEffect(() => {
const view = viewRef.current;
if (!view) return;
receivedRef.current = false;
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: tx("Waiting for output…") } });
stickRef.current = true;
let stopped = false;
let pending = "";
let sequence: unknown;
let frame = 0;
const append = (text: string) => {
if (stopped) return;
const previous = receivedRef.current ? view.state.doc.toString() : "";
receivedRef.current = true;
const clean = text.replace(/\r\n/g, "\n").replace(ANSI, "");
const result = boundedLog(previous + clean);
const removed = previous.length + clean.length - result.length;
const stick = stickRef.current;
if (view.state.doc.toString() !== previous || removed > previous.length) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: result } });
} else {
view.dispatch({
changes: [
{ from: 0, to: removed, insert: "" },
{ from: previous.length, insert: clean },
],
});
}
if (stick && !findOpenRef.current) view.scrollDOM.scrollTop = view.scrollDOM.scrollHeight;
};
const flush = () => {
frame = 0;
append(pending);
pending = "";
const consumed = sequence;
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 m = message as Record<string, unknown>;
if (stopped) return;
if (m.op === "pty-data") {
pending = String(m.data);
sequence = m.sequence;
frame ||= requestAnimationFrame(flush);
} else if (m.op === "pty-exit") {
const exitCode = m.exitCode;
append(m.exitCode === null ? tx("\n[Process finished]") : tx`\n[Process finished with exit code ${exitCode}]`);
} else if (m.op === "pty-error") {
if (frame) cancelAnimationFrame(frame);
frame = 0;
append(pending);
pending = "";
const error = m.error;
append(tx`\nError: ${error}`);
}
});
bridge.handle(
JSON.stringify({
op: "pty-spawn",
sessionId: "log",
program: data.program,
args: [...data.baseArgs, ...data.toggles.filter((t) => on.has(t.id)).flatMap((t) => t.args)],
cols: 240,
rows: 40,
}),
);
const stop = (report = true) => {
if (stopped) return;
bridge.close();
if (frame) cancelAnimationFrame(frame);
if (report) append(pending + tx("\n[Log stream stopped]"));
stopped = true;
pending = "";
};
const offPlugin = usePlugins.subscribe((state, previous) => {
const key = (plugins: typeof state.plugins) => {
const p = plugins.find((p) => p.id === data.pluginId);
return (
p &&
JSON.stringify([
p.path,
p.version,
p.enabled,
["failed", "disabled", "needsConsent"].includes(p.status),
p.capabilities,
])
);
};
if (key(state.plugins) !== key(previous.plugins)) stop();
});
const offProject = useProject.subscribe((state, previous) => {
if (state.project !== previous.project) stop();
});
const onUnload = () => stop();
window.addEventListener("pagehide", onUnload);
return () => {
stop(false);
offPlugin();
offProject();
window.removeEventListener("pagehide", onUnload);
};
}, [data, on]);
const toggle = (id: string, checked: boolean) => {
const next = new Set(on);
if (checked) next.add(id);
else next.delete(id);
setOn(next);
};
const clearLogs = () => {
const view = viewRef.current;
if (!view) return;
receivedRef.current = true;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: "" },
});
};
useEffect(() =>
bindLogControls(findId, {
key: input.key,
find: openFind,
clear: clearLogs,
wrap: () => setWrap((value) => !value),
toggle,
}),
);
return (
<div ref={rootRef} className={logStyles.pane} onKeyDown={onKeyDown}>
<div className={logStyles.toolbar}>
{(data.toggles ?? []).map((t) => (
<Toggle
key={t.id}
checked={on.has(t.id)}
onChange={(checked) =>
void executeAction("plugins.log.toggleArgument", { target: findId, id: t.id, checked })
}
label={t.label}
aria-label={t.label}
/>
))}
<Toggle
checked={wrap}
onChange={() => void executeAction("plugins.log.wrap", findId)}
label={tx("Soft-wrap")}
aria-label={tx("Soft-wrap")}
/>
<span className={logStyles.flex} />
<IconButton icon="search" size="small" title="Find in Logs" action="plugins.log.find" actionArg={findId} />
<IconButton icon="trash" size="small" title="Clear" action="plugins.log.clear" actionArg={findId} />
</div>
{find && <FindPanel target={findTarget} version={viewGen} replace={false} seq={find.seq} onClose={closeFind} />}
<div ref={hostRef} className={logStyles.output} />
</div>
);
}
+58
Переглянути файл
@@ -0,0 +1,58 @@
import { tx } from "@/platform/i18n";
import { usePlugins } from "../store";
export interface PluginLogToggleSpec {
id: string;
label: string;
args: string[];
}
export interface PluginLogEditorData {
pluginId: string;
view: string;
key?: string;
title: string;
program: string;
baseArgs: string[];
toggles: PluginLogToggleSpec[];
}
const text = (v: unknown, max: number): v is string =>
typeof v === "string" && v.length > 0 && v.length <= max && !/[\0\r\n]/.test(v);
const args = (v: unknown): v is string[] =>
Array.isArray(v) && v.length <= 256 && v.every((a) => typeof a === "string" && a.length <= 4096 && !a.includes("\0"));
/** Reject malformed requests instead of silently running a different command. */
export function logTabData(pluginId: string, view: string, value: object, length: number): PluginLogEditorData {
const m = value as Record<string, unknown>;
const { program, baseArgs = [], toggles = [], key, title = program } = m;
if (
length > 2 << 20 ||
!text(program, 256) ||
program.trim() !== program ||
!text(title, 256) ||
(key !== undefined && !text(key, 128)) ||
!args(baseArgs) ||
!Array.isArray(toggles) ||
toggles.length > 32 ||
toggles.some((t) => !t || !text(t.id, 64) || !text(t.label, 128) || !args(t.args)) ||
new Set(toggles.map((t) => t.id)).size !== toggles.length ||
baseArgs.length + toggles.reduce((n, t) => n + t.args.length, 0) > 256 ||
"cwd" in m ||
"env" in m
)
throw new Error(tx("Invalid log stream configuration"));
const plugin = usePlugins.getState().plugins.find((p) => p.id === pluginId);
if (!plugin?.enabled || !plugin.capabilities.some((c) => c.id === `process:${program}` && c.granted))
throw new Error(tx`Cannot start PTY: process:${program} capability not granted.`);
return { pluginId, view, key: key as string | undefined, title, program, baseArgs, toggles };
}
/** Bound both line count and text size, including output with no newlines. */
export function boundedLog(text: string): string {
let result = text.slice(-(1 << 20));
if (/^[\uDC00-\uDFFF]/.test(result)) result = result.slice(1);
const lines = result.split("\n");
if (lines.length > 5000) result = lines.slice(-5000).join("\n");
return result;
}
+27
Переглянути файл
@@ -0,0 +1,27 @@
import { Channel } from "@tauri-apps/api/core";
import { tx } from "@/platform/i18n";
import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
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 {
spawn: async (command, output) => {
const current = usePlugins.getState().plugins.find((p) => p.id === pluginId);
if (
!isTauri() ||
!current?.enabled ||
!current.capabilities.some((c) => c.id === `process:${command.program}` && c.granted)
)
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));
},
write: (id, data) => call(commands.termWrite(id, data)),
resize: (id, cols, rows) => call(commands.termResize(id, cols, rows)),
kill: (id) => call(commands.termKill(id)),
onExit: (listener) => onIdeEvent("terminalExited", (ev) => listener(ev.terminal, ev.code)),
};
}
+1 -1
Переглянути файл
@@ -43,7 +43,7 @@ function dimensions(cols: unknown, rows: unknown): cols is number {
);
}
/** One opaque frame owns this bridge. Terminal ids never cross the frame boundary. */
/** One frame or log tab owns this bridge. Terminal ids never cross the frame boundary. */
export function ptyBridge(pluginId: string, backend: PtyBackend, send: (message: object) => void) {
const sessions = new Map<string, Session>();
const earlyExits = new Map<TerminalId, number | null>();