Skip to content
Open
58 changes: 52 additions & 6 deletions extension/src/debugger/AspireDebugConfigurationProvider.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,40 @@
import * as vscode from 'vscode';
import { defaultConfigurationName } from '../loc/strings';
import { AppHostDiscoveryService, getDebugTargetForCandidate } from '../utils/appHostDiscovery';
import type { CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery';
import { checkCliAvailableOrRedirect } from '../utils/workspace';
import { extensionLogOutputChannel } from '../utils/logging';

export class AspireDebugConfigurationProvider implements vscode.DebugConfigurationProvider {
constructor(private readonly _appHostDiscoveryService: AppHostDiscoveryService) {
}

async provideDebugConfigurations(folder: vscode.WorkspaceFolder | undefined, token?: vscode.CancellationToken): Promise<vscode.DebugConfiguration[]> {
if (folder === undefined) {
return [];
}

const configurations: vscode.DebugConfiguration[] = [];
configurations.push({
const activeEditor = vscode.window.activeTextEditor;
if (!activeEditor) {
return [];
}

const activeEditorFolder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri);
if (activeEditorFolder?.uri.toString() !== folder.uri.toString()) {
return [];
}

const candidate = await this.tryFindCandidateForEditorFile(activeEditor.document.uri.fsPath, folder);
if (!candidate) {
return [];
}

return [{
type: 'aspire',
request: 'launch',
name: defaultConfigurationName,
program: '${workspaceFolder}'
});

return configurations;
program: getDebugTargetForCandidate(candidate)
}];
}

async resolveDebugConfiguration(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise<vscode.DebugConfiguration | null | undefined> {
Expand Down Expand Up @@ -44,4 +62,32 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati

return config;
}

async resolveDebugConfigurationWithSubstitutedVariables(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise<vscode.DebugConfiguration | null | undefined> {
if (typeof config.program === 'string') {
config.program = await this.resolveDebugTarget(config.program, folder);
}

return config;
}

private async tryFindCandidateForEditorFile(filePath: string, folder: vscode.WorkspaceFolder): Promise<CandidateAppHostDisplayInfo | undefined> {
try {
return await this._appHostDiscoveryService.tryFindCandidateForEditorFile(filePath, folder);
}
catch (error) {
extensionLogOutputChannel.warn(`Failed to discover AppHost for debug configuration file ${filePath}: ${error}`);
return undefined;
}
}

private async resolveDebugTarget(filePath: string, folder: vscode.WorkspaceFolder | undefined): Promise<string> {
try {
return await this._appHostDiscoveryService.resolveDebugTarget(filePath, folder);
}
catch (error) {
extensionLogOutputChannel.warn(`Failed to resolve AppHost debug target ${filePath}: ${error}`);
return filePath;
}
}
}
225 changes: 64 additions & 161 deletions extension/src/editor/AspireEditorCommandProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,20 @@ import * as path from 'path';
import { noAppHostInWorkspace } from '../loc/strings';
import { getResourceDebuggerExtensions } from '../debugger/debuggerExtensions';
import { AspireCommandType } from '../dcp/types';
import { aspireConfigFileName, getAppHostPathFromConfig, readJsonFile } from '../utils/cliTypes';
import { AppHostDiscoveryService, getDebugTargetForCandidate, selectWorkspaceAppHostPath } from '../utils/appHostDiscovery';
import type { CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery';
import { extensionLogOutputChannel } from '../utils/logging';

export class AspireEditorCommandProvider implements vscode.Disposable {
private _workspaceAppHostPath: string | null = null;
private _workspaceSettingsJsonWatchers: Map<vscode.WorkspaceFolder, vscode.Disposable> = new Map();
private _disposables: vscode.Disposable[] = [];

constructor() {
// Watch for both aspire.config.json and .aspire/settings.json changes
const workspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file('/'));
if (workspaceFolder) {
this._workspaceSettingsJsonWatchers.set(workspaceFolder, this.watchWorkspaceForAppHostPathChanges(workspaceFolder, this.onChangeAppHostPath.bind(this)));
}
else {
vscode.workspace.workspaceFolders?.forEach(folder => {
this._workspaceSettingsJsonWatchers.set(folder, this.watchWorkspaceForAppHostPathChanges(folder, this.onChangeAppHostPath.bind(this)));
});
}

// As additional workspace folders are added/removed, we need to watch/unwatch them too
constructor(private readonly _appHostDiscoveryService: AppHostDiscoveryService) {
this._disposables.push(vscode.workspace.onDidChangeWorkspaceFolders(event => {
event.added.forEach(folder => {
this._workspaceSettingsJsonWatchers.set(folder, this.watchWorkspaceForAppHostPathChanges(folder, this.onChangeAppHostPath.bind(this)));
});
event.removed.forEach(folder => {
const disposable = this._workspaceSettingsJsonWatchers.get(folder);
if (disposable) {
disposable.dispose();
this._workspaceSettingsJsonWatchers.delete(folder);
}
});
void this.updateWorkspaceAppHostContext();
}));

this._disposables.push(this._appHostDiscoveryService.onDidChangeCandidates(workspaceFolder => {
void this.processActiveDocumentForWorkspace(workspaceFolder);
}));

this._disposables.push(vscode.window.onDidChangeActiveTextEditor(async (editor) => {
Expand All @@ -54,160 +37,81 @@ export class AspireEditorCommandProvider implements vscode.Disposable {
}
}

private async processActiveDocumentForWorkspace(workspaceFolder: vscode.WorkspaceFolder): Promise<void> {
const activeDocument = vscode.window.activeTextEditor?.document;
if (!activeDocument) {
await this.updateWorkspaceAppHostContext();
return;
}

const activeWorkspaceFolder = vscode.workspace.getWorkspaceFolder(activeDocument.uri);
if (activeWorkspaceFolder?.uri.toString() === workspaceFolder.uri.toString()) {
await this.processDocument(activeDocument);
}
}

public async processDocument(document: vscode.TextDocument): Promise<void> {
const fileExtension = path.extname(document.uri.fsPath).toLowerCase();
const isSupportedFile = getResourceDebuggerExtensions().some(extension => extension.getSupportedFileTypes().includes(fileExtension));

vscode.commands.executeCommand('setContext', 'aspire.editorSupportsRunDebug', isSupportedFile);

if (await this.isAppHostFile(document.uri.fsPath)) {
vscode.commands.executeCommand('setContext', 'aspire.fileIsAppHost', true);
}
else {
vscode.commands.executeCommand('setContext', 'aspire.fileIsAppHost', false);
}
vscode.commands.executeCommand('setContext', 'aspire.fileIsAppHost', await this.tryFindCandidateForEditorFile(document.uri.fsPath) !== undefined);
await this.updateWorkspaceAppHostContext();
}

private async isAppHostFile(filePath: string): Promise<boolean> {
const fileText = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)).then(buffer => buffer.toString());
const lines = fileText.split(/\r?\n/);

// C# apphost detection
if (lines.some(line => line.startsWith('#:sdk Aspire.AppHost.Sdk'))) {
return true;
}

if (lines.some(line => line === 'var builder = DistributedApplication.CreateBuilder(args);')) {
return true;
private async updateWorkspaceAppHostContext(): Promise<void> {
const workspaceFolder = vscode.window.activeTextEditor
? vscode.workspace.getWorkspaceFolder(vscode.window.activeTextEditor.document.uri)
: vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
vscode.commands.executeCommand('setContext', 'aspire.workspaceHasAppHost', false);
return;
}

// TypeScript/JavaScript apphost detection
const ext = path.extname(filePath).toLowerCase();
if (['.ts', '.js', '.mts', '.mjs'].includes(ext)) {
// Match both the new `.aspire/modules/aspire` import path and the legacy
// `.modules/aspire` path so legacy stable-channel TypeScript AppHosts that
// still import from `./.modules/aspire.js` continue to expose Run/Debug
// commands via the `aspire.fileIsAppHost` context.
if (lines.some(line => /import\s+.*createBuilder.*from\s+['"].*(\.modules|\.aspire\/modules)\/aspire/.test(line))) {
return true;
}
const appHostPath = await this.trySelectWorkspaceAppHostPath(workspaceFolder);
vscode.commands.executeCommand('setContext', 'aspire.workspaceHasAppHost', appHostPath !== undefined);
}

if (lines.some(line => /require\s*\(['"].*(\.modules|\.aspire\/modules)\/aspire/.test(line))) {
return true;
/**
* Returns the resolved AppHost path from the active editor or workspace settings, or null if none is available.
*/
public async getAppHostPath(): Promise<string | null> {
if (vscode.window.activeTextEditor) {
const candidate = await this.tryFindCandidateForEditorFile(vscode.window.activeTextEditor.document.uri.fsPath);
if (candidate) {
return getDebugTargetForCandidate(candidate);
}
}

return false;
}
const workspaceFolder = vscode.window.activeTextEditor
? vscode.workspace.getWorkspaceFolder(vscode.window.activeTextEditor.document.uri)
: vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
return null;
}

private onChangeAppHostPath(newPath: string | null) {
vscode.commands.executeCommand('setContext', 'aspire.workspaceHasAppHost', !!newPath);
this._workspaceAppHostPath = newPath;
return await this.trySelectWorkspaceAppHostPath(workspaceFolder) ?? null;
}

private watchWorkspaceForAppHostPathChanges(workspaceFolder: vscode.WorkspaceFolder, onChangeAppHostPath: (newPath: string | null) => void): vscode.Disposable {
const disposables: vscode.Disposable[] = [];

// Watch new format: aspire.config.json in workspace root
const newFormatWatcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(workspaceFolder, aspireConfigFileName)
);
newFormatWatcher.onDidCreate(async uri => readConfigFileAndInvokeCallback(uri));
newFormatWatcher.onDidChange(uri => readConfigFileAndInvokeCallback(uri));
newFormatWatcher.onDidDelete(() => {
// When new format is deleted, try to fall back to legacy format
const legacyUri = vscode.Uri.joinPath(workspaceFolder.uri, '.aspire', 'settings.json');
vscode.workspace.fs.stat(legacyUri).then(
() => readConfigFileAndInvokeCallback(legacyUri),
() => onChangeAppHostPath(null)
);
});
disposables.push(newFormatWatcher);

// Watch legacy format: .aspire/settings.json
const legacyWatcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(workspaceFolder, '.aspire/settings.json')
);
legacyWatcher.onDidCreate(async uri => {
// Only use legacy if new format doesn't exist
const newFormatUri = vscode.Uri.joinPath(workspaceFolder.uri, aspireConfigFileName);
try {
await vscode.workspace.fs.stat(newFormatUri);
// New format exists, ignore legacy change
} catch {
readConfigFileAndInvokeCallback(uri);
}
});
legacyWatcher.onDidChange(async uri => {
const newFormatUri = vscode.Uri.joinPath(workspaceFolder.uri, aspireConfigFileName);
try {
await vscode.workspace.fs.stat(newFormatUri);
// New format exists, ignore legacy change
} catch {
readConfigFileAndInvokeCallback(uri);
}
});
legacyWatcher.onDidDelete(() => {
// Legacy deleted; check if new format exists
const newFormatUri = vscode.Uri.joinPath(workspaceFolder.uri, aspireConfigFileName);
vscode.workspace.fs.stat(newFormatUri).then(
() => readConfigFileAndInvokeCallback(newFormatUri),
() => onChangeAppHostPath(null)
);
});
disposables.push(legacyWatcher);

// Read the initial value, preferring new format over legacy
const newFormatUri = vscode.Uri.joinPath(workspaceFolder.uri, aspireConfigFileName);
const legacyUri = vscode.Uri.joinPath(workspaceFolder.uri, '.aspire', 'settings.json');
vscode.workspace.fs.stat(newFormatUri).then(
() => readConfigFileAndInvokeCallback(newFormatUri),
() => {
// New format doesn't exist, try legacy
vscode.workspace.fs.stat(legacyUri).then(
() => readConfigFileAndInvokeCallback(legacyUri),
() => onChangeAppHostPath(null)
);
}
);

return {
dispose() {
disposables.forEach(d => d.dispose());
}
};

async function readConfigFileAndInvokeCallback(uri: vscode.Uri) {
try {
const json = await readJsonFile(uri);
const appHostRelativePath = getAppHostPathFromConfig(json);
if (!appHostRelativePath) {
onChangeAppHostPath(null);
return;
}

// Resolve relative path based on the config file's directory
const configDir = path.dirname(uri.fsPath);
const appHostPath = path.isAbsolute(appHostRelativePath)
? appHostRelativePath
: path.join(configDir, appHostRelativePath);
onChangeAppHostPath(appHostPath);
}
catch {
onChangeAppHostPath(null);
}
private async tryFindCandidateForEditorFile(filePath: string): Promise<CandidateAppHostDisplayInfo | undefined> {
try {
return await this._appHostDiscoveryService.tryFindCandidateForEditorFile(filePath);
}
catch (error) {
extensionLogOutputChannel.warn(`Failed to discover AppHost for editor file ${filePath}: ${error}`);
return undefined;
}
}

/**
* Returns the resolved AppHost path from the active editor or workspace settings, or null if none is available.
*/
public async getAppHostPath(): Promise<string | null> {
if (vscode.window.activeTextEditor && await this.isAppHostFile(vscode.window.activeTextEditor.document.uri.fsPath)) {
return vscode.window.activeTextEditor.document.uri.fsPath;
private async trySelectWorkspaceAppHostPath(workspaceFolder: vscode.WorkspaceFolder): Promise<string | undefined> {
try {
const appHosts = await this._appHostDiscoveryService.discover(workspaceFolder);
return await selectWorkspaceAppHostPath(workspaceFolder, appHosts);
}
catch (error) {
extensionLogOutputChannel.warn(`Failed to discover AppHost candidates for workspace ${workspaceFolder.uri.fsPath}: ${error}`);
return undefined;
}

return this._workspaceAppHostPath;
}

public async tryExecuteRunAppHost(noDebug: boolean): Promise<void> {
Expand Down Expand Up @@ -251,6 +155,5 @@ export class AspireEditorCommandProvider implements vscode.Disposable {

dispose() {
this._disposables.forEach(disposable => disposable.dispose());
this._workspaceSettingsJsonWatchers.forEach(disposable => disposable.dispose());
}
}
Loading
Loading