Files
vs-code-dotnet-solution-lau…/src/model.ts
T
maxandClaude Fable 5.1 e2ba32a647 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
2026-09-08 13:10:55 +02:00

299 lines
13 KiB
TypeScript

import * as vscode from 'vscode';
import * as path from 'path';
import { parseProject, ProjectInfo } from './csproj';
import { evaluateTarget, TargetInfo } from './msbuild';
import {
configurationKey, msbuildPlatform, parseConfigurationKey, parseSolution,
projectConfigurationFor, Solution, SolutionConfiguration, SolutionProject,
} from './sln';
const decoder = new TextDecoder('utf-8');
export interface StartupProject {
solutionProject: SolutionProject;
info: ProjectInfo;
}
/** Everything the build and launch need, resolved from the current selection. */
export interface ActiveTarget {
solution: Solution;
project: StartupProject;
/** The solution configuration shown in the status bar, e.g. Debug|x64. */
solutionConfiguration: SolutionConfiguration;
/** What that maps to for the startup project, in MSBuild spelling (AnyCPU). */
projectConfiguration: string;
projectPlatform: string;
/** The solution's own `Any CPU` spelling, for `-p:Platform` on a solution build. */
solutionPlatform: string;
targetFramework: string | undefined;
}
const STATE_SOLUTION = 'dotnetSolution.solution';
const STATE_PROJECT = 'dotnetSolution.project';
const STATE_CONFIGURATION = 'dotnetSolution.configuration';
/**
* The solution, its startup project and the selected configuration.
*
* The selection is stored per workspace, keyed by paths, so reopening the folder keeps
* it. Everything derived from files is re-read when a solution or project file changes.
*/
export class SolutionModel implements vscode.Disposable {
private readonly changed = new vscode.EventEmitter<void>();
readonly onDidChange = this.changed.event;
private solutions: Solution[] = [];
private solution: Solution | undefined;
private projects: StartupProject[] = [];
private project: StartupProject | undefined;
private configuration: SolutionConfiguration | undefined;
private readonly targets = new Map<string, Promise<TargetInfo>>();
private readonly watcher: vscode.FileSystemWatcher;
private reloadTimer: NodeJS.Timeout | undefined;
constructor(
private readonly state: vscode.Memento,
private readonly log: vscode.OutputChannel,
) {
this.watcher = vscode.workspace.createFileSystemWatcher('**/*.{sln,slnx,csproj,fsproj,vbproj,props,targets}');
const schedule = () => {
clearTimeout(this.reloadTimer);
this.reloadTimer = setTimeout(() => void this.reload(), 500);
};
this.watcher.onDidChange(schedule);
this.watcher.onDidCreate(schedule);
this.watcher.onDidDelete(schedule);
}
dispose(): void {
clearTimeout(this.reloadTimer);
this.watcher.dispose();
this.changed.dispose();
}
get activeSolution(): Solution | undefined { return this.solution; }
get allSolutions(): Solution[] { return this.solutions; }
get startupProject(): StartupProject | undefined { return this.project; }
/** Executable projects of the active solution, sorted by name. */
get startupCandidates(): StartupProject[] { return this.projects; }
get activeConfiguration(): SolutionConfiguration | undefined { return this.configuration; }
/** Solution configurations, with the ones that actually build the startup project first. */
get configurations(): SolutionConfiguration[] {
if (!this.solution) {
return [];
}
const project = this.project?.solutionProject;
const declared = new Set(this.project?.info.platforms.map(msbuildPlatform) ?? []);
const score = (config: SolutionConfiguration): number => {
if (!project) {
return 0;
}
const mapped = projectConfigurationFor(project, config);
let value = mapped.build ? 0 : 2;
if (declared.size > 0 && !declared.has(msbuildPlatform(mapped.platform))) {
value += 1;
}
return value;
};
return [...this.solution.configurations].sort((a, b) => score(a) - score(b));
}
/** True when the solution maps this configuration onto a platform the startup project declares. */
isNaturalConfiguration(config: SolutionConfiguration): boolean {
const project = this.project;
if (!project) {
return true;
}
const mapped = projectConfigurationFor(project.solutionProject, config);
const declared = project.info.platforms.map(msbuildPlatform);
return mapped.build && (declared.length === 0 || declared.includes(msbuildPlatform(mapped.platform)));
}
get active(): ActiveTarget | undefined {
if (!this.solution || !this.project || !this.configuration) {
return undefined;
}
const mapped = projectConfigurationFor(this.project.solutionProject, this.configuration);
const configured = vscode.workspace.getConfiguration('dotnetSolution').get<string>('targetFramework', '');
const frameworks = this.project.info.targetFrameworks;
const targetFramework = this.project.info.multiTargeting
? (configured && frameworks.includes(configured) ? configured : frameworks[0])
: undefined;
return {
solution: this.solution,
project: this.project,
solutionConfiguration: this.configuration,
projectConfiguration: mapped.configuration,
projectPlatform: msbuildPlatform(mapped.platform),
solutionPlatform: this.configuration.platform,
targetFramework,
};
}
async reload(): Promise<void> {
this.targets.clear();
this.solutions = await discoverSolutions(this.log);
this.solution = this.pickSolution();
this.projects = this.solution ? await loadStartupCandidates(this.solution, this.log) : [];
this.project = this.pickProject();
this.configuration = this.pickConfiguration();
this.log.appendLine(
`Loaded ${this.solutions.length} solution(s); active: ${this.solution?.name ?? 'none'}, ` +
`startup: ${this.project?.info.name ?? 'none'}, ` +
`configuration: ${this.configuration ? configurationKey(this.configuration.configuration, this.configuration.platform) : 'none'}`);
this.changed.fire();
}
async selectSolution(solution: Solution): Promise<void> {
await this.state.update(STATE_SOLUTION, solution.fsPath);
await this.reload();
}
async selectProject(project: StartupProject): Promise<void> {
this.project = project;
await this.state.update(STATE_PROJECT, project.info.fsPath);
// A different project may map the same solution configuration differently, or not
// declare the platform at all; keep the selection but re-validate it.
this.configuration = this.pickConfiguration();
this.changed.fire();
}
/** Selects the startup project by path, e.g. from the explorer or from DotRush. */
async selectProjectByPath(fsPath: string): Promise<boolean> {
const normalized = path.normalize(fsPath).toLowerCase();
const match = this.projects.find(project => path.normalize(project.info.fsPath).toLowerCase() === normalized);
if (!match) {
return false;
}
if (match !== this.project) {
await this.selectProject(match);
}
return true;
}
async selectConfiguration(config: SolutionConfiguration): Promise<void> {
this.configuration = config;
await this.state.update(STATE_CONFIGURATION, configurationKey(config.configuration, config.platform));
this.changed.fire();
}
/** Where the startup project's output lands under the active configuration. Cached until files change. */
resolveTarget(): Promise<TargetInfo> {
const active = this.active;
if (!active) {
return Promise.reject(new Error('No startup project selected.'));
}
const key = [active.project.info.fsPath, active.projectConfiguration, active.projectPlatform, active.targetFramework ?? ''].join('|');
let pending = this.targets.get(key);
if (!pending) {
pending = evaluateTarget({
projectPath: active.project.info.fsPath,
configuration: active.projectConfiguration,
platform: active.projectPlatform,
targetFramework: active.targetFramework,
}, this.log);
pending.catch(() => this.targets.delete(key));
this.targets.set(key, pending);
}
return pending;
}
private pickSolution(): Solution | undefined {
if (this.solutions.length === 0) {
return undefined;
}
const configured = vscode.workspace.getConfiguration('dotnetSolution').get<string>('solution', '');
const wanted = configured ? resolveWorkspacePath(configured) : this.state.get<string>(STATE_SOLUTION);
if (wanted) {
const match = this.solutions.find(solution => samePath(solution.fsPath, wanted));
if (match) {
return match;
}
if (configured) {
void vscode.window.showWarningMessage(`dotnetSolution.solution points at "${configured}", which was not found.`);
}
}
// Closest to the workspace root wins; among equals the one with more projects.
return [...this.solutions].sort((a, b) =>
depth(a.fsPath) - depth(b.fsPath) || b.projects.length - a.projects.length)[0];
}
private pickProject(): StartupProject | undefined {
if (this.projects.length === 0) {
return undefined;
}
const remembered = this.state.get<string>(STATE_PROJECT);
const match = remembered ? this.projects.find(project => samePath(project.info.fsPath, remembered)) : undefined;
return match ?? this.projects[0];
}
private pickConfiguration(): SolutionConfiguration | undefined {
const configurations = this.configurations;
if (configurations.length === 0) {
return undefined;
}
const remembered = this.state.get<string>(STATE_CONFIGURATION);
if (remembered) {
const wanted = parseConfigurationKey(remembered);
const match = configurations.find(config =>
config.configuration === wanted.configuration && config.platform === wanted.platform);
if (match) {
return match;
}
}
// Debug on a platform the project declares, otherwise the best-scored one.
return configurations.find(config => /^debug$/i.test(config.configuration) && this.isNaturalConfiguration(config))
?? configurations[0];
}
}
function depth(fsPath: string): number {
return vscode.workspace.asRelativePath(fsPath, false).split(/[\\/]/).length;
}
function samePath(a: string, b: string): boolean {
return path.normalize(a).toLowerCase() === path.normalize(b).toLowerCase();
}
function resolveWorkspacePath(relative: string): string {
if (path.isAbsolute(relative)) {
return relative;
}
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
return path.join(root, relative);
}
async function discoverSolutions(log: vscode.OutputChannel): Promise<Solution[]> {
const files = await vscode.workspace.findFiles('**/*.{sln,slnx}', '**/{bin,obj,node_modules,.git}/**');
const solutions: Solution[] = [];
for (const uri of files) {
try {
const text = decoder.decode(await vscode.workspace.fs.readFile(uri));
const solution = parseSolution(text, uri.fsPath);
if (solution.projects.length > 0) {
solutions.push(solution);
}
} catch (error) {
log.appendLine(`Could not read ${uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return solutions;
}
async function loadStartupCandidates(solution: Solution, log: vscode.OutputChannel): Promise<StartupProject[]> {
const loaded = await Promise.all(solution.projects.map(async solutionProject => {
try {
const xml = decoder.decode(await vscode.workspace.fs.readFile(vscode.Uri.file(solutionProject.fsPath)));
const info = parseProject(xml, solutionProject.fsPath);
return info.executable ? { solutionProject, info } satisfies StartupProject : undefined;
} catch (error) {
log.appendLine(`Could not read ${solutionProject.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
}
}));
return loaded
.filter((project): project is StartupProject => project !== undefined)
.sort((a, b) => a.info.name.localeCompare(b.info.name));
}