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
+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']);
});
});