fix(run): 优化 Run/Debug 启动动画反馈 (#2087)

Clicking Run or Debug gave no feedback until a session existed, so a slow launch looked as if nothing had happened.

The widget now shows a spinner as soon as a launch is requested and keeps it through the session's starting state. Pending launches are tracked per configuration, so switching to one already running does not replay it.

Siblings covered: Stop stays available during startup, including the Java build and classpath step, and launches that fail, are cancelled or do nothing clear the spinner.

Co-authored-by: PLZY <[email protected]>
This commit is contained in:
zhangdagouandPLZY authored and GitHub committed 2026-11-08 22:23:17 +08:00
1 parent a601a51b94
commit e8cf744463
7 files changed
+278 -17

No files matched your search

+5
View File
@@ -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;
+176 -3
View File
@@ -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(<RunWidget />);
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(<RunWidget />);
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(<RunWidget />);
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(<RunWidget />);
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(<RunWidget />);
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(<RunWidget />);
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(<RunWidget />);
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(<RunWidget />);
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(<RunWidget />);
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<void>((resolve) => {
finish = resolve;
}),
});
try {
render(<RunWidget />);
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(<RunWidget />);
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<void>((resolve) => finish.push(resolve)) });
try {
render(<RunWidget />);
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);
}
});
+64 -9
View File
@@ -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.
<span className={styles.widgetConfigIcon}>
<Icon name={configIcon(selected)} size={16} />
{active && (
{active && !showLoading && (
<span className={styles.widgetStatusDot} aria-label={debugging ? "Debugging" : "Running"} role="img" />
)}
</span>
@@ -84,7 +84,33 @@ export function RunWidget() {
<Icon name="chevron-down" size={14} className={styles.widgetChevron} />
</BarButton>
<span className={styles.widgetButtons}>
{active ? (
{showLoading ? (
<>
{debugging && (
<IconButton
icon="run"
size="large"
iconSize={18}
className={clsx(styles.widgetButton, styles.widgetRun)}
title="Run"
disabled
/>
)}
<span className={clsx(styles.widgetButton, styles.widgetLoading)}>
<Spinner variant="spokes" size={18} label={tx("Starting…")} />
</span>
{!debugging && (
<IconButton
icon="debug"
size="large"
iconSize={18}
className={styles.widgetButton}
title="Debug"
disabled
/>
)}
</>
) : active ? (
<>
{target?.paused && (
<IconButton
@@ -105,7 +131,7 @@ export function RunWidget() {
title={tx`Rerun '${selected?.name ?? target?.name ?? ""}'`}
shortcut={shortcutLabel("run.run")}
disabled={!!target && (!target.rerun || targetStopping)}
onClick={() => 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")}
/>
<IconButton
icon="debug"
@@ -136,7 +162,7 @@ export function RunWidget() {
title={selected ? tx`Debug '${selected.name}'` : currentFile ? "Debug Current File" : "Debug"}
shortcut={shortcutLabel("run.debug")}
disabled={!selected && !currentFile}
onClick={() => 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}
>
<Spinner size={14} />
</button>
@@ -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}
/>
))}
<IconButton
@@ -186,3 +212,32 @@ export function RunWidget() {
</div>
);
}
/** Acknowledges widget clicks before a session exists; session state covers other entry points. */
function useLaunchFeedback(
selectedId: string | null,
target: ReturnType<typeof selectedTarget>,
compoundRunning: ReturnType<typeof compoundTargets>,
) {
const [pendingLaunches, setPendingLaunches] = useState<{ configId: string | null; debug: boolean }[]>([]);
const launching = useRef(new Set<string | null>());
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 };
}
+2 -1
View File
@@ -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)),
];
}
+1
View File
@@ -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"
}
+6
View File
@@ -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;
}
}
+24 -4
View File
@@ -38,22 +38,42 @@ export function Spinner({
size = 14,
className,
label = "Loading",
variant = "ring",
}: {
size?: number;
className?: string;
label?: string;
variant?: "ring" | "spokes";
}) {
return (
<svg
className={clsx(styles.spinner, className)}
className={clsx(styles.spinner, variant === "spokes" && styles.spokes, className)}
width={size}
height={size}
viewBox="0 0 24 24"
viewBox={variant === "spokes" ? "0 0 16 16" : "0 0 24 24"}
role="status"
aria-label={label}
>
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeOpacity="0.2" strokeWidth="2.4" />
<path d="M21 12a9 9 0 0 0-9-9" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" />
{variant === "spokes" ? (
Array.from({ length: 8 }, (_, index) => (
<rect
key={index}
x="7"
y="1"
width="2"
height="4"
rx="1"
fill="currentColor"
opacity={1 - index * 0.1}
transform={`rotate(${-index * 45} 8 8)`}
/>
))
) : (
<>
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeOpacity="0.2" strokeWidth="2.4" />
<path d="M21 12a9 9 0 0 0-9-9" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" />
</>
)}
</svg>
);
}