Per-project launch options, publish/test tasks, explorer badge, keybindings

- dotnetSolution.launch.projects: args, env, cwd, console and profile
  per project name, layered over the global settings and
  Properties/launchSettings.json. Applied to the debug button and,
  through resolveDebugConfiguration, to the launch.json entry too.
- publish and test task targets with the same configuration/platform.
- Problems panel opens when a task fails.
- The startup csproj and its folder are marked in the explorer.
- F5 / Ctrl+F5 / Ctrl+Shift+B map to debug/run/build until a launch.json
  entry exists; Ctrl+Alt+C and Ctrl+Alt+P open the pickers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169iPWwKHZoBTNN9qwXiwqk
This commit is contained in:
max
2026-09-08 13:33:12 +02:00
co-authored by Claude Fable 5.1
parent 3433162cf4
commit 63de8a31f6
7 changed files with 504 additions and 68 deletions
+52 -11
View File
@@ -48,8 +48,17 @@ own workspace state — so this extension owns the build rather than wrapping
| `solution` | `dotnet build MyGame.sln -c Test -p:Platform=x64` | Like Visual Studio's *Build Solution*. MSBuild maps every project through the `.sln`: MyGame.* build as `Test\|x64`, MoonWorks as `Debug\|Any CPU`, and the Editor → Builder `ProjectDependencies` entry is honoured, so the Builder exists before the Editor's post-build step needs it. |
| `project` | `dotnet build MyGame.Editor.csproj -c Test -p:Platform=x64` | Only the startup project and its `ProjectReference`s. Faster, but solution-only dependencies are ignored. |
Rebuild adds `--no-incremental`; Clean runs `dotnet clean`. All three are tasks of type
`dotnet-solution`, so they show up under *Run Task* and can be used as `preLaunchTask`.
Rebuild adds `--no-incremental`; Clean runs `dotnet clean`. Two more targets use the same
selection:
| Target | Command | Notes |
| --- | --- | --- |
| `publish` | `dotnet publish MyGame.Editor.csproj -c Release -p:Platform=x64 [-r win-x64]` | Always the startup project. `dotnetSolution.publish.runtime` sets `-r`, which `PublishAot` needs; `publish.args` adds the rest |
| `test` | `dotnet test MyGame.sln -c Test -p:Platform=x64` | Follows `buildScope`; `dotnetSolution.test.args` adds filters or `--no-build` |
All five are tasks of type `dotnet-solution`, so they show up under *Run Task* and can be
used as `preLaunchTask`. When one fails the Problems panel opens
(`dotnetSolution.showProblemsOnFailure`).
## What a launch does
@@ -60,23 +69,49 @@ and `AppendTargetFrameworkToOutputPath` is off. The result is cached until a pro
solution file changes.
The apphost `.exe` is launched when the project produces one, since vsdbg wants an
executable; otherwise it runs `dotnet <TargetPath>`. Working directory is `TargetDir`
unless `dotnetSolution.launch.cwd` says otherwise. DotRush's debug configuration provider
still runs after this one and fills in `justMyCode`, symbol options and the console.
executable; otherwise it runs `dotnet <TargetPath>`. DotRush's debug configuration provider
still runs after this one and fills in `justMyCode` and symbol options.
### Arguments, environment, working directory
Builder and Editor want different arguments, so these are layered, most specific wins:
1. `dotnetSolution.launch.projects`, keyed by project name:
```jsonc
"dotnetSolution.launch.projects": {
"MyGame.Builder": { "args": ["-build", "-resourcePath", "Resources"], "cwd": "../MyGame.Editor" },
"MyGame.Editor": { "console": "integratedTerminal" }
}
```
`cwd` is relative to the project folder. `profile` names a launchSettings.json profile.
2. The global `dotnetSolution.launch.args` / `.env` / `.cwd` / `.console`.
3. `Properties/launchSettings.json` next to the project: `commandLineArgs`,
`environmentVariables` and `workingDirectory` of the first `"commandName": "Project"`
profile, or the one named by `dotnetSolution.launch.profile`.
4. Otherwise no arguments and `TargetDir` as the working directory.
The same layering applies to F5 through the launch.json entry: its `args` stay empty in
the file and are filled in at launch time.
## Usage
| Action | How |
| --- | --- |
| Pick the startup project | Click the project name, or right-click a `.csproj`*Set as Startup Project (.NET Solution)* |
| Pick the configuration | Click `Debug \| x64`. Entries the solution does not build the project under are marked with `$(warning)` |
| Build / Rebuild / Clean | *.NET Solution: Build* etc., or the `dotnet-solution` tasks |
| Debug | The `$(debug-alt)` button, *.NET Solution: Debug Startup Project*, or F5 with the launch.json entry below |
| Run without debugging | *.NET Solution: Run Startup Project (without debugging)* |
| Pick the startup project | Click the project name (`Ctrl+Alt+P`), or right-click a `.csproj` → *Set as Startup Project (.NET Solution)*. The explorer marks it with ▶ |
| Pick the configuration | Click `Debug \| x64` (`Ctrl+Alt+C`). Entries the solution does not build the project under are marked with `$(warning)` |
| Build / Rebuild / Clean | *.NET Solution: Build* etc., or the `dotnet-solution` tasks. `Ctrl+Shift+B` until a launch.json entry exists, after that VS Code's default build task |
| Publish / Test | *.NET Solution: Publish Startup Project*, *.NET Solution: Run Tests* |
| Debug | The `$(debug-alt)` button, *.NET Solution: Debug Startup Project*, or F5 |
| Run without debugging | *.NET Solution: Run Startup Project (without debugging)*, or `Ctrl+F5` |
| Several `.sln` files | *.NET Solution: Select Solution*, or `dotnetSolution.solution` |
### F5
Without a launch.json entry, `F5`, `Ctrl+F5` and `Ctrl+Shift+B` are bound to this
extension's debug, run and build commands (only while a solution is loaded and no debug
session is running). Once the entry below exists those keys go back to VS Code's own
handling, which then uses the entry — same result, but editable in launch.json.
The first time it sees a solution the extension offers to write a `launch.json` entry.
Later, run *.NET Solution: Create launch.json and tasks.json entries* from the command
palette, or pick it at the bottom of the startup-project list (click the project name):
@@ -87,7 +122,7 @@ palette, or pick it at the bottom of the startup-project list (click the project
"type": "coreclr",
"request": "launch",
"program": "${command:dotnetSolution.activeProgram}",
"cwd": "${command:dotnetSolution.activeTargetDir}",
"cwd": "${command:dotnetSolution.activeCwd}",
"preLaunchTask": "dotnet-solution: Build"
}
```
@@ -103,6 +138,7 @@ The same entry is offered dynamically in the *Run and Debug* dropdown even witho
| `dotnetSolution.activeProgram` | apphost `.exe`, or the `.dll` when there is none |
| `dotnetSolution.activeTargetPath` | the built assembly |
| `dotnetSolution.activeTargetDir` | its directory |
| `dotnetSolution.activeCwd` | the working directory after the layering above |
| `dotnetSolution.activeProjectPath`, `activeProjectName` | the startup project |
| `dotnetSolution.activeSolutionPath` | the solution |
| `dotnetSolution.activeConfiguration`, `activePlatform` | solution configuration, e.g. `Test`, `Any CPU` |
@@ -116,6 +152,11 @@ The same entry is offered dynamically in the *Run and Debug* dropdown even witho
| `dotnetSolution.buildScope` | `solution` | See above |
| `dotnetSolution.targetFramework` | `""` | For `TargetFrameworks` projects; empty takes the first |
| `dotnetSolution.launch.args` / `.env` / `.cwd` / `.console` | | Passed to the launched application |
| `dotnetSolution.launch.projects` | `{}` | The same, per project name; wins over the global ones |
| `dotnetSolution.launch.profile` | `""` | launchSettings.json profile to read |
| `dotnetSolution.publish.runtime` / `.args` | `""` / `[]` | `-r` and extra arguments for publish |
| `dotnetSolution.test.args` | `[]` | Extra arguments for test |
| `dotnetSolution.showProblemsOnFailure` | `true` | Open Problems when a task fails |
| `dotnetSolution.debugType` | `coreclr` | |
| `dotnetSolution.syncDotRush` | `true` | Push the startup project to DotRush and follow its changes |
| `dotnetSolution.syncDotRushWorkspaceProperties` | `false` | Write `Configuration=…;Platform=…` into `dotrush.roslyn.workspaceProperties` so IntelliSense sees the same `DefineConstants`. Off because DotRush reloads its workspace on every change |
+209 -24
View File
@@ -28,18 +28,81 @@
"main": "./out/extension.js",
"contributes": {
"commands": [
{ "command": "dotnetSolution.selectStartupProject", "title": "Select Startup Project", "category": ".NET Solution" },
{ "command": "dotnetSolution.selectConfiguration", "title": "Select Configuration", "category": ".NET Solution" },
{ "command": "dotnetSolution.selectSolution", "title": "Select Solution", "category": ".NET Solution" },
{ "command": "dotnetSolution.build", "title": "Build", "category": ".NET Solution", "icon": "$(tools)" },
{ "command": "dotnetSolution.rebuild", "title": "Rebuild", "category": ".NET Solution" },
{ "command": "dotnetSolution.clean", "title": "Clean", "category": ".NET Solution" },
{ "command": "dotnetSolution.debug", "title": "Debug Startup Project", "category": ".NET Solution", "icon": "$(debug-alt)" },
{ "command": "dotnetSolution.run", "title": "Run Startup Project (without debugging)", "category": ".NET Solution", "icon": "$(play)" },
{ "command": "dotnetSolution.generateLaunchConfig", "title": "Create launch.json and tasks.json entries", "category": ".NET Solution" },
{ "command": "dotnetSolution.reload", "title": "Reload Solution", "category": ".NET Solution" },
{ "command": "dotnetSolution.showOutput", "title": "Show Log", "category": ".NET Solution" },
{ "command": "dotnetSolution.setStartupProject", "title": "Set as Startup Project (.NET Solution)", "category": ".NET Solution" }
{
"command": "dotnetSolution.selectStartupProject",
"title": "Select Startup Project",
"category": ".NET Solution"
},
{
"command": "dotnetSolution.selectConfiguration",
"title": "Select Configuration",
"category": ".NET Solution"
},
{
"command": "dotnetSolution.selectSolution",
"title": "Select Solution",
"category": ".NET Solution"
},
{
"command": "dotnetSolution.build",
"title": "Build",
"category": ".NET Solution",
"icon": "$(tools)"
},
{
"command": "dotnetSolution.rebuild",
"title": "Rebuild",
"category": ".NET Solution"
},
{
"command": "dotnetSolution.clean",
"title": "Clean",
"category": ".NET Solution"
},
{
"command": "dotnetSolution.publish",
"title": "Publish Startup Project",
"category": ".NET Solution",
"icon": "$(package)"
},
{
"command": "dotnetSolution.test",
"title": "Run Tests",
"category": ".NET Solution",
"icon": "$(beaker)"
},
{
"command": "dotnetSolution.debug",
"title": "Debug Startup Project",
"category": ".NET Solution",
"icon": "$(debug-alt)"
},
{
"command": "dotnetSolution.run",
"title": "Run Startup Project (without debugging)",
"category": ".NET Solution",
"icon": "$(play)"
},
{
"command": "dotnetSolution.generateLaunchConfig",
"title": "Create launch.json and tasks.json entries",
"category": ".NET Solution"
},
{
"command": "dotnetSolution.reload",
"title": "Reload Solution",
"category": ".NET Solution"
},
{
"command": "dotnetSolution.showOutput",
"title": "Show Log",
"category": ".NET Solution"
},
{
"command": "dotnetSolution.setStartupProject",
"title": "Set as Startup Project (.NET Solution)",
"category": ".NET Solution"
}
],
"menus": {
"explorer/context": [
@@ -50,7 +113,10 @@
}
],
"commandPalette": [
{ "command": "dotnetSolution.setStartupProject", "when": "false" }
{
"command": "dotnetSolution.setStartupProject",
"when": "false"
}
]
},
"taskDefinitions": [
@@ -60,18 +126,29 @@
"properties": {
"target": {
"type": "string",
"enum": ["build", "rebuild", "clean"],
"enum": [
"build",
"rebuild",
"clean",
"publish",
"test"
],
"default": "build",
"description": "What to do. Defaults to build."
"description": "What to do. Publish always targets the startup project; test runs the solution or project depending on scope."
},
"scope": {
"type": "string",
"enum": ["solution", "project"],
"enum": [
"solution",
"project"
],
"description": "Build the whole solution with the selected solution configuration, or only the startup project with the project configuration the solution maps it to. Defaults to the dotnetSolution.buildScope setting."
},
"args": {
"type": "array",
"items": { "type": "string" },
"items": {
"type": "string"
},
"description": "Extra arguments appended to the dotnet command."
}
}
@@ -88,7 +165,10 @@
},
"dotnetSolution.buildScope": {
"type": "string",
"enum": ["solution", "project"],
"enum": [
"solution",
"project"
],
"enumDescriptions": [
"dotnet build <solution> -c <Configuration> -p:Platform=<Platform>. Honours solution-level project dependencies and per-project configuration mapping, like Visual Studio's Build Solution.",
"dotnet build <startup project> with the configuration and platform the solution maps it to. Faster, but ignores dependencies that only exist in the .sln (ProjectDependencies)."
@@ -103,24 +183,32 @@
},
"dotnetSolution.launch.args": {
"type": "array",
"items": { "type": "string" },
"items": {
"type": "string"
},
"default": [],
"description": "Arguments passed to the startup project when launched from this extension."
},
"dotnetSolution.launch.env": {
"type": "object",
"additionalProperties": { "type": "string" },
"additionalProperties": {
"type": "string"
},
"default": {},
"description": "Environment variables for the launched startup project."
},
"dotnetSolution.launch.cwd": {
"type": "string",
"default": "",
"description": "Working directory for the launched startup project. Empty uses the output directory (TargetDir)."
"description": "Working directory for the launched startup project, relative to the project folder. Empty uses launchSettings.json or the output directory (TargetDir)."
},
"dotnetSolution.launch.console": {
"type": "string",
"enum": ["internalConsole", "integratedTerminal", "externalTerminal"],
"enum": [
"internalConsole",
"integratedTerminal",
"externalTerminal"
],
"default": "internalConsole",
"description": "Console for the debugged application."
},
@@ -141,12 +229,109 @@
},
"dotnetSolution.additionalBuildArguments": {
"type": "array",
"items": { "type": "string" },
"items": {
"type": "string"
},
"default": [],
"description": "Extra arguments for every dotnet build / clean run by this extension."
},
"dotnetSolution.launch.projects": {
"type": "object",
"default": {},
"markdownDescription": "Per-project launch options, keyed by project name. Each entry may set `args`, `env`, `cwd` (relative to the project folder), `console` and `profile` (a launchSettings.json profile name). These override the global `dotnetSolution.launch.*` settings and `Properties/launchSettings.json`.\n\nExample: `{ \"MyGame.Builder\": { \"args\": [\"-build\"], \"cwd\": \"../Bin/MyGame.Builder\" } }`",
"additionalProperties": {
"type": "object",
"properties": {
"args": {
"type": "array",
"items": {
"type": "string"
}
},
"env": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"cwd": {
"type": "string"
},
"console": {
"type": "string",
"enum": [
"internalConsole",
"integratedTerminal",
"externalTerminal"
]
},
"profile": {
"type": "string"
}
}
}
},
"dotnetSolution.launch.profile": {
"type": "string",
"default": "",
"description": "Profile to take from Properties/launchSettings.json. Empty picks the first profile with commandName \"Project\"."
},
"dotnetSolution.publish.runtime": {
"type": "string",
"default": "",
"description": "Runtime identifier for dotnet publish, e.g. win-x64. Required for PublishAot. Empty omits -r."
},
"dotnetSolution.publish.args": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"description": "Extra arguments for dotnet publish, e.g. [\"--self-contained\"]."
},
"dotnetSolution.test.args": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"description": "Extra arguments for dotnet test, e.g. [\"--no-build\"] or a filter."
},
"dotnetSolution.showProblemsOnFailure": {
"type": "boolean",
"default": true,
"description": "Open the Problems panel when a build, publish or test run fails."
}
}
}
},
"keybindings": [
{
"command": "dotnetSolution.build",
"key": "ctrl+shift+b",
"mac": "cmd+shift+b",
"when": "dotnetSolution.active && !dotnetSolution.hasLaunchEntry"
},
{
"command": "dotnetSolution.debug",
"key": "f5",
"when": "dotnetSolution.active && !inDebugMode && !dotnetSolution.hasLaunchEntry && !terminalFocus"
},
{
"command": "dotnetSolution.run",
"key": "ctrl+f5",
"when": "dotnetSolution.active && !inDebugMode && !dotnetSolution.hasLaunchEntry"
},
{
"command": "dotnetSolution.selectConfiguration",
"key": "ctrl+alt+c",
"when": "dotnetSolution.active"
},
{
"command": "dotnetSolution.selectStartupProject",
"key": "ctrl+alt+p",
"when": "dotnetSolution.active"
}
]
},
"scripts": {
"vscode:prepublish": "npm run compile",
+69 -4
View File
@@ -3,7 +3,8 @@ import * as path from 'path';
import { buildLaunchConfiguration, LAUNCH_NAME, launchJsonEntry, SolutionDebugConfigurationProvider } from './launch';
import { SolutionModel, StartupProject } from './model';
import { configurationKey, projectConfigurationFor, SolutionConfiguration } from './sln';
import { createTask, runTask, SolutionTaskProvider, TASK_TYPE, taskLabel, BuildTarget } from './tasks';
import { ALL_TARGETS, createTask, runTask, SolutionTaskProvider, TASK_TYPE, taskLabel, BuildTarget } from './tasks';
import { launchOptionsFor } from './launch';
import { StatusBar } from './status';
let model: SolutionModel;
@@ -109,8 +110,12 @@ async function build(target: BuildTarget): Promise<number> {
try {
const code = await runTask(task);
if (code !== 0) {
if (settings().get<boolean>('showProblemsOnFailure', true)) {
// The $msCompile matcher has already filled the Problems panel by now.
void vscode.commands.executeCommand('workbench.actions.view.problems');
}
void vscode.window.showErrorMessage(
`${task.name} failed (exit code ${code}). See the terminal or the Problems panel.`);
`${task.name} failed (exit code ${code}). See the Problems panel or the terminal.`);
}
return code;
} finally {
@@ -167,8 +172,7 @@ async function generateLaunchConfig(): Promise<void> {
const tasks = vscode.workspace.getConfiguration('tasks', folder.uri);
const existing = (tasks.get<Record<string, unknown>[]>('tasks') ?? [])
.filter(entry => entry.type !== TASK_TYPE);
const targets: BuildTarget[] = ['build', 'rebuild', 'clean'];
for (const target of targets) {
for (const target of ALL_TARGETS) {
existing.push({
label: taskLabel(target),
type: TASK_TYPE,
@@ -253,6 +257,46 @@ async function syncWorkspaceProperties(): Promise<void> {
}
}
// ---- explorer badge ---------------------------------------------------------------
/** Marks the startup project file, and the folder holding it, in the explorer. */
class StartupProjectDecorations implements vscode.FileDecorationProvider {
private readonly changed = new vscode.EventEmitter<vscode.Uri | vscode.Uri[] | undefined>();
readonly onDidChangeFileDecorations = this.changed.event;
private current: string | undefined;
update(projectPath: string | undefined): void {
this.current = projectPath ? path.normalize(projectPath).toLowerCase() : undefined;
this.changed.fire(undefined);
}
provideFileDecoration(uri: vscode.Uri): vscode.FileDecoration | undefined {
if (!this.current || uri.scheme !== 'file') {
return undefined;
}
const fsPath = path.normalize(uri.fsPath).toLowerCase();
const isProject = fsPath === this.current;
const isFolder = fsPath === path.dirname(this.current);
if (!isProject && !isFolder) {
return undefined;
}
const decoration = new vscode.FileDecoration('▶', 'Startup project (.NET Solution)',
new vscode.ThemeColor('debugIcon.startForeground'));
decoration.propagate = false;
return decoration;
}
}
/** `when` clause contexts for the keybindings. */
function updateContexts(): void {
const active = model.active;
void vscode.commands.executeCommand('setContext', 'dotnetSolution.active', active !== undefined);
const folder = active ? vscode.workspace.getWorkspaceFolder(vscode.Uri.file(active.solution.fsPath)) : undefined;
const configurations = vscode.workspace.getConfiguration('launch', folder?.uri).get<{ name?: string }[]>('configurations') ?? [];
void vscode.commands.executeCommand('setContext', 'dotnetSolution.hasLaunchEntry',
configurations.some(entry => entry.name === LAUNCH_NAME));
}
// ---- activation -------------------------------------------------------------------
export function activate(context: vscode.ExtensionContext): void {
@@ -279,6 +323,8 @@ export function activate(context: vscode.ExtensionContext): void {
command('dotnetSolution.build', () => build('build')),
command('dotnetSolution.rebuild', () => build('rebuild')),
command('dotnetSolution.clean', () => build('clean')),
command('dotnetSolution.publish', () => build('publish')),
command('dotnetSolution.test', () => build('test')),
command('dotnetSolution.debug', () => launch(false)),
command('dotnetSolution.run', () => launch(true)),
command('dotnetSolution.generateLaunchConfig', generateLaunchConfig),
@@ -301,6 +347,11 @@ export function activate(context: vscode.ExtensionContext): void {
}),
command('dotnetSolution.activeTargetPath', async () => (await model.resolveTarget()).targetPath),
command('dotnetSolution.activeTargetDir', async () => (await model.resolveTarget()).targetDir.replace(/[\\/]+$/, '')),
command('dotnetSolution.activeCwd', async () => {
const info = active()?.project.info;
const own = info ? launchOptionsFor(info.name, info.fsPath, path.dirname(info.fsPath)).cwd : undefined;
return own ?? (await model.resolveTarget()).targetDir.replace(/[\\/]+$/, '');
}),
command('dotnetSolution.activeProjectPath', () => active()?.project.info.fsPath),
command('dotnetSolution.activeProjectName', () => active()?.project.info.name),
command('dotnetSolution.activeSolutionPath', () => active()?.solution.fsPath),
@@ -320,6 +371,20 @@ export function activate(context: vscode.ExtensionContext): void {
}),
);
const decorations = new StartupProjectDecorations();
context.subscriptions.push(
vscode.window.registerFileDecorationProvider(decorations),
model.onDidChange(() => {
decorations.update(model.startupProject?.info.fsPath);
updateContexts();
}),
vscode.workspace.onDidChangeConfiguration(event => {
if (event.affectsConfiguration('launch')) {
updateContexts();
}
}),
);
connectDotRush(context);
void model.reload().then(() => offerLaunchJson(context));
}
+104 -20
View File
@@ -10,13 +10,84 @@ function launchSettings() {
return vscode.workspace.getConfiguration('dotnetSolution');
}
/** Launch options for one project, from settings or launchSettings.json. */
export interface ProjectLaunchOptions {
args?: string[];
env?: Record<string, string>;
cwd?: string;
console?: string;
/** Profile name in Properties/launchSettings.json; empty picks the first "Project" profile. */
profile?: string;
}
interface LaunchProfile {
commandName?: string;
commandLineArgs?: string;
workingDirectory?: string;
environmentVariables?: Record<string, string>;
}
/** Splits a launchSettings commandLineArgs string the way the SDK does: on whitespace, honouring quotes. */
export function splitCommandLine(text: string): string[] {
const args: string[] = [];
const pattern = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
for (const match of text.matchAll(pattern)) {
args.push(match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2] ?? match[3]);
}
return args;
}
/** Reads the profile that applies to a project, if it has a Properties/launchSettings.json. */
export function readLaunchProfile(projectPath: string, profileName: string | undefined): LaunchProfile | undefined {
const file = path.join(path.dirname(projectPath), 'Properties', 'launchSettings.json');
if (!fs.existsSync(file)) {
return undefined;
}
try {
const profiles: Record<string, LaunchProfile> = JSON.parse(fs.readFileSync(file, 'utf8')).profiles ?? {};
if (profileName) {
return profiles[profileName];
}
// The first profile that launches the project itself, not IIS Express or a container.
return Object.values(profiles).find(profile => (profile.commandName ?? 'Project') === 'Project')
?? Object.values(profiles)[0];
} catch {
return undefined;
}
}
/**
* The launch options for one project: per-project settings override the global ones,
* and both override Properties/launchSettings.json.
*
* Builder and Editor want different arguments and working directories, which is why
* `dotnetSolution.launch.projects` is keyed by project name.
*/
export function launchOptionsFor(projectName: string, projectPath: string, projectDir: string): Required<Pick<ProjectLaunchOptions, 'args' | 'env' | 'console'>> & { cwd: string | undefined } {
const settings = launchSettings();
const perProject = settings.get<Record<string, ProjectLaunchOptions>>('launch.projects', {});
const own = perProject[projectName] ?? {};
const profile = readLaunchProfile(projectPath, own.profile ?? settings.get<string>('launch.profile', ''));
const args = own.args ?? (settings.get<string[]>('launch.args', []).length
? settings.get<string[]>('launch.args', [])
: profile?.commandLineArgs ? splitCommandLine(profile.commandLineArgs) : []);
const env = { ...(profile?.environmentVariables ?? {}), ...settings.get<Record<string, string>>('launch.env', {}), ...(own.env ?? {}) };
const rawCwd = own.cwd ?? settings.get<string>('launch.cwd', '') ?? '';
const cwd = rawCwd
? path.resolve(projectDir, rawCwd)
: profile?.workingDirectory ? path.resolve(projectDir, profile.workingDirectory) : undefined;
const console = own.console ?? settings.get<string>('launch.console', 'internalConsole');
return { args, env, cwd, console };
}
/**
* A complete launch configuration for the startup project under the active configuration.
*
* `program` is the apphost .exe when the project makes one, because vsdbg wants an
* executable. Projects without an apphost run through the dotnet host instead. The
* debugger options DotRush normally fills in (justMyCode, symbol servers, console) are
* left out so its own provider still adds them.
* debugger options DotRush normally fills in (justMyCode, symbol servers) are left out so
* its own provider still adds them.
*/
export async function buildLaunchConfiguration(
model: SolutionModel, options: { noDebug?: boolean; preLaunchTask?: string }): Promise<vscode.DebugConfiguration> {
@@ -25,36 +96,30 @@ export async function buildLaunchConfiguration(
throw new Error('No startup project selected.');
}
const target = await model.resolveTarget();
const settings = launchSettings();
const userArgs = settings.get<string[]>('launch.args', []);
const cwd = settings.get<string>('launch.cwd', '') || target.targetDir.replace(/[\\/]+$/, '');
const console = settings.get<string>('launch.console', 'internalConsole');
const info = active.project.info;
const launch = launchOptionsFor(info.name, info.fsPath, path.dirname(info.fsPath));
const cwd = launch.cwd ?? target.targetDir.replace(/[\\/]+$/, '');
const program = target.executablePath ?? 'dotnet';
const args = target.executablePath ? userArgs : [target.targetPath, ...userArgs];
const args = target.executablePath ? launch.args : [target.targetPath, ...launch.args];
return {
name: `${active.project.info.name} (${active.solutionConfiguration.configuration}|${active.solutionPlatform})`,
type: settings.get<string>('debugType', 'coreclr'),
name: `${info.name} (${active.solutionConfiguration.configuration}|${active.solutionPlatform})`,
type: launchSettings().get<string>('debugType', 'coreclr'),
request: 'launch',
program,
args,
cwd,
env: settings.get<Record<string, string>>('launch.env', {}),
console,
env: launch.env,
console: launch.console,
noDebug: options.noDebug ?? false,
preLaunchTask: options.preLaunchTask,
// DotRush looks for Properties/launchSettings.json next to *its* startup project;
// this one is synced, so that still works. Point it explicitly anyway.
launchSettingsFilePath: launchSettingsPath(active.project.info.fsPath),
// launchSettings.json is applied above so per-project settings can override it.
// DotRush still points vsdbg at the file; vsdbg only takes commandLineArgs from it
// when `args` is empty, and the environment merge is idempotent.
};
}
function launchSettingsPath(projectPath: string): string | undefined {
const candidate = path.join(path.dirname(projectPath), 'Properties', 'launchSettings.json');
return fs.existsSync(candidate) ? candidate : undefined;
}
/** The static entry written to launch.json: values come back through `${command:…}` at launch time. */
export function launchJsonEntry(): Record<string, unknown> {
return {
@@ -63,7 +128,7 @@ export function launchJsonEntry(): Record<string, unknown> {
request: 'launch',
program: '${command:dotnetSolution.activeProgram}',
args: [],
cwd: '${command:dotnetSolution.activeTargetDir}',
cwd: '${command:dotnetSolution.activeCwd}',
preLaunchTask: taskLabel('build'),
};
}
@@ -82,4 +147,23 @@ export class SolutionDebugConfigurationProvider implements vscode.DebugConfigura
}
return [launchJsonEntry() as vscode.DebugConfiguration];
}
/**
* The launch.json entry carries no arguments, since ${command:} variables can only be
* strings. Fill in the per-project options here so F5 behaves like the debug button.
*/
resolveDebugConfiguration(_folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration): vscode.DebugConfiguration {
const active = this.model.active;
if (config.name !== LAUNCH_NAME || !active) {
return config;
}
const info = active.project.info;
const launch = launchOptionsFor(info.name, info.fsPath, path.dirname(info.fsPath));
if (!config.args || (Array.isArray(config.args) && config.args.length === 0)) {
config.args = launch.args;
}
config.env = { ...launch.env, ...(config.env ?? {}) };
config.console ??= launch.console;
return config;
}
}
+28 -9
View File
@@ -3,7 +3,8 @@ import { dotnetPath } from './msbuild';
import { ActiveTarget, SolutionModel } from './model';
export const TASK_TYPE = 'dotnet-solution';
export type BuildTarget = 'build' | 'rebuild' | 'clean';
export type BuildTarget = 'build' | 'rebuild' | 'clean' | 'publish' | 'test';
export const ALL_TARGETS: BuildTarget[] = ['build', 'rebuild', 'clean', 'publish', 'test'];
export type BuildScope = 'solution' | 'project';
export interface SolutionTaskDefinition extends vscode.TaskDefinition {
@@ -31,7 +32,14 @@ function configuredScope(): BuildScope {
* the mapped project configuration directly.
*/
export function buildArguments(active: ActiveTarget, target: BuildTarget, scope: BuildScope, extra: string[] = []): string[] {
const args: string[] = [target === 'clean' ? 'clean' : 'build'];
const settings = vscode.workspace.getConfiguration('dotnetSolution');
const verb = target === 'rebuild' ? 'build' : target;
const args: string[] = [verb];
// Publish is always about the startup project: a solution publish makes little sense,
// and the runtime identifier and AOT settings belong to one project.
if (target === 'publish') {
scope = 'project';
}
if (scope === 'solution') {
args.push(active.solution.fsPath,
'-c', active.solutionConfiguration.configuration,
@@ -47,8 +55,17 @@ export function buildArguments(active: ActiveTarget, target: BuildTarget, scope:
if (target === 'rebuild') {
args.push('--no-incremental');
}
const settings = vscode.workspace.getConfiguration('dotnetSolution').get<string[]>('additionalBuildArguments', []);
args.push(...settings, ...extra);
if (target === 'publish') {
const runtime = settings.get<string>('publish.runtime', '');
if (runtime) {
args.push('-r', runtime);
}
args.push(...settings.get<string[]>('publish.args', []));
}
if (target === 'test') {
args.push(...settings.get<string[]>('test.args', []));
}
args.push(...settings.get<string[]>('additionalBuildArguments', []), ...extra);
return args;
}
@@ -75,13 +92,16 @@ export function createTask(model: SolutionModel, definition: SolutionTaskDefinit
'$msCompile');
task.group = target === 'clean' ? vscode.TaskGroup.Clean
: target === 'rebuild' ? vscode.TaskGroup.Rebuild
: vscode.TaskGroup.Build;
const what = scope === 'solution'
: target === 'test' ? vscode.TaskGroup.Test
: vscode.TaskGroup.Build;
const what = scope === 'solution' && target !== 'publish'
? `${active.solution.name}.sln ${active.solutionConfiguration.configuration}|${active.solutionPlatform}`
: `${active.project.info.name} ${active.projectConfiguration}|${active.projectPlatform}`;
// The detail line is what the task picker shows; say what is built, then how.
task.detail = `${what} — dotnet ${args.join(' ')}`;
task.presentationOptions = { reveal: vscode.TaskRevealKind.Silent, clear: true, showReuseMessage: false };
// Test and publish output is the point, so show it; build output only matters on failure.
const reveal = target === 'test' || target === 'publish' ? vscode.TaskRevealKind.Always : vscode.TaskRevealKind.Silent;
task.presentationOptions = { reveal, clear: true, showReuseMessage: false };
return task;
}
@@ -89,8 +109,7 @@ export class SolutionTaskProvider implements vscode.TaskProvider {
constructor(private readonly model: SolutionModel) { }
provideTasks(): vscode.Task[] {
const targets: BuildTarget[] = ['build', 'rebuild', 'clean'];
return targets
return ALL_TARGETS
.map(target => createTask(this.model, { type: TASK_TYPE, target }))
.filter((task): task is vscode.Task => task !== undefined);
}
+28
View File
@@ -66,3 +66,31 @@ suite('.NET Solution Launcher on MyGame', () => {
assert.ok(execution.args.some(arg => arg.endsWith('.sln')), 'default scope builds the solution');
});
});
suite('.NET Solution Launcher additions', () => {
test('publish task targets the startup project, test task exists', async () => {
const tasks = await vscode.tasks.fetchTasks({ type: 'dotnet-solution' });
const publish = tasks.find(task => task.definition.target === 'publish');
const test = tasks.find(task => task.definition.target === 'test');
assert.ok(publish && test, 'publish/test tasks missing');
const args = (publish.execution as vscode.ProcessExecution).args;
assert.strictEqual(args[0], 'publish');
assert.ok(args[1].endsWith('.csproj'), `publish should build a project: ${args.join(' ')}`);
assert.ok(args.some(arg => arg.startsWith('-p:Platform=')));
assert.strictEqual((test.execution as vscode.ProcessExecution).args[0], 'test');
});
test('per-project launch options are honoured', async () => {
const name = await command<string>('dotnetSolution.activeProjectName');
// Global, so the test host's own user-data dir takes the write, not MyGame's .vscode/settings.json.
const settings = vscode.workspace.getConfiguration('dotnetSolution');
await settings.update('launch.projects', { [name]: { cwd: '..', args: ['--from-test'] } }, vscode.ConfigurationTarget.Global);
try {
const cwd = await command<string>('dotnetSolution.activeCwd');
const projectDir = path.dirname(await command<string>('dotnetSolution.activeProjectPath'));
assert.strictEqual(path.normalize(cwd).toLowerCase(), path.normalize(path.resolve(projectDir, '..')).toLowerCase());
} finally {
await settings.update('launch.projects', undefined, vscode.ConfigurationTarget.Global);
}
});
});
+14
View File
@@ -85,3 +85,17 @@ suite('csproj parser', () => {
assert.strictEqual(info.executable, true);
});
});
suite('launch helpers', () => {
// Loaded lazily: launch.ts imports vscode, which only exists inside the editor host.
test('splitCommandLine honours quotes', () => {
let split: (text: string) => string[];
try {
({ splitCommandLine: split } = require('../../launch'));
} catch {
return; // outside VS Code
}
assert.deepStrictEqual(split('-build -resourcePath "C:\My Res" \'x y\' plain'),
['-build', '-resourcePath', 'C:\My Res', 'x y', 'plain']);
});
});