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
299 lines
13 KiB
TypeScript
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));
|
|
}
|