From 63de8a31f63de8392eadeb80f42c9aef6cb86d0c Mon Sep 17 00:00:00 2001 From: max Date: Tue, 8 Sep 2026 13:33:12 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_0169iPWwKHZoBTNN9qwXiwqk --- README.md | 63 +++++++-- package.json | 233 ++++++++++++++++++++++++++++---- src/extension.ts | 73 +++++++++- src/launch.ts | 124 ++++++++++++++--- src/tasks.ts | 37 +++-- src/test/suite/launcher.test.ts | 28 ++++ src/test/unit/sln.test.ts | 14 ++ 7 files changed, 504 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index bdec4b7..3c4bd66 100644 --- a/README.md +++ b/README.md @@ -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 `. 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 `. 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 | diff --git a/package.json b/package.json index 50bd53d..7f36348 100644 --- a/package.json +++ b/package.json @@ -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 -c -p:Platform=. Honours solution-level project dependencies and per-project configuration mapping, like Visual Studio's Build Solution.", "dotnet build 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", diff --git a/src/extension.ts b/src/extension.ts index 4eb2ca5..c34f95c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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 { try { const code = await runTask(task); if (code !== 0) { + if (settings().get('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 { const tasks = vscode.workspace.getConfiguration('tasks', folder.uri); const existing = (tasks.get[]>('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 { } } +// ---- 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(); + 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)); } diff --git a/src/launch.ts b/src/launch.ts index 2d3bc61..38885b3 100644 --- a/src/launch.ts +++ b/src/launch.ts @@ -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; + 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; +} + +/** 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 = 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> & { cwd: string | undefined } { + const settings = launchSettings(); + const perProject = settings.get>('launch.projects', {}); + const own = perProject[projectName] ?? {}; + const profile = readLaunchProfile(projectPath, own.profile ?? settings.get('launch.profile', '')); + + const args = own.args ?? (settings.get('launch.args', []).length + ? settings.get('launch.args', []) + : profile?.commandLineArgs ? splitCommandLine(profile.commandLineArgs) : []); + const env = { ...(profile?.environmentVariables ?? {}), ...settings.get>('launch.env', {}), ...(own.env ?? {}) }; + const rawCwd = own.cwd ?? settings.get('launch.cwd', '') ?? ''; + const cwd = rawCwd + ? path.resolve(projectDir, rawCwd) + : profile?.workingDirectory ? path.resolve(projectDir, profile.workingDirectory) : undefined; + const console = own.console ?? settings.get('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 { @@ -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('launch.args', []); - const cwd = settings.get('launch.cwd', '') || target.targetDir.replace(/[\\/]+$/, ''); - const console = settings.get('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('debugType', 'coreclr'), + name: `${info.name} (${active.solutionConfiguration.configuration}|${active.solutionPlatform})`, + type: launchSettings().get('debugType', 'coreclr'), request: 'launch', program, args, cwd, - env: settings.get>('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 { return { @@ -63,7 +128,7 @@ export function launchJsonEntry(): Record { 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; + } } diff --git a/src/tasks.ts b/src/tasks.ts index 8d5541e..a5e654d 100644 --- a/src/tasks.ts +++ b/src/tasks.ts @@ -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('additionalBuildArguments', []); - args.push(...settings, ...extra); + if (target === 'publish') { + const runtime = settings.get('publish.runtime', ''); + if (runtime) { + args.push('-r', runtime); + } + args.push(...settings.get('publish.args', [])); + } + if (target === 'test') { + args.push(...settings.get('test.args', [])); + } + args.push(...settings.get('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); } diff --git a/src/test/suite/launcher.test.ts b/src/test/suite/launcher.test.ts index 5f48951..5b02ba0 100644 --- a/src/test/suite/launcher.test.ts +++ b/src/test/suite/launcher.test.ts @@ -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('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('dotnetSolution.activeCwd'); + const projectDir = path.dirname(await command('dotnetSolution.activeProjectPath')); + assert.strictEqual(path.normalize(cwd).toLowerCase(), path.normalize(path.resolve(projectDir, '..')).toLowerCase()); + } finally { + await settings.update('launch.projects', undefined, vscode.ConfigurationTarget.Global); + } + }); +}); diff --git a/src/test/unit/sln.test.ts b/src/test/unit/sln.test.ts index eecef49..7a95b20 100644 --- a/src/test/unit/sln.test.ts +++ b/src/test/unit/sln.test.ts @@ -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']); + }); +});