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