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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user