дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15: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
@@ -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);
|
||||
|
||||
Reference in new issue
Заблокувати користувача