diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 9655f46cb..3460be206 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -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: () => diff --git a/src/features/run/services.test.ts b/src/features/run/services.test.ts index 4d5b2e518..86c14bf7d 100644 --- a/src/features/run/services.test.ts +++ b/src/features/run/services.test.ts @@ -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()), startRun })); +vi.mock("./serviceSessions", async (importOriginal) => ({ + ...(await importOriginal()), + stopServiceSessions, +})); + +vi.mock("./navigate", () => ({ openUrl })); +vi.mock("@/ui", async (importOriginal) => ({ ...(await importOriginal()), 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" }; diff --git a/src/features/run/services.tsx b/src/features/run/services.tsx index c9cfe5311..26ec0716d 100644 --- a/src/features/run/services.tsx +++ b/src/features/run/services.tsx @@ -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 { + 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(); + 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, diff --git a/src/features/services/ServicesTree.debug.test.tsx b/src/features/services/ServicesTree.debug.test.tsx index cdd11427a..e8df47d4c 100644 --- a/src/features/services/ServicesTree.debug.test.tsx +++ b/src/features/services/ServicesTree.debug.test.tsx @@ -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 = {}): 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(); 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(); diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 16588b41b..32044e5ad 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -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}'", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 4447871be..961548479 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "构建未启动",