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:
+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