Files
vs-code-dotnet-solution-lau…/src/extension.ts
T
maxandClaude Fable 5.1 0cae24a1f0 Hot reload as a third launch mode, rewritten from dotnet-hot-reload
dotnet watch run with the startup project, configuration, platform and
per-project launch options from the status bar. The debugger is no
longer attached automatically: attach on demand, one notification with
Re-attach when the watcher replaces the process. Rude edits follow a
setting (restart / ask / warn). Warns before starting under an optimised
configuration, which SDK 10's dotnet watch cannot hot reload. Solution
build runs first so the Editor's post-build step finds the Builder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169iPWwKHZoBTNN9qwXiwqk
2026-09-08 13:47:37 +02:00

427 lines
19 KiB
TypeScript

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 { ALL_TARGETS, createTask, runTask, SolutionTaskProvider, TASK_TYPE, taskLabel, BuildTarget } from './tasks';
import { launchOptionsFor } from './launch';
import { StatusBar } from './status';
import { HotReloadController } from './hotreload/controller';
let model: SolutionModel;
let status: StatusBar;
let log: vscode.OutputChannel;
let hotReload: HotReloadController;
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;
}
type Item = vscode.QuickPickItem & { project?: StartupProject; action?: () => Promise<void> };
const items: Item[] = 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,
}));
items.push(
{ label: '', kind: vscode.QuickPickItemKind.Separator },
{
label: '$(json) Create launch.json and tasks.json entries',
description: 'so F5 builds with the selected configuration',
action: generateLaunchConfig,
},
{
label: '$(file-submodule) Select solution',
description: model.allSolutions.length > 1 ? `${model.allSolutions.length} found` : undefined,
action: pickSolution,
});
const picked = await vscode.window.showQuickPick(items,
{ title: `${model.activeSolution?.name}: startup project`, matchOnDescription: true });
if (picked?.project) {
await model.selectProject(picked.project);
} else if (picked?.action) {
await picked.action();
}
}
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) {
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 Problems panel or the terminal.`);
}
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);
for (const target of ALL_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);
}
}
// ---- 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 {
log = vscode.window.createOutputChannel('.NET Solution');
model = new SolutionModel(context.workspaceState, log);
status = new StatusBar(model);
hotReload = new HotReloadController(model, log, () => build('build'));
const active = () => model.active;
const command = (id: string, handler: (...args: unknown[]) => unknown) =>
vscode.commands.registerCommand(id, handler);
context.subscriptions.push(
log, model, status, hotReload,
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.publish', () => build('publish')),
command('dotnetSolution.test', () => build('test')),
command('dotnetSolution.debug', () => launch(false)),
command('dotnetSolution.run', () => launch(true)),
command('dotnetSolution.generateLaunchConfig', generateLaunchConfig),
command('dotnetSolution.hotReload.start', () => hotReload.start()),
command('dotnetSolution.hotReload.apply', () => hotReload.apply()),
command('dotnetSolution.hotReload.restart', () => hotReload.restart()),
command('dotnetSolution.hotReload.stop', () => hotReload.stop()),
command('dotnetSolution.hotReload.attach', () => hotReload.attach()),
command('dotnetSolution.hotReload.detach', () => hotReload.detach()),
command('dotnetSolution.hotReload.showTerminal', () => hotReload.showTerminal()),
command('dotnetSolution.hotReload.menu', () => hotReload.menu()),
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.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),
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();
}
}),
);
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));
}
/** 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 { }