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 { 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 }; 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 { 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 { 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 { 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('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 { 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 { 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[]>('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[]>('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('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('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('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 { if (!settings().get('syncDotRushWorkspaceProperties', false)) { return; } const active = model.active; if (!active) { return; } const roslyn = vscode.workspace.getConfiguration('dotrush.roslyn'); const current = roslyn.get('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(); 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('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 { const KEY = 'dotnetSolution.offeredLaunchJson'; if (!model.active || context.workspaceState.get(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 { }