feat(run): Run All, Restart All, Stop All and Open in Browser on a type group (#2090)

Services type groups offer Run All, Restart All, Stop All and Open in Browser. Batches follow display order, skip live runs, wait for exits before restarting and continue after a launch failure. Open in Browser uses reported HTTP ports, with a chooser when there are several.

Co-authored-by: qtvbwfn <[email protected]>
Цей коміт міститься в:
Qingтаqtvbwfn authored and GitHub committed 2026-11-09 01:12:11 +08:00
1 джерело 6d8e41c2c1
коміт a73e3c21ad
6 files changed
+368 -8

No files matched your search

+2 -1
Переглянути файл
@@ -42,7 +42,7 @@ import { invalidateGoModules } from "./goModules";
import { invalidatePythonPackages } from "./pythonPackages";
import { RunWidget } from "./RunWidget";
import { runConfigsRunAnything } from "./runAnything";
import { cleanupRemovedProject, recordRun, runServiceProvider } from "./services";
import { cleanupRemovedProject, recordRun, registerRunServiceActions, runServiceProvider } from "./services";
import { liveServiceSession, serviceSessions } from "./serviceSessions";
import { cmakeBuildId } from "./cmake";
import { runBeforeLaunch } from "./beforeLaunch";
@@ -342,6 +342,7 @@ export function activate(): () => void {
}),
registerOverlay({ id: "run.dialogs", component: RunDialogsOverlay }),
registerServiceProvider(runServiceProvider),
registerRunServiceActions(),
registerRunningProcessesProvider({
id: "run",
running: () =>
+205 -1
Переглянути файл
@@ -10,7 +10,10 @@ vi.mock("@/platform/ipc", async (importOriginal) => ({
}));
import {
addType,
cleanupRemovedProject,
runServiceProvider,
registerRunServiceActions,
DEFAULT_TYPES,
readRan,
readTypes,
@@ -19,8 +22,32 @@ import {
sessionStatus,
} from "./services";
import { startedPort } from "@/platform/serverPort";
import { useDebugRunSessions, type DebugServiceSession } from "@/workbench/contributions";
import { useRun, type RunSession } from "./store";
import type { DebugServiceSession } from "@/workbench/contributions";
const { startRun, stopServiceSessions, openUrl, showContextMenu } = vi.hoisted(() => ({
startRun: vi.fn(),
openUrl: vi.fn(),
showContextMenu: vi.fn(),
stopServiceSessions: vi.fn(),
}));
vi.mock("./store", async (importOriginal) => ({ ...(await importOriginal<typeof import("./store")>()), startRun }));
vi.mock("./serviceSessions", async (importOriginal) => ({
...(await importOriginal<typeof import("./serviceSessions")>()),
stopServiceSessions,
}));
vi.mock("./navigate", () => ({ openUrl }));
vi.mock("@/ui", async (importOriginal) => ({ ...(await importOriginal<typeof import("@/ui")>()), showContextMenu }));
let disposeActions: () => void;
beforeEach(() => {
disposeActions = registerRunServiceActions();
useDebugRunSessions.setState({ serviceSessions: [] });
openUrl.mockReset();
showContextMenu.mockReset();
});
afterEach(() => disposeActions());
const config = (id: string, kind: string): RunConfig => ({
id,
@@ -307,6 +334,183 @@ describe("embedded web server port", () => {
});
});
describe("open in browser", () => {
const item = () => ({ key: "run:config:a", label: "a", icon: "" });
const hasBrowser = () => !runServiceProvider.actions!(item()).find((a) => a.id === "open-browser")?.disabled;
beforeEach(() => {
useProject.setState({ project: null });
useRun.setState({ configs: [], sessions: [] });
});
it("offers Open in Browser while a session runs with an HTTP port", () => {
useRun.setState({
configs: [config("a", "spring-boot")],
sessions: [session("s1", config("a", "spring-boot"), "running", 1, { httpPort: 8080 })],
});
expect(hasBrowser()).toBe(true);
});
it("disables Open in Browser without a port or a running session", () => {
useRun.setState({
configs: [config("a", "spring-boot")],
sessions: [session("s1", config("a", "spring-boot"), "running", 1)],
});
expect(hasBrowser()).toBe(false);
useRun.setState({ sessions: [session("s1", config("a", "spring-boot"), "finished", 1, { httpPort: 8080 })] });
expect(hasBrowser()).toBe(false);
});
});
describe("group batch actions", () => {
const group = () => ({ key: "run:type:spring-boot", label: "Spring Boot", icon: "" });
const action = (id: string) =>
runServiceProvider.actions!(group() as never).find((a: { id: string }) => a.id === id)!;
beforeEach(() => {
startRun.mockReset().mockResolvedValue(null);
stopServiceSessions.mockReset().mockImplementation(async (sessions) => {
const ids = sessions.flatMap((s: { run?: RunSession }) => (s.run ? [s.run.id] : []));
useRun.setState((state) => ({
sessions: state.sessions.map((s) => (ids.includes(s.id) ? { ...s, status: "finished" } : s)),
}));
});
useProject.setState({ project: null });
useRun.setState({ configs: [], sessions: [] });
addType("spring-boot");
});
it("enables Run All and Restart All with stopped configurations, Stop All only with running ones", () => {
useRun.setState({ configs: [config("a", "spring-boot"), config("b", "spring-boot")], sessions: [] });
expect(action("run-all").disabled).toBe(false);
expect(action("restart-all").disabled).toBe(false);
expect(action("stop-all").disabled).toBe(true);
});
it("enables Stop All when one runs and Run All while others are stopped", () => {
useRun.setState({
configs: [config("a", "spring-boot"), config("b", "spring-boot")],
sessions: [session("s1", config("a", "spring-boot"), "running", 1)],
});
expect(action("stop-all").disabled).toBe(false);
expect(action("run-all").disabled).toBe(false);
});
it("disables Run All when everything runs", () => {
useRun.setState({
configs: [config("a", "spring-boot")],
sessions: [session("s1", config("a", "spring-boot"), "running", 1)],
});
expect(action("run-all").disabled).toBe(true);
});
it("starts stopped configurations in display order, skipping running ones", async () => {
const a = config("a", "spring-boot");
const b = config("b", "spring-boot");
const c = config("c", "spring-boot");
useRun.setState({ configs: [a, b, c], sessions: [session("s1", a, "running", 1)] });
await action("run-all").run();
expect(startRun.mock.calls).toEqual([
[b, undefined, undefined, { show: false }],
[c, undefined, undefined, { show: false }],
]);
});
it("restarts: stops the running ones, then starts every configuration in order", async () => {
const a = config("a", "spring-boot");
const b = config("b", "spring-boot");
useRun.setState({ configs: [a, b], sessions: [session("s1", a, "running", 1)] });
await action("restart-all").run();
expect(stopServiceSessions).toHaveBeenCalledOnce();
expect(stopServiceSessions.mock.calls[0]![0].map((s: { config: RunConfig }) => s.config.id)).toEqual(["a"]);
expect(startRun.mock.calls).toEqual([
[a, undefined, undefined, { show: false }],
[b, undefined, undefined, { show: false }],
]);
});
it("waits for process exit before restarting any configuration and continues after a launch failure", async () => {
const a = config("a", "spring-boot");
const b = config("b", "spring-boot");
useRun.setState({ configs: [a, b], sessions: [session("s1", a, "running", 1, { runId: 1 })] });
stopServiceSessions.mockResolvedValue(undefined);
startRun.mockRejectedValueOnce(new Error("launch failed"));
const pending = action("restart-all").run();
await vi.waitFor(() => expect(stopServiceSessions).toHaveBeenCalledOnce());
expect(startRun).not.toHaveBeenCalled();
useRun.setState({ sessions: [session("s1", a, "finished", 1, { runId: 1 })] });
await pending;
expect(startRun.mock.calls.map(([c]) => c.id)).toEqual(["a", "b"]);
});
it("uses the saved display order and leaves other types alone", async () => {
useProject.setState({ project: { root: "/group-order" } as never });
localStorage.setItem("openidea.services.runOrder:/group-order", JSON.stringify({ "spring-boot": ["c", "a", "b"] }));
const configs = [
config("a", "spring-boot"),
config("b", "spring-boot"),
config("c", "spring-boot"),
config("d", "npm"),
];
useRun.setState({ configs, sessions: [session("other", configs[3]!, "running", 1)] });
await action("run-all").run();
expect(startRun.mock.calls.map(([c]) => c.id)).toEqual(["c", "a", "b"]);
expect(action("stop-all").disabled).toBe(true);
await action("stop-all").run();
expect(stopServiceSessions).not.toHaveBeenCalled();
});
it("does not resurrect a deleted configuration retained only as finished output", async () => {
const a = config("removed", "spring-boot");
useRun.setState({ configs: [a], sessions: [] });
useRun.setState({ configs: [], sessions: [session("old", a, "finished", 1)] });
expect(action("run-all").disabled).toBe(true);
expect(action("restart-all").disabled).toBe(true);
});
it("rechecks enabled state when a registered action runs", async () => {
const a = config("a", "spring-boot");
useRun.setState({ configs: [a], sessions: [] });
const run = action("run-all");
useRun.setState({ sessions: [session("s1", a, "starting", 1)] });
await run.run();
expect(startRun).not.toHaveBeenCalled();
});
it("opens the known group URL and offers a named choice for several web apps", async () => {
const a = config("a", "spring-boot");
const b = config("b", "spring-boot");
useRun.setState({ configs: [a, b], sessions: [session("s1", a, "running", 1, { httpPort: 8123 })] });
await action("open-browser").run();
expect(openUrl).toHaveBeenCalledWith("http://localhost:8123");
openUrl.mockClear();
useDebugRunSessions.setState({
serviceSessions: [
{
id: 1,
config: b,
status: "running",
processId: null,
startedAt: 2,
httpPort: 8456,
stop: async () => {},
rerun: null,
show: () => {},
},
],
});
await action("open-browser").run();
expect(openUrl).not.toHaveBeenCalled();
const choices = showContextMenu.mock.calls[0]![1];
expect(choices.map((c: { label: string }) => c.label)).toEqual([
"a — http://localhost:8123",
"b — http://localhost:8456",
]);
choices[1].run();
expect(openUrl).toHaveBeenCalledWith("http://localhost:8456");
});
});
describe("removing a workspace member from Services", () => {
const main = { ...config("app", "spring-boot"), cwd: "/w/main" };
const vue = { ...config("dev", "npm"), cwd: "/w/vue" };
+150 -3
Переглянути файл
@@ -8,12 +8,12 @@
*/
import { useEffect, useMemo } from "react";
import { create } from "zustand";
import { executeAction } from "@/platform/actions";
import { executeAction, registerActions } from "@/platform/actions";
import type { RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { projectLocation, useRemote } from "@/platform/remote";
import { tx, useT, useTxTemplate } from "@/platform/i18n";
import { Button, EmptyState, T } from "@/ui";
import { Button, EmptyState, T, showContextMenu } from "@/ui";
import type { MenuItem } from "@/ui";
import {
toolWindowService,
@@ -26,6 +26,7 @@ import {
type ServiceTreeItem,
} from "@/workbench/contributions";
import { commandLine, configIcon, KIND_OPTIONS, kindLabel } from "./configs";
import { openUrl } from "./navigate";
import { SessionPanel } from "./RunView";
import { showApplicationUpdate } from "./applicationUpdate";
import { isUpdatable } from "./updatePolicies";
@@ -38,6 +39,7 @@ import {
startRun,
stopSession,
useRun,
waitForExit,
type RunSession,
isLive,
} from "./store";
@@ -369,9 +371,115 @@ function debug(r: Resolved) {
else if (r.config) void executeAction("run.debugConfig", { config: r.config, show: false });
}
function actions(item: ServiceTreeItem): ServiceItemAction[] {
function groupConfigs(kind: string): RunConfig[] {
const root = useProject.getState().project?.root ?? null;
const location = projectLocation(root);
const { configs, sessions } = useRun.getState();
const debug = useDebugRunSessions.getState().serviceSessions;
const latest = currentServiceSessions(serviceSessions(sessions, debug));
const group = runServiceItems(
configs,
sessions,
currentTypes(),
ranIds(root),
location ? currentServiceOrder(location) : {},
debug,
configRemoved,
).find((item) => item.key === TYPE_PREFIX + kind);
return (group?.children ?? []).flatMap((item) => {
const id = item.key.slice(CONFIG_PREFIX.length);
const config = configs.find((c) => c.id === id) ?? latest.get(id)?.config;
return config ? [config] : [];
});
}
async function startAll(kind: string, restart: boolean): Promise<void> {
const root = useProject.getState().project?.root;
const entries = groupConfigs(kind).filter((c) => !isDebugOnly(c));
if (restart) {
const { sessions } = useRun.getState();
const running = serviceSessions(sessions, useDebugRunSessions.getState().serviceSessions).filter(
(s) => s.config.kind === kind && liveServiceSession(s),
);
await stopServiceSessions(running);
await waitForExit(
running.flatMap((s) => (s.run ? [s.run.id] : [])),
null,
);
}
for (const config of entries) {
if (useProject.getState().project?.root !== root) return;
const running = serviceSessions(useRun.getState().sessions, useDebugRunSessions.getState().serviceSessions);
if (running.some((s) => s.config.id === config.id && liveServiceSession(s))) continue;
try {
await startRun(config, undefined, undefined, { show: false });
} catch (e) {
notifications().notify({ group: "run", level: "error", title: tx`Cannot run '${config.name}'`, body: String(e) });
}
}
}
function browserActions(sessions: readonly ServiceSession[]): ServiceItemAction[] {
const urls = new Map<string, string>();
for (const s of sessions) {
if (!liveServiceSession(s)) continue;
const port = (s.run && isLive(s.run) ? s.run.httpPort : undefined) ?? serviceDebugger(s)?.httpPort;
if (port && Number.isInteger(port) && port > 0 && port < 65536) urls.set(`http://localhost:${port}`, s.config.name);
}
return [
{
id: "open-browser",
title: "Open in Browser",
icon: "web",
disabled: !urls.size,
run: () => {
if (urls.size === 1) return void openUrl(urls.keys().next().value!);
if (!urls.size) return;
showContextMenu(
{ x: window.innerWidth / 2, y: window.innerHeight / 2 },
[...urls].map(([url, name]) => ({
label: `${name} — ${url}`,
verbatim: true,
icon: "web",
run: () => void openUrl(url),
})),
);
},
},
];
}
function itemActions(item: ServiceTreeItem): ServiceItemAction[] {
const r = resolve(item);
const running = r.sessions.filter(liveServiceSession);
if (!r.config && r.kind) {
const kind = r.kind;
const entries = groupConfigs(kind).filter((c) => !isDebugOnly(c));
return [
{
id: "run-all",
title: "Run All",
icon: "run",
disabled: !entries.some((c) => !running.some((s) => s.config.id === c.id)),
run: () => startAll(kind, false),
},
{
id: "restart-all",
title: "Restart All",
icon: "rerun",
disabled: !entries.length,
run: () => startAll(kind, true),
},
{
id: "stop-all",
title: "Stop All",
icon: "stop",
disabled: !running.length,
run: () => stopServiceSessions(running),
},
...browserActions(r.sessions),
];
}
const stop = {
id: "stop",
title: "Stop",
@@ -391,6 +499,7 @@ function actions(item: ServiceTreeItem): ServiceItemAction[] {
run: () => run(r),
},
{ id: "debug", title: "Debug", icon: "debug", disabled: !!debugging && !debugging.rerun, run: () => debug(r) },
...browserActions(r.sessions),
stop,
...(isUpdatable(r.config)
? [
@@ -406,6 +515,44 @@ function actions(item: ServiceTreeItem): ServiceItemAction[] {
];
}
const SERVICE_ACTIONS = [
{ id: "run-all", action: "run.services.runAll", title: "Run All", icon: "run" },
{ id: "restart-all", action: "run.services.restartAll", title: "Restart All", icon: "rerun" },
{ id: "stop-all", action: "run.services.stopAll", title: "Stop All", icon: "stop" },
{ id: "open-browser", action: "run.services.openBrowser", title: "Open in Browser", icon: "web" },
];
export function registerRunServiceActions(): () => void {
return registerActions(
SERVICE_ACTIONS.map(({ id, action, title, icon }) => ({
id: action,
title,
icon,
category: "Run",
hidden: true,
run: async ({ arg }) => {
if (typeof arg !== "string" || (!arg.startsWith(TYPE_PREFIX) && !arg.startsWith(CONFIG_PREFIX))) return;
const action = itemActions({ key: arg, label: "", icon: "" }).find((a) => a.id === id);
if (action && !action.disabled) await action.run();
},
})),
);
}
function actions(item: ServiceTreeItem): ServiceItemAction[] {
return itemActions(item).map((entry) => {
const action = SERVICE_ACTIONS.find((a) => a.id === entry.id);
return action
? {
...entry,
run: async () => {
await executeAction(action.action, item.key);
},
}
: entry;
});
}
function menu(item: ServiceTreeItem): MenuItem[] {
const items: MenuItem[] = actions(item).map((a) => ({
id: a.id,
+6 -3
Переглянути файл
@@ -6,7 +6,7 @@ import { useProject } from "@/platform/project";
import { validateSettings } from "@/features/settings/model/schema";
import { adoptSession, appendConsole, useDebug } from "@/features/debugger/state";
import { DebugServiceConsole } from "@/features/debugger/ui/DebugConsole";
import { runServiceProvider } from "@/features/run/services";
import { registerRunServiceActions, runServiceProvider } from "@/features/run/services";
import { onRunExited, runtime, startRun, useRun } from "@/features/run/store";
import {
provideToolWindowService,
@@ -53,6 +53,7 @@ const summary = (id: number, extra: Partial<DebugServiceSession> = {}): DebugSer
...extra,
});
let dispose: () => void;
let disposeActions: () => void;
beforeEach(() => {
localStorage.clear();
useProject.setState({
@@ -68,10 +69,12 @@ beforeEach(() => {
expanded: new Set(["run:type:spring-boot"]),
});
dispose = registerServiceProvider(runServiceProvider);
disposeActions = registerRunServiceActions();
});
afterEach(() => {
vi.restoreAllMocks();
dispose();
disposeActions();
useDebugRunSessions.setState({ sessions: [], serviceSessions: [], Console: null });
});
const select = () =>
@@ -238,13 +241,13 @@ it("shows the owning Run output for attached debugging and overrides both sets o
expect(actions.getByRole("button", { name: "Stop" })).toBeEnabled();
});
it("group Stop stops all roots but terminates a shared Maven process only once", async () => {
it("group Stop All stops all roots but terminates a shared Maven process only once", async () => {
const first = summary(1, { processId: 7 }),
second = summary(2, { processId: 7 });
useDebugRunSessions.setState({ serviceSessions: [first, second] });
render(<ServicesTreeView />);
fireEvent.mouseDown(within(screen.getByRole("tree")).getByText("Spring Boot").closest('[role="treeitem"]')!);
fireEvent.click(toolbar().getByRole("button", { name: "Stop" }));
fireEvent.click(toolbar().getByRole("button", { name: "Stop All" }));
await vi.waitFor(() => expect(second.stop).toHaveBeenCalledOnce());
expect(first.stop).toHaveBeenCalledExactlyOnceWith({ stopProcess: false });
expect(second.stop).toHaveBeenCalledExactlyOnceWith();
+4
Переглянути файл
@@ -494,6 +494,10 @@
"Could not copy. Select the details below and copy them.": "Could not copy. Select the details below and copy them.",
"Startup error details": "Startup error details",
"Startup did not finish within 30 seconds.": "Startup did not finish within 30 seconds.",
"Run All": "Run All",
"Restart All": "Restart All",
"Stop All": "Stop All",
"Open in Browser": "Open in Browser",
"Build Module": "Build Module",
"Build Artifacts": "Build Artifacts",
"Build Module '{target}'": "Build Module '{target}'",
+1
Переглянути файл
@@ -6836,6 +6836,7 @@
"Could not copy. Select the details below and copy them.": "无法复制。请选择下方详情并手动复制。",
"Startup error details": "启动错误详情",
"Startup did not finish within 30 seconds.": "启动未在 30 秒内完成。",
"Restart All": "全部重启",
"Build Module '{target}'": "构建模块“{target}”",
"Build cancelled": "构建已取消",
"Build did not start": "构建未启动",