diff --git a/CHANGELOG.md b/CHANGELOG.md index d5b0d6ac91..013b174bbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Features: - Add support for the FASTBuild generator (CMake 4.2+). [#4690](https://github.com/microsoft/vscode-cmake-tools/pull/4690) - Add support for `${workspaceFolder}`, `${workspaceFolder:name}` variables and relative paths in `cmake.exclude` setting for multi-root workspaces. [#4689](https://github.com/microsoft/vscode-cmake-tools/pull/4689) +- Add `cmake.launchConfig` setting to customize "Run Without Debugging"; supports delegating to a VS Code task or running a custom program (e.g., firmware flash tool). [#4904](https://github.com/microsoft/vscode-cmake-tools/issues/4904) Improvements: - Improve responsiveness to CMake path changes made by vendor extensions during configure-on-open retry. [#4908](https://github.com/microsoft/vscode-cmake-tools/pull/4908) Contributed by STMicroelectronics diff --git a/docs/cmake-settings.md b/docs/cmake-settings.md index e210e7eb0b..7479aaed67 100644 --- a/docs/cmake-settings.md +++ b/docs/cmake-settings.md @@ -45,6 +45,7 @@ Options that support substitution, in the table below, allow variable references | `cmake.ctestDefaultArgs` | Default arguments to pass to CTest. | `["-T", "test", "--output-on-failure"]` | no | | `cmake.ctestPath` | Path to CTest executable. | `null` | no | | `cmake.debugConfig`| The debug configuration to use when debugging a target. When `type` is specified, automatic debugger detection is skipped and a custom debug adapter can be used. Additional properties required by the debug adapter can be added freely. See [Debug and launch](debug-launch.md#customize-the-debug-adapter) for examples, including Natvis via `visualizerFile` without a `launch.json`. | `null` (no values) | yes | +| `cmake.launchConfig`| Customize the "Run Without Debugging" command to delegate to a VS Code task or run a custom program (e.g., firmware flash tool, OpenOCD, west). Set to either `task` (a string or `{name, type?}` object) or `program` (with optional `args`, `cwd`, `environment`). Supports `${command:cmake.launchTargetPath}`, `${workspaceFolder}`, and `${env:VAR}` variable substitution. `cmake.buildBeforeRun` and `cmake.launchBehavior` (`reuseTerminal`/`newTerminal`/`breakAndReuseTerminal`) still apply in program mode. **Important:** when set, `cmake.debugConfig.args`, `cwd`, and `environment` are not applied to the launch path (debug-only). A one-time warning is shown if both settings are populated. [#4904](https://github.com/microsoft/vscode-cmake-tools/issues/4904) | `null` | yes | | `cmake.defaultActiveFolder`| The name of active folder, which be used as default (Only works when `cmake.autoSelectActiveFolder` is disabled). | `""` | no | | `cmake.defaultVariants` | Override the default set of variants that will be supplied when no variants file is present. See [CMake variants](variants.md). | See package.json | no | | `cmake.deleteBuildDirOnCleanConfigure` | If `true`, delete build directory during clean configure. | `false` | no | diff --git a/package.json b/package.json index a834357834..d44288eaad 100644 --- a/package.json +++ b/package.json @@ -2943,6 +2943,60 @@ }, "scope": "resource" }, + "cmake.launchConfig": { + "type": "object", + "scope": "resource", + "markdownDescription": "%cmake-tools.configuration.cmake.launchConfig.markdownDescription%", + "additionalProperties": false, + "oneOf": [ + { "required": ["task"] }, + { "required": ["program"] } + ], + "properties": { + "task": { + "description": "%cmake-tools.configuration.cmake.launchConfig.task.description%", + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "type": { "type": "string" } + } + } + ] + }, + "program": { + "type": "string", + "description": "%cmake-tools.configuration.cmake.launchConfig.program.description%" + }, + "args": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "%cmake-tools.configuration.cmake.launchConfig.args.description%" + }, + "cwd": { + "type": "string", + "description": "%cmake-tools.configuration.cmake.launchConfig.cwd.description%" + }, + "environment": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "value"], + "properties": { + "name": { "type": "string" }, + "value": { "type": "string" } + } + }, + "description": "%cmake-tools.configuration.cmake.launchConfig.environment.description%" + } + } + }, "cmake.defaultVariants": { "type": "object", "$schema": "cmake-tools-schema://schemas/variants-schema.json", diff --git a/package.nls.json b/package.nls.json index 487ac5f8e2..bc427dc895 100644 --- a/package.nls.json +++ b/package.nls.json @@ -250,6 +250,24 @@ "cmake-tools.configuration.cmake.debugConfig.setupCommands.description": "Command to set up gdb or lldb.", "cmake-tools.configuration.cmake.debugConfig.setupCommands.text.description": "Command to run.", "cmake-tools.configuration.cmake.debugConfig.setupCommands.description.description": "Description of the command.", + "cmake-tools.configuration.cmake.launchConfig.markdownDescription": "Redirect the **CMake: Run Without Debugging** command (`cmake.launchTarget`) to either a VS Code task from `tasks.json` (`task` mode) or a custom external program (`program` mode). The active CMake target is still built first when `#cmake.buildBeforeRun#` is enabled, and its path is available via `${command:cmake.launchTargetPath}`. When set, this setting **fully replaces** `#cmake.debugConfig#`'s `args`, `cwd`, and `environment` on the launch path; provide equivalents inside `cmake.launchConfig` instead. The debug command (`cmake.debugTarget`) is unaffected. Only one of `task` or `program` may be set. When the delegated task itself depends on a CMake build (e.g. via `dependsOn`), consider setting `#cmake.buildBeforeRun#` to `false` to avoid building twice. When using `program`, the terminal lifecycle follows the `#cmake.launchBehavior#` setting (reuse, break-and-reuse, or new terminal).", + "cmake-tools.configuration.cmake.launchConfig.task.description": "Name of a task from `tasks.json` to run instead of launching the built executable. Accepts either a bare string label or `{ \"name\": \"...\", \"type\": \"...\" }` to disambiguate when multiple providers register tasks with the same name. Mutually exclusive with `program`.", + "cmake-tools.configuration.cmake.launchConfig.program.description": "Path to a custom program to run instead of the built executable. Supports `${command:cmake.launchTargetPath}` and other VS Code variable substitutions. Mutually exclusive with `task`.", + "cmake-tools.configuration.cmake.launchConfig.args.description": "Arguments to pass to the custom `program` (ignored in `task` mode). Each entry is shell-quoted before being sent to the integrated terminal.", + "cmake-tools.configuration.cmake.launchConfig.cwd.description": "Working directory for the custom `program` (ignored in `task` mode). Defaults to the directory of the built executable.", + "cmake-tools.configuration.cmake.launchConfig.environment.description": "Environment variables to set when running the custom `program` (ignored in `task` mode). Array of `{ \"name\": ..., \"value\": ... }` objects, mirroring `cmake.debugConfig.environment`.", + "cmake-tools.launchConfig.task.notFound": "Task '{0}' referenced by cmake.launchConfig was not found.", + "cmake-tools.launchConfig.task.executeFailed": "Failed to execute task \"{0}\" referenced by cmake.launchConfig: {1}", + "cmake-tools.launchConfig.task.ambiguous": "Multiple tasks named '{0}' found; using the first. Specify 'type' in cmake.launchConfig.task to disambiguate.", + "cmake-tools.launchConfig.launching.task": "Launching via task '{0}'", + "cmake-tools.launchConfig.launching.task.withType": "Launching via task '{0}' (type: {1})", + "cmake-tools.launchConfig.launching.program": "Launching custom program '{0}'", + "cmake-tools.launchConfig.debugConfigShadow.message": "cmake.launchConfig is set, so cmake.debugConfig.args, cmake.debugConfig.cwd, and cmake.debugConfig.environment will be ignored on the Run-Without-Debugging path. (cmake.debugConfig still applies when debugging.)", + "cmake-tools.launchConfig.debugConfigShadow.openSettings": "Open Settings", + "cmake-tools.launchConfig.debugConfigShadow.dontShowAgain": "Don't show again", + "cmake-tools.launchConfig.task.ignoredFields": "cmake.launchConfig: args, cwd, and environment are ignored in task mode. These settings only apply when using \"program\".", + "cmake-tools.projectStatus.launchConfig.viaTask": "(via task: {0})", + "cmake-tools.projectStatus.launchConfig.viaProgram": "(via program: {0})", "cmake-tools.configuration.cmake.defaultVariants.overall.description": "Configure the default variant settings.", "cmake-tools.configuration.cmake.defaultVariants.buildType.description": "The build type.", "cmake-tools.configuration.cmake.defaultVariants.buildType.unspecified.long": "Let CMake pick the default build type.", diff --git a/src/cmakeProject.ts b/src/cmakeProject.ts index 67767cdc62..4222eddce6 100644 --- a/src/cmakeProject.ts +++ b/src/cmakeProject.ts @@ -42,6 +42,7 @@ import * as nls from 'vscode-nls'; import { ConfigurationWebview } from '@cmt/ui/cacheView'; import { enableFullFeatureSet, extensionManager, updateFullFeatureSet, setContextAndStore } from '@cmt/extension'; import { CMakeCommunicationMode, ConfigurationReader, OptionConfig, UseCMakePresets, checkConfigureOverridesPresent } from '@cmt/config'; +import { LaunchConfig, selectMode as selectLaunchMode } from '@cmt/launchConfig'; import * as preset from '@cmt/presets/preset'; import * as util from '@cmt/util'; import { Environment, EnvironmentUtils } from '@cmt/environmentVariables'; @@ -908,7 +909,11 @@ export class CMakeProject { log.debug(localize({key: 'disposing.extension', comment: ["'CMake Tools' shouldn't be localized"]}, 'Disposing CMake Tools extension')); this.disposeEmitter.fire(); this.termCloseSub.dispose(); + this.launchTaskEndSub.dispose(); this.launchTerminals.forEach(term => term.dispose()); + this.launchTerminals.clear(); + this.launchTaskExecutions.forEach(exec => exec.terminate()); + this.launchTaskExecutions.clear(); for (const sub of [ this.generatorSub, this.preferredGeneratorsSub, @@ -3405,6 +3410,7 @@ export class CMakeProject { } private launchTerminals = new Map(); + private launchTaskExecutions = new Map(); private launchTerminalTargetName = '_CMAKE_TOOLS_LAUNCH_TERMINAL_TARGET_NAME'; private launchTerminalPath = '_CMAKE_TOOLS_LAUNCH_TERMINAL_PATH'; // Watch for the user closing our terminal @@ -3414,6 +3420,15 @@ export class CMakeProject { this.launchTerminals.delete(processId!); } }); + // Watch for our launched tasks ending so we can drop them from the map. + private readonly launchTaskEndSub = vscode.tasks.onDidEndTask(e => { + for (const [key, exec] of this.launchTaskExecutions) { + if (exec === e.execution) { + this.launchTaskExecutions.delete(key); + break; + } + } + }); private async createTerminal(executable: ExecutableTarget): Promise { // Create terminal options @@ -3464,6 +3479,22 @@ export class CMakeProject { return null; } + const launchCfg = this.workspaceContext.config.launchConfig; + const mode = selectLaunchMode(launchCfg); + telemetry.logEvent('launch', { all: 'false', customSetting: mode }); + + if (mode !== 'none') { + await this.maybeWarnDebugConfigShadow(); + } + + if (mode === 'task') { + await this.runLaunchAsTask(executable, launchCfg!); + return null; + } + if (mode === 'program') { + return this.runLaunchAsProgram(executable, launchCfg!); + } + const userConfig = this.workspaceContext.config.debugConfig; const terminal: vscode.Terminal = await this.createTerminal(executable); @@ -3500,6 +3531,195 @@ export class CMakeProject { return terminal; } + /** + * Run the user-supplied VS Code task instead of launching the built target + * directly. Returns `null` because there is no `Terminal` to surface — VS + * Code's task runner manages presentation according to the task's own + * `presentation.panel`/`isBackground` settings. + */ + private async runLaunchAsTask(_executable: ExecutableTarget, cfg: LaunchConfig): Promise { + if (cfg.args?.length || cfg.cwd || cfg.environment?.length) { + log.warning(localize('launchConfig.task.ignoredFields', + 'cmake.launchConfig: args, cwd, and environment are ignored in task mode. These settings only apply when using "program".')); + } + const ref: { name: string; type?: string } = + typeof cfg.task === 'string' ? { name: cfg.task } : cfg.task!; + // Always fetch all tasks — passing { type: 'shell' } or { type: 'process' } + // to fetchTasks() returns nothing because those are built-in execution + // types, not contributed task-provider types. + const all = await vscode.tasks.fetchTasks(); + const matches = all.filter(t => + t.name === ref.name && + (!ref.type || t.definition.type === ref.type || t.source === ref.type) && + (t.scope === vscode.TaskScope.Global || + t.scope === vscode.TaskScope.Workspace || + t.scope === this.workspaceFolder)); + if (matches.length === 0) { + void vscode.window.showErrorMessage( + localize('launchConfig.task.notFound', + "Task '{0}' referenced by cmake.launchConfig was not found.", ref.name)); + return; + } + if (matches.length > 1) { + log.warning( + localize('launchConfig.task.ambiguous', + "Multiple tasks named '{0}' found; using the first. Specify 'type' in cmake.launchConfig.task to disambiguate.", ref.name)); + } + const chosen = matches[0]; + const scopeKey = typeof chosen.scope === 'object' && chosen.scope !== null + ? (chosen.scope as vscode.WorkspaceFolder).uri.toString() + : (chosen.scope ?? 'global').toString(); + const key = `${scopeKey}::${chosen.definition.type}::${chosen.name}`; + + const behavior = (this.workspaceContext.config.launchBehavior || '').toLowerCase(); + if (behavior === 'breakandreuseterminal') { + const prior = this.launchTaskExecutions.get(key); + if (prior) { + prior.terminate(); + } + } + + if (ref.type) { + log.info(localize('launchConfig.launching.task.withType', + "Launching via task '{0}' (type: {1})", chosen.name, ref.type)); + } else { + log.info(localize('launchConfig.launching.task', + "Launching via task '{0}'", chosen.name)); + } + let exec: vscode.TaskExecution; + try { + exec = await vscode.tasks.executeTask(chosen); + } catch (e) { + const errMsg = e instanceof Error ? e.message : String(e); + log.error(localize('launchConfig.task.executeFailed', + 'Failed to execute task "{0}" referenced by cmake.launchConfig: {1}', chosen.name, errMsg)); + void vscode.window.showErrorMessage( + localize('launchConfig.task.executeFailed', + 'Failed to execute task "{0}" referenced by cmake.launchConfig: {1}', chosen.name, errMsg)); + return; + } + this.launchTaskExecutions.set(key, exec); + } + + /** + * Run a custom program in the same terminal lifecycle that the legacy + * launch path uses, so `cmake.launchBehavior` (`reuseTerminal` / + * `breakAndReuseTerminal` / `newTerminal`) and the `launchTerminals` + * dispose hook continue to work transparently. + */ + private async runLaunchAsProgram(executable: ExecutableTarget, cfg: LaunchConfig): Promise { + const opts = await this.getExpansionOptions(); + const program = await expandString(cfg.program!, opts); + const args = cfg.args ? await expandStrings(cfg.args, opts) : []; + const cwd = cfg.cwd ? await expandString(cfg.cwd, opts) : undefined; + + // Build a synthetic ExecutableTarget so the existing createTerminal() + // path produces a terminal whose name/lifecycle key is tied to the + // user's program path while still showing the CMake target name. + const drv = await this.getCMakeDriverInstance(); + const launchEnv = await this.getTargetLaunchEnvironment(drv, cfg.environment); + const options: vscode.TerminalOptions = { + name: `CMake/Launch - ${executable.name}`, + env: launchEnv, + cwd: cwd || path.dirname(program) + }; + if (options && options.env) { + options.env[this.launchTerminalTargetName] = executable.name; + options.env[this.launchTerminalPath] = vscode.env.shell; + } + + const launchBehavior = (this.workspaceContext.config.launchBehavior || '').toLowerCase(); + let terminal: vscode.Terminal | undefined; + if (launchBehavior !== 'newterminal') { + for (const [, t] of this.launchTerminals) { + const creationOptions = t.creationOptions! as vscode.TerminalOptions; + if (JSON.stringify(creationOptions.env) !== JSON.stringify(options.env) || + JSON.stringify(creationOptions.cwd) !== JSON.stringify(options.cwd)) { + t.dispose(); + break; + } + if (launchBehavior === 'breakandreuseterminal') { + t.sendText('\u0003'); + } + terminal = t; + break; + } + } + if (!terminal) { + terminal = vscode.window.createTerminal(options); + } + + log.info(localize('launchConfig.launching.program', + "Launching custom program '{0}'", program)); + + let programPath = shlex.quote(program); + if (programPath.startsWith("\"")) { + let launchTerminalPath = (terminal.creationOptions as vscode.TerminalOptions).env?.[this.launchTerminalPath]; + if (process.platform === 'win32') { + programPath = programPath.replace(/\\/g, "/"); + launchTerminalPath = launchTerminalPath?.toLocaleLowerCase(); + if (launchTerminalPath?.includes("pwsh.exe") || launchTerminalPath?.includes("powershell")) { + programPath = `.${programPath}`; + } + } else { + if (launchTerminalPath?.endsWith("pwsh")) { + programPath = `.${programPath}`; + } + } + } + + terminal.sendText(programPath, false); + for (const a of args) { + terminal.sendText(` ${shlex.quote(a)}`, false); + } + terminal.sendText('', true); + terminal.show(true); + + const pid = await terminal.processId; + if (pid !== undefined) { + this.launchTerminals.set(pid, terminal); + } + return terminal; + } + + /** + * One-time non-modal warning when `cmake.launchConfig` is set but + * `cmake.debugConfig.{args,cwd,environment}` are also populated — those + * `debugConfig` fields are silently dropped on the launch path now. + * Mirrors the twxs warning pattern at `src/extension.ts:2962-2983`. + */ + private async maybeWarnDebugConfigShadow(): Promise { + const ctx = this.workspaceContext.state.extensionContext; + const KEY = 'cmake.launchConfig.debugConfigShadowWarned'; + if (ctx.globalState.get(KEY, false)) { + return; + } + const debugCfg = this.workspaceContext.config.debugConfig; + const shadowed = !!debugCfg && ( + (Array.isArray(debugCfg.args) && debugCfg.args.length > 0) || + !!debugCfg.cwd || + (Array.isArray(debugCfg.environment) && debugCfg.environment.length > 0) + ); + if (!shadowed) { + return; + } + const openSettings = localize('launchConfig.debugConfigShadow.openSettings', 'Open Settings'); + const dontShow = localize('launchConfig.debugConfigShadow.dontShowAgain', "Don't show again"); + void vscode.window.showWarningMessage( + localize('launchConfig.debugConfigShadow.message', + 'cmake.launchConfig is set, so cmake.debugConfig.args, cmake.debugConfig.cwd, and cmake.debugConfig.environment will be ignored on the Run-Without-Debugging path. (cmake.debugConfig still applies when debugging.)'), + openSettings, dontShow + ).then(async (selection) => { + if (selection === openSettings) { + void vscode.commands.executeCommand('workbench.action.openSettings', 'cmake.launchConfig'); + } else if (selection === dontShow) { + await ctx.globalState.update(KEY, true); + } + // Dismissal (undefined) intentionally does NOT persist — + // user may want a reminder next session. + }); + } + /** * Implementation of `cmake.quickStart` */ diff --git a/src/config.ts b/src/config.ts index 43540d418d..bf72100274 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,7 @@ import * as telemetry from '@cmt/telemetry'; import * as vscode from 'vscode'; import * as nls from 'vscode-nls'; import { CppDebugConfiguration } from '@cmt/debug/debugger'; +import { LaunchConfig } from '@cmt/launchConfig'; import { Environment } from '@cmt/environmentVariables'; import { BuildProblemMatcherConfig } from '@cmt/diagnostics/custom'; @@ -208,6 +209,7 @@ export interface ExtensionConfigurationSettings { parseBuildDiagnostics: boolean; enabledOutputParsers: string[]; debugConfig: CppDebugConfiguration; + launchConfig: LaunchConfig | undefined; defaultVariants: object; ctestArgs: string[]; ctestDefaultArgs: string[]; @@ -469,6 +471,9 @@ export class ConfigurationReader implements vscode.Disposable { get debugConfig(): CppDebugConfiguration { return this.configData.debugConfig; } + get launchConfig(): LaunchConfig | undefined { + return this.configData.launchConfig; + } get environment() { return this.configData.environment; } @@ -724,6 +729,7 @@ export class ConfigurationReader implements vscode.Disposable { parseBuildDiagnostics: new vscode.EventEmitter(), enabledOutputParsers: new vscode.EventEmitter(), debugConfig: new vscode.EventEmitter(), + launchConfig: new vscode.EventEmitter(), defaultVariants: new vscode.EventEmitter(), ctestArgs: new vscode.EventEmitter(), ctestDefaultArgs: new vscode.EventEmitter(), diff --git a/src/extension.ts b/src/extension.ts index 7eb8615d9b..fc54071df8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2133,7 +2133,6 @@ export class ExtensionManager implements vscode.Disposable { } launchTarget(folder?: vscode.WorkspaceFolder, name?: string, sourceDir?: string): Promise { - telemetry.logEvent("launch", { all: "false" }); return this.runCMakeCommand(cmakeProject => cmakeProject.launchTarget(name), folder, undefined, true, sourceDir); } diff --git a/src/launchConfig.ts b/src/launchConfig.ts new file mode 100644 index 0000000000..ae86e9aae4 --- /dev/null +++ b/src/launchConfig.ts @@ -0,0 +1,40 @@ +/** + * Type definitions for the `cmake.launchConfig` setting. + * + * Kept free of `vscode` imports so it can be consumed from backend unit + * tests as well as from `src/config.ts` and `src/cmakeProject.ts`. + */ + +export type LaunchConfigTaskRef = string | { name: string; type?: string }; + +export interface LaunchConfigEnvironmentEntry { + name: string; + value: string; +} + +export interface LaunchConfig { + task?: LaunchConfigTaskRef; + program?: string; + args?: string[]; + cwd?: string; + environment?: LaunchConfigEnvironmentEntry[]; +} + +/** + * Pure predicate: which mode the launch path should take given a + * `LaunchConfig` value. Lives here (not in `cmakeProject.ts`) so it can be + * exercised by backend unit tests with no `vscode` dependency. + * + * - `'task'` if `cfg.task` is set (task wins if both are set). + * - `'program'` if only `cfg.program` is set. + * - `'none'` otherwise (including `undefined` and `{}`). + */ +export function selectMode(cfg: LaunchConfig | undefined): 'task' | 'program' | 'none' { + if (cfg?.task) { + return 'task'; + } + if (cfg?.program) { + return 'program'; + } + return 'none'; +} diff --git a/src/ui/projectStatus.ts b/src/ui/projectStatus.ts index 9460687e13..b41b5b2ae6 100644 --- a/src/ui/projectStatus.ts +++ b/src/ui/projectStatus.ts @@ -1,3 +1,4 @@ +import * as path from 'path'; import * as vscode from 'vscode'; import * as nls from 'vscode-nls'; import CMakeProject from '@cmt/cmakeProject'; @@ -186,6 +187,7 @@ class TreeDataProvider implements vscode.TreeDataProvider, vscode.Disposab private testNode: TestNode | undefined; private packageNode: PackageNode | undefined; private workflowNode: WorkflowNode | undefined; + private launchNode: LaunchNode | undefined; get onDidChangeTreeData(): vscode.Event { return this._onDidChangeTreeData.event; @@ -336,12 +338,13 @@ class TreeDataProvider implements vscode.TreeDataProvider, vscode.Disposab nodes.push(debugNode); } if (!this.isLaunchButtonHidden) { - const launchNode = new LaunchNode(); - await launchNode.initialize(); + this.launchNode?.dispose(); + this.launchNode = new LaunchNode(); + await this.launchNode.initialize(); if (this.isBusy) { - launchNode.convertToStopCommand(); + this.launchNode.convertToStopCommand(); } - nodes.push(launchNode); + nodes.push(this.launchNode); } return nodes; } @@ -786,6 +789,10 @@ class LaunchNode extends Node { return [this.launchTarget!]; } + dispose(): void { + this.launchTarget?.dispose(); + } + } class FolderNode extends Node { @@ -1083,6 +1090,8 @@ class DebugTarget extends Node { class LaunchTarget extends Node { + protected disposables: vscode.Disposable[] = []; + async initialize(): Promise { if (!treeDataProvider.cmakeProject) { return; @@ -1092,6 +1101,14 @@ class LaunchTarget extends Node { this.tooltip = title; this.collapsibleState = vscode.TreeItemCollapsibleState.None; this.contextValue = 'launchTarget'; + this.updateLaunchConfigDescription(); + + // Subscribe to launchConfig changes to update description reactively + this.disposables.push( + treeDataProvider.cmakeProject.workspaceContext.config.onChange('launchConfig', () => { + void treeDataProvider.refresh(this); + }) + ); } async refresh() { @@ -1099,6 +1116,25 @@ class LaunchTarget extends Node { return; } this.label = treeDataProvider.cmakeProject.launchTargetName || await treeDataProvider.cmakeProject.allTargetName; + this.updateLaunchConfigDescription(); + } + + private updateLaunchConfigDescription(): void { + const launchCfg = treeDataProvider.cmakeProject?.workspaceContext.config.launchConfig; + if (launchCfg?.task) { + const taskName = typeof launchCfg.task === 'string' ? launchCfg.task : launchCfg.task.name; + this.description = localize('projectStatus.launchConfig.viaTask', '(via task: {0})', taskName); + } else if (launchCfg?.program) { + const base = path.basename(launchCfg.program); + this.description = localize('projectStatus.launchConfig.viaProgram', '(via program: {0})', base); + } else { + this.description = ''; + } + } + + /** Releases the `config.onChange('launchConfig', ...)` subscription registered in `initialize()`. */ + dispose(): void { + vscode.Disposable.from(...this.disposables).dispose(); } } diff --git a/test/end-to-end-tests/successful-build/test/debugger.test.ts b/test/end-to-end-tests/successful-build/test/debugger.test.ts index e5c54df130..549dc458f3 100644 --- a/test/end-to-end-tests/successful-build/test/debugger.test.ts +++ b/test/end-to-end-tests/successful-build/test/debugger.test.ts @@ -402,4 +402,51 @@ suite('Debug/Launch interface', () => { // Needed to ensure things get disposed await new Promise((resolve) => setTimeout(resolve, 3000)); }).timeout(60000); + + // --- cmake.launchConfig regression / new-feature coverage (issue #4904) --- + + test('launchConfig empty object falls through to legacy launch path', async () => { + // Regression: VS Code materializes object settings as `{}`. The launch + // path must gate on `task` / `program` field presence, NOT on object + // truthiness, otherwise a stray `"cmake.launchConfig": {}` would break + // every user's launch button. + testEnv.config.updatePartial({ buildBeforeRun: false, launchConfig: {} }); + + const executablesTargets = await cmakeProject.executableTargets; + expect(executablesTargets.length).to.not.eq(0); + await cmakeProject.setLaunchTargetByName(executablesTargets[0].name); + + const terminal = await cmakeProject.launchTarget(); + try { + expect(terminal).to.be.not.null; + expect(terminal!.creationOptions.name).to.eq(`CMake/Launch - ${executablesTargets[0].name}`); + } finally { + terminal?.dispose(); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + }).timeout(60000); + + test('launchConfig.program routes through createTerminal lifecycle', async () => { + // When `program` is set, we still need a `Terminal` back (so callers + // and `cmake.launchBehavior` keep working) and the terminal name should + // follow the existing `CMake/Launch - ` convention because we + // reuse the createTerminal lifecycle. + const executablesTargets = await cmakeProject.executableTargets; + expect(executablesTargets.length).to.not.eq(0); + await cmakeProject.setLaunchTargetByName(executablesTargets[0].name); + + testEnv.config.updatePartial({ + buildBeforeRun: false, + launchConfig: { program: executablesTargets[0].path } + }); + + const terminal = await cmakeProject.launchTarget(); + try { + expect(terminal).to.be.not.null; + expect(terminal!.creationOptions.name).to.eq(`CMake/Launch - ${executablesTargets[0].name}`); + } finally { + terminal?.dispose(); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + }).timeout(60000); }); diff --git a/test/unit-tests/backend/launchConfig.test.ts b/test/unit-tests/backend/launchConfig.test.ts new file mode 100644 index 0000000000..1cf3dcb071 --- /dev/null +++ b/test/unit-tests/backend/launchConfig.test.ts @@ -0,0 +1,42 @@ +import { expect } from 'chai'; +import { selectMode, LaunchConfig } from '@cmt/launchConfig'; + +/** + * Pure-logic tests for the cmake.launchConfig mode-selection predicate. + * src/launchConfig.ts is intentionally vscode-free, so it can be imported + * directly here without the mirror-inline workaround used elsewhere. + */ +suite('launchConfig.selectMode', () => { + test('undefined config => none', () => { + expect(selectMode(undefined)).to.equal('none'); + }); + + test('empty object => none (regression: VS Code materializes object settings as {})', () => { + expect(selectMode({})).to.equal('none'); + }); + + test('only "args" populated, no task/program => none', () => { + const cfg: LaunchConfig = { args: ['--foo'] }; + expect(selectMode(cfg)).to.equal('none'); + }); + + test('task as bare string => task', () => { + expect(selectMode({ task: 'flash' })).to.equal('task'); + }); + + test('task as object form => task', () => { + expect(selectMode({ task: { name: 'flash', type: 'shell' } })).to.equal('task'); + }); + + test('program only => program', () => { + expect(selectMode({ program: '/usr/bin/openocd' })).to.equal('program'); + }); + + test('both task and program (schema-impossible) => task wins', () => { + expect(selectMode({ task: 'flash', program: '/usr/bin/openocd' })).to.equal('task'); + }); + + test('empty-string task is falsy => falls through', () => { + expect(selectMode({ task: '' as any, program: '/p' })).to.equal('program'); + }); +}); diff --git a/test/unit-tests/config.test.ts b/test/unit-tests/config.test.ts index a81c7d5bbb..0d1aedd751 100644 --- a/test/unit-tests/config.test.ts +++ b/test/unit-tests/config.test.ts @@ -38,6 +38,7 @@ function createConfig(conf: Partial): Configurat parseBuildDiagnostics: true, enabledOutputParsers: [], debugConfig: {}, + launchConfig: undefined, defaultVariants: {}, ctestArgs: [], cpackArgs: [], @@ -203,4 +204,37 @@ suite('Configuration', () => { conf.updatePartial({ removeStaleKitsOnScan: true }); expect(conf.removeStaleKitsOnScan).to.be.true; }); + + test('launchConfig defaults to undefined', () => { + const conf = createConfig({}); + expect(conf.launchConfig).to.equal(undefined); + }); + + test('launchConfig round-trips a populated value', () => { + const conf = createConfig({ + launchConfig: { + program: '/usr/bin/openocd', + args: ['-f', 'board.cfg'], + cwd: '/tmp', + environment: [{ name: 'FOO', value: 'BAR' }] + } + }); + expect(conf.launchConfig?.program).to.equal('/usr/bin/openocd'); + expect(conf.launchConfig?.args).to.deep.equal(['-f', 'board.cfg']); + expect(conf.launchConfig?.cwd).to.equal('/tmp'); + expect(conf.launchConfig?.environment).to.deep.equal([{ name: 'FOO', value: 'BAR' }]); + }); + + test('launchConfig onChange fires on update', async () => { + const conf = createConfig({ launchConfig: { task: 'initial' } }); + let observed: any = null; + await new Promise(resolve => { + conf.onChange('launchConfig', cfg => { + observed = cfg; + resolve(); + }); + conf.updatePartial({ launchConfig: { task: 'flash' } }); + }); + expect(observed?.task).to.equal('flash'); + }); });