Initial version of .NET Solution Launcher

Startup project and solution configuration (Debug|x64, Test|x64, ...) in
the status bar, with build and debug tasks that pass both configuration
and platform. Made for DotRush, whose build never passes -p:Platform and
whose status item shows the configuration but not the project.

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:10:55 +02:00
co-authored by Claude Fable 5.1
commit 3f67eef9aa
21 changed files with 3279 additions and 0 deletions
+334
View File
@@ -0,0 +1,334 @@
import * as vscode from 'vscode';
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 { StatusBar } from './status';
let model: SolutionModel;
let status: StatusBar;
let log: vscode.OutputChannel;
function settings() {
return vscode.workspace.getConfiguration('dotnetSolution');
}
async function pickStartupProject(): Promise<void> {
const candidates = model.startupCandidates;
if (candidates.length === 0) {
void vscode.window.showWarningMessage(
model.activeSolution
? `${model.activeSolution.name} has no executable project (OutputType Exe).`
: 'No solution found in the workspace.');
return;
}
const picked = await vscode.window.showQuickPick(
candidates.map(project => ({
label: project.info.name,
description: vscode.workspace.asRelativePath(project.info.fsPath, false),
detail: project.info.platforms.length ? `Platforms: ${project.info.platforms.join(', ')}` : undefined,
picked: project === model.startupProject,
project,
})),
{ title: `${model.activeSolution?.name}: startup project`, matchOnDescription: true });
if (picked) {
await model.selectProject(picked.project);
}
}
async function pickConfiguration(): Promise<void> {
const project = model.startupProject;
const configurations = model.configurations;
if (!project || configurations.length === 0) {
return;
}
const picked = await vscode.window.showQuickPick(
configurations.map(config => {
const mapped = projectConfigurationFor(project.solutionProject, config);
const natural = model.isNaturalConfiguration(config);
return {
label: `${natural ? '' : '$(warning) '}${config.configuration} | ${config.platform}`,
description: mapped.build
? `${project.info.name}${mapped.configuration}|${mapped.platform}`
: `${project.info.name} is not built under this configuration`,
picked: config === model.activeConfiguration,
config,
};
}),
{ title: `${project.info.name}: solution configuration`, matchOnDescription: true });
if (picked) {
await model.selectConfiguration(picked.config);
}
}
async function pickSolution(): Promise<void> {
const solutions = model.allSolutions;
if (solutions.length === 0) {
void vscode.window.showWarningMessage('No .sln or .slnx found in the workspace.');
return;
}
const picked = await vscode.window.showQuickPick(
solutions.map(solution => ({
label: solution.name,
description: vscode.workspace.asRelativePath(solution.fsPath, false),
detail: `${solution.projects.length} project(s), ${solution.configurations.length} configuration(s)`,
picked: solution === model.activeSolution,
solution,
})),
{ title: 'Solution to use', matchOnDescription: true });
if (picked) {
await model.selectSolution(picked.solution);
}
}
/** Builds with the active selection and reports the exit code; -1 when nothing could be built. */
async function build(target: BuildTarget): Promise<number> {
const task = createTask(model, { type: TASK_TYPE, target });
if (!task) {
void vscode.window.showWarningMessage('No startup project selected — nothing to build.');
return -1;
}
const active = model.active!;
status.setBusy(`${target} ${active.solutionConfiguration.configuration}|${active.solutionPlatform}`);
try {
const code = await runTask(task);
if (code !== 0) {
void vscode.window.showErrorMessage(
`${task.name} failed (exit code ${code}). See the terminal or the Problems panel.`);
}
return code;
} finally {
status.setBusy(undefined);
}
}
async function launch(noDebug: boolean): Promise<void> {
if (!model.active) {
await pickStartupProject();
if (!model.active) {
return;
}
}
if (await build('build') !== 0) {
return;
}
try {
const configuration = await buildLaunchConfiguration(model, { noDebug });
log.appendLine(`Launching: ${JSON.stringify(configuration)}`);
const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(model.active.solution.fsPath));
const started = await vscode.debug.startDebugging(folder, configuration);
if (!started) {
void vscode.window.showErrorMessage(
`The ${configuration.type} debugger did not start. Is DotRush (or another coreclr adapter) installed?`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.appendLine(message);
void vscode.window.showErrorMessage(message, 'Show Log').then(choice => choice && log.show());
}
}
/** Adds this extension's entries to .vscode/launch.json and tasks.json, replacing older ones by name. */
async function generateLaunchConfig(): Promise<void> {
const active = model.active;
const folder = active
? vscode.workspace.getWorkspaceFolder(vscode.Uri.file(active.solution.fsPath))
: vscode.workspace.workspaceFolders?.[0];
if (!folder) {
void vscode.window.showWarningMessage('Open a folder first.');
return;
}
const launch = vscode.workspace.getConfiguration('launch', folder.uri);
const configurations = (launch.get<Record<string, unknown>[]>('configurations') ?? [])
.filter(entry => entry.name !== LAUNCH_NAME);
configurations.unshift(launchJsonEntry());
await launch.update('configurations', configurations, vscode.ConfigurationTarget.WorkspaceFolder);
if (!launch.get('version')) {
await launch.update('version', '0.2.0', vscode.ConfigurationTarget.WorkspaceFolder);
}
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) {
existing.push({
label: taskLabel(target),
type: TASK_TYPE,
target,
problemMatcher: '$msCompile',
...(target === 'build' ? { group: { kind: 'build', isDefault: true } } : {}),
});
}
await tasks.update('tasks', existing, vscode.ConfigurationTarget.WorkspaceFolder);
if (!tasks.get('version')) {
await tasks.update('version', '2.0.0', vscode.ConfigurationTarget.WorkspaceFolder);
}
const choice = await vscode.window.showInformationMessage(
`Added "${LAUNCH_NAME}" to launch.json and ${TASK_TYPE} tasks to tasks.json.`, 'Open launch.json');
if (choice) {
await vscode.window.showTextDocument(vscode.Uri.joinPath(folder.uri, '.vscode', 'launch.json'));
}
}
// ---- DotRush interop -------------------------------------------------------------
interface DotRushExports {
onActiveProjectChanged?: { add(callback: (project: { path: string; name: string }) => void): void };
}
let syncingFromDotRush = false;
/**
* Keeps DotRush's startup project equal to ours, both ways.
*
* DotRush cannot be told the configuration or platform (its selection lives in its own
* workspace state), which is why this extension owns the build instead of pointing at
* `dotrush: Build`. But the *project* can be pushed through `dotrush.setStartupProject`,
* and pulled back through its exports, so the two status bar items never disagree about
* which .csproj is meant.
*/
function connectDotRush(context: vscode.ExtensionContext): void {
const dotrush = vscode.extensions.getExtension<DotRushExports>('nromanov.dotrush');
if (!dotrush) {
log.appendLine('DotRush is not installed; nothing to sync.');
return;
}
void dotrush.activate().then(exports => {
exports?.onActiveProjectChanged?.add(project => {
if (!settings().get<boolean>('syncDotRush', true) || !project?.path) {
return;
}
syncingFromDotRush = true;
void model.selectProjectByPath(project.path).finally(() => { syncingFromDotRush = false; });
});
}, error => log.appendLine(`DotRush did not activate: ${error}`));
context.subscriptions.push(model.onDidChange(() => {
const project = model.startupProject;
if (syncingFromDotRush || !project || !settings().get<boolean>('syncDotRush', true)) {
return;
}
void vscode.commands.executeCommand('dotrush.setStartupProject', vscode.Uri.file(project.info.fsPath))
.then(undefined, error => log.appendLine(`dotrush.setStartupProject failed: ${error}`));
}));
context.subscriptions.push(model.onDidChange(() => void syncWorkspaceProperties()));
}
/** Optionally mirrors Configuration/Platform into DotRush's Roslyn workspace properties. */
async function syncWorkspaceProperties(): Promise<void> {
if (!settings().get<boolean>('syncDotRushWorkspaceProperties', false)) {
return;
}
const active = model.active;
if (!active) {
return;
}
const roslyn = vscode.workspace.getConfiguration('dotrush.roslyn');
const current = roslyn.get<string[]>('workspaceProperties', []);
const kept = current.filter(entry => !/^(Configuration|Platform)=/i.test(entry));
const next = [...kept, `Configuration=${active.projectConfiguration}`, `Platform=${active.projectPlatform}`];
if (JSON.stringify(next) !== JSON.stringify(current)) {
await roslyn.update('workspaceProperties', next, vscode.ConfigurationTarget.Workspace);
}
}
// ---- activation -------------------------------------------------------------------
export function activate(context: vscode.ExtensionContext): void {
log = vscode.window.createOutputChannel('.NET Solution');
model = new SolutionModel(context.workspaceState, log);
status = new StatusBar(model);
const active = () => model.active;
const command = (id: string, handler: (...args: unknown[]) => unknown) =>
vscode.commands.registerCommand(id, handler);
context.subscriptions.push(
log, model, status,
vscode.tasks.registerTaskProvider(TASK_TYPE, new SolutionTaskProvider(model)),
vscode.debug.registerDebugConfigurationProvider(
settings().get<string>('debugType', 'coreclr'),
new SolutionDebugConfigurationProvider(model),
vscode.DebugConfigurationProviderTriggerKind.Dynamic),
command('dotnetSolution.selectStartupProject', pickStartupProject),
command('dotnetSolution.selectConfiguration', pickConfiguration),
command('dotnetSolution.selectSolution', pickSolution),
command('dotnetSolution.build', () => build('build')),
command('dotnetSolution.rebuild', () => build('rebuild')),
command('dotnetSolution.clean', () => build('clean')),
command('dotnetSolution.debug', () => launch(false)),
command('dotnetSolution.run', () => launch(true)),
command('dotnetSolution.generateLaunchConfig', generateLaunchConfig),
command('dotnetSolution.reload', () => model.reload()),
command('dotnetSolution.showOutput', () => log.show()),
command('dotnetSolution.setStartupProject', async (resource?: unknown) => {
const uri = resource instanceof vscode.Uri ? resource : undefined;
if (uri && !(await model.selectProjectByPath(uri.fsPath))) {
void vscode.window.showWarningMessage(
`${path.basename(uri.fsPath)} is not an executable project of ${model.activeSolution?.name ?? 'the solution'}.`);
} else if (!uri) {
await pickStartupProject();
}
}),
// Variables for launch.json / tasks.json: ${command:dotnetSolution.xxx}
command('dotnetSolution.activeProgram', async () => {
const target = await model.resolveTarget();
return target.executablePath ?? target.targetPath;
}),
command('dotnetSolution.activeTargetPath', async () => (await model.resolveTarget()).targetPath),
command('dotnetSolution.activeTargetDir', async () => (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),
command('dotnetSolution.activeConfiguration', () => active()?.solutionConfiguration.configuration),
command('dotnetSolution.activePlatform', () => active()?.solutionPlatform),
command('dotnetSolution.activeProjectConfiguration', () => active()?.projectConfiguration),
command('dotnetSolution.activeProjectPlatform', () => active()?.projectPlatform),
command('dotnetSolution.activeConfigurationKey', () => {
const config: SolutionConfiguration | undefined = active()?.solutionConfiguration;
return config ? configurationKey(config.configuration, config.platform) : undefined;
}),
vscode.workspace.onDidChangeConfiguration(event => {
if (event.affectsConfiguration('dotnetSolution')) {
void model.reload();
}
}),
);
connectDotRush(context);
void model.reload().then(() => offerLaunchJson(context));
}
/** Once per workspace: if there is a solution but no launch.json entry of ours, offer to add one. */
async function offerLaunchJson(context: vscode.ExtensionContext): Promise<void> {
const KEY = 'dotnetSolution.offeredLaunchJson';
if (!model.active || context.workspaceState.get<boolean>(KEY)) {
return;
}
await context.workspaceState.update(KEY, true);
const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(model.active.solution.fsPath));
const configurations = vscode.workspace.getConfiguration('launch', folder?.uri).get<{ name?: string }[]>('configurations') ?? [];
if (configurations.some(entry => entry.name === LAUNCH_NAME)) {
return;
}
const project: StartupProject = model.active.project;
const choice = await vscode.window.showInformationMessage(
`Found ${model.active.solution.name}.sln with startup project ${project.info.name}. ` +
'Add a launch.json entry that builds with the selected solution configuration?',
'Add', 'Not now');
if (choice === 'Add') {
await generateLaunchConfig();
}
}
export function deactivate(): void { }