дзеркало
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
@@ -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";
|
||||
Reference in new issue
Заблокувати користувача