feat(run): mark running configurations in the chooser, with Rerun and Stop (#1118)

A configuration with a running or debugged process gets a green dot on its icon in the Run/Debug configuration popup; its row offers Rerun and Stop in place of Run and Debug. Rerun closes the popup, Stop keeps it open.

Refs #997
This commit is contained in:
l0ng-ai
2026-11-04 08:02:54 +08:00
committed by GitHub
parent b9f3c07f9b
commit aeb3abca34
3 changed files with 137 additions and 6 deletions
+41 -6
View File
@@ -5,9 +5,11 @@ import { shortcutLabel } from "@/platform/keymap";
import { HighlightedText, Icon, IconButton, List, Popup, SearchField, fuzzyMatch, listNavigate, T } from "@/ui";
import type { RunConfig } from "@/platform/ipc";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { useDebugRunSessions } from "@/workbench/contributions";
import { commandLine, configIcon, groupConfigs } from "./configs";
import { targetName } from "./remote";
import { canDeleteConfig, CURRENT_FILE, deleteConfig, saveTemporary, selectConfig, startRun, useRun } from "./store";
import { selectedTarget } from "./sessionTargets";
import styles from "./Run.module.css";
import { tx } from "@/platform/i18n";
@@ -79,6 +81,8 @@ function ConfigChooser() {
const selectedId = useRun((s) => s.selectedId);
const temporary = useRun((s) => s.temporary);
const runTargets = useWorkflowSettings((s) => s.runTargets);
const sessions = useRun((s) => s.sessions);
const debugSessions = useDebugRunSessions((s) => s.sessions);
const [query, setQuery] = useState("");
const rows = useMemo(
() =>
@@ -272,13 +276,16 @@ function ConfigChooser() {
}
if (r.type !== "config") return null;
const c = r.config;
const running = selectedTarget(c.id, sessions, debugSessions);
return (
<span className={styles.chooserRow} title={commandLine(c)}>
<Icon
name={configIcon(c)}
size={13}
className={clsx(styles.chooserIcon, temporary.includes(c.id) && styles.chooserTemporary)}
/>
<span className={clsx(styles.chooserIcon, running && styles.chooserRunning)}>
<Icon
name={configIcon(c)}
size={13}
className={clsx(temporary.includes(c.id) && styles.chooserTemporary)}
/>
</span>
{numbers.size > 0 && <span className={styles.chooserMnemonic}>{numbers.get(r.key) ?? ""}</span>}
<span className={clsx(styles.chooserName, c.id === selectedId && styles.chooserSelected)}>
{r.indices ? <HighlightedText text={c.name} indices={r.indices} /> : c.name}
@@ -299,7 +306,35 @@ function ConfigChooser() {
<T>temporary</T>
</span>
)}
{isActive && (
{isActive && running && (
<span className={styles.chooserActions}>
<IconButton
icon={running.debug ? "restart-debug-filled" : "rerun-filled"}
size="small"
iconSize={11}
title={tx`Rerun '${c.name}'`}
tabIndex={-1}
disabled={!running.rerun}
onClick={(e) => {
e.stopPropagation();
close();
running.rerun?.();
}}
/>
<IconButton
icon="stop-filled"
size="small"
iconSize={11}
title={tx`Stop '${c.name}'`}
tabIndex={-1}
onClick={(e) => {
e.stopPropagation();
running.stop();
}}
/>
</span>
)}
{isActive && !running && (
<span className={styles.chooserActions}>
<IconButton
icon="run"
+16
View File
@@ -331,6 +331,22 @@
flex: none;
color: var(--fg-subtle);
}
/* A running configuration: a dot on its icon. */
.chooserRunning {
position: relative;
display: inline-flex;
}
.chooserRunning::after {
content: "";
position: absolute;
right: -2px;
bottom: -1px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 0 1px var(--bg-popup);
}
.chooserName {
flex: 1;
min-width: 0;
+80
View File
@@ -0,0 +1,80 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
vi.mock("@/platform/ipc", async (importOriginal) => ({
...(await importOriginal<typeof import("@/platform/ipc")>()),
isTauri: () => false,
}));
import type { RunConfig } from "@/platform/ipc";
import { useDebugRunSessions } from "@/workbench/contributions";
import { ConfigChooserOverlay } from "./ConfigChooser";
import { resetRuns, useRun, type RunSession } from "./store";
const config = (id: string, name: string): RunConfig => ({
id,
name,
kind: "npm",
command: "run",
args: [],
cwd: null,
env: {},
detected: true,
isTest: false,
debugAdapter: null,
});
const server = config("npm:server", "server");
const worker = config("npm:worker", "worker");
const idle = config("npm:idle", "idle");
const rerun = vi.fn();
const stop = vi.fn();
beforeEach(() => {
resetRuns();
const session = { id: "s1", config: server, runId: null, status: "running", startedAt: 1 } as RunSession;
useRun.setState({
configs: [server, worker, idle],
loaded: true,
selectedId: server.id,
sessions: [session],
chooserOpen: true,
});
useDebugRunSessions.setState({
sessions: [
{ id: 7, configId: worker.id, name: "worker", startedAt: 2, paused: false, rerun, stop, resume: () => {} },
],
});
});
afterEach(() => {
cleanup();
useDebugRunSessions.setState({ sessions: [] });
rerun.mockReset();
stop.mockReset();
});
const row = (name: string) => screen.getByRole("option", { name: new RegExp(`^${name}`) });
it("marks running and debugged configurations", () => {
render(<ConfigChooserOverlay />);
const marked = (name: string) => !!row(name).querySelector("[class*='chooserRunning']");
expect([marked("server"), marked("worker"), marked("idle")]).toEqual([true, true, false]);
});
it("offers Rerun and Stop for a running configuration instead of Run and Debug", () => {
render(<ConfigChooserOverlay />);
fireEvent.pointerMove(row("worker"));
expect(screen.queryByRole("button", { name: "Run 'worker'" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Stop 'worker'" }));
expect(stop).toHaveBeenCalledOnce();
expect(useRun.getState().chooserOpen).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Rerun 'worker'" }));
expect(rerun).toHaveBeenCalledOnce();
expect(useRun.getState().chooserOpen).toBe(false);
});
it("keeps Run and Debug for a configuration that isn't running", () => {
render(<ConfigChooserOverlay />);
fireEvent.pointerMove(row("idle"));
expect(screen.getByRole("button", { name: "Run 'idle'" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Stop 'idle'" })).not.toBeInTheDocument();
});