diff --git a/src/features/debugger/__tests__/runOrigin.test.ts b/src/features/debugger/__tests__/runOrigin.test.ts new file mode 100644 index 000000000..1f108bf1a --- /dev/null +++ b/src/features/debugger/__tests__/runOrigin.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { executeAction, registerActions } from "@/platform/actions"; +import { useProject } from "@/platform/project"; +import { + provideNotificationService, + provideToolWindowService, + useDebugRunSessions, + type ToolWindowService, +} from "@/workbench/contributions"; +import type { RunConfig } from "@/platform/ipc"; +import { registerDebugActions } from "../actions"; +import { adoptSession, publishContext, rerunSession, useDebug } from "../state"; + +const ipc = vi.hoisted(() => ({ + javaDebugServer: vi.fn(async () => ({ status: "ok", data: 5005 })), + debugStart: vi.fn(async () => ({ status: "ok", data: 42 })), + debugStop: vi.fn(async () => ({ status: "ok", data: null })), +})); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } }; +}); +const config: RunConfig = { + id: "node:server", + name: "server", + kind: "node", + command: "node", + args: ["server.js"], + cwd: null, + env: {}, + detected: true, + isTest: false, + debugAdapter: "js-debug", +}; +beforeEach(() => { + useDebug.setState({ sessions: {}, active: null, console: {} }); + useDebugRunSessions.setState({ sessions: [] }); + useProject.setState({ project: { root: "/p" } as never }); + provideNotificationService({ notify: vi.fn() }); + provideToolWindowService({ show: vi.fn() } as unknown as ToolWindowService); + vi.clearAllMocks(); +}); + +it("keeps the RunConfig through debug.startConfig and reruns through its debug preparation", async () => { + const dispose = registerDebugActions(); + const restart = vi.fn(); + const unregister = registerActions([{ id: "run.debugConfig", title: "Debug", run: ({ arg }) => restart(arg) }]); + try { + await executeAction("debug.startConfig", config); + const session = useDebug.getState().sessions[42]!; + expect(session.runConfig).toEqual(config); + expect(session.startedAt).toBeGreaterThan(0); + expect(useDebugRunSessions.getState().sessions[0]?.configId).toBe(config.id); + await rerunSession(42); + expect(ipc.debugStop).toHaveBeenCalledWith(42); + expect(restart).toHaveBeenCalledWith(config); + } finally { + dispose(); + unregister(); + } +}); + +it("preserves the source configuration for a separately built debug binary", async () => { + const dispose = registerDebugActions(); + try { + await executeAction("debug.startConfig", { config, origin: { ...config, id: "source:server" } }); + expect(useDebug.getState().sessions[42]?.runConfig?.id).toBe("source:server"); + } finally { + dispose(); + } +}); + +it("keeps the originating JVM configuration when attaching to its build process", async () => { + const dispose = registerDebugActions(); + try { + await executeAction("debug.attach", { + name: "server", + module: "", + hostName: "127.0.0.1", + port: 5005, + process: 7, + runConfig: config, + }); + expect(useDebug.getState().sessions[42]).toMatchObject({ runConfig: config, process: 7, launch: null }); + expect(useDebugRunSessions.getState().sessions[0]?.rerun).toBeTypeOf("function"); + } finally { + dispose(); + } +}); + +it("exposes a paused child through its originating root rather than the active unrelated session", () => { + adoptSession(1, { runConfig: config }); + adoptSession(2, { parent: 1, status: "paused", stoppedThread: 1 }); + adoptSession(3, { runConfig: { ...config, id: "other" } }); + useDebug.setState({ active: 3 }); + publishContext(); + const roots = useDebugRunSessions.getState().sessions; + expect(roots.map((s) => [s.configId, s.paused])).toEqual([ + [config.id, true], + ["other", false], + ]); + roots[0]!.resume(); + expect(useDebug.getState().active).toBe(2); +}); diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 88338717c..d40740b5f 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -211,17 +211,19 @@ async function startRemote(config: RunConfig, root: string) { } const launch = await javaAttachConfig({ name: config.name, module: t.module, hostName, port }, root); // A listen relay accepts one debugger: rerunning listens again through the configuration. - await startSession(launch, { rerunnable: t.mode === "attach" }); + await startSession(launch, { runConfig: config, rerunnable: t.mode === "attach" }); return; } const launch = remoteAttachConfig(config, root); - if (launch) await startSession(launch); + if (launch) await startSession(launch, { runConfig: config }); } async function startConfig(arg: unknown) { const root = useProject.getState().project?.root; if (!root) return; - let config: RunConfig | null = isRunConfig(arg) ? arg : null; + const origin = arg && typeof arg === "object" && "origin" in arg && isRunConfig(arg.origin) ? arg.origin : null; + const input = arg && typeof arg === "object" && "config" in arg ? arg.config : arg; + let config: RunConfig | null = isRunConfig(input) ? input : null; if (!config) { if (!isTauri()) { notifications().notify({ group: "run", level: "warning", title: "Debugging needs the desktop app" }); @@ -256,7 +258,7 @@ async function startConfig(arg: unknown) { await maybeEditorService() ?.saveAll() .catch(() => undefined); - await startSession(await javaLaunchConfig(config, root)); + await startSession(await javaLaunchConfig(config, root), { runConfig: origin ?? config }); return; } const launch = launchConfigFor(config, root); @@ -272,7 +274,7 @@ async function startConfig(arg: unknown) { await maybeEditorService() ?.saveAll() .catch(() => undefined); - await startSession(launch); + await startSession(launch, { runConfig: origin ?? config }); } export function registerDebugActions(): () => void { @@ -312,7 +314,11 @@ export function registerDebugActions(): () => void { const root = useProject.getState().project?.root; if (!root || !isJavaAttachTarget(arg)) return; const launch = await javaAttachConfig(arg, root); - await startSession(launch, { process: (arg.process ?? null) as RunId | null, rerunnable: false }); + await startSession(launch, { + process: (arg.process ?? null) as RunId | null, + runConfig: arg.runConfig, + rerunnable: false, + }); })(), ), }, diff --git a/src/features/debugger/java.ts b/src/features/debugger/java.ts index d5b3d45b7..5f5f93c4f 100644 --- a/src/features/debugger/java.ts +++ b/src/features/debugger/java.ts @@ -102,7 +102,9 @@ export interface JavaAttachTarget { port: number; } -export function isJavaAttachTarget(arg: unknown): arg is JavaAttachTarget & { process?: number | null } { +export function isJavaAttachTarget( + arg: unknown, +): arg is JavaAttachTarget & { process?: number | null; runConfig?: RunConfig } { return !!arg && typeof arg === "object" && "port" in arg && typeof (arg as { port: unknown }).port === "number"; } diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index 336c9eb56..59e5f9888 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -16,11 +16,12 @@ import { type DebugSessionId, type LaunchConfig, type RunId, + type RunConfig, type StackFrame, type ThreadInfo, type Variable, } from "@/platform/ipc"; -import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions"; +import { maybeEditorService, notifications, toolWindowService, useDebugRunSessions } from "@/workbench/contributions"; import { breakpointsOf, consumeTemporary, refreshStates, resetStatuses } from "./breakpoints/store"; import { interpolateLog, logParts, suspends } from "./breakpoints/model"; import { syncSpecial, useSpecialBreakpoints } from "./breakpoints/special"; @@ -32,6 +33,9 @@ export type SessionStatus = "starting" | "running" | "paused" | "terminated"; export interface DebugSession { id: DebugSessionId; name: string; + /** Originating configuration, retained for toolbar targeting and prepared debug reruns. */ + runConfig?: RunConfig; + startedAt?: number; adapter: string; parent: DebugSessionId | null; /** Launch configuration (root sessions), used by Rerun. */ @@ -136,6 +140,7 @@ function blank(id: DebugSessionId, p: Partial = {}): DebugSession return { id, name: "Debug", + startedAt: Date.now(), adapter: "", parent: null, launch: null, @@ -211,10 +216,32 @@ function settleStart(id: DebugSessionId | null) { export function publishContext() { const s = activeSession(); + useDebugRunSessions.setState({ + sessions: Object.values(useDebug.getState().sessions) + .filter((session) => session.parent == null && isLive(session)) + .map((session) => { + const paused = Object.values(useDebug.getState().sessions).find( + (child) => child.status === "paused" && rootOf(child).id === session.id, + ); + return { + id: session.id, + configId: session.runConfig?.id ?? null, + name: session.runConfig?.name ?? session.name, + startedAt: session.startedAt ?? 0, + paused: !!paused, + rerun: session.runConfig || session.launch ? () => void rerunSession(session.id) : null, + stop: () => void stopSession(session.id), + resume: () => { + selectSession(paused?.id ?? session.id); + void executeAction("debug.resume"); + }, + }; + }), + }); const anyLive = Object.values(useDebug.getState().sessions).some(isLive); setContext("debugSessionActive", anyLive); setContext("debugSessionPaused", s?.status === "paused"); - setContext("debugCanRerun", !!s && !!rootOf(s).launch); + setContext("debugCanRerun", !!s && !!(rootOf(s).runConfig || rootOf(s).launch)); setContext( "debugCanHotSwap", Object.values(useDebug.getState().sessions).some((x) => isLive(x) && !!x.caps?.hotCodeReplace), @@ -253,6 +280,7 @@ export function clearConsole(session: DebugSessionId) { export async function startSession( launch: LaunchConfig, options: { + runConfig?: RunConfig; /** A process started for this session (e.g. Maven with a JDWP agent): Stop ends it too. */ process?: RunId | null; /** Rerun repeats the launch (false for attaching to a process that is gone afterwards). */ @@ -275,6 +303,7 @@ export async function startSession( } catch { // Workbench not ready (tests). } + const startedAt = Date.now(); pendingStarts++; try { const id = await call(commands.debugStart(launch)); @@ -287,6 +316,8 @@ export async function startSession( [id]: { ...cur, name: launch.name, + runConfig: options.runConfig, + startedAt, adapter: launch.adapter, launch: options.rerunnable === false ? null : launch, process: options.process ?? null, @@ -353,6 +384,11 @@ export async function rerunSession(id: DebugSessionId) { const s = useDebug.getState().sessions[id]; if (!s) return; const root = rootOf(s); + if (root.runConfig) { + if (isLive(root)) await stopSession(root.id); + await executeAction("run.debugConfig", root.runConfig); + return; + } const launch = root.launch; if (!launch) return; if (isLive(root)) await stopSession(root.id); diff --git a/src/features/run/RunWidget.test.tsx b/src/features/run/RunWidget.test.tsx new file mode 100644 index 000000000..abd5fdea8 --- /dev/null +++ b/src/features/run/RunWidget.test.tsx @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { executeAction } from "@/platform/actions"; +import { setContext } from "@/platform/context"; +import type { RunConfig } from "@/platform/ipc"; +import { useDebugRunSessions, type DebugRunSession } from "@/workbench/contributions"; +import { applicationConfig } from "./application"; +import { activate } from "./index"; +import { RunWidget } from "./RunWidget"; +import { resetRuns, useRun, type RunSession } from "./store"; +import { selectedTarget } from "./sessionTargets"; + +const config = (id: string): RunConfig => ({ + id, + name: id, + kind: "shell", + command: "sleep", + args: ["60"], + cwd: null, + env: {}, + detected: false, + isTest: false, + debugAdapter: null, +}); +const app = config("SdCloudOaApplication"); +const npm = config("sdcloud-oa-vue3: npm run dev"); +const run = (c: RunConfig, startedAt = 1): RunSession => ({ + id: c.id, + config: c, + runId: 1, + status: "running", + exitCode: null, + startedAt, + endedAt: null, + stdinClosed: false, +}); +const debug = (c: RunConfig, startedAt = 2): DebugRunSession => ({ + id: 1, + configId: c.id, + name: c.name, + startedAt, + paused: false, + rerun: vi.fn(), + stop: vi.fn(), + resume: vi.fn(), +}); +let dispose: () => void; +beforeEach(() => { + resetRuns(); + useRun.setState({ configs: [app, npm], selectedId: app.id, loaded: true }); + useDebugRunSessions.setState({ sessions: [] }); + setContext("projectOpen", true); + dispose = activate(); +}); +afterEach(() => { + cleanup(); + dispose(); + useDebugRunSessions.setState({ sessions: [] }); +}); + +it("reruns and stops the selected configuration among several running sessions", () => { + useRun.setState({ sessions: [run(app), run(npm, 3)] }); + render(); + expect(screen.getByRole("button", { name: `Rerun '${app.name}'` })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: `Stop '${app.name}'` })); + expect(useRun.getState().sessions.map((s) => !!s.stopped)).toEqual([true, false]); +}); + +it("offers Run/Debug for an idle selection and Stop for the other process", () => { + useRun.setState({ sessions: [run(npm)] }); + render(); + expect(screen.getByRole("button", { name: `Run '${app.name}'` })).toBeTruthy(); + expect(screen.getByRole("button", { name: `Debug '${app.name}'` })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Rerun/ })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: `Stop '${npm.name}'` })); + expect(useRun.getState().sessions[0]?.stopped).toBe(true); +}); + +it("offers a stop list when the selection is idle and several other sessions run", () => { + useRun.setState({ sessions: [run(npm), run(config("other"))] }); + render(); + expect(screen.getByRole("button", { name: "Stop (2 running)" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Rerun/ })).toBeNull(); +}); + +it("targets the selected debug session while npm runs, through buttons and shortcut actions", async () => { + const session = debug(app); + useRun.setState({ sessions: [run(npm, 3)] }); + useDebugRunSessions.setState({ sessions: [session] }); + render(); + fireEvent.click(screen.getByRole("button", { name: `Rerun '${app.name}'` })); + await vi.waitFor(() => expect(session.rerun).toHaveBeenCalledOnce()); + await executeAction("run.run"); + expect(session.rerun).toHaveBeenCalledTimes(2); + fireEvent.click(screen.getByRole("button", { name: `Stop '${app.name}'` })); + await executeAction("run.stop"); + expect(session.stop).toHaveBeenCalledTimes(2); + expect(useRun.getState().sessions[0]?.stopped).toBeUndefined(); +}); + +it("ignores an unrelated debug session for an idle selection", () => { + useDebugRunSessions.setState({ sessions: [debug(npm)] }); + render(); + expect(screen.getByRole("button", { name: `Run '${app.name}'` })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Rerun/ })).toBeNull(); +}); + +it.each([1, 3])("prefers the newest Run or Debug session of the same config (run start %s)", (startedAt) => { + expect(selectedTarget(app.id, [run(app, startedAt)], [debug(app, 2)])?.debug).toBe(startedAt < 2); +}); + +it("matches a detected Spring configuration after its object and id are regenerated", () => { + const detect = () => + applicationConfig( + { className: "com.example.SdCloudOaApplication", spring: true }, + "src/main/java/com/example/SdCloudOaApplication.java", + ); + const original = detect(); + const regenerated = detect(); + expect(regenerated).not.toBe(original); + expect(regenerated.id).toBe(original.id); + expect(selectedTarget(regenerated.id, [run(original), run(npm)], [])?.name).toBe(original.name); +}); diff --git a/src/features/run/RunWidget.tsx b/src/features/run/RunWidget.tsx index 910af3594..bfb150cd8 100644 --- a/src/features/run/RunWidget.tsx +++ b/src/features/run/RunWidget.tsx @@ -2,13 +2,13 @@ import { useRef, useState } from "react"; import clsx from "clsx"; import { BarButton, Icon, IconButton, Menu, type MenuItem } from "@/ui"; import { executeAction } from "@/platform/actions"; -import { useContextKeys } from "@/platform/context"; import { useTx } from "@/platform/i18n"; import { shortcutLabel } from "@/platform/keymap"; -import { runExecutors, useRunExecutors } from "@/workbench/contributions"; +import { runExecutors, useRunExecutors, useDebugRunSessions } from "@/workbench/contributions"; import { configIcon } from "./configs"; import { useRemoteLabel } from "./remote"; -import { CURRENT_FILE, rerunSession, sessionsOf, startRun, stopSession, useRun } from "./store"; +import { CURRENT_FILE, useRun } from "./store"; +import { compoundTargets, selectedTarget } from "./sessionTargets"; import { isDebugOnly } from "./types"; import styles from "./Run.module.css"; @@ -25,15 +25,15 @@ export function RunWidget() { // Remote JVM Debug and the like: only Debug applies, as in IDEA. const debugOnly = !!selected && isDebugOnly(selected); const running = sessions.filter((s) => s.status === "running" || s.status === "starting"); - // A compound is running while any of its configurations is; Stop stops them all. - const compoundRunning = selected?.kind === "compound" ? sessionsOf(selected, running) : []; - const runningSelected = compoundRunning[0] ?? running.find((s) => s.config.id === selectedId) ?? null; - const target = runningSelected ?? running[running.length - 1] ?? null; - // Debug sessions belong to the debugger feature; like IDEA the widget offers their Rerun / Stop. - const debugging = useContextKeys((c) => !!c.debugSessionActive); - const canRerunDebug = useContextKeys((c) => !!c.debugCanRerun); - const debugPaused = useContextKeys((c) => !!c.debugSessionPaused); - const active = !!runningSelected || debugging; + 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 count = running.length + debug.length; + const stopName = compoundRunning.length + ? selected?.name + : (target?.name ?? running[0]?.config.name ?? debug[0]?.name); const moreRef = useRef(null); const [moreOpen, setMoreOpen] = useState(false); const executors = useRunExecutors((s) => s.executors); @@ -80,43 +80,9 @@ export function RunWidget() { - {target ? ( + {active ? ( <> - (compoundRunning.length && selected ? void startRun(selected) : rerunSession(target.id))} - /> - 1 - ? tx`Stop (${running.length} running)` - : tx`Stop '${target.config.name}'` - } - shortcut={shortcutLabel("run.stop")} - onClick={() => - compoundRunning.length - ? compoundRunning.forEach((s) => void stopSession(s.id)) - : running.length > 1 - ? void executeAction("run.stop") - : void stopSession(target.id) - } - /> - - ) : debugging ? ( - // IDEA while debugging: Resume (when paused), Rerun Debug and Stop as filled buttons. - <> - {debugPaused && ( + {target?.paused && ( void executeAction("debug.resume")} + onClick={() => target.resume()} /> )} void executeAction("debug.rerun")} - /> - void executeAction("debug.stop")} + title={tx`Rerun '${selected?.name ?? target?.name ?? ""}'`} + shortcut={shortcutLabel("run.run")} + disabled={!!target && !target.rerun} + onClick={() => void executeAction("run.run")} /> ) : ( @@ -179,6 +136,17 @@ export function RunWidget() { /> )} + {count > 0 && ( + void executeAction("run.stop")} + /> + )} return; } if (!(await buildFirst(p.debug.build))) return; - await executeAction("debug.startConfig", p.debug.config); + await executeAction("debug.startConfig", { config: p.debug.config, origin: config }); return; } await executeAction("run.debugConfig", config); diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 699455018..b0b1a2e19 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -11,6 +11,7 @@ import { onWorkflowSettingsChange } from "@/platform/workflowSettings"; import { showContextMenu, lazyComponent, type MenuItem } from "@/ui"; import { menuGroupItems, + useDebugRunSessions, notifications, registerMenuGroup, registerOverlay, @@ -50,7 +51,6 @@ import { CURRENT_FILE, currentFileSelected, prepareLaunch, - sessionsOf, setBeforeLaunchRunner, loadConfigs, failedTests, @@ -66,6 +66,7 @@ import { stopSession, useRun, } from "./store"; +import { compoundTargets, selectedTarget } from "./sessionTargets"; import { tx } from "@/platform/i18n"; function hasProject() { @@ -84,6 +85,11 @@ async function runSelected() { useRun.setState({ chooserOpen: true, chooserMode: "run" }); return; } + const target = selectedTarget(config.id, useRun.getState().sessions, useDebugRunSessions.getState().sessions); + if (target) { + target.rerun?.(); + return; + } await startRun(config); } @@ -172,9 +178,8 @@ function configFromArg(arg: unknown): RunConfig | undefined { } /** - * IDEA's Stop: in the Run or Debug tool window, the selected tab's process; otherwise the - * only running process, or a chooser of every running one (run sessions and the - * `stopProcess` items other features contribute, e.g. debug sessions). + * Stop: in the Run or Debug tool window, the selected tab's process; otherwise the selected + * configuration's session, or a chooser of every running process. */ function stopAction() { const ctx = getContext(); @@ -183,22 +188,24 @@ function stopAction() { return; } const running = runningSessions(); - const { activeSession } = useRun.getState(); if (ctx.toolWindowFocus && ctx.activeToolWindow === "run") { - const active = running.find((s) => s.id === activeSession); + const active = running.find((s) => s.id === useRun.getState().activeSession); if (active) { void stopSession(active.id); return; } } - // A selected compound stops all of its configurations. + const debug = useDebugRunSessions.getState().sessions; const selected = selectedConfig(); - if (selected?.kind === "compound") { - const own = sessionsOf(selected, running); - if (own.length) { - own.forEach((s) => void stopSession(s.id)); - return; - } + const compound = compoundTargets(selected, running, debug); + if (compound.length) { + compound.forEach((s) => s.stop()); + return; + } + const target = selectedTarget(useRun.getState().selectedId, running, debug); + if (target) { + target.stop(); + return; } const items = [ ...running.map((s): MenuItem => ({ diff --git a/src/features/run/javaDebug.ts b/src/features/run/javaDebug.ts index 0fbed5b2f..d06f77cb9 100644 --- a/src/features/run/javaDebug.ts +++ b/src/features/run/javaDebug.ts @@ -77,6 +77,7 @@ export async function debugWithAgent(config: RunConfig): Promise { hostName: "127.0.0.1", port, process: runId, + runConfig: config, }); }; const session = await startRun(config, undefined, undefined, { diff --git a/src/features/run/session.test.tsx b/src/features/run/session.test.tsx index f278da6d0..cc8f1d5de 100644 --- a/src/features/run/session.test.tsx +++ b/src/features/run/session.test.tsx @@ -4,6 +4,7 @@ import { executeAction } from "@/platform/actions"; import { setContext } from "@/platform/context"; import type { RunConfig } from "@/platform/ipc"; import { + useDebugRunSessions, provideNotificationService, provideToolWindowService, registerMenuGroup, @@ -51,6 +52,7 @@ const running = (id: string, c: RunConfig, runId: number | null = null): RunSess beforeEach(() => { resetRuns(); + useDebugRunSessions.setState({ sessions: [] }); confirm.mockClear(); confirm.mockResolvedValue(true); provideToolWindowService({ show: vi.fn(), isVisible: () => true } as unknown as ToolWindowService); @@ -153,6 +155,8 @@ describe("Stop", () => { setContext("projectOpen", true); useRun.setState({ sessions: [running("run1", config("a"), 1), running("run2", config("b"), 2)], + configs: [config("a"), config("b")], + selectedId: "a", activeSession: "run2", }); setContext("toolWindowFocus", true); diff --git a/src/features/run/sessionTargets.ts b/src/features/run/sessionTargets.ts new file mode 100644 index 000000000..367a9e37f --- /dev/null +++ b/src/features/run/sessionTargets.ts @@ -0,0 +1,32 @@ +import { compoundChildren } from "./configs"; +import type { RunConfig } from "@/platform/ipc"; +import type { DebugRunSession } from "@/workbench/contributions"; +import { rerunSession, sessionsOf, stopSession, useRun, type RunSession } from "./store"; + +/** Selection, never tool-window focus or an unrelated process, determines the primary target. */ +export function selectedTarget(selectedId: string | null, runs: RunSession[], debug: DebugRunSession[]) { + const candidates = [ + ...runs + .filter((s) => (s.status === "running" || s.status === "starting") && s.config.id === selectedId) + .map((s) => ({ + name: s.config.name, + startedAt: s.startedAt, + debug: false, + paused: false, + rerun: () => rerunSession(s.id), + stop: () => void stopSession(s.id), + resume: () => {}, + })), + ...debug.filter((s) => s.configId != null && s.configId === selectedId).map((s) => ({ ...s, debug: true })), + ]; + return candidates.sort((a, b) => b.startedAt - a.startedAt)[0] ?? null; +} + +export function compoundTargets(config: RunConfig | undefined, runs: RunSession[], debug: DebugRunSession[]) { + 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) })), + ...debug.filter((s) => s.configId != null && ids.has(s.configId)), + ]; +} diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 21dff33dc..8e3cd8091 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -19,7 +19,7 @@ import { downloadJdkActions } from "@/platform/jdk"; import { useProject } from "@/platform/project"; import { executeAction } from "@/platform/actions"; import { workflowSettings } from "@/platform/workflowSettings"; -import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions"; +import { maybeEditorService, notifications, toolWindowService, useDebugRunSessions } from "@/workbench/contributions"; import { ConsoleModel } from "./console"; import { jvmFrameTarget } from "./links"; import { confirmRerun, confirmTerminate } from "./dialogs"; @@ -261,6 +261,7 @@ export function adoptTemporary(config: RunConfig): RunConfig { const busy = new Set( state.sessions.filter((x) => x.status === "running" || x.status === "starting").map((x) => x.config.id), ); + for (const session of useDebugRunSessions.getState().sessions) if (session.configId) busy.add(session.configId); while (temporary.length > MAX_TEMPORARY) { const evict = temporary.find((id) => id !== config.id && !busy.has(id)) ?? temporary[0]!; temporary = temporary.filter((id) => id !== evict); diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index b3fa65d61..78bf02f24 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -1256,3 +1256,17 @@ export function showStatusInfo(text: string, ms = 8000) { if (useStatusInfo.getState().seq === seq) useStatusInfo.setState({ text: null }); }, ms); } + +/** Live debugger roots exposed to the run toolbar without coupling feature stores. */ +export interface DebugRunSession { + id: number; + configId: string | null; + name: string; + startedAt: number; + paused: boolean; + rerun: (() => void) | null; + stop: () => void; + resume: () => void; +} + +export const useDebugRunSessions = create<{ sessions: DebugRunSession[] }>(() => ({ sessions: [] }));