From cfd2970531954da472dad144dc4d1a1a8f01be28 Mon Sep 17 00:00:00 2001
From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Date: Thu, 29 Oct 2026 01:06:32 +0800
Subject: [PATCH] feat(services): List run configurations in the Services tool
window (#160)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Services tool window lists run configurations next to Docker. Spring Boot configurations appear by default; other types (Application, npm, Maven, Gradle, …) are added with + › Run Configuration Type and removed with Remove from Services, remembered per project. Nodes group by type and show running / stopped, with Run / Rerun, Debug, Stop, Show in Run Tool Window and Edit Configuration… in the toolbar and context menu; the detail pane shows the same console as the Run tool window. Running Spring Boot apps show the HTTP port read from the embedded server's "started on port" line.
Rerun now waits for the old process to exit before starting the new one, so a restarted server no longer fails with its port already in use, and a process stopped by the user no longer raises an exit-code notification.
Closes #150
Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
---
src/features/run/RunView.tsx | 2 +-
src/features/run/configs.test.ts | 5 +
src/features/run/serverPort.ts | 15 +++
src/features/run/services.test.ts | 49 ++++++++-
src/features/run/services.tsx | 138 +++++++++++++++----------
src/features/run/session.test.tsx | 29 +++++-
src/features/run/store.ts | 41 +++++++-
src/features/services/services.test.ts | 11 +-
src/features/services/tree.ts | 18 +++-
src/platform/locales/text/zh.json | 3 +-
src/workbench/contributions.ts | 2 +
11 files changed, 248 insertions(+), 65 deletions(-)
create mode 100644 src/features/run/serverPort.ts
diff --git a/src/features/run/RunView.tsx b/src/features/run/RunView.tsx
index f45f471aa..acc964e22 100644
--- a/src/features/run/RunView.tsx
+++ b/src/features/run/RunView.tsx
@@ -42,7 +42,7 @@ function sessionIcon(s: RunSession): string {
return s.config.isTest ? "check" : "run";
}
-function SessionPanel({ session }: { session: RunSession }) {
+export function SessionPanel({ session }: { session: RunSession }) {
const version = useRun((s) => s.versions[session.id] ?? 0);
const rt = runtime(session.id);
const [follow, setFollow] = useState(true);
diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts
index 6258c3f86..81026a41b 100644
--- a/src/features/run/configs.test.ts
+++ b/src/features/run/configs.test.ts
@@ -82,6 +82,11 @@ describe("config merge / override", () => {
cfg({ id: "cargo:run:app", name: "cargo run app" }),
];
expect(defaultSelection(list)?.id).toBe("cargo:run:app");
+ const spring = [
+ cfg({ id: "maven:spring-boot:run", kind: "maven" }),
+ cfg({ id: "application:demo.App", kind: "spring-boot" }),
+ ];
+ expect(defaultSelection(spring)?.id).toBe("application:demo.App");
});
});
diff --git a/src/features/run/serverPort.ts b/src/features/run/serverPort.ts
new file mode 100644
index 000000000..8ed67ebab
--- /dev/null
+++ b/src/features/run/serverPort.ts
@@ -0,0 +1,15 @@
+/**
+ * The HTTP port of a running Spring Boot application, from the embedded web
+ * server's startup line: "Tomcat started on port 8080 (http) with context path '/'",
+ * "Netty started on port 8080", "Undertow started on port(s) 8080 (http)",
+ * "Jetty started on port(s) 8080 (http/1.1)".
+ */
+const STARTED = /\b(?:Tomcat|Netty|Undertow|Jetty)\b.*?\bstarted on port(?:\(s\))?:?\s*(\d{1,5})/i;
+
+export function startedPort(line: string): number | null {
+ if (!line.includes("tarted on port")) return null;
+ const m = STARTED.exec(line);
+ if (!m) return null;
+ const port = Number(m[1]);
+ return port > 0 && port < 65536 ? port : null;
+}
diff --git a/src/features/run/services.test.ts b/src/features/run/services.test.ts
index 3390e5735..b17c04c67 100644
--- a/src/features/run/services.test.ts
+++ b/src/features/run/services.test.ts
@@ -1,6 +1,7 @@
-import { describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it } from "vitest";
import type { RunConfig } from "@/platform/ipc";
-import { runServiceItems, sessionStatus } from "./services";
+import { DEFAULT_TYPES, readTypes, runServiceItems, sessionDetail, sessionStatus } from "./services";
+import { startedPort } from "./serverPort";
import type { RunSession } from "./store";
const config = (id: string, kind: string): RunConfig => ({
@@ -73,4 +74,48 @@ describe("run configurations in Services", () => {
expect(sessionStatus(session("a", build, "failed", 0, { stopped: true }))).toBe("Stopped");
expect(sessionStatus(session("a", build, "finished", 0))).toBe("Finished");
});
+
+ it("shows Spring Boot by default, hidden while the project has none", () => {
+ const app = config("spring:app", "spring-boot");
+ expect(runServiceItems([build], [], DEFAULT_TYPES)).toEqual([]);
+ const items = runServiceItems([build, app], [], DEFAULT_TYPES);
+ expect(items.map((i) => [i.key, i.label, i.expanded])).toEqual([["run:type:spring-boot", "Spring Boot", true]]);
+ expect(items[0]!.children!.map((c) => [c.label, c.detail, c.dim])).toEqual([["spring:app", undefined, true]]);
+ });
+
+ it("shows the HTTP port of a running application instead of its state", () => {
+ const app = config("spring:app", "spring-boot");
+ const running = session("s1", app, "running", 1, { httpPort: 8080 });
+ expect(sessionDetail(running)).toBe(":8080");
+ expect(sessionDetail(session("s1", app, "running", 1))).toBe("Running");
+ expect(sessionDetail(session("s1", app, "finished", 1, { httpPort: 8080, stopped: true }))).toBe("Stopped");
+ expect(runServiceItems([app], [running], DEFAULT_TYPES)[0]!.children![0]!.detail).toBe(":8080");
+ });
+});
+
+describe("types added to Services", () => {
+ beforeEach(() => localStorage.clear());
+
+ it("defaults to Spring Boot and persists the user's list per project", () => {
+ expect(readTypes("/p")).toEqual(["spring-boot"]);
+ localStorage.setItem("openidea.services.runTypes:/p", JSON.stringify(["npm"]));
+ expect(readTypes("/p")).toEqual(["npm"]);
+ localStorage.setItem("openidea.services.runTypes:/p", "[]");
+ expect(readTypes("/p")).toEqual([]);
+ expect(readTypes("/q")).toEqual(["spring-boot"]);
+ });
+});
+
+describe("embedded web server port", () => {
+ it("reads the port from the started line", () => {
+ expect(startedPort("TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'")).toBe(8080);
+ expect(startedPort("Tomcat started on port(s): 8081 (http) with context path ''")).toBe(8081);
+ expect(startedPort("o.s.b.web.embedded.netty.NettyWebServer : Netty started on port 9090")).toBe(9090);
+ expect(startedPort("Netty started on port(s): 9091")).toBe(9091);
+ expect(startedPort("Undertow started on port(s) 8082 (http)")).toBe(8082);
+ expect(startedPort("Jetty started on port 8083 (http/1.1) with context path '/'")).toBe(8083);
+ expect(startedPort("Tomcat initialized with port 8080 (http)")).toBeNull();
+ expect(startedPort("Started DemoApplication in 1.2 seconds")).toBeNull();
+ expect(startedPort("Tomcat started on port 99999")).toBeNull();
+ });
});
diff --git a/src/features/run/services.tsx b/src/features/run/services.tsx
index 348b0a16e..b0a4c5b21 100644
--- a/src/features/run/services.tsx
+++ b/src/features/run/services.tsx
@@ -1,8 +1,9 @@
/**
- * Run configurations in the Services tool window (IDEA's Run Dashboard): the
- * configuration types added there ("Add Service › Run Configuration Type") and
- * the types of this window's run sessions, one group each, with run / stop /
- * rerun / show output per configuration.
+ * Run configurations in the Services tool window (IDEA's Run Dashboard): Spring
+ * Boot by default, the configuration types added there ("Add Service › Run
+ * Configuration Type") and the types of this window's run sessions, one group
+ * each, with run / debug / stop / rerun per configuration. A configuration that
+ * ran shows its console in the detail pane, a running web application its HTTP port.
*/
import { useEffect, useMemo } from "react";
import { create } from "zustand";
@@ -14,30 +15,38 @@ import { Button, EmptyState, T } from "@/ui";
import type { MenuItem } from "@/ui";
import {
toolWindowService,
+ useRunConfigTypes,
type ServiceItemAction,
type ServiceProviderContribution,
type ServiceTreeItem,
} from "@/workbench/contributions";
import { commandLine, configIcon, KIND_OPTIONS, kindLabel } from "./configs";
-import { rerunSession, stopSession, useRun, type RunSession } from "./store";
+import { SessionPanel } from "./RunView";
+import { startRun, stopSession, useRun, type RunSession } from "./store";
+import { isDebugOnly } from "./types";
import styles from "./RunServices.module.css";
export const RUN_PROVIDER = "runConfigurations";
const TYPE_PREFIX = "run:type:";
const CONFIG_PREFIX = "run:config:";
const STORAGE_KEY = "openidea.services.runTypes:";
+/** Types shown in Services until the user changes the list. */
+export const DEFAULT_TYPES: readonly string[] = ["spring-boot"];
// ---------------------------------------------------------------------------
// Types added to Services, per project
const useAddedTypes = create<{ root: string | null; types: string[] }>(() => ({ root: null, types: [] }));
-function readTypes(root: string): string[] {
+/** The project's types; Spring Boot until the user changes the list. */
+export function readTypes(root: string): string[] {
try {
- const v: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY + root) ?? "[]");
+ const raw = localStorage.getItem(STORAGE_KEY + root);
+ if (raw === null) return [...DEFAULT_TYPES];
+ const v: unknown = JSON.parse(raw);
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
} catch {
- return [];
+ return [...DEFAULT_TYPES];
}
}
@@ -57,13 +66,15 @@ function setAddedTypes(types: string[]) {
}
}
+const currentTypes = () => addedTypes(useProject.getState().project?.root ?? null);
+
export function addType(kind: string) {
- const types = addedTypes(useProject.getState().project?.root ?? null);
+ const types = currentTypes();
if (!types.includes(kind)) setAddedTypes([...types, kind]);
}
export function removeType(kind: string) {
- setAddedTypes(addedTypes(useProject.getState().project?.root ?? null).filter((t) => t !== kind));
+ setAddedTypes(currentTypes().filter((t) => t !== kind));
}
// ---------------------------------------------------------------------------
@@ -89,7 +100,15 @@ export function sessionStatus(s: RunSession | undefined): string | undefined {
return "Finished";
}
-/** Groups by configuration type: added types list all their configurations, other types only those that ran. */
+/** A node's grey text: the HTTP port of a running web application, else the state. */
+export function sessionDetail(s: RunSession | undefined): string | undefined {
+ return s && busy(s) && s.httpPort ? `:${s.httpPort}` : sessionStatus(s);
+}
+
+/**
+ * Groups by configuration type: added types list all their configurations, other
+ * types only those that ran. A default type (Spring Boot) without configurations is hidden.
+ */
export function runServiceItems(
configs: readonly RunConfig[],
sessions: readonly RunSession[],
@@ -98,17 +117,18 @@ export function runServiceItems(
const latest = latestSessions(sessions);
const kinds = [...added];
for (const s of sessions) if (!kinds.includes(s.config.kind)) kinds.push(s.config.kind);
- return kinds.map((kind) => {
+ return kinds.flatMap((kind) => {
const entries: RunConfig[] = added.includes(kind) ? configs.filter((c) => c.kind === kind) : [];
for (const s of latest.values())
if (s.config.kind === kind && !entries.some((c) => c.id === s.config.id)) entries.push(s.config);
+ if (!entries.length && DEFAULT_TYPES.includes(kind)) return [];
const children = entries.map((c): ServiceTreeItem => {
const s = latest.get(c.id);
return {
key: CONFIG_PREFIX + c.id,
label: c.name,
icon: s && busy(s) ? "run" : configIcon(c),
- detail: sessionStatus(s),
+ detail: sessionDetail(s),
dim: !s || !busy(s),
};
});
@@ -116,13 +136,16 @@ export function runServiceItems(
const s = latest.get(c.id);
return !!s && busy(s);
}).length;
- return {
- key: TYPE_PREFIX + kind,
- label: kindLabel(kind),
- icon: configIcon({ kind, isTest: false }),
- detail: running ? tx`${running} running` : undefined,
- children,
- };
+ return [
+ {
+ key: TYPE_PREFIX + kind,
+ label: kindLabel(kind),
+ icon: configIcon({ kind, isTest: false }),
+ detail: running ? tx`${running} running` : undefined,
+ expanded: true,
+ children,
+ },
+ ];
});
}
@@ -167,9 +190,15 @@ function showOutput(s: RunSession) {
toolWindowService().show("run", true);
}
+/** Runs (or reruns, in its tab) a configuration; the output stays in Services instead of opening Run. */
function run(r: Resolved) {
- if (r.session && r.session.config.id === r.config?.id) rerunSession(r.session.id);
- else if (r.config) void executeAction("run.runConfig", r.config);
+ if (r.session && r.session.config.id === r.config?.id)
+ void startRun(r.session.config, r.session.id, r.session.baseConfig, { show: false });
+ else if (r.config) void startRun(r.config, undefined, undefined, { show: false });
+}
+
+function debug(r: Resolved) {
+ if (r.config) void executeAction("run.debugConfig", r.config);
}
function actions(item: ServiceTreeItem): ServiceItemAction[] {
@@ -185,15 +214,15 @@ function actions(item: ServiceTreeItem): ServiceItemAction[] {
if (!r.config) return [stop];
const live = !!r.session && busy(r.session);
return [
- { id: "run", title: live ? "Rerun" : "Run", icon: live ? "rerun" : "run", run: () => run(r) },
- stop,
{
- id: "output",
- title: "Show Output",
- icon: "toolwindow-run",
- disabled: !r.session,
- run: () => r.session && showOutput(r.session),
+ id: "run",
+ title: live ? "Rerun" : "Run",
+ icon: live ? "rerun" : "run",
+ disabled: isDebugOnly(r.config),
+ run: () => run(r),
},
+ { id: "debug", title: "Debug", icon: "debug", run: () => debug(r) },
+ stop,
];
}
@@ -205,14 +234,21 @@ function menu(item: ServiceTreeItem): MenuItem[] {
run: a.run,
}));
const r = resolve(item);
- if (!r.config && r.kind && addedTypes(useProject.getState().project?.root ?? null).includes(r.kind)) {
+ if (!r.config && r.kind && currentTypes().includes(r.kind)) {
const kind = r.kind;
items.push({ kind: "separator" }, { label: "Remove from Services", icon: "close", run: () => removeType(kind) });
}
if (r.config) {
const config = r.config;
+ const session = r.session;
items.push(
{ kind: "separator" },
+ {
+ label: "Show in Run Tool Window",
+ icon: "toolwindow-run",
+ disabled: !session,
+ run: () => session && showOutput(session),
+ },
{ label: "Edit Configuration…", icon: "edit", run: () => void executeAction("run.editConfigs", config.id) },
);
}
@@ -235,8 +271,8 @@ function RunDetail({ item }: { item: ServiceTreeItem }) {
);
- const s = r.session;
- const live = !!s && busy(s);
+ // The configuration ran: its console, as in the Run tool window.
+ if (r.session) return