diff --git a/src/features/run/Run.module.css b/src/features/run/Run.module.css
index aff76271c..be2541b50 100644
--- a/src/features/run/Run.module.css
+++ b/src/features/run/Run.module.css
@@ -256,6 +256,11 @@
height: 30px;
border-radius: 6px;
}
+.widgetLoading {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
.widgetConfigIcon {
position: relative;
display: inline-flex;
diff --git a/src/features/run/RunWidget.test.tsx b/src/features/run/RunWidget.test.tsx
index 8fe01e078..cab613355 100644
--- a/src/features/run/RunWidget.test.tsx
+++ b/src/features/run/RunWidget.test.tsx
@@ -1,9 +1,14 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
-import { cleanup, fireEvent, render, screen } from "@testing-library/react";
-import { executeAction } from "@/platform/actions";
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { executeAction, executeActionFrom, getAction, registerAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import type { RunConfig } from "@/platform/ipc";
-import { registerMenuGroup, useDebugRunSessions, type DebugRunSession } from "@/workbench/contributions";
+import {
+ registerMenuGroup,
+ registerRunConfigType,
+ useDebugRunSessions,
+ type DebugRunSession,
+} from "@/workbench/contributions";
import { useContextMenuStore } from "@/ui";
import { applicationConfig } from "./application";
import { activate } from "./index";
@@ -93,6 +98,7 @@ it("targets the selected debug session while npm runs, through buttons and short
await vi.waitFor(() => expect(session.rerun).toHaveBeenCalledOnce());
await executeAction("run.run");
expect(session.rerun).toHaveBeenCalledTimes(2);
+ await vi.waitFor(() => expect(screen.getByRole("button", { name: `Stop '${app.name}'` })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: `Stop '${app.name}'` }));
await vi.waitFor(() => expect(session.stop).toHaveBeenCalledOnce());
expect(useRun.getState().sessions[0]?.stopped).toBeUndefined();
@@ -156,3 +162,170 @@ it("matches a detected Spring configuration after its object and id are regenera
expect(regenerated.id).toBe(original.id);
expect(selectedTarget(regenerated.id, [run(original), run(npm)], [])?.name).toBe(original.name);
});
+
+it("keeps Stop available during startup and restores Rerun immediately when running", () => {
+ useRun.setState({ sessions: [{ ...run(app), status: "starting" }] });
+ render();
+ expect(screen.getByRole("status", { name: "Starting…" })).toBeTruthy();
+ expect(screen.queryByRole("button", { name: `Rerun '${app.name}'` })).toBeNull();
+ expect(screen.getByRole("button", { name: `Stop '${app.name}'` })).toBeEnabled();
+ act(() => useRun.setState({ sessions: [run(app)] }));
+ expect(screen.queryByRole("status", { name: "Starting…" })).toBeNull();
+ expect(screen.getByRole("button", { name: `Stop '${app.name}'` })).toBeEnabled();
+ expect(screen.getByRole("button", { name: `Rerun '${app.name}'` })).toBeEnabled();
+});
+
+it("stops the selected starting debugger", async () => {
+ const session = { ...debug(app), status: "starting" as const };
+ useDebugRunSessions.setState({ sessions: [session] });
+ render();
+ expect(screen.getByRole("status", { name: "Starting…" })).toBeTruthy();
+ expect(screen.queryByRole("button", { name: `Rerun '${app.name}'` })).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: `Stop '${app.name}'` }));
+ await vi.waitFor(() => expect(session.stop).toHaveBeenCalledOnce());
+});
+
+it("does not replay startup feedback when selecting an already running configuration", () => {
+ useRun.setState({ sessions: [run(app), run(npm)] });
+ render();
+ act(() => useRun.setState({ selectedId: npm.id }));
+ expect(screen.queryByRole("status", { name: "Starting…" })).toBeNull();
+ expect(screen.getByRole("button", { name: `Rerun '${npm.name}'` })).toBeEnabled();
+ act(() => useRun.setState({ selectedId: app.id }));
+ expect(screen.queryByRole("status", { name: "Starting…" })).toBeNull();
+});
+
+it("does not replay startup feedback when selecting an already running debugger", () => {
+ useRun.setState({ sessions: [run(app)] });
+ useDebugRunSessions.setState({ sessions: [debug(npm)] });
+ render();
+ act(() => useRun.setState({ selectedId: npm.id }));
+ expect(screen.queryByRole("status", { name: "Starting…" })).toBeNull();
+ expect(screen.getByRole("button", { name: `Rerun '${npm.name}'` })).toBeEnabled();
+});
+
+it("stops the selected starting process while other Run and Debug sessions remain live", () => {
+ const session = debug(npm);
+ useRun.setState({ sessions: [{ ...run(app), status: "starting" }, run(npm)] });
+ useDebugRunSessions.setState({ sessions: [session] });
+ render();
+ fireEvent.click(screen.getByRole("button", { name: `Stop '${app.name}'` }));
+ expect(useRun.getState().sessions.map((s) => !!s.stopped)).toEqual([true, false]);
+ expect(session.stop).not.toHaveBeenCalled();
+});
+
+it("stops every child of a starting compound, including its starting debugger", async () => {
+ const compound = { ...config("compound"), kind: "compound", compound: [app.id, npm.id] };
+ const session = { ...debug(npm), status: "starting" as const };
+ useRun.setState({
+ configs: [app, npm, compound],
+ selectedId: compound.id,
+ sessions: [{ ...run(app), status: "starting" }],
+ });
+ useDebugRunSessions.setState({ sessions: [session] });
+ render();
+ expect(screen.getByRole("status", { name: "Starting…" })).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: `Stop '${compound.name}'` }));
+ await vi.waitFor(() => expect(session.stop).toHaveBeenCalledOnce());
+ expect(useRun.getState().sessions[0]?.stopped).toBe(true);
+});
+
+it("keeps the selected starting session stoppable after switching configurations", () => {
+ useRun.setState({ sessions: [{ ...run(app), status: "starting" }, run(npm)] });
+ render();
+ act(() => useRun.setState({ selectedId: npm.id }));
+ expect(screen.queryByRole("status", { name: "Starting…" })).toBeNull();
+ expect(screen.getByRole("button", { name: `Rerun '${npm.name}'` })).toBeEnabled();
+ act(() => useRun.setState({ selectedId: app.id }));
+ expect(screen.getByRole("status", { name: "Starting…" })).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: `Stop '${app.name}'` }));
+ expect(useRun.getState().sessions.map((s) => !!s.stopped)).toEqual([true, false]);
+});
+
+it.each(["failed", "finished"] as const)("clears startup feedback when the launch is %s", (status) => {
+ useRun.setState({ sessions: [{ ...run(app), status: "starting" }] });
+ render();
+ act(() => useRun.setState({ sessions: [{ ...run(app), status, endedAt: Date.now() }] }));
+ expect(screen.queryByRole("status", { name: "Starting…" })).toBeNull();
+ expect(screen.getByRole("button", { name: `Run '${app.name}'` })).toBeEnabled();
+});
+
+it.each(["keyboard", "menu"] as const)("shows feedback for a %s launch and clears it on failure", async (source) => {
+ const original = getAction("run.run")!;
+ registerAction({ ...original, run: () => useRun.setState({ sessions: [{ ...run(app), status: "starting" }] }) });
+ try {
+ render();
+ await act(() => executeActionFrom(source, "run.run"));
+ expect(screen.getByRole("status", { name: "Starting…" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: `Stop '${app.name}'` })).toBeEnabled();
+ act(() => useRun.setState({ sessions: [] }));
+ expect(screen.queryByRole("status", { name: "Starting…" })).toBeNull();
+ } finally {
+ registerAction(original);
+ }
+});
+
+it.each(["run.run", "run.debug"] as const)("clears feedback when %s returns without a session", async (action) => {
+ const original = getAction(action)!;
+ let finish!: () => void;
+ registerAction({
+ ...original,
+ run: () =>
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ });
+ try {
+ render();
+ fireEvent.click(screen.getByRole("button", { name: `${action === "run.run" ? "Run" : "Debug"} '${app.name}'` }));
+ expect(screen.getByRole("status", { name: "Starting…" })).toBeTruthy();
+ await act(async () => finish());
+ expect(screen.queryByRole("status", { name: "Starting…" })).toBeNull();
+ expect(screen.getByRole("button", { name: `Run '${app.name}'` })).toBeEnabled();
+ } finally {
+ registerAction(original);
+ }
+});
+
+it("keeps a debug-only configuration stoppable during attachment", () => {
+ const unregister = registerRunConfigType({
+ id: "test-remote",
+ commandLine: () => null,
+ label: "Remote",
+ icon: "debug",
+ debugOnly: true,
+ fields: [],
+ });
+ const remote = { ...app, kind: "test-remote" };
+ try {
+ useRun.setState({ configs: [remote], selectedId: remote.id });
+ render();
+ expect(screen.getByRole("button", { name: `'${remote.name}' can only be debugged` })).toBeDisabled();
+ act(() => useDebugRunSessions.setState({ sessions: [{ ...debug(remote), status: "starting" }] }));
+ expect(screen.getByRole("status", { name: "Starting…" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: `Stop '${remote.name}'` })).toBeEnabled();
+ } finally {
+ unregister();
+ }
+});
+
+it("allows another configuration to launch while the first launch is pending", async () => {
+ const original = getAction("run.run")!;
+ const finish: (() => void)[] = [];
+ registerAction({ ...original, run: () => new Promise((resolve) => finish.push(resolve)) });
+ try {
+ render();
+ fireEvent.click(screen.getByRole("button", { name: `Run '${app.name}'` }));
+ act(() => useRun.setState({ selectedId: npm.id }));
+ fireEvent.click(screen.getByRole("button", { name: `Run '${npm.name}'` }));
+ expect(finish).toHaveLength(2);
+ expect(screen.getByRole("status", { name: "Starting…" })).toBeTruthy();
+ await act(async () => finish[0]!());
+ expect(screen.getByRole("status", { name: "Starting…" })).toBeTruthy();
+ await act(async () => finish[1]!());
+ expect(screen.queryByRole("status", { name: "Starting…" })).toBeNull();
+ } finally {
+ await act(async () => finish.forEach((resolve) => resolve()));
+ registerAction(original);
+ }
+});
diff --git a/src/features/run/RunWidget.tsx b/src/features/run/RunWidget.tsx
index 83e4fe1e1..c87b18e00 100644
--- a/src/features/run/RunWidget.tsx
+++ b/src/features/run/RunWidget.tsx
@@ -28,10 +28,10 @@ export function RunWidget() {
const debug = useDebugRunSessions((s) => s.sessions);
const compoundRunning = compoundTargets(selected, running, debug);
const target = selectedTarget(selectedId, running, debug);
- const debugging = !!target?.debug;
- const active = !!target || compoundRunning.length > 0;
+ const { active, debugging, showLoading, launch } = useLaunchFeedback(selectedId, target, compoundRunning);
const count = running.length + debug.length;
const stopping = running.some((s) => s.status === "stopping") || debug.some((s) => s.status === "stopping");
+ const stop = () => void executeAction("run.stop", "widget");
const targetStopping =
running.some((s) => s.status === "stopping" && s.config.id === selectedId) ||
debug.some((s) => s.status === "stopping" && s.configId === selectedId);
@@ -73,7 +73,7 @@ export function RunWidget() {
// IDEA: the configuration's type icon, with a status dot on its top-right corner while active.
- {active && (
+ {active && !showLoading && (
)}
@@ -84,7 +84,33 @@ export function RunWidget() {
- {active ? (
+ {showLoading ? (
+ <>
+ {debugging && (
+
+ )}
+
+
+
+ {!debugging && (
+
+ )}
+ >
+ ) : active ? (
<>
{target?.paused && (
void executeAction("run.run")}
+ onClick={() => void launch("run.run")}
/>
>
) : (
@@ -126,7 +152,7 @@ export function RunWidget() {
}
shortcut={shortcutLabel("run.run")}
disabled={(!selected && !currentFile) || debugOnly}
- onClick={() => void executeAction("run.run")}
+ onClick={() => void launch("run.run")}
/>
void executeAction("run.debug")}
+ onClick={() => void launch("run.debug")}
/>
>
)}
@@ -149,7 +175,7 @@ export function RunWidget() {
className={clsx(styles.widgetButton, styles.widgetFilled, styles.widgetFilledStop, styles.widgetStopping)}
title={tx`Kill process`}
aria-label={tx`Kill process`}
- onClick={() => void executeAction("run.stop", "widget")}
+ onClick={stop}
>
@@ -161,7 +187,7 @@ export function RunWidget() {
className={clsx(styles.widgetButton, styles.widgetFilled, styles.widgetFilledStop)}
title={active || count === 1 ? tx`Stop '${stopName ?? ""}'` : tx`Stop (${count} running)`}
shortcut={shortcutLabel("run.stop")}
- onClick={() => void executeAction("run.stop", "widget")}
+ onClick={stop}
/>
))}
);
}
+
+/** Acknowledges widget clicks before a session exists; session state covers other entry points. */
+function useLaunchFeedback(
+ selectedId: string | null,
+ target: ReturnType,
+ compoundRunning: ReturnType,
+) {
+ const [pendingLaunches, setPendingLaunches] = useState<{ configId: string | null; debug: boolean }[]>([]);
+ const launching = useRef(new Set());
+ const pending = pendingLaunches.find((launch) => launch.configId === selectedId);
+ const debugging = pending ? pending.debug : !!target?.debug;
+ const starting =
+ !!pending || target?.status === "starting" || compoundRunning.some((session) => session.status === "starting");
+ const stopping = target?.status === "stopping" || compoundRunning.some((session) => session.status === "stopping");
+ const launch = async (action: "run.run" | "run.debug") => {
+ if (launching.current.has(selectedId)) return;
+ launching.current.add(selectedId);
+ setPendingLaunches((pending) => [...pending, { configId: selectedId, debug: action === "run.debug" }]);
+ try {
+ await executeAction(action);
+ } finally {
+ launching.current.delete(selectedId);
+ setPendingLaunches((pending) => pending.filter((launch) => launch.configId !== selectedId));
+ }
+ };
+ const active = !!target || compoundRunning.length > 0;
+ const showLoading = !stopping && starting;
+ return { active, debugging, showLoading, launch };
+}
diff --git a/src/features/run/sessionTargets.ts b/src/features/run/sessionTargets.ts
index 6c3c1c46e..ae6f175f0 100644
--- a/src/features/run/sessionTargets.ts
+++ b/src/features/run/sessionTargets.ts
@@ -13,6 +13,7 @@ export function selectedTarget(selectedId: string | null, runs: RunSession[], de
startedAt: s.startedAt,
debug: false,
paused: false,
+ status: s.status,
rerun: () => rerunSession(s.id),
stop: () => void stopSession(s.id),
resume: () => {},
@@ -26,7 +27,7 @@ export function compoundTargets(config: RunConfig | undefined, runs: RunSession[
if (config?.kind !== "compound") return [];
const ids = new Set(compoundChildren(config, useRun.getState().configs).map((c) => c.id));
return [
- ...sessionsOf(config, runs).map((s) => ({ stop: () => void stopSession(s.id) })),
+ ...sessionsOf(config, runs).map((s) => ({ status: s.status, stop: () => void stopSession(s.id) })),
...debug.filter((s) => s.configId != null && ids.has(s.configId)),
];
}
diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json
index 57654aaac..70262d01a 100644
--- a/src/platform/locales/text/en.json
+++ b/src/platform/locales/text/en.json
@@ -433,6 +433,7 @@
"Declined": "Declined",
"Always declined": "Always declined",
"Branch '{name}' is checked out in the worktree at {path}.\nDelete the worktree and the branch? Its folder is deleted.": "Branch '{name}' is checked out in the worktree at {path}.\nDelete the worktree and the branch? Its folder is deleted.",
+ "Starting…": "Starting…",
"Maven Goals": "Maven Goals",
"Gradle Tasks": "Gradle Tasks"
}
diff --git a/src/ui/Feedback.module.css b/src/ui/Feedback.module.css
index 921ad3d20..cc46ab10e 100644
--- a/src/ui/Feedback.module.css
+++ b/src/ui/Feedback.module.css
@@ -33,6 +33,9 @@
color: var(--fg-muted);
animation: spin 0.9s linear infinite;
}
+.spokes {
+ animation: spin 1s steps(8, end) infinite;
+}
@keyframes spin {
to {
transform: rotate(360deg);
@@ -207,4 +210,7 @@
.spinner {
animation-duration: 2.4s;
}
+ .spokes {
+ animation: none;
+ }
}
diff --git a/src/ui/Feedback.tsx b/src/ui/Feedback.tsx
index f44b4b3e4..9f776214c 100644
--- a/src/ui/Feedback.tsx
+++ b/src/ui/Feedback.tsx
@@ -38,22 +38,42 @@ export function Spinner({
size = 14,
className,
label = "Loading",
+ variant = "ring",
}: {
size?: number;
className?: string;
label?: string;
+ variant?: "ring" | "spokes";
}) {
return (
);
}