feat(run): keep service debugging in the Services tool window (#1844)

Closes #1684. Debug and rerun started from the Services tool window used startSession(), which opened the Debug tool window whenever run.showOnStart was on. Services-originated debug sessions and reruns now start with show: false and stay in the Services detail pane (in-place launches and reruns keep that origin; a breakpoint hit still shows the paused session). Debug from the toolbar, gutter, context menu and the Debug window keeps opening the Debug window. Also: palette themes inherit the macOS window button colours, and the title bar's fullscreen buttons skip Tauri calls outside Tauri (fixes the theme-token and title-bar test failures on main).

Co-authored-by: xtr-hub <[email protected]>
Цей коміт міститься в:
xtr-hubтаxtr-hub authored and GitHub committed 2026-11-08 00:46:39 +08:00
1 джерело f407880204
коміт b790a9ddb6
16 files changed
+314 -92

No files matched your search

+14
Переглянути файл
@@ -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 {
+39 -1
Переглянути файл
@@ -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<typeof import("@/platform/ipc")>();
@@ -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> = [];
+30 -24
Переглянути файл
@@ -236,7 +236,7 @@ async function listenForJvm(name: string, host: string, port: number): Promise<n
}
/** Remote JVM Debug, Go Remote, Python / Node.js remote: attach over host:port. */
async function startRemote(config: RunConfig, root: string) {
async function startRemote(config: RunConfig, root: string, show?: boolean) {
if (config.kind === REMOTE_JVM) {
const t = remoteJvmTarget(config);
let hostName = t.host;
@@ -248,37 +248,42 @@ 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, { runConfig: config, rerunnable: t.mode === "attach" });
await startSession(launch, { runConfig: config, rerunnable: t.mode === "attach", show });
return;
}
const launch = remoteAttachConfig(config, root);
if (launch) await startSession(launch, { runConfig: config });
if (launch) await startSession(launch, { runConfig: config, show });
}
/** Preparation belongs to a session from the moment the action begins. */
async function startJavaConfig(config: RunConfig, root: string, origin: RunConfig) {
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);
}
});
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);
+18 -6
Переглянути файл
@@ -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<DebugSessionId, AbortController>();
export async function startPreparedSession(
config: RunConfig,
prepare: (signal: AbortSignal, report: (text: string, kind?: "system" | "stderr") => void) => Promise<LaunchConfig>,
options: { show?: boolean } = {},
): Promise<DebugSessionId | null> {
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<DebugSessionId | null> {
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) {
+16 -8
Переглянути файл
@@ -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<boolean> {
async function runToEnd(config: RunConfig, chain: readonly string[], show?: boolean): Promise<boolean> {
let done!: (ok: boolean) => void;
const exited = new Promise<boolean>((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<string | null> {
async function runTask(
task: BeforeLaunchTask,
config: RunConfig,
chain: readonly string[],
show?: boolean,
): Promise<string | null> {
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<boolean> {
export async function runBeforeLaunch(config: RunConfig, chain: readonly string[], show?: boolean): Promise<boolean> {
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);
}
+6 -6
Переглянути файл
@@ -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<boolean> {
const session = await startRun(build);
async function buildFirst(build: RunConfig, show?: boolean): Promise<boolean> {
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<boolean> {
}
/** Runs or debugs a production (adopting it as the selected temporary configuration). */
export async function launchProduced(p: Produced, debug: boolean): Promise<void> {
export async function launchProduced(p: Produced, debug: boolean, show?: boolean): Promise<void> {
if (p.missing || !p.config) {
reportMissing(p);
return;
@@ -160,11 +160,11 @@ export async function launchProduced(p: Produced, debug: boolean): Promise<void>
});
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);
}
/**
+12 -11
Переглянути файл
@@ -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);
},
},
{
+3 -1
Переглянути файл
@@ -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<boolean> {
export async function debugWithAgent(config: RunConfig, show?: boolean): Promise<boolean> {
const launch = jdwpLaunch(config);
if (!launch) return false;
let sessionId: string | null = null;
@@ -88,10 +88,12 @@ export async function debugWithAgent(config: RunConfig): Promise<boolean> {
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;
+32 -1
Переглянути файл
@@ -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", () => {
+7 -5
Переглянути файл
@@ -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 (
<SessionPanel
key={execution.run!.id}
session={execution.run!}
onRerun={d ? d.rerun : undefined}
onDebug={d ? d.rerun : undefined}
onRerun={d ? rerun : () => run(r)}
onDebug={d ? rerun : () => debugSession(execution.run!.id, { show: false })}
onStop={d ? () => void stopServiceSessions([execution]) : undefined}
/>
);
+21 -11
Переглянути файл
@@ -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<boolean>;
export type BeforeLaunchRunner = (config: RunConfig, chain: readonly string[], show?: boolean) => Promise<boolean>;
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<boolean> {
export async function prepareLaunch(
config: RunConfig,
chain: readonly string[] = [],
show?: boolean,
): Promise<boolean> {
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<RunSession[]> {
if (!(await prepareLaunch(config, chain))) return [];
export async function startCompound(
config: RunConfig,
chain: readonly string[] = [],
show?: boolean,
): Promise<RunSession[]> {
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<RunSession | null> {
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. */
+86 -1
Переглянути файл
@@ -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(<ServicesTreeView />);
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(<ServicesTreeView />);
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(<ServicesTreeView />);
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(<ServicesTreeView />);
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(<ServicesTreeView />);
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);
+2 -2
Переглянути файл
@@ -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"];
+3 -1
Переглянути файл
@@ -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;
+23 -14
Переглянути файл
@@ -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());
}
+2
Переглянути файл
@@ -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<void>;
show: () => void;
}