mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:02:17 +00:00
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:
1 parent
a601a51b94
commit
e8cf744463
7 files changed
+278
-17
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)),
|
||||
];
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user