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:
@@ -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 { }
|
||||
Reference in New Issue
Block a user