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 ( - - + {variant === "spokes" ? ( + Array.from({ length: 8 }, (_, index) => ( + + )) + ) : ( + <> + + + + )} ); }