diff --git a/src/features/debugger/__tests__/runOrigin.test.ts b/src/features/debugger/__tests__/runOrigin.test.ts index 023f2fb3b..12fe981d1 100644 --- a/src/features/debugger/__tests__/runOrigin.test.ts +++ b/src/features/debugger/__tests__/runOrigin.test.ts @@ -174,6 +174,20 @@ it("keeps the RunConfig through debug.startConfig and reruns through its debug p } }); +it("reruns a debug service in place without showing the Debug tool window", 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); + await rerunSession(42, { show: false }); + expect(restart).toHaveBeenCalledWith({ config, show: false }); + } finally { + dispose(); + unregister(); + } +}); + it("preserves the source configuration for a separately built debug binary", async () => { const dispose = registerDebugActions(); try { diff --git a/src/features/debugger/__tests__/startupBreakpoints.test.ts b/src/features/debugger/__tests__/startupBreakpoints.test.ts index 452e61a66..b5fab268d 100644 --- a/src/features/debugger/__tests__/startupBreakpoints.test.ts +++ b/src/features/debugger/__tests__/startupBreakpoints.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { addBreakpoint, loadBreakpoints, removeAllBreakpoints, useBreakpoints } from "../breakpoints/store"; -import { startSession, useDebug } from "../state"; +import { handleDebugEvent, startPreparedSession, startSession, useDebug } from "../state"; import type { LaunchConfig } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; import { provideNotificationService, provideToolWindowService, @@ -13,6 +14,8 @@ const ipc = vi.hoisted(() => ({ debugStart: vi.fn(async () => ({ status: "ok", data: 42 })), debugCapabilities: vi.fn(async () => ({ status: "ok", data: {} })), debugExceptionFilters: vi.fn(async () => ({ status: "ok", data: [] })), + debugThreads: vi.fn(async () => ({ status: "ok", data: [] })), + debugStackTrace: vi.fn(async () => ({ status: "ok", data: [] })), })); vi.mock("@/platform/ipc", async (original) => { const real = await original(); @@ -34,11 +37,46 @@ beforeEach(() => { localStorage.clear(); useBreakpoints.setState({ root: "/p", byFile: {}, muted: false, status: {} }); useDebug.setState({ sessions: {}, active: null, console: {} }); + useProject.setState({ settings: null }); provideNotificationService({ notify: vi.fn() }); provideToolWindowService({ show: vi.fn() } as unknown as ToolWindowService); }); afterEach(() => vi.useRealTimers()); +it.each([undefined, true, false])( + "respects showOnStart for direct and prepared starts (show: %s)", + async (foreground) => { + for (const showOnStart of [true, false]) { + useProject.setState({ settings: { run: { showOnStart } } as never }); + for (const prepared of [false, true]) { + const show = vi.fn(); + provideToolWindowService({ show } as unknown as ToolWindowService); + if (prepared) { + await startPreparedSession({ id: "java", name: "Java" } as never, async () => config("launch"), { + show: foreground, + }); + } else await startSession(config("launch"), { show: foreground }); + if (showOnStart && foreground !== false) expect(show).toHaveBeenCalledWith("debug"); + else expect(show).not.toHaveBeenCalled(); + } + } + }, +); + +it.each(["breakpoint", "function breakpoint", "exception", "pause"])( + "reveals an in-place session on suspend (%s)", + async (reason) => { + const show = vi.fn(); + provideToolWindowService({ show } as unknown as ToolWindowService); + await startSession(config("launch"), { show: false }); + expect(show).not.toHaveBeenCalled(); + handleDebugEvent(42, { kind: "stopped", threadId: 1, reason, description: null, allThreadsStopped: true }); + expect(show).toHaveBeenCalledWith("debug"); + expect(useDebug.getState().active).toBe(42); + expect(useDebug.getState().sessions[42]).toMatchObject({ status: "paused", selectedThread: 1 }); + }, +); + it.each(["launch", "attach"])("awaits every file before %s can send configurationDone", async (request) => { const completed: string[] = []; const release: Array<() => void> = []; diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 33f9b073f..05fb556b7 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -236,7 +236,7 @@ async function listenForJvm(name: string, host: string, port: number): Promise { - const request = nextRequestId(); - const cancel = () => { - void commands.cancelRequest(request).catch(() => undefined); - }; - signal.addEventListener("abort", cancel, { once: true }); - try { - await maybeEditorService() - ?.saveAll() - .catch(() => undefined); - signal.throwIfAborted(); - return await javaLaunchConfig(config, root, { request, signal, report }); - } finally { - signal.removeEventListener("abort", cancel); - } - }); +async function startJavaConfig(config: RunConfig, root: string, origin: RunConfig, show?: boolean) { + await startPreparedSession( + origin, + async (signal, report) => { + const request = nextRequestId(); + const cancel = () => { + void commands.cancelRequest(request).catch(() => undefined); + }; + signal.addEventListener("abort", cancel, { once: true }); + try { + await maybeEditorService() + ?.saveAll() + .catch(() => undefined); + signal.throwIfAborted(); + return await javaLaunchConfig(config, root, { request, signal, report }); + } finally { + signal.removeEventListener("abort", cancel); + } + }, + { show }, + ); } async function startConfig(arg: unknown) { const root = useProject.getState().project?.root; if (!root) return; const origin = arg && typeof arg === "object" && "origin" in arg && isRunConfig(arg.origin) ? arg.origin : null; + const show = arg && typeof arg === "object" && "show" in arg ? (arg as { show?: boolean }).show : undefined; const input = arg && typeof arg === "object" && "config" in arg ? arg.config : arg; let config: RunConfig | null = isRunConfig(input) ? input : null; if (!config) { @@ -303,16 +308,16 @@ async function startConfig(arg: unknown) { config = configs[0]!; // Through Run's debug flow: Maven / Gradle / scripts start the JVM with a JDWP agent there. if (getAction("run.debugConfig")) { - await executeAction("run.debugConfig", config); + await executeAction("run.debugConfig", show === false ? { config, show: false } : config); return; } } if (isRemoteConfig(config)) { - await startRemote(config, root); + await startRemote(config, root, show); return; } if (isJavaDebuggable(config)) { - await startJavaConfig(config, root, origin ?? config); + await startJavaConfig(config, root, origin ?? config, show); return; } const dotnet = config.kind === "dotnet" && config.debugAdapter === "netcoredbg"; @@ -334,7 +339,7 @@ async function startConfig(arg: unknown) { await maybeEditorService() ?.saveAll() .catch(() => undefined); - await startSession(launch, { runConfig: origin ?? config }); + await startSession(launch, { runConfig: origin ?? config, show }); await xdebugCheck; } @@ -410,6 +415,7 @@ export function registerDebugActions(): () => void { process: (arg.process ?? null) as RunId | null, runConfig: arg.runConfig, rerunnable: false, + show: (arg as { show?: boolean }).show, }); if (session == null && isPythonAttachTarget(arg) && arg.process != null) await stopAssociatedProcess(arg.process); diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index 9ecccad7a..eeac79e1d 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -308,6 +308,8 @@ export function publishContext() { processId: session.process, httpPort: sessionHttpPort(session.id), rerun: session.runConfig || session.launch ? () => void rerunSession(session.id) : null, + rerunInPlace: + session.runConfig || session.launch ? () => void rerunSession(session.id, { show: false }) : null, stop: (options?: { stopProcess?: boolean }) => stopSession(session.id, options), show: () => { if (!useDebug.getState().sessions[session.id]) return; @@ -497,6 +499,7 @@ const preparations = new Map(); export async function startPreparedSession( config: RunConfig, prepare: (signal: AbortSignal, report: (text: string, kind?: "system" | "stderr") => void) => Promise, + options: { show?: boolean } = {}, ): Promise { const id = --preparationSeq as DebugSessionId; const controller = new AbortController(); @@ -511,7 +514,7 @@ export async function startPreparedSession( })); publishContext(); try { - if (workflowSettings().run.showOnStart) toolWindowService().show("debug"); + if (options.show !== false && workflowSettings().run.showOnStart) toolWindowService().show("debug"); } catch { /* no workbench (tests) */ } @@ -522,7 +525,11 @@ export async function startPreparedSession( report(tx`Starting debugger for ${config.name}…` + "\n"); const launch = await prepare(controller.signal, report); controller.signal.throwIfAborted(); - return await startSession(launch, { runConfig: config, preparation: { id, signal: controller.signal } }); + return await startSession(launch, { + runConfig: config, + preparation: { id, signal: controller.signal }, + show: options.show, + }); } catch (e) { if (controller.signal.aborted || isCancelled(e)) return null; report((e instanceof Error ? e.message : String(e)) + "\n", "stderr"); @@ -542,6 +549,8 @@ export async function startSession( process?: RunId | null; /** Rerun repeats the launch (false for attaching to a process that is gone afterwards). */ rerunnable?: boolean; + /** `false`: keep the current tool window (the Services panel starts debuggers in place). */ + show?: boolean; } = {}, ): Promise { if (!isTauri()) { @@ -553,7 +562,7 @@ export async function startSession( const previousActive = useDebug.getState().active; if (!afterDetach) dropFinished(); try { - if (workflowSettings().run.showOnStart) toolWindowService().show("debug"); + if (options.show !== false && workflowSettings().run.showOnStart) toolWindowService().show("debug"); } catch { // Workbench not ready (tests). } @@ -733,19 +742,22 @@ export function removeSession(id: DebugSessionId) { publishContext(); } -export async function rerunSession(id: DebugSessionId) { +export async function rerunSession(id: DebugSessionId, options: { show?: boolean } = {}) { 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); + await executeAction( + "run.debugConfig", + options.show === false ? { config: root.runConfig, show: false } : root.runConfig, + ); return; } const launch = root.launch; if (!launch) return; if (isLive(root)) await stopSession(root.id); - await startSession(launch); + await startSession(launch, { show: options.show }); } async function loadCapabilities(id: DebugSessionId) { diff --git a/src/features/run/beforeLaunch.ts b/src/features/run/beforeLaunch.ts index 8ce13fbd7..c5984c722 100644 --- a/src/features/run/beforeLaunch.ts +++ b/src/features/run/beforeLaunch.ts @@ -48,22 +48,28 @@ export function taskLabel(task: BeforeLaunchTask, configs: readonly RunConfig[] } /** Runs `config` to completion: true when it exited with code 0. */ -async function runToEnd(config: RunConfig, chain: readonly string[]): Promise { +async function runToEnd(config: RunConfig, chain: readonly string[], show?: boolean): Promise { let done!: (ok: boolean) => void; const exited = new Promise((resolve) => (done = resolve)); const session = await startRun(config, undefined, undefined, { chain, + show, onExit: (code, stopped) => done(code === 0 && !stopped), }); if (!session || session.status === "failed") return false; return exited; } -async function runTask(task: BeforeLaunchTask, config: RunConfig, chain: readonly string[]): Promise { +async function runTask( + task: BeforeLaunchTask, + config: RunConfig, + chain: readonly string[], + show?: boolean, +): Promise { const target = task.target ?? ""; switch (task.kind) { case "dotnetBuild": - return (await runToEnd(dotnetBuildConfig(config), chain)) ? null : "Build failed"; + return (await runToEnd(dotnetBuildConfig(config), chain, show)) ? null : "Build failed"; case "build": { if (!getAction(BUILD_ACTION)) return "Building is not available"; if (!(await executeAction(BUILD_ACTION, { activate: false }))) return "A build is already running"; @@ -73,14 +79,16 @@ async function runTask(task: BeforeLaunchTask, config: RunConfig, chain: readonl if (chain.includes(target)) return "The before launch tasks run in a cycle"; const other = useRun.getState().configs.find((c) => c.id === target); if (!other) return `Configuration '${target}' no longer exists`; - return (await runToEnd(other, chain)) ? null : `'${other.name}' failed`; + return (await runToEnd(other, chain, show)) ? null : `'${other.name}' failed`; } case "tool": { const tool = workflowSettings().externalTools.find((t) => t.id === target); if (!tool) return `External tool '${target}' no longer exists`; const run = await externalToolConfig(tool); if (!run) return `'${tool.name}' was not run`; - return (await runToEnd({ ...run, id: `${run.id}:before:${config.id}` }, chain)) ? null : `'${tool.name}' failed`; + return (await runToEnd({ ...run, id: `${run.id}:before:${config.id}` }, chain, show)) + ? null + : `'${tool.name}' failed`; } case "command": { if (!target.trim()) return "No command"; @@ -96,7 +104,7 @@ async function runTask(task: BeforeLaunchTask, config: RunConfig, chain: readonl isTest: false, debugAdapter: null, }; - return (await runToEnd(run, chain)) ? null : `'${target}' failed`; + return (await runToEnd(run, chain, show)) ? null : `'${target}' failed`; } case "toolWindow": { if (!useContributions.getState().toolWindows.has(target)) return `Tool window '${target}' is not available`; @@ -109,11 +117,11 @@ async function runTask(task: BeforeLaunchTask, config: RunConfig, chain: readonl } /** The installed runner: every task in order; a failure is reported and aborts. */ -export async function runBeforeLaunch(config: RunConfig, chain: readonly string[]): Promise { +export async function runBeforeLaunch(config: RunConfig, chain: readonly string[], show?: boolean): Promise { for (const task of config.beforeLaunch ?? []) { let error: string | null; try { - error = await runTask(task, config, chain); + error = await runTask(task, config, chain, show); } catch (e) { error = e instanceof Error ? e.message : String(e); } diff --git a/src/features/run/contextRun.ts b/src/features/run/contextRun.ts index f600cddb1..9d9215fb9 100644 --- a/src/features/run/contextRun.ts +++ b/src/features/run/contextRun.ts @@ -120,8 +120,8 @@ function reportMissing(p: Produced) { } /** Builds with `build` in the Run window; resolves whether it succeeded. */ -async function buildFirst(build: RunConfig): Promise { - const session = await startRun(build); +async function buildFirst(build: RunConfig, show?: boolean): Promise { + const session = await startRun(build, undefined, undefined, { show }); if (!session) return false; return new Promise((resolve) => { const check = () => { @@ -139,7 +139,7 @@ async function buildFirst(build: RunConfig): Promise { } /** Runs or debugs a production (adopting it as the selected temporary configuration). */ -export async function launchProduced(p: Produced, debug: boolean): Promise { +export async function launchProduced(p: Produced, debug: boolean, show?: boolean): Promise { if (p.missing || !p.config) { reportMissing(p); return; @@ -160,11 +160,11 @@ export async function launchProduced(p: Produced, debug: boolean): Promise }); return; } - if (!(await buildFirst(p.debug.build))) return; - await executeAction("debug.startConfig", { config: p.debug.config, origin: config }); + if (!(await buildFirst(p.debug.build, show))) return; + await executeAction("debug.startConfig", { config: p.debug.config, origin: config, show }); return; } - await executeAction("run.debugConfig", config); + await executeAction("run.debugConfig", show === false ? { config, show: false } : config); } /** diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 4a4caec19..4f8670ecf 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -107,15 +107,15 @@ async function runSelected() { await startRun(config); } -async function debugConfig(config: RunConfig, chain: readonly string[] = []) { +async function debugConfig(config: RunConfig, chain: readonly string[] = [], show?: boolean) { if (config.kind === "compound") { - if (!(await prepareLaunch(config, chain))) return; + if (!(await prepareLaunch(config, chain, show))) return; const children = compoundChildren(config, useRun.getState().configs); - await Promise.all(children.map((c) => debugConfig(c, [...chain, config.id]))); + await Promise.all(children.map((c) => debugConfig(c, [...chain, config.id], show))); return; } if (remoteDebugBlocked(config)) return; - if (!(await prepareLaunch(config, chain))) return; + if (!(await prepareLaunch(config, chain, show))) return; recordRun(config.id); // Python on a Docker / Compose target: debugpy in the container, the debugger attaches. if (containerDebuggable(config) && getAction("debug.attach")) { @@ -124,28 +124,28 @@ async function debugConfig(config: RunConfig, chain: readonly string[] = []) { } // Remote JVM Debug, Go Remote…: the debugger attaches, nothing is launched here. if (isDebugOnly(config) && getAction("debug.startConfig")) { - await executeAction("debug.startConfig", config); + await executeAction("debug.startConfig", show === false ? { config, show: false } : config); return; } // A single compiled file (rustc / clang): build with debug info, then debug the binary. const compiled = await compiledDebugPlan(config); if (compiled?.debug) { - await launchProduced(compiled, true); + await launchProduced(compiled, true, show); return; } // "Build and run using: Gradle": the JavaExec JVM gets the agent, the debugger attaches to it. const delegated = getAction("debug.attach") ? await gradleDelegation(config) : null; if (delegated) { - await debugWithAgent(delegated); + await debugWithAgent(delegated, show); return; } // Maven / Gradle: the build tool starts the JVM with a debug agent, the debugger attaches. if (jdwpLaunch(config) && getAction("debug.attach")) { - await debugWithAgent(config); + await debugWithAgent(config, show); return; } if (getAction("debug.startConfig")) { - await executeAction("debug.startConfig", config); + await executeAction("debug.startConfig", show === false ? { config, show: false } : config); return; } notifications().notify({ @@ -155,7 +155,7 @@ async function debugConfig(config: RunConfig, chain: readonly string[] = []) { body: tx`Running '${config.name}' without the debugger.`, key: "run-no-debugger", }); - await startRun(config, undefined, undefined, { prepared: true }); + await startRun(config, undefined, undefined, { prepared: true, show }); } async function debugSelected() { @@ -537,7 +537,8 @@ export function activate(): () => void { run: async ({ arg }) => { await ensureConfigs(); const config = configFromArg(arg); - if (config) await debugConfig(config); + const show = arg && typeof arg === "object" && "show" in arg ? (arg as { show?: boolean }).show : undefined; + if (config) await debugConfig(config, [], show); }, }, { diff --git a/src/features/run/javaDebug.ts b/src/features/run/javaDebug.ts index 225cf18cf..bb09353cf 100644 --- a/src/features/run/javaDebug.ts +++ b/src/features/run/javaDebug.ts @@ -73,7 +73,7 @@ export function jdwpLaunch(config: RunConfig): RunConfig | null { } /** Runs `config` with a JDWP agent and attaches the Java debugger to every JVM it starts. */ -export async function debugWithAgent(config: RunConfig): Promise { +export async function debugWithAgent(config: RunConfig, show?: boolean): Promise { const launch = jdwpLaunch(config); if (!launch) return false; let sessionId: string | null = null; @@ -88,10 +88,12 @@ export async function debugWithAgent(config: RunConfig): Promise { port, process: runId, runConfig: config, + show, }); }; const session = await startRun(config, undefined, undefined, { launch, + show, onLine: (line) => { const port = jdwpListeningPort(line); if (port === null) return; diff --git a/src/features/run/runConfigTypes.test.tsx b/src/features/run/runConfigTypes.test.tsx index d66540726..08f86896f 100644 --- a/src/features/run/runConfigTypes.test.tsx +++ b/src/features/run/runConfigTypes.test.tsx @@ -21,7 +21,17 @@ import { compoundChildren, compoundCycle, formatEnv, parseEnv, validateConfig } import { contextConfig } from "./context"; import { EditConfigsOverlay, moveConfig, treeRows } from "./EditConfigsDialog"; import { pasteRows } from "./EnvDialog"; -import { CURRENT_FILE, resetRuns, selectConfig, sessionsOf, startCompound, startRun, useRun } from "./store"; +import { + CURRENT_FILE, + prepareLaunch, + resetRuns, + selectConfig, + sessionsOf, + setBeforeLaunchRunner, + startCompound, + startRun, + useRun, +} from "./store"; import { BUILTIN_TYPES, blankTemplate, @@ -69,6 +79,15 @@ beforeEach(() => { afterEach(() => disposeTypes()); +it.each([undefined, false])( + "respects the launch origin when a configuration requests focus (%s)", + async (showOption) => { + await startRun(cfg("focused", { focusToolWindow: true }), undefined, undefined, { show: showOption }); + if (showOption === false) expect(show).not.toHaveBeenCalled(); + else expect(show).toHaveBeenCalledWith("run", true); + }, +); + describe("configuration types", () => { it("runs an Application with the chosen JRE and the classpath of the chosen module", () => { const type = typeFor("application")!; @@ -585,6 +604,18 @@ describe("before launch tasks", () => { dispose(); disposeBuild(); }); + + it("forwards the tool-window visibility choice to the before launch runner", async () => { + const runner = vi.fn(async () => true); + setBeforeLaunchRunner(runner); + try { + const app = cfg("app", { beforeLaunch: [{ kind: "run", target: "other" }] }); + expect(await prepareLaunch(app, [], false)).toBe(true); + expect(runner).toHaveBeenCalledWith(app, ["app"], false); + } finally { + setBeforeLaunchRunner(null); + } + }); }); describe("environment variables", () => { diff --git a/src/features/run/services.tsx b/src/features/run/services.tsx index 7c27f6ea3..c9cfe5311 100644 --- a/src/features/run/services.tsx +++ b/src/features/run/services.tsx @@ -31,6 +31,7 @@ import { showApplicationUpdate } from "./applicationUpdate"; import { isUpdatable } from "./updatePolicies"; import { configRemoved, + debugSession, loadConfigs, persistTemporary, rerunConfig, @@ -355,7 +356,7 @@ function showOutput(s: RunSession) { /** Runs (or reruns, in its tab) a configuration; the output stays in Services instead of opening Run. */ function run(r: Resolved) { const debug = serviceDebugger(r.session); - if (debug) debug.rerun?.(); + if (debug) (debug.rerunInPlace ?? debug.rerun)?.(); else if (r.session?.run) { const s = r.session.run; void startRun(rerunConfig(s), s.id, s.baseConfig, { show: false }); @@ -364,8 +365,8 @@ function run(r: Resolved) { function debug(r: Resolved) { const session = serviceDebugger(r.session); - if (session) session.rerun?.(); - else if (r.config) void executeAction("run.debugConfig", r.config); + if (session) (session.rerunInPlace ?? session.rerun)?.(); + else if (r.config) void executeAction("run.debugConfig", { config: r.config, show: false }); } function actions(item: ServiceTreeItem): ServiceItemAction[] { @@ -464,12 +465,13 @@ function RunDetail({ item }: { item: ServiceTreeItem }) { if (r.session?.run) { const execution = r.session; const d = serviceDebugger(execution); + const rerun = d ? (d.rerunInPlace ?? d.rerun) : undefined; return ( run(r)} + onDebug={d ? rerun : () => debugSession(execution.run!.id, { show: false })} onStop={d ? () => void stopServiceSessions([execution]) : undefined} /> ); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 73f583e62..3e3b25314 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -627,7 +627,7 @@ function newRuntime(config: RunConfig): SessionRuntime { } /** Runs a configuration's before launch tasks; false aborts the launch. Installed by the feature. */ -export type BeforeLaunchRunner = (config: RunConfig, chain: readonly string[]) => Promise; +export type BeforeLaunchRunner = (config: RunConfig, chain: readonly string[], show?: boolean) => Promise; let beforeLaunch: BeforeLaunchRunner | null = null; export function setBeforeLaunchRunner(runner: BeforeLaunchRunner | null) { @@ -635,17 +635,25 @@ export function setBeforeLaunchRunner(runner: BeforeLaunchRunner | null) { } /** Runs `config`'s before launch tasks (none: true). `chain` holds the configurations already launching. */ -export async function prepareLaunch(config: RunConfig, chain: readonly string[] = []): Promise { +export async function prepareLaunch( + config: RunConfig, + chain: readonly string[] = [], + show?: boolean, +): Promise { if (!config.beforeLaunch?.length || !beforeLaunch) return true; - return beforeLaunch(config, [...chain, config.id]); + return beforeLaunch(config, [...chain, config.id], show); } /** Starts every configuration of a compound (after the compound's own before launch tasks). */ -export async function startCompound(config: RunConfig, chain: readonly string[] = []): Promise { - if (!(await prepareLaunch(config, chain))) return []; +export async function startCompound( + config: RunConfig, + chain: readonly string[] = [], + show?: boolean, +): Promise { + if (!(await prepareLaunch(config, chain, show))) return []; const children = compoundChildren(config, useRun.getState().configs); const sessions = await Promise.all( - children.map((c) => startRun(c, undefined, undefined, { chain: [...chain, config.id] })), + children.map((c) => startRun(c, undefined, undefined, { chain: [...chain, config.id], show })), ); return sessions.filter((s): s is RunSession => !!s); } @@ -677,7 +685,7 @@ export async function startRun( } = {}, ): Promise { if (config.kind === "compound") { - await startCompound(config, options.chain); + await startCompound(config, options.chain, options.show); return null; } // IDEA offers only Debug for Remote JVM Debug and the like: there is nothing to run. @@ -692,7 +700,7 @@ export async function startRun( }); return null; } - if (!options.launch && !options.prepared && !(await prepareLaunch(config, options.chain))) return null; + if (!options.launch && !options.prepared && !(await prepareLaunch(config, options.chain, options.show))) return null; const state = useRun.getState(); const busy = (s: RunSession) => isLive(s); let session = reuse ? state.sessions.find((s) => s.id === reuse) : undefined; @@ -789,7 +797,7 @@ export async function startRun( }); rt.onExit = options.onExit; try { - if (config.focusToolWindow) toolWindowService().show("run", true); + if (options.show !== false && config.focusToolWindow) toolWindowService().show("run", true); else if (options.show ?? config.openToolWindow ?? workflowSettings().run.showOnStart) toolWindowService().show("run"); } catch { @@ -1142,9 +1150,11 @@ export function rerunSession(sessionId: string) { * `run.debugConfig` entry the Services view and the editor context menu use * (`baseConfig` keeps the original configuration for derived launches). */ -export function debugSession(sessionId: string) { +export function debugSession(sessionId: string, options: { show?: boolean } = {}) { const session = getSession(sessionId); - if (session) void executeAction("run.debugConfig", baseConfigOf(session)); + if (!session) return; + const config = baseConfigOf(session); + void executeAction("run.debugConfig", options.show === false ? { config, show: false } : config); } /** Paths of the failed tests of a session's last run. */ diff --git a/src/features/services/ServicesTree.debug.test.tsx b/src/features/services/ServicesTree.debug.test.tsx index e7e122956..cdd11427a 100644 --- a/src/features/services/ServicesTree.debug.test.tsx +++ b/src/features/services/ServicesTree.debug.test.tsx @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { registerActions } from "@/platform/actions"; import { commands, type RunConfig } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { validateSettings } from "@/features/settings/model/schema"; @@ -7,7 +8,13 @@ import { adoptSession, appendConsole, useDebug } from "@/features/debugger/state import { DebugServiceConsole } from "@/features/debugger/ui/DebugConsole"; import { runServiceProvider } from "@/features/run/services"; import { onRunExited, runtime, startRun, useRun } from "@/features/run/store"; -import { registerServiceProvider, useDebugRunSessions, type DebugServiceSession } from "@/workbench/contributions"; +import { + provideToolWindowService, + registerServiceProvider, + useDebugRunSessions, + type DebugServiceSession, + type ToolWindowService, +} from "@/workbench/contributions"; import { ServicesTreeView } from "./ServicesTree"; import { useServicesTree } from "./tree"; @@ -109,6 +116,84 @@ it("shows the service port announced by a debug session's application", () => { expect(within(screen.getByRole("tree")).getByText(":8080")).toBeTruthy(); }); +it("debugs a service in place without switching to the Debug tool window", async () => { + const start = vi.fn(); + const disposeAction = registerActions([ + { id: "run.debugConfig", title: "Debug Configuration", run: ({ arg }) => start(arg) }, + ]); + try { + render(); + select(); + fireEvent.click(toolbar().getByRole("button", { name: "Debug" })); + await vi.waitFor(() => expect(start).toHaveBeenCalledWith({ config, show: false })); + } finally { + disposeAction(); + } +}); + +it("reruns a debug service in place from the Services panel", () => { + const d = summary(1, { rerun: vi.fn(), rerunInPlace: vi.fn() }); + useDebugRunSessions.setState({ serviceSessions: [d] }); + render(); + select(); + fireEvent.click(toolbar().getByRole("button", { name: "Rerun" })); + expect(d.rerunInPlace).toHaveBeenCalledOnce(); + expect(d.rerun).not.toHaveBeenCalled(); +}); + +it("starts a debug session in place from a run service's detail toolbar", async () => { + const start = vi.fn(); + const disposeAction = registerActions([ + { id: "run.debugConfig", title: "Debug Configuration", run: ({ arg }) => start(arg) }, + ]); + const c = { ...config, kind: "shell", command: "echo", debugAdapter: "java-debug" }; + const s = await startRun(c); + expect(s).not.toBeNull(); + useRun.setState({ configs: [c], sessions: [{ ...s!, config: c, status: "running" as const }] }); + try { + render(); + select(); + const actions = within(screen.getByRole("toolbar", { name: "Run actions" })); + fireEvent.click(actions.getByRole("button", { name: "Debug" })); + await vi.waitFor(() => expect(start).toHaveBeenCalledWith({ config: c, show: false })); + } finally { + disposeAction(); + } +}); + +it("reruns a plain run service in place from its detail toolbar", async () => { + const c = { ...config, kind: "shell", command: "echo", debugAdapter: null }; + const s = await startRun(c); + expect(s).not.toBeNull(); + useRun.setState({ configs: [c] }); + useServicesTree.setState({ expanded: new Set(["run:type:shell"]) }); + const show = vi.fn(); + provideToolWindowService({ show } as unknown as ToolWindowService); + const previousRuntime = runtime(s!.id); + render(); + select(); + fireEvent.click(within(screen.getByRole("toolbar", { name: "Run actions" })).getByRole("button", { name: "Rerun" })); + await vi.waitFor(() => expect(runtime(s!.id)).not.toBe(previousRuntime)); + expect(show).not.toHaveBeenCalled(); +}); + +it("reruns an attached debugger in place from a run service's detail toolbar", async () => { + const c = { ...config, kind: "shell", command: "echo", debugAdapter: null }; + const s = await startRun(c); + expect(s).not.toBeNull(); + const active = { ...s!, config, status: "running" as const, runId: 7 }; + runtime(s!.id)!.console.append({ kind: "stdout", text: "Maven application log\n" }); + useRun.setState({ configs: [config], sessions: [active] }); + const d = summary(1, { processId: 7, rerun: vi.fn(), rerunInPlace: vi.fn() }); + useDebugRunSessions.setState({ serviceSessions: [d] }); + render(); + select(); + const actions = within(screen.getByRole("toolbar", { name: "Run actions" })); + fireEvent.click(actions.getByRole("button", { name: "Debug" })); + expect(d.rerunInPlace).toHaveBeenCalledOnce(); + expect(d.rerun).not.toHaveBeenCalled(); +}); + it("keeps the other parallel instance alive and then displays it", async () => { adoptSession(1); adoptSession(2); diff --git a/src/features/settings/__tests__/contrast.test.ts b/src/features/settings/__tests__/contrast.test.ts index ec3dff203..b86e397d3 100644 --- a/src/features/settings/__tests__/contrast.test.ts +++ b/src/features/settings/__tests__/contrast.test.ts @@ -121,9 +121,9 @@ describe("theme tokens", () => { /** * Tokens the palette themes (Solarized, One) take from their light / dark base * on purpose: colours the user assigns (project and data source colours), the - * platform's caption close button, and the tint strength. + * platform's caption close and macOS window buttons, and the tint strength. */ -const INHERITED_FROM_BASE = /^--(project-color-|db-color-|caption-close-|fg-caption-close-|tint-strength$)/; +const INHERITED_FROM_BASE = /^--(project-color-|db-color-|caption-close-|fg-caption-close-|mac-window-|tint-strength$)/; describe("palette themes", () => { const ISLANDS = ["light", "dark", "dark-hc"]; diff --git a/src/styles/tokens.css b/src/styles/tokens.css index ea1a96da6..46e414cbf 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -437,7 +437,9 @@ html { } /* macOS window buttons drawn by the web title bar in full screen: the system colours in every theme. */ -:root { +:root, +:root[data-theme="light"], +:root[data-theme="dark-hc"] { --mac-window-close: #ff5f57; --mac-window-minimize: #febc2e; --mac-window-zoom: #28c840; diff --git a/src/workbench/MacWindowButtons.tsx b/src/workbench/MacWindowButtons.tsx index fe4113345..28f6da788 100644 --- a/src/workbench/MacWindowButtons.tsx +++ b/src/workbench/MacWindowButtons.tsx @@ -8,7 +8,7 @@ import { useEffect, useState } from "react"; import clsx from "clsx"; import { t } from "@/platform/i18n"; -import { commands, onIdeEvent } from "@/platform/ipc"; +import { commands, isTauri, onIdeEvent } from "@/platform/ipc"; import { Icon } from "@/ui"; import styles from "./TitleBar.module.css"; @@ -18,23 +18,31 @@ function useFullscreenWindow(): { fullscreen: boolean; active: boolean } { const [fullscreen, setFullscreen] = useState(false); const [active, setActive] = useState(() => document.hasFocus()); useEffect(() => { + // The plain-browser shell and tests have no Tauri window to ask. + if (!isTauri()) return; let disposed = false; const offs: (() => void)[] = []; - const sync = () => void commands.windowIsFullscreen().then((on) => !disposed && setFullscreen(on)); + const sync = () => + void commands + .windowIsFullscreen() + .then((on) => !disposed && setFullscreen(on)) + .catch(() => {}); sync(); offs.push(onIdeEvent("windowFullscreenChanged", sync)); - void import("@tauri-apps/api/window").then(async ({ getCurrentWindow }) => { - const w: AppWindow = getCurrentWindow(); - const listeners = await Promise.all([ - w.onResized(sync), - w.onFocusChanged(({ payload }) => { - setActive(payload); - sync(); - }), - ]); - if (disposed) listeners.forEach((off) => off()); - else offs.push(...listeners); - }); + void import("@tauri-apps/api/window") + .then(async ({ getCurrentWindow }) => { + const w: AppWindow = getCurrentWindow(); + const listeners = await Promise.all([ + w.onResized(sync), + w.onFocusChanged(({ payload }) => { + setActive(payload); + sync(); + }), + ]); + if (disposed) listeners.forEach((off) => off()); + else offs.push(...listeners); + }) + .catch(() => {}); return () => { disposed = true; offs.forEach((off) => off()); @@ -44,6 +52,7 @@ function useFullscreenWindow(): { fullscreen: boolean; active: boolean } { } function closeWindow() { + if (!isTauri()) return; void import("@tauri-apps/api/window").then(({ getCurrentWindow }) => getCurrentWindow().close()); } diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 141a6bfa9..99f7a5eef 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -1563,6 +1563,8 @@ export interface DebugServiceSession { /** The debuggee's HTTP port announced on its console (not the JDWP attach port), if known. */ httpPort?: number; rerun: (() => void) | null; + /** Rerun without opening the Debug tool window (the Services panel debugs in place). */ + rerunInPlace?: (() => void) | null; stop: (options?: { stopProcess?: boolean }) => Promise; show: () => void; }