diff --git a/extension/src/debugger/AspireDebugConfigurationProvider.ts b/extension/src/debugger/AspireDebugConfigurationProvider.ts index 643db6ed958..f490b89b7ca 100644 --- a/extension/src/debugger/AspireDebugConfigurationProvider.ts +++ b/extension/src/debugger/AspireDebugConfigurationProvider.ts @@ -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 { 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 { @@ -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 { + if (typeof config.program === 'string') { + config.program = await this.resolveDebugTarget(config.program, folder); + } + + return config; + } + + private async tryFindCandidateForEditorFile(filePath: string, folder: vscode.WorkspaceFolder): Promise { + 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 { + try { + return await this._appHostDiscoveryService.resolveDebugTarget(filePath, folder); + } + catch (error) { + extensionLogOutputChannel.warn(`Failed to resolve AppHost debug target ${filePath}: ${error}`); + return filePath; + } + } } diff --git a/extension/src/editor/AspireEditorCommandProvider.ts b/extension/src/editor/AspireEditorCommandProvider.ts index 1970d4f172d..5c041cdfade 100644 --- a/extension/src/editor/AspireEditorCommandProvider.ts +++ b/extension/src/editor/AspireEditorCommandProvider.ts @@ -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 = 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) => { @@ -54,160 +37,81 @@ export class AspireEditorCommandProvider implements vscode.Disposable { } } + private async processActiveDocumentForWorkspace(workspaceFolder: vscode.WorkspaceFolder): Promise { + 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 { 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 { - 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 { + 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 { + 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 { + 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 { - 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 { + 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 { @@ -251,6 +155,5 @@ export class AspireEditorCommandProvider implements vscode.Disposable { dispose() { this._disposables.forEach(disposable => disposable.dispose()); - this._workspaceSettingsJsonWatchers.forEach(disposable => disposable.dispose()); } } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 96690464e68..604e136af1b 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -37,6 +37,7 @@ import { getSupportedLanguageIds } from './editor/parsers/AppHostResourceParser' import { readGitCommitSha } from './utils/versionInfo'; import { collectResourceCommandArguments, hasSecretResourceCommandArguments } from './views/ResourceCommandArguments'; import { ResourceCommandJson } from './views/AppHostDataRepository'; +import { AppHostDiscoveryService } from './utils/appHostDiscovery'; let aspireExtensionContext = new AspireExtensionContext(); @@ -60,7 +61,10 @@ export async function activate(context: vscode.ExtensionContext) { terminalProvider.dcpServerConnectionInfo = dcpServer.connectionInfo; terminalProvider.closeAllOpenAspireTerminals(); - const editorCommandProvider = new AspireEditorCommandProvider(); + const appHostDiscoveryService = new AppHostDiscoveryService(terminalProvider); + context.subscriptions.push(appHostDiscoveryService); + + const editorCommandProvider = new AspireEditorCommandProvider(appHostDiscoveryService); const cliAddCommandRegistration = vscode.commands.registerCommand('aspire-vscode.add', () => tryExecuteCommand('aspire-vscode.add', terminalProvider, (tp) => addCommand(tp, editorCommandProvider))); const cliNewCommandRegistration = vscode.commands.registerCommand('aspire-vscode.new', () => tryExecuteCommand('aspire-vscode.new', terminalProvider, newCommand)); @@ -84,7 +88,7 @@ export async function activate(context: vscode.ExtensionContext) { const verifyCliInstalledRegistration = vscode.commands.registerCommand('aspire-vscode.verifyCliInstalled', verifyCliInstalledCommand); // Aspire panel - running app hosts tree view - const dataRepository = new AppHostDataRepository(terminalProvider); + const dataRepository = new AppHostDataRepository(terminalProvider, appHostDiscoveryService); const appHostTreeProvider = new AspireAppHostTreeProvider(dataRepository, terminalProvider, context.globalState); const appHostTreeView = vscode.window.createTreeView('aspire-vscode.runningAppHosts', { treeDataProvider: appHostTreeProvider, @@ -184,7 +188,7 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(cliUpdateCommandRegistration, cliUpdateSelfCommandRegistration, settingsCommandRegistration, openLocalSettingsCommandRegistration, openGlobalSettingsCommandRegistration, runAppHostCommandRegistration, debugAppHostCommandRegistration); context.subscriptions.push(installCliStableRegistration, installCliDailyRegistration, verifyCliInstalledRegistration); - const debugConfigProvider = new AspireDebugConfigurationProvider(); + const debugConfigProvider = new AspireDebugConfigurationProvider(appHostDiscoveryService); context.subscriptions.push( vscode.debug.registerDebugConfigurationProvider('aspire', debugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Dynamic) ); @@ -208,7 +212,7 @@ export async function activate(context: vscode.ExtensionContext) { const getEnableSettingsFileCreationPromptOnStartup = () => vscode.workspace.getConfiguration('aspire').get('enableSettingsFileCreationPromptOnStartup', true); const setEnableSettingsFileCreationPromptOnStartup = async (value: boolean) => await vscode.workspace.getConfiguration('aspire').update('enableSettingsFileCreationPromptOnStartup', value, vscode.ConfigurationTarget.Workspace); const appHostDisposablePromise = checkForExistingAppHostPathInWorkspace( - terminalProvider, + appHostDiscoveryService, getEnableSettingsFileCreationPromptOnStartup, setEnableSettingsFileCreationPromptOnStartup ); diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index b53d9bf384d..36b404532ac 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -410,7 +410,7 @@ suite('AppHostDataRepository', () => { '/workspace/samples/Store/AppHost.csproj', ], })); - await waitForMicrotasks(); + await waitForAppHostDiscovery(); assert.strictEqual(repository.viewMode, 'workspace'); assert.strictEqual(repository.workspaceAppHostPath, '/workspace/apps/Store/AppHost.csproj'); @@ -675,7 +675,7 @@ suite('AppHostDataRepository', () => { selected_project_file: '/workspace/apphost/apphost.cs', all_project_file_candidates: ['/workspace/apphost/apphost.cs'], })); - await waitForMicrotasks(); + await waitForAppHostDiscovery(); assert.ok(psOptions); assert.deepStrictEqual(psArgs, ['ps', '--follow', '--format', 'json', '--resources']); @@ -749,7 +749,7 @@ suite('AppHostDataRepository', () => { }, ], })); - await waitForMicrotasks(); + await waitForAppHostDiscovery(); assert.ok(psOptions); psOptions.lineCallback(JSON.stringify([ @@ -820,7 +820,7 @@ suite('AppHostDataRepository', () => { }, ], })); - await waitForMicrotasks(); + await waitForAppHostDiscovery(); assert.ok(psOptions); psOptions.lineCallback(JSON.stringify([])); @@ -875,7 +875,7 @@ suite('AppHostDataRepository', () => { selected_project_file: '/workspace/labs/ops/apphost.cs', all_project_file_candidates: ['/workspace/labs/ops/apphost.cs'], })); - await waitForMicrotasks(); + await waitForAppHostDiscovery(); assert.ok(describeOptions); assert.ok(psOptions); @@ -1308,7 +1308,7 @@ async function waitForAppHostDiscovery(): Promise { } async function waitForCondition(condition: () => boolean, message: string): Promise { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 100; i++) { if (condition()) { return; } diff --git a/extension/src/test/appHostDiscovery.test.ts b/extension/src/test/appHostDiscovery.test.ts new file mode 100644 index 00000000000..7f8d997bf86 --- /dev/null +++ b/extension/src/test/appHostDiscovery.test.ts @@ -0,0 +1,315 @@ +/// + +import * as assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import * as cliModule from '../debugger/languages/cli'; +import { AppHostDiscoveryService, findCandidateForEditorFile, getDebugTargetForCandidate } from '../utils/appHostDiscovery'; +import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; + +suite('AppHost discovery', () => { + test('resolves SDK-style C# AppHost source file to discovered project candidate', () => { + const appHostProjectPath = buildPath('workspace', 'AppHost', 'AppHost.csproj'); + const programPath = buildPath('workspace', 'AppHost', 'Program.cs'); + + const candidate = findCandidateForEditorFile(programPath, [{ + path: appHostProjectPath, + language: 'csharp', + status: 'buildable', + }]); + + assert.strictEqual(candidate?.path, appHostProjectPath); + assert.strictEqual(candidate ? getDebugTargetForCandidate(candidate) : undefined, appHostProjectPath); + }); + + test('keeps file-based C# AppHost candidate as source file', () => { + const appHostPath = buildPath('workspace', 'AppHost', 'apphost.cs'); + + const candidate = findCandidateForEditorFile(appHostPath, [{ + path: appHostPath, + language: 'csharp', + status: 'buildable', + }]); + + assert.strictEqual(candidate?.path, appHostPath); + assert.strictEqual(candidate ? getDebugTargetForCandidate(candidate) : undefined, appHostPath); + }); + + test('keeps TypeScript AppHost candidate as source file', () => { + const appHostPath = buildPath('workspace', 'AppHost', 'apphost.ts'); + + const candidate = findCandidateForEditorFile(appHostPath, [{ + path: appHostPath, + language: 'typescript/nodejs', + status: 'buildable', + }]); + + assert.strictEqual(candidate?.path, appHostPath); + assert.strictEqual(candidate ? getDebugTargetForCandidate(candidate) : undefined, appHostPath); + }); + + test('returns undefined when no discovered candidate contains C# source file', () => { + const programPath = buildPath('workspace', 'Web', 'Program.cs'); + + const candidate = findCandidateForEditorFile(programPath, [{ + path: buildPath('workspace', 'AppHost', 'AppHost.csproj'), + language: 'csharp', + status: 'buildable', + }]); + + assert.strictEqual(candidate, undefined); + }); + + test('does not map source file to non-C# project candidate', () => { + const programPath = buildPath('workspace', 'AppHost', 'Program.cs'); + + const candidate = findCandidateForEditorFile(programPath, [{ + path: buildPath('workspace', 'AppHost', 'apphost.ts'), + language: 'typescript/nodejs', + status: 'buildable', + }]); + + assert.strictEqual(candidate, undefined); + }); + + test('maps C# file in AppHost project directory to discovered project candidate', () => { + const helperPath = buildPath('workspace', 'AppHost', 'Helper.cs'); + + const candidate = findCandidateForEditorFile(helperPath, [{ + path: buildPath('workspace', 'AppHost', 'AppHost.csproj'), + language: 'csharp', + status: 'buildable', + }]); + + assert.strictEqual(candidate?.path, buildPath('workspace', 'AppHost', 'AppHost.csproj')); + }); + + test('does not map C# file under bin directory to discovered project candidate', () => { + const generatedPath = buildPath('workspace', 'AppHost', 'bin', 'Debug', 'net10.0', 'Generated.cs'); + + const candidate = findCandidateForEditorFile(generatedPath, [{ + path: buildPath('workspace', 'AppHost', 'AppHost.csproj'), + language: 'csharp', + status: 'buildable', + }]); + + assert.strictEqual(candidate, undefined); + }); + + suite('service', () => { + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + }); + + teardown(() => { + sandbox.restore(); + }); + + test('does not force refresh discovery after cached negative editor lookup', async () => { + stubFileSystemWatchers(sandbox); + const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + options?.stdoutCallback?.(JSON.stringify([{ + path: buildPath('workspace', 'AppHost', 'AppHost.csproj'), + language: 'csharp', + status: 'buildable', + }])); + options?.exitCallback?.(0); + return { kill: () => { } } as any; + }); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + + try { + const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); + const firstResult = await service.tryFindCandidateForEditorFile(buildPath('workspace', 'Web', 'Program.cs'), workspaceFolder); + const secondResult = await service.tryFindCandidateForEditorFile(buildPath('workspace', 'Web', 'Program.cs'), workspaceFolder); + + assert.strictEqual(firstResult, undefined); + assert.strictEqual(secondResult, undefined); + assert.strictEqual(spawnStub.callCount, 1); + } + finally { + service.dispose(); + } + }); + + test('fires change event and invalidates cache when watched files change', async () => { + const watcherCallbacks = stubFileSystemWatchers(sandbox); + const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + options?.stdoutCallback?.('[]'); + options?.exitCallback?.(0); + return { kill: () => { } } as any; + }); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); + let changedWorkspaceFolder: vscode.WorkspaceFolder | undefined; + const subscription = service.onDidChangeCandidates(folder => { + changedWorkspaceFolder = folder; + }); + + try { + await service.discover(workspaceFolder); + assert.strictEqual(spawnStub.callCount, 1); + + watcherCallbacks[0](); + assert.strictEqual(changedWorkspaceFolder, workspaceFolder); + + await service.discover(workspaceFolder); + assert.strictEqual(spawnStub.callCount, 2); + } + finally { + subscription.dispose(); + service.dispose(); + } + }); + + test('ignores watched files in excluded directories', async () => { + const watcherCallbacks = stubFileSystemWatchers(sandbox); + const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + options?.stdoutCallback?.('[]'); + options?.exitCallback?.(0); + return { kill: () => { } } as any; + }); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); + let changeCount = 0; + const subscription = service.onDidChangeCandidates(() => { + changeCount++; + }); + + try { + await service.discover(workspaceFolder); + assert.strictEqual(spawnStub.callCount, 1); + + watcherCallbacks[0](vscode.Uri.file(buildPath('workspace', 'AppHost', 'bin', 'Debug', 'Generated.csproj'))); + assert.strictEqual(changeCount, 0); + + await service.discover(workspaceFolder); + assert.strictEqual(spawnStub.callCount, 1); + } + finally { + subscription.dispose(); + service.dispose(); + } + }); + + test('kills in-flight CLI process when disposed', async () => { + stubFileSystemWatchers(sandbox); + const childProcess = { + killed: false, + kill: sandbox.stub().callsFake(() => { + childProcess.killed = true; + return true; + }), + }; + const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').returns(childProcess as any); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); + + const discovery = service.discover(workspaceFolder); + await waitForMicrotasks(); + + service.dispose(); + + await assert.rejects(discovery, /disposed/); + assert.strictEqual(spawnStub.callCount, 1); + assert.strictEqual(childProcess.kill.callCount, 1); + assert.strictEqual(childProcess.killed, true); + }); + + test('times out hung CLI process and allows retry', async () => { + stubFileSystemWatchers(sandbox); + const clock = sandbox.useFakeTimers(); + const killedArgs: string[][] = []; + let hangCli = true; + const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args = [], options) => { + const childProcess = { + killed: false, + kill: sandbox.stub().callsFake(() => { + childProcess.killed = true; + killedArgs.push(args); + return true; + }), + }; + if (!hangCli) { + options?.stdoutCallback?.('[]'); + options?.exitCallback?.(0); + } + return childProcess as any; + }); + const service = new AppHostDiscoveryService(makeTerminalProvider()); + const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); + + try { + const discovery = service.discover(workspaceFolder); + await waitForMicrotasks(); + + await clock.tickAsync(30_000); + await waitForMicrotasks(); + await clock.tickAsync(30_000); + + await assert.rejects(discovery, /timed out after 30 seconds/); + assert.deepStrictEqual(killedArgs, [ + ['ls', '--format', 'json'], + ['extension', 'get-apphosts'], + ]); + + hangCli = false; + const retryResult = await service.discover(workspaceFolder); + assert.deepStrictEqual(retryResult, []); + assert.strictEqual(spawnStub.callCount, 3); + } + finally { + service.dispose(); + clock.restore(); + } + }); + }); +}); + +function buildPath(...segments: string[]): string { + return path.join(path.sep, ...segments); +} + +function makeWorkspaceFolder(folderPath: string): vscode.WorkspaceFolder { + return { + uri: vscode.Uri.file(folderPath), + name: path.basename(folderPath), + index: 0, + }; +} + +function makeTerminalProvider(): AspireTerminalProvider { + return { + getAspireCliExecutablePath: async () => 'aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; +} + +async function waitForMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function stubFileSystemWatchers(sandbox: sinon.SinonSandbox): Array<(uri?: vscode.Uri) => void> { + const callbacks: Array<(uri?: vscode.Uri) => void> = []; + sandbox.stub(vscode.workspace, 'createFileSystemWatcher').callsFake(() => ({ + onDidCreate: callback => { + callbacks.push(uri => callback(uri ?? vscode.Uri.file(buildPath('workspace', 'AppHost', 'AppHost.csproj')))); + return { dispose: () => { } }; + }, + onDidChange: callback => { + callbacks.push(uri => callback(uri ?? vscode.Uri.file(buildPath('workspace', 'AppHost', 'AppHost.csproj')))); + return { dispose: () => { } }; + }, + onDidDelete: callback => { + callbacks.push(uri => callback(uri ?? vscode.Uri.file(buildPath('workspace', 'AppHost', 'AppHost.csproj')))); + return { dispose: () => { } }; + }, + dispose: () => { }, + } as vscode.FileSystemWatcher)); + + return callbacks; +} diff --git a/extension/src/test/aspireDebugConfigurationProvider.test.ts b/extension/src/test/aspireDebugConfigurationProvider.test.ts new file mode 100644 index 00000000000..93212f61854 --- /dev/null +++ b/extension/src/test/aspireDebugConfigurationProvider.test.ts @@ -0,0 +1,182 @@ +/// + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { AspireDebugConfigurationProvider } from '../debugger/AspireDebugConfigurationProvider'; +import { AppHostDiscoveryService } from '../utils/appHostDiscovery'; + +suite('AspireDebugConfigurationProvider', () => { + let tempDir: string; + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-debug-configuration-provider-')); + }); + + teardown(() => { + sandbox.restore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('resolves launch config SDK-style AppHost Program.cs to containing project file', async () => { + const appHostDirectory = path.join(tempDir, 'AppHost'); + fs.mkdirSync(appHostDirectory); + + const programPath = path.join(appHostDirectory, 'Program.cs'); + const projectPath = path.join(appHostDirectory, 'AppHost.csproj'); + fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);\nbuilder.Build().Run();'); + fs.writeFileSync(projectPath, ''); + + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(projectPath)); + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: programPath + }); + + assert.strictEqual(config?.program, projectPath); + }); + + test('leaves launch config single-file apphost.cs unchanged', async () => { + const appHostPath = path.join(tempDir, 'apphost.cs'); + fs.writeFileSync(appHostPath, '#:sdk Aspire.AppHost.Sdk\nvar builder = DistributedApplication.CreateBuilder(args);'); + + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(appHostPath)); + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: appHostPath + }); + + assert.strictEqual(config?.program, appHostPath); + }); + + test('leaves launch config TypeScript apphost.ts unchanged', async () => { + const appHostPath = path.join(tempDir, 'apphost.ts'); + fs.writeFileSync(appHostPath, 'import { createBuilder } from "./.aspire/modules/aspire";'); + + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(appHostPath, appHostPath, 'typescript/nodejs')); + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: appHostPath + }); + + assert.strictEqual(config?.program, appHostPath); + }); + + test('leaves launch config non-AppHost C# source file unchanged', async () => { + const appDirectory = path.join(tempDir, 'App'); + fs.mkdirSync(appDirectory); + + const programPath = path.join(appDirectory, 'Program.cs'); + fs.writeFileSync(programPath, 'Console.WriteLine("Hello");'); + fs.writeFileSync(path.join(appDirectory, 'App.csproj'), ''); + + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(programPath)); + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: programPath + }); + + assert.strictEqual(config?.program, programPath); + }); + + test('provides dynamic launch config when active file resolves to AppHost candidate', async () => { + const folder = createWorkspaceFolder(tempDir); + const programPath = path.join(tempDir, 'AppHost', 'Program.cs'); + const projectPath = path.join(tempDir, 'AppHost', 'AppHost.csproj'); + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(projectPath)); + setActiveEditor(programPath, folder); + + const configs = await provider.provideDebugConfigurations(folder); + + assert.strictEqual(configs.length, 1); + assert.strictEqual(configs[0].program, projectPath); + }); + + test('does not provide dynamic launch config when active file is not an AppHost candidate', async () => { + const folder = createWorkspaceFolder(tempDir); + const programPath = path.join(tempDir, 'Web', 'Program.cs'); + const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(programPath, null)); + setActiveEditor(programPath, folder); + + const configs = await provider.provideDebugConfigurations(folder); + + assert.deepStrictEqual(configs, []); + }); + + test('does not provide dynamic launch config when discovery fails', async () => { + const folder = createWorkspaceFolder(tempDir); + const programPath = path.join(tempDir, 'AppHost', 'Program.cs'); + const provider = new AspireDebugConfigurationProvider(createFailingAppHostDiscoveryService()); + setActiveEditor(programPath, folder); + + const configs = await provider.provideDebugConfigurations(folder); + + assert.deepStrictEqual(configs, []); + }); + + test('leaves launch config program unchanged when debug target resolution fails', async () => { + const programPath = path.join(tempDir, 'AppHost', 'Program.cs'); + const provider = new AspireDebugConfigurationProvider(createFailingAppHostDiscoveryService()); + + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: programPath + }); + + assert.strictEqual(config?.program, programPath); + }); + + function setActiveEditor(filePath: string, folder: vscode.WorkspaceFolder): void { + sandbox.stub(vscode.window, 'activeTextEditor').value({ + document: { + uri: vscode.Uri.file(filePath), + }, + }); + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').returns(folder); + } +}); + +function createWorkspaceFolder(folderPath: string): vscode.WorkspaceFolder { + return { + uri: vscode.Uri.file(folderPath), + name: 'workspace', + index: 0, + }; +} + +function createAppHostDiscoveryService(resolvedPath: string, candidatePath: string | null = resolvedPath, language = 'csharp'): AppHostDiscoveryService { + return { + resolveDebugTarget: async () => resolvedPath, + tryFindCandidateForEditorFile: async () => candidatePath ? { + path: candidatePath, + language: language, + status: 'buildable', + } : undefined, + } as unknown as AppHostDiscoveryService; +} + +function createFailingAppHostDiscoveryService(): AppHostDiscoveryService { + return { + resolveDebugTarget: async () => { + throw new Error('discovery failed'); + }, + tryFindCandidateForEditorFile: async () => { + throw new Error('discovery failed'); + }, + } as unknown as AppHostDiscoveryService; +} diff --git a/extension/src/test/aspireEditorCommandProvider.test.ts b/extension/src/test/aspireEditorCommandProvider.test.ts new file mode 100644 index 00000000000..bede4044c7b --- /dev/null +++ b/extension/src/test/aspireEditorCommandProvider.test.ts @@ -0,0 +1,165 @@ +/// + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; +import { AppHostDiscoveryService } from '../utils/appHostDiscovery'; + +function createEditor(filePath: string): vscode.TextEditor { + return { + document: { + uri: vscode.Uri.file(filePath), + fileName: filePath, + languageId: filePath.endsWith('.ts') ? 'typescript' : 'csharp' + } as vscode.TextDocument + } as vscode.TextEditor; +} + +suite('AspireEditorCommandProvider', () => { + let tempDir: string; + let activeEditor: vscode.TextEditor | undefined; + let activeEditorStub: sinon.SinonStub; + let workspaceFoldersStub: sinon.SinonStub; + let getWorkspaceFolderStub: sinon.SinonStub; + let onDidChangeWorkspaceFoldersStub: sinon.SinonStub; + let onDidChangeActiveTextEditorStub: sinon.SinonStub; + let executeCommandStub: sinon.SinonStub; + + setup(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-editor-command-provider-')); + activeEditor = undefined; + + activeEditorStub = sinon.stub(vscode.window, 'activeTextEditor').get(() => activeEditor); + workspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').value(undefined); + getWorkspaceFolderStub = sinon.stub(vscode.workspace, 'getWorkspaceFolder').callsFake((uri: vscode.Uri) => { + if (uri.fsPath.startsWith(tempDir)) { + return { uri: vscode.Uri.file(tempDir), name: 'test', index: 0 }; + } + + return undefined; + }); + onDidChangeWorkspaceFoldersStub = sinon.stub(vscode.workspace, 'onDidChangeWorkspaceFolders').returns({ dispose: () => { } } as vscode.Disposable); + onDidChangeActiveTextEditorStub = sinon.stub(vscode.window, 'onDidChangeActiveTextEditor').returns({ dispose: () => { } } as vscode.Disposable); + executeCommandStub = sinon.stub(vscode.commands, 'executeCommand').resolves(undefined); + }); + + teardown(() => { + executeCommandStub.restore(); + onDidChangeActiveTextEditorStub.restore(); + onDidChangeWorkspaceFoldersStub.restore(); + getWorkspaceFolderStub.restore(); + workspaceFoldersStub.restore(); + activeEditorStub.restore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('returns containing project file when active editor is SDK-style AppHost Program.cs', async () => { + const appHostDirectory = path.join(tempDir, 'AppHost'); + fs.mkdirSync(appHostDirectory); + + const programPath = path.join(appHostDirectory, 'Program.cs'); + const projectPath = path.join(appHostDirectory, 'AppHost.csproj'); + fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);\nbuilder.Build().Run();'); + fs.writeFileSync(projectPath, ''); + activeEditor = createEditor(programPath); + + const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(projectPath)); + try { + assert.strictEqual(await provider.getAppHostPath(), projectPath); + } + finally { + provider.dispose(); + } + }); + + test('returns source file when active editor is single-file apphost.cs', async () => { + const appHostPath = path.join(tempDir, 'apphost.cs'); + fs.writeFileSync(appHostPath, '#:sdk Aspire.AppHost.Sdk\nvar builder = DistributedApplication.CreateBuilder(args);'); + activeEditor = createEditor(appHostPath); + + const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath)); + try { + assert.strictEqual(await provider.getAppHostPath(), appHostPath); + } + finally { + provider.dispose(); + } + }); + + test('returns source file when active editor is TypeScript apphost.ts', async () => { + const appHostPath = path.join(tempDir, 'apphost.ts'); + fs.writeFileSync(appHostPath, 'import { createBuilder } from "./.aspire/modules/aspire";'); + activeEditor = createEditor(appHostPath); + + const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath, 'typescript/nodejs')); + try { + assert.strictEqual(await provider.getAppHostPath(), appHostPath); + } + finally { + provider.dispose(); + } + }); + + test('clears AppHost contexts when discovery fails while processing document', async () => { + const programPath = path.join(tempDir, 'Program.cs'); + fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);'); + activeEditor = createEditor(programPath); + + const provider = new AspireEditorCommandProvider(createFailingAppHostDiscoveryService()); + try { + await provider.processDocument(activeEditor.document); + + assert.ok(executeCommandStub.calledWith('setContext', 'aspire.fileIsAppHost', false)); + assert.ok(executeCommandStub.calledWith('setContext', 'aspire.workspaceHasAppHost', false)); + } + finally { + provider.dispose(); + } + }); + + test('returns null when discovery fails while resolving AppHost path', async () => { + const programPath = path.join(tempDir, 'Program.cs'); + fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);'); + activeEditor = createEditor(programPath); + + const provider = new AspireEditorCommandProvider(createFailingAppHostDiscoveryService()); + try { + assert.strictEqual(await provider.getAppHostPath(), null); + } + finally { + provider.dispose(); + } + }); +}); + +function createAppHostDiscoveryService(resolvedPath: string, language = 'csharp'): AppHostDiscoveryService { + return { + onDidChangeCandidates: () => ({ dispose: () => { } }), + tryFindCandidateForEditorFile: async () => ({ + path: resolvedPath, + language: language, + status: 'buildable', + }), + discover: async () => [{ + path: resolvedPath, + language: language, + status: 'buildable', + }], + } as unknown as AppHostDiscoveryService; +} + +function createFailingAppHostDiscoveryService(): AppHostDiscoveryService { + return { + onDidChangeCandidates: () => ({ dispose: () => { } }), + tryFindCandidateForEditorFile: async () => { + throw new Error('discovery failed'); + }, + discover: async () => { + throw new Error('discovery failed'); + }, + } as unknown as AppHostDiscoveryService; +} diff --git a/extension/src/test/workspace.test.ts b/extension/src/test/workspace.test.ts index 691e13239c6..3c4bdbeb821 100644 --- a/extension/src/test/workspace.test.ts +++ b/extension/src/test/workspace.test.ts @@ -1,15 +1,12 @@ import * as assert from 'assert'; -import type { ChildProcessWithoutNullStreams } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; -import * as cliModule from '../debugger/languages/cli'; -import type { SpawnProcessOptions } from '../debugger/languages/cli'; -import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { yesLabel } from '../loc/strings'; -import { checkForExistingAppHostPathInWorkspace, findAppHostsWithAspireLs, getCommonExcludeGlob, findAspireSettingsFiles } from '../utils/workspace'; +import { checkForExistingAppHostPathInWorkspace, getCommonExcludeGlob, findAspireSettingsFiles } from '../utils/workspace'; +import { AppHostDiscoveryService, getWorkspaceAppHostProjectSearchResult } from '../utils/appHostDiscovery'; suite('utils/workspace tests', () => { let sandbox: sinon.SinonSandbox; @@ -79,11 +76,6 @@ suite('utils/workspace tests', () => { }); test('AppHost selection quick pick shows aspire ls language and status metadata', async () => { - const terminalProvider = { - getAspireCliExecutablePath: async () => 'aspire', - createEnvironment: () => ({}), - } as unknown as AspireTerminalProvider; - let spawnOptions: SpawnProcessOptions | undefined; sandbox.stub(vscode.workspace, 'workspaceFolders').value([{ uri: vscode.Uri.file('/workspace'), name: 'workspace', @@ -92,30 +84,20 @@ suite('utils/workspace tests', () => { sandbox.stub(vscode.workspace, 'findFiles').resolves([]); sandbox.stub(vscode.window, 'showInformationMessage').resolves(yesLabel as never); const showQuickPickStub = sandbox.stub(vscode.window, 'showQuickPick').resolves(undefined); - sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { - spawnOptions = options; - return { kill: () => true } as ChildProcessWithoutNullStreams; - }); - - const disposable = await checkForExistingAppHostPathInWorkspace(terminalProvider, () => true, async () => { }); - assert.ok(spawnOptions); - assert.ok(spawnOptions.stdoutCallback); - assert.ok(spawnOptions.exitCallback); - spawnOptions.stdoutCallback(JSON.stringify([ + const appHostDiscoveryService = createAppHostDiscoveryService([ { - relativePath: 'apps/Store/AppHost.csproj', path: '/workspace/apps/Store/AppHost.csproj', language: 'csharp', status: 'buildable', }, { - relativePath: 'samples/Store/AppHost.csproj', path: '/workspace/samples/Store/AppHost.csproj', language: 'typescript/nodejs', status: 'possibly-unbuildable', }, - ])); - spawnOptions.exitCallback(0); + ]); + + const disposable = await checkForExistingAppHostPathInWorkspace(appHostDiscoveryService, () => true, async () => { }); await waitForStubCall(showQuickPickStub); const items = showQuickPickStub.getCall(0).args[0] as readonly vscode.QuickPickItem[]; @@ -125,12 +107,12 @@ suite('utils/workspace tests', () => { detail: item.detail, })), [ { - label: 'apps/Store/AppHost.csproj', + label: path.join('apps', 'Store', 'AppHost.csproj'), description: 'C# · buildable', detail: '/workspace/apps/Store/AppHost.csproj', }, { - label: 'samples/Store/AppHost.csproj', + label: path.join('samples', 'Store', 'AppHost.csproj'), description: 'TypeScript · possibly-unbuildable', detail: '/workspace/samples/Store/AppHost.csproj', }, @@ -146,20 +128,15 @@ suite('utils/workspace tests', () => { const secondDiscoveredAppHostPath = path.join(workspaceRoot, 'samples', 'Store', 'AppHost.csproj'); try { - fs.writeFileSync(path.join(workspaceRoot, 'aspire.config.json'), JSON.stringify({ + const configPath = path.join(workspaceRoot, 'aspire.config.json'); + fs.writeFileSync(configPath, JSON.stringify({ appHost: { path: configuredAppHostPath, }, })); - - const terminalProvider = { - getAspireCliExecutablePath: async () => 'aspire', - createEnvironment: () => ({}), - } as unknown as AspireTerminalProvider; - let spawnOptions: SpawnProcessOptions | undefined; - sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { - spawnOptions = options; - return { kill: () => true } as ChildProcessWithoutNullStreams; + sandbox.stub(vscode.workspace, 'findFiles').callsFake(async (include) => { + const pattern = typeof include === 'string' ? include : include.pattern; + return pattern.includes('aspire.config.json') ? [vscode.Uri.file(configPath)] : []; }); const rootFolder = { @@ -167,28 +144,24 @@ suite('utils/workspace tests', () => { name: 'workspace', index: 0, }; - const discovery = findAppHostsWithAspireLs(terminalProvider, 'aspire', rootFolder); - - assert.ok(spawnOptions); - assert.ok(spawnOptions.stdoutCallback); - assert.ok(spawnOptions.exitCallback); - spawnOptions.stdoutCallback(JSON.stringify([ + const result = await getWorkspaceAppHostProjectSearchResult(rootFolder, [ { - relativePath: 'apps/Store/AppHost.csproj', path: discoveredAppHostPath, language: 'csharp', status: 'buildable', }, { - relativePath: 'samples/Store/AppHost.csproj', path: secondDiscoveredAppHostPath, language: 'csharp', status: 'buildable', }, - ])); - spawnOptions.exitCallback(0); - - const result = await discovery.result; + { + path: configuredAppHostPath, + language: null, + status: 'buildable', + selected: true, + }, + ]); assert.strictEqual(result.selected_project_file, configuredAppHostPath); assert.deepStrictEqual(result.all_project_file_candidates, [ @@ -230,3 +203,10 @@ async function waitForStubCall(stub: sinon.SinonStub): Promise { assert.ok(stub.called); } + +function createAppHostDiscoveryService(candidates: Awaited>): AppHostDiscoveryService { + return { + onDidChangeCandidates: () => ({ dispose: () => { } }), + discover: async () => candidates, + } as unknown as AppHostDiscoveryService; +} diff --git a/extension/src/utils/appHostDiscovery.ts b/extension/src/utils/appHostDiscovery.ts new file mode 100644 index 00000000000..dc4a135c0b7 --- /dev/null +++ b/extension/src/utils/appHostDiscovery.ts @@ -0,0 +1,635 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import * as vscode from 'vscode'; +import type { ChildProcessWithoutNullStreams } from 'child_process'; +import { spawnCliProcess } from '../debugger/languages/cli'; +import { AspireTerminalProvider } from './AspireTerminalProvider'; +import { aspireConfigFileName, getAppHostPathFromConfig, readJsonFile } from './cliTypes'; +import { EnvironmentVariables } from './environment'; +import { extensionLogOutputChannel } from './logging'; + +// Mirrors the `aspire ls --format json` candidate shape documented in +// docs/specs/cli-output-formats.md. Older CLI fallback results are adapted into +// this shape so extension code can keep using the modern discovery contract. +export interface CandidateAppHostDisplayInfo { + path: string; + language: string | null; + status: string | null; + selected?: boolean; +} + +export interface AppHostCandidate { + relativePath: string; + path: string; + language: string; + status: string; +} + +export interface AppHostProjectSearchResult { + selected_project_file: string | null; + all_project_file_candidates: string[]; + app_host_candidates: AppHostCandidate[]; +} + +interface LegacyAppHostProjectSearchResult { + selected_project_file: string | null; + all_project_file_candidates: string[]; +} + +const discoveryExcludePattern = '{**/artifacts/**,**/[Bb]in/**,**/[Oo]bj/**,**/node_modules/**,**/.git/**,**/.vs/**,**/.vscode-test/**,**/.idea/**,**/.aspire/modules/**}'; + +export class AppHostDiscoveryService implements vscode.Disposable { + private static readonly _cliDiscoveryTimeoutMs = 30_000; + + private readonly _onDidChangeCandidates = new vscode.EventEmitter(); + private readonly _cache = new Map>(); + private readonly _watchers = new Map(); + private readonly _activeCliProcesses = new Set(); + private readonly _cancelActiveCliProcesses = new Set<(error: Error) => void>(); + private _disposed = false; + readonly onDidChangeCandidates = this._onDidChangeCandidates.event; + + constructor(private readonly _terminalProvider: AspireTerminalProvider) { + } + + async discover(workspaceFolder: vscode.WorkspaceFolder, forceRefresh = false): Promise { + this._throwIfDisposed(); + + const key = path.resolve(workspaceFolder.uri.fsPath); + if (forceRefresh) { + this._cache.delete(key); + } + + this._ensureWatchers(workspaceFolder, key); + + let resultPromise = this._cache.get(key); + if (!resultPromise) { + resultPromise = this._discoverCore(workspaceFolder) + .then(candidates => this._includeConfiguredAppHostCandidate(workspaceFolder, candidates)) + .catch(error => { + this._cache.delete(key); + throw error; + }); + this._cache.set(key, resultPromise); + } + + return resultPromise; + } + + async resolveDebugTarget(filePath: string, workspaceFolder?: vscode.WorkspaceFolder): Promise { + return await this.tryResolveDebugTarget(filePath, workspaceFolder) ?? filePath; + } + + async tryResolveDebugTarget(filePath: string, workspaceFolder?: vscode.WorkspaceFolder): Promise { + const candidate = await this.tryFindCandidateForEditorFile(filePath, workspaceFolder); + return candidate ? getDebugTargetForCandidate(candidate) : undefined; + } + + async tryFindCandidateForEditorFile(filePath: string, workspaceFolder?: vscode.WorkspaceFolder): Promise { + const folder = workspaceFolder ?? vscode.workspace.getWorkspaceFolder(vscode.Uri.file(filePath)); + if (!folder) { + return undefined; + } + + const result = await this.discover(folder); + return findCandidateForEditorFile(filePath, result); + } + + dispose(): void { + if (this._disposed) { + return; + } + + this._disposed = true; + for (const disposables of this._watchers.values()) { + disposables.forEach(disposable => disposable.dispose()); + } + this._watchers.clear(); + this._cache.clear(); + for (const cancel of [...this._cancelActiveCliProcesses]) { + cancel(new Error('AppHost discovery service was disposed.')); + } + this._cancelActiveCliProcesses.clear(); + this._activeCliProcesses.clear(); + this._onDidChangeCandidates.dispose(); + } + + private async _discoverCore(workspaceFolder: vscode.WorkspaceFolder): Promise { + try { + const appHosts = await this._discoverWithLs(workspaceFolder); + extensionLogOutputChannel.info(`Discovered ${appHosts.length} AppHost candidate(s) via aspire ls`); + return appHosts; + } + catch (error) { + this._throwIfDisposed(); + extensionLogOutputChannel.warn(`aspire ls discovery failed, falling back to aspire extension get-apphosts: ${error}`); + const appHosts = await this._discoverWithLegacyGetAppHosts(workspaceFolder); + extensionLogOutputChannel.info(`Discovered ${appHosts.length} AppHost candidate(s) via aspire extension get-apphosts`); + return appHosts; + } + } + + private async _discoverWithLs(workspaceFolder: vscode.WorkspaceFolder): Promise { + this._throwIfDisposed(); + + const cliPath = await this._terminalProvider.getAspireCliExecutablePath(); + const args = ['ls', '--format', 'json']; + if (process.env[EnvironmentVariables.ASPIRE_CLI_STOP_ON_ENTRY] === 'true') { + args.push('--cli-wait-for-debugger'); + } + + const output = await this._runCliForStdout(cliPath, args, workspaceFolder.uri.fsPath); + return parseCandidateOutput(output, 'aspire ls'); + } + + private async _discoverWithLegacyGetAppHosts(workspaceFolder: vscode.WorkspaceFolder): Promise { + this._throwIfDisposed(); + + const cliPath = await this._terminalProvider.getAspireCliExecutablePath(); + const args = ['extension', 'get-apphosts']; + if (process.env[EnvironmentVariables.ASPIRE_CLI_STOP_ON_ENTRY] === 'true') { + args.push('--cli-wait-for-debugger'); + } + + const output = await this._runCliForStdout(cliPath, args, workspaceFolder.uri.fsPath); + const parsed = parseLegacyGetAppHostsOutput(output); + return toCandidatesFromLegacySearchResult(parsed); + } + + private _ensureWatchers(workspaceFolder: vscode.WorkspaceFolder, key: string): void { + if (this._watchers.has(key)) { + return; + } + + const invalidate = (uri: vscode.Uri) => { + if (isExcludedDiscoveryUri(workspaceFolder, uri)) { + return; + } + + this._cache.delete(key); + this._onDidChangeCandidates.fire(workspaceFolder); + }; + const patterns = [ + '**/*.csproj', + '**/*.fsproj', + '**/*.vbproj', + '**/apphost.cs', + '**/apphost.ts', + '**/apphost.js', + '**/apphost.mts', + '**/apphost.mjs', + `**/${aspireConfigFileName}`, + '**/.aspire/settings.json', + ]; + + const watchers = patterns.map(pattern => { + const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(workspaceFolder, pattern)); + watcher.onDidCreate(uri => invalidate(uri)); + watcher.onDidChange(uri => invalidate(uri)); + watcher.onDidDelete(uri => invalidate(uri)); + return watcher; + }); + this._watchers.set(key, watchers); + } + + private _throwIfDisposed(): void { + if (this._disposed) { + throw new Error('AppHost discovery service has been disposed.'); + } + } + + private async _includeConfiguredAppHostCandidate(workspaceFolder: vscode.WorkspaceFolder, candidates: CandidateAppHostDisplayInfo[]): Promise { + if (candidates.some(candidate => candidate.selected)) { + return candidates; + } + + const configuredPath = await findRootConfiguredAppHostPath(workspaceFolder); + if (!configuredPath) { + return candidates; + } + + const matchingCandidate = candidates.find(candidate => isSamePath(candidate.path, configuredPath)); + if (matchingCandidate) { + return candidates.map(candidate => ({ + ...candidate, + selected: isSamePath(candidate.path, configuredPath), + })); + } + + return [ + ...candidates, + { + path: configuredPath, + language: null, + status: 'buildable', + selected: true, + }, + ]; + } + + private _runCliForStdout(cliPath: string, args: string[], workingDirectory: string): Promise { + return new Promise((resolve, reject) => { + this._throwIfDisposed(); + + let stdout = ''; + let stderr = ''; + let settled = false; + let childProcess: ChildProcessWithoutNullStreams | undefined; + let timeout: ReturnType | undefined; + const cancel = (error: Error) => { + if (childProcess && !childProcess.killed) { + try { + if (!childProcess.kill()) { + extensionLogOutputChannel.warn(`Failed to stop AppHost discovery command: aspire ${args.join(' ')}`); + } + } + catch (killError) { + extensionLogOutputChannel.warn(`Failed to stop AppHost discovery command: ${killError}`); + } + } + + settle(() => reject(error)); + }; + const cleanup = () => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + if (childProcess) { + this._activeCliProcesses.delete(childProcess); + } + this._cancelActiveCliProcesses.delete(cancel); + }; + const settle = (complete: () => void) => { + if (settled) { + return; + } + + settled = true; + cleanup(); + complete(); + }; + + this._cancelActiveCliProcesses.add(cancel); + try { + childProcess = spawnCliProcess(this._terminalProvider, cliPath, args, { + noExtensionVariables: true, + workingDirectory, + stdoutCallback: data => { stdout += data; }, + stderrCallback: data => { stderr += data; }, + exitCallback: code => { + settle(() => { + if (code === 0) { + resolve(stdout); + } + else { + reject(new Error(stderr || `exit code ${code ?? 1}`)); + } + }); + }, + errorCallback: error => { + settle(() => reject(error)); + }, + }); + } + catch (error) { + settle(() => reject(error instanceof Error ? error : new Error(String(error)))); + return; + } + + if (settled) { + return; + } + + this._activeCliProcesses.add(childProcess); + timeout = setTimeout(() => { + cancel(new Error(`aspire ${args.join(' ')} timed out after ${AppHostDiscoveryService._cliDiscoveryTimeoutMs / 1000} seconds.`)); + }, AppHostDiscoveryService._cliDiscoveryTimeoutMs); + }); + } +} + +export function findCandidateForEditorFile(filePath: string, candidates: readonly CandidateAppHostDisplayInfo[]): CandidateAppHostDisplayInfo | undefined { + const matchingCandidate = candidates.find(candidate => isSamePath(candidate.path, filePath)); + if (matchingCandidate) { + return matchingCandidate; + } + + if (path.extname(filePath).toLowerCase() !== '.cs') { + return undefined; + } + + // IMPORTANT: `aspire ls` is still the source of truth for what is a valid AppHost. + // This block does not discover AppHosts by reading C# source files or by deciding + // that a project "looks like" an AppHost. It only handles the editor affordance gap + // in the current CLI shape: + // + // aspire ls --format json + // [ + // { "path": "/repo/AppHost/AppHost.csproj", "language": "csharp", "status": "buildable" } + // ] + // + // For SDK-style .NET AppHosts the launch target is the `.csproj`, but users usually + // have `Program.cs` or another C# source file open when they invoke Run/Debug from + // the editor or debug picker. Until the CLI returns source identity/project membership + // in the candidate payload, treat C# files under a candidate `.csproj` directory as + // editor aliases for that candidate. Pick the deepest candidate directory so nested + // AppHost candidates prefer their own project over an outer candidate. Keep this + // heuristic bounded to C# project candidates from `aspire ls` and remove it when the + // CLI can report the canonical source file or owning project for each candidate. + const projectCandidate = candidates + .filter(candidate => isCSharpProjectCandidate(candidate) && isCSharpSourceFileForProjectCandidate(filePath, candidate.path)) + .sort((left, right) => path.dirname(right.path).length - path.dirname(left.path).length)[0]; + return projectCandidate; +} + +export function getDebugTargetForCandidate(candidate: CandidateAppHostDisplayInfo): string { + return candidate.path; +} + +export function getWorkspaceAppHostProjectSearchResult(workspaceFolder: vscode.WorkspaceFolder, candidates: readonly CandidateAppHostDisplayInfo[]): AppHostProjectSearchResult { + const appHostCandidates = candidates.map(candidate => toAppHostCandidate(workspaceFolder, candidate)); + const selectedAppHostPath = candidates.find(candidate => candidate.selected)?.path + ?? (candidates.length === 1 ? candidates[0].path : null); + const effectiveAppHostCandidates = selectedAppHostPath && !appHostCandidates.some(candidate => isSamePath(candidate.path, selectedAppHostPath)) + ? [...appHostCandidates, toConfiguredAppHostCandidate(workspaceFolder, selectedAppHostPath)] + : appHostCandidates; + const buildableCandidates = effectiveAppHostCandidates.filter(isBuildableAppHostCandidate); + + return { + selected_project_file: selectedAppHostPath && buildableCandidates.some(candidate => isSamePath(candidate.path, selectedAppHostPath)) + ? selectedAppHostPath + : null, + all_project_file_candidates: buildableCandidates.map(candidate => candidate.path), + app_host_candidates: effectiveAppHostCandidates, + }; +} + +async function findRootConfiguredAppHostPath(workspaceFolder: vscode.WorkspaceFolder): Promise { + const configUris = [ + vscode.Uri.joinPath(workspaceFolder.uri, aspireConfigFileName), + vscode.Uri.joinPath(workspaceFolder.uri, '.aspire', 'settings.json'), + ]; + + for (const uri of configUris) { + if (!fs.existsSync(uri.fsPath)) { + continue; + } + + try { + const json = await readJsonFile(uri); + const appHostPath = getAppHostPathFromConfig(json); + if (appHostPath) { + return path.isAbsolute(appHostPath) + ? appHostPath + : path.join(path.dirname(uri.fsPath), appHostPath); + } + } + catch { + } + } + + return undefined; +} + +export function isBuildableAppHostCandidate(candidate: AppHostCandidate): boolean { + return candidate.status === 'buildable'; +} + +export function formatAppHostLanguage(language: string): string | undefined { + if (!language) { + return undefined; + } + + switch (language.toLowerCase()) { + case 'csharp': + return 'C#'; + case 'typescript': + case 'typescript/nodejs': + return 'TypeScript'; + default: + return language.charAt(0).toUpperCase() + language.slice(1); + } +} + +export async function selectWorkspaceAppHostPath(workspaceFolder: vscode.WorkspaceFolder, candidates: readonly CandidateAppHostDisplayInfo[]): Promise { + const selectedCandidate = candidates.find(candidate => candidate.selected); + if (selectedCandidate) { + return selectedCandidate.path; + } + + const configuredPaths = await findConfiguredAppHostPaths(workspaceFolder); + for (const configuredPath of configuredPaths) { + const candidate = candidates.find(candidate => isSamePath(candidate.path, configuredPath)); + if (candidate) { + return candidate.path; + } + + return configuredPath; + } + + return candidates.length === 1 ? candidates[0].path : undefined; +} + +export async function findConfiguredAppHostPaths(workspaceFolder: vscode.WorkspaceFolder): Promise { + const [newConfigFiles, legacySettingsFiles] = await Promise.all([ + vscode.workspace.findFiles(new vscode.RelativePattern(workspaceFolder, `**/${aspireConfigFileName}`), discoveryExcludePattern), + vscode.workspace.findFiles(new vscode.RelativePattern(workspaceFolder, '**/.aspire/settings.json'), discoveryExcludePattern), + ]); + + const newConfigDirs = new Set(newConfigFiles.map(uri => path.dirname(uri.fsPath))); + const filteredLegacyFiles = legacySettingsFiles.filter(legacyUri => { + const projectRoot = path.dirname(path.dirname(legacyUri.fsPath)); + return !newConfigDirs.has(projectRoot); + }); + + const configuredPaths: string[] = []; + for (const uri of [...newConfigFiles, ...filteredLegacyFiles]) { + try { + const json = await readJsonFile(uri); + const appHostPath = getAppHostPathFromConfig(json); + if (appHostPath) { + configuredPaths.push(path.isAbsolute(appHostPath) ? appHostPath : path.join(path.dirname(uri.fsPath), appHostPath)); + } + } + catch { + } + } + + return configuredPaths; +} + +function isExcludedDiscoveryUri(workspaceFolder: vscode.WorkspaceFolder, uri: vscode.Uri): boolean { + const relativePath = path.relative(workspaceFolder.uri.fsPath, uri.fsPath); + if (relativePath === '' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return true; + } + + const segments = relativePath.split(/[\\/]+/); + return segments.some((segment, index) => { + const lowerSegment = segment.toLowerCase(); + return lowerSegment === 'artifacts' + || lowerSegment === 'bin' + || lowerSegment === 'obj' + || lowerSegment === 'node_modules' + || lowerSegment === '.git' + || lowerSegment === '.vs' + || lowerSegment === '.vscode-test' + || lowerSegment === '.idea' + || (lowerSegment === '.aspire' && segments[index + 1]?.toLowerCase() === 'modules'); + }); +} + +function toAppHostCandidate(workspaceFolder: vscode.WorkspaceFolder, candidate: CandidateAppHostDisplayInfo): AppHostCandidate { + return { + relativePath: path.relative(workspaceFolder.uri.fsPath, candidate.path), + path: candidate.path, + language: candidate.language ?? '', + status: candidate.status ?? 'buildable', + }; +} + +function toConfiguredAppHostCandidate(workspaceFolder: vscode.WorkspaceFolder, appHostPath: string): AppHostCandidate { + return { + relativePath: path.relative(workspaceFolder.uri.fsPath, appHostPath), + path: appHostPath, + language: '', + status: 'buildable', + }; +} + +function parseCandidateOutput(output: string, commandName: string): CandidateAppHostDisplayInfo[] { + const trimmed = output.trim(); + if (!trimmed) { + return []; + } + + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + const appHosts = parsed + .filter(isLsCandidate) + .map(candidate => ({ + path: candidate.path, + language: candidate.language, + status: candidate.status, + })); + + if (appHosts.length !== parsed.length) { + throw new Error(`${commandName} returned an unexpected candidate shape.`); + } + + return appHosts; + } + + if (isAppHostProjectSearchResult(parsed)) { + return parsed.app_host_candidates.map(candidate => ({ + path: candidate.path, + language: candidate.language, + status: candidate.status, + selected: typeof parsed.selected_project_file === 'string' && isSamePath(parsed.selected_project_file, candidate.path), + })); + } + + if (isLegacyAppHostProjectSearchResult(parsed)) { + return toCandidatesFromLegacySearchResult(parsed); + } + + throw new Error(`${commandName} returned an unexpected output shape.`); +} + +function parseLegacyGetAppHostsOutput(output: string): LegacyAppHostProjectSearchResult { + // `aspire extension get-apphosts` prints a single JSON object: + // {"selected_project_file":"/repo/AppHost/AppHost.csproj","all_project_file_candidates":["/repo/AppHost/AppHost.csproj"]} + // Older builds can include log lines, so scan for the first line with the expected shape. + for (const line of output.split(/\r?\n/)) { + try { + const parsed = JSON.parse(line); + if (isLegacyAppHostProjectSearchResult(parsed)) { + return parsed; + } + } + catch { + } + } + + const parsed = JSON.parse(output.trim()); + if (isLegacyAppHostProjectSearchResult(parsed)) { + return parsed; + } + + throw new Error('aspire extension get-apphosts returned an unexpected output shape.'); +} + +function isLsCandidate(obj: unknown): obj is CandidateAppHostDisplayInfo { + return !!obj + && typeof obj === 'object' + && typeof (obj as CandidateAppHostDisplayInfo).path === 'string' + && typeof (obj as CandidateAppHostDisplayInfo).language === 'string' + && typeof (obj as CandidateAppHostDisplayInfo).status === 'string'; +} + +function isLegacyAppHostProjectSearchResult(obj: unknown): obj is LegacyAppHostProjectSearchResult { + return !!obj + && typeof obj === 'object' + && (typeof (obj as LegacyAppHostProjectSearchResult).selected_project_file === 'string' || (obj as LegacyAppHostProjectSearchResult).selected_project_file === null) + && Array.isArray((obj as LegacyAppHostProjectSearchResult).all_project_file_candidates); +} + +function isAppHostProjectSearchResult(obj: unknown): obj is AppHostProjectSearchResult { + return !!obj + && typeof obj === 'object' + && (typeof (obj as AppHostProjectSearchResult).selected_project_file === 'string' || (obj as AppHostProjectSearchResult).selected_project_file === null) + && Array.isArray((obj as AppHostProjectSearchResult).app_host_candidates) + && (obj as AppHostProjectSearchResult).app_host_candidates.every(candidate => + candidate + && typeof candidate.relativePath === 'string' + && typeof candidate.path === 'string' + && typeof candidate.language === 'string' + && typeof candidate.status === 'string'); +} + +function toCandidatesFromLegacySearchResult(parsed: LegacyAppHostProjectSearchResult): CandidateAppHostDisplayInfo[] { + return parsed.all_project_file_candidates.filter(candidate => typeof candidate === 'string').map(candidatePath => ({ + path: candidatePath, + language: null, + status: null, + selected: typeof parsed.selected_project_file === 'string' && isSamePath(parsed.selected_project_file, candidatePath), + })); +} + +function isCSharpProjectCandidate(candidate: CandidateAppHostDisplayInfo): boolean { + // Only `.csproj` candidates can own nearby C# source files for the editor alias + // heuristic above. Modern `aspire ls` candidates include the CLI language id + // (`language: "csharp"`); legacy `aspire extension get-apphosts` fallback + // candidates do not have a language, so `null` is treated as C# here to + // preserve old CLI support while keeping the compatibility gap local to + // candidate adaptation/matching. + return path.extname(candidate.path).toLowerCase() === '.csproj' + && (candidate.language === null || candidate.language.toLowerCase() === 'csharp'); +} + +function isCSharpSourceFileForProjectCandidate(filePath: string, projectPath: string): boolean { + const projectDirectory = path.dirname(path.resolve(projectPath)); + const sourcePath = path.resolve(filePath); + const comparison = process.platform === 'win32' || process.platform === 'darwin' + ? 'case-insensitive' + : 'case-sensitive'; + const normalizedProjectDirectory = comparison === 'case-insensitive' ? projectDirectory.toLowerCase() : projectDirectory; + const normalizedSourcePath = comparison === 'case-insensitive' ? sourcePath.toLowerCase() : sourcePath; + const relativePath = path.relative(normalizedProjectDirectory, normalizedSourcePath); + return relativePath !== '' + && !relativePath.startsWith('..') + && !path.isAbsolute(relativePath) + && !relativePath.split(path.sep).some(segment => segment.toLowerCase() === 'bin' || segment.toLowerCase() === 'obj'); +} + +function isSamePath(left: string, right: string): boolean { + const comparison = process.platform === 'win32' || process.platform === 'darwin' + ? 'case-insensitive' + : 'case-sensitive'; + const resolvedLeft = path.resolve(left); + const resolvedRight = path.resolve(right); + return comparison === 'case-insensitive' + ? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase() + : resolvedLeft === resolvedRight; +} diff --git a/extension/src/utils/cliTypes.ts b/extension/src/utils/cliTypes.ts index 861a788cb47..ff2185b91e3 100644 --- a/extension/src/utils/cliTypes.ts +++ b/extension/src/utils/cliTypes.ts @@ -7,7 +7,7 @@ import { stripComments } from 'jsonc-parser'; */ export async function readJsonFile(uri: vscode.Uri): Promise { const buffer = await vscode.workspace.fs.readFile(uri); - const raw = buffer.toString(); + const raw = Buffer.from(buffer).toString('utf8'); return JSON.parse(stripComments(raw)); } diff --git a/extension/src/utils/workspace.ts b/extension/src/utils/workspace.ts index 87e7edc6216..fa7c4cb0db2 100644 --- a/extension/src/utils/workspace.ts +++ b/extension/src/utils/workspace.ts @@ -1,13 +1,11 @@ import * as vscode from 'vscode'; import { appHostCandidateDescription, cliNotAvailable, cliFoundAtDefaultPath, dismissLabel, dontShowAgainLabel, doYouWantToSetDefaultApphost, noLabel, noWorkspaceOpen, openCliInstallInstructions, selectDefaultLaunchApphost, yesLabel } from '../loc/strings'; import path from 'path'; -import { spawnCliProcess } from '../debugger/languages/cli'; -import { AspireTerminalProvider } from './AspireTerminalProvider'; -import { ChildProcessWithoutNullStreams } from 'child_process'; import { AspireConfigFile, aspireConfigFileName, getAppHostPathFromConfig, readJsonFile } from './cliTypes'; import { extensionLogOutputChannel } from './logging'; -import { EnvironmentVariables } from './environment'; import { resolveCliPath } from './cliPath'; +import { AppHostDiscoveryService, AppHostProjectSearchResult, formatAppHostLanguage, getWorkspaceAppHostProjectSearchResult } from './appHostDiscovery'; +import type { AppHostCandidate } from './appHostDiscovery'; /** * Common file patterns to exclude from workspace file searches. @@ -110,19 +108,6 @@ export function getRelativePathToWorkspace(filePath: string): string { return filePath; } -export interface AppHostCandidate { - relativePath: string; - path: string; - language: string; - status: string; -} - -export interface AppHostProjectSearchResult { - selected_project_file: string | null; - all_project_file_candidates: string[]; - app_host_candidates: AppHostCandidate[]; -} - interface AppHostQuickPickItem extends vscode.QuickPickItem { appHostPath: string; } @@ -131,132 +116,6 @@ export function isBuildableAppHostCandidate(candidate: AppHostCandidate): boolea return candidate.status === 'buildable'; } -function isAppHostCandidate(obj: any): obj is AppHostCandidate { - return obj - && typeof obj.relativePath === 'string' - && typeof obj.path === 'string' - && typeof obj.language === 'string' - && typeof obj.status === 'string'; -} - -interface ParsedAppHostCandidates { - candidates: AppHostCandidate[]; - selectedProjectFile: string | null; - isAspireLsOutput: boolean; -} - -function parseAppHostCandidates(stdout: string): ParsedAppHostCandidates { - const parsed = JSON.parse(stdout); - if (Array.isArray(parsed)) { - return { - candidates: parsed.filter(isAppHostCandidate), - selectedProjectFile: null, - isAspireLsOutput: true, - }; - } - - if (parsed - && (typeof parsed.selected_project_file === 'string' || parsed.selected_project_file === null) - && Array.isArray(parsed.all_project_file_candidates)) { - const candidates = parsed.all_project_file_candidates - .filter((appHostPath: unknown): appHostPath is string => typeof appHostPath === 'string') - .map((appHostPath: string) => ({ - relativePath: path.basename(appHostPath), - path: appHostPath, - language: '', - status: 'buildable', - })); - - return { - candidates, - selectedProjectFile: parsed.selected_project_file, - isAspireLsOutput: false, - }; - } - - return { - candidates: [], - selectedProjectFile: null, - isAspireLsOutput: true, - }; -} - -async function getConfiguredAppHostPathFromWorkspaceRoot(rootFolder: vscode.WorkspaceFolder): Promise { - const configUris = [ - vscode.Uri.joinPath(rootFolder.uri, aspireConfigFileName), - vscode.Uri.joinPath(rootFolder.uri, '.aspire', 'settings.json'), - ]; - - for (const uri of configUris) { - try { - const json = await readJsonFile(uri); - const appHostPath = getAppHostPathFromConfig(json); - if (!appHostPath) { - continue; - } - - const configDir = path.dirname(uri.fsPath); - return path.isAbsolute(appHostPath) - ? appHostPath - : path.join(configDir, appHostPath); - } catch { - // Missing or invalid settings files do not block AppHost discovery. - } - } - - return null; -} - -function createAppHostProjectSearchResult(appHostCandidates: AppHostCandidate[], selectedProjectFile: string | null, rootFolder: vscode.WorkspaceFolder): AppHostProjectSearchResult { - const effectiveAppHostCandidates = selectedProjectFile && !appHostCandidates.some(candidate => isSamePath(candidate.path, selectedProjectFile)) - ? [...appHostCandidates, createConfiguredAppHostCandidate(selectedProjectFile, rootFolder)] - : appHostCandidates; - const buildableCandidates = effectiveAppHostCandidates.filter(isBuildableAppHostCandidate); - const allProjectFileCandidates = buildableCandidates.map(candidate => candidate.path); - const selectedCandidate = selectedProjectFile && buildableCandidates.some(candidate => isSamePath(candidate.path, selectedProjectFile)) - ? selectedProjectFile - : null; - - return { - selected_project_file: selectedCandidate, - all_project_file_candidates: allProjectFileCandidates, - app_host_candidates: effectiveAppHostCandidates, - }; -} - -function createConfiguredAppHostCandidate(appHostPath: string, rootFolder: vscode.WorkspaceFolder): AppHostCandidate { - return { - relativePath: path.relative(rootFolder.uri.fsPath, appHostPath), - path: appHostPath, - language: '', - status: 'buildable', - }; -} - -function isSamePath(left: string, right: string): boolean { - const normalizedLeft = path.normalize(left); - const normalizedRight = path.normalize(right); - return process.platform === 'win32' - ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() - : normalizedLeft === normalizedRight; -} - -export function formatAppHostLanguage(language: string): string | undefined { - if (!language) { - return undefined; - } - - switch (language.toLowerCase()) { - case 'csharp': - return 'C#'; - case 'typescript': - case 'typescript/nodejs': - return 'TypeScript'; - default: - return language.charAt(0).toUpperCase() + language.slice(1); - } -} - function createAppHostQuickPickItems(result: AppHostProjectSearchResult, rootFolder: vscode.WorkspaceFolder): AppHostQuickPickItem[] { const candidates = result.app_host_candidates.length > 0 ? result.app_host_candidates @@ -279,66 +138,7 @@ function createAppHostQuickPickItems(result: AppHostProjectSearchResult, rootFol }); } -export function findAppHostsWithAspireLs(terminalProvider: AspireTerminalProvider, cliPath: string, rootFolder: vscode.WorkspaceFolder): { process: ChildProcessWithoutNullStreams; result: Promise } { - let stdout = ''; - let stderr = ''; - const configuredAppHostPathPromise = getConfiguredAppHostPathFromWorkspaceRoot(rootFolder); - - const args = ['ls', '--format', 'json']; - if (process.env[EnvironmentVariables.ASPIRE_CLI_STOP_ON_ENTRY] === 'true') { - args.push('--cli-wait-for-debugger'); - } - - let proc: ChildProcessWithoutNullStreams; - const result = new Promise((resolve, reject) => { - let settled = false; - proc = spawnCliProcess(terminalProvider, cliPath, args, { - errorCallback: error => { - settled = true; - extensionLogOutputChannel.error(`Error executing aspire ls command: ${error}`); - reject(error); - }, - exitCallback: async code => { - if (settled) { - return; - } - - if (code !== 0) { - settled = true; - extensionLogOutputChannel.warn(`aspire ls command exited with code: ${code}`); - reject(new Error(stderr || `aspire ls exited with code ${code}`)); - return; - } - - try { - const parsed = parseAppHostCandidates(stdout); - const selectedProjectFile = parsed.isAspireLsOutput - ? await configuredAppHostPathPromise - : null; - const effectiveSelectedProjectFile = selectedProjectFile ?? parsed.selectedProjectFile; - extensionLogOutputChannel.info(`Found ${parsed.candidates.length} AppHost candidates with aspire ls`); - settled = true; - resolve(createAppHostProjectSearchResult(parsed.candidates, effectiveSelectedProjectFile, rootFolder)); - } catch (error) { - settled = true; - reject(error); - } - }, - stdoutCallback: data => { - stdout += data; - }, - stderrCallback: data => { - stderr += data; - }, - noExtensionVariables: true, - workingDirectory: rootFolder.uri.fsPath - }); - }); - - return { process: proc!, result }; -} - -export async function checkForExistingAppHostPathInWorkspace(terminalProvider: AspireTerminalProvider, getEnableSettingsFileCreationPromptOnStartup: () => boolean, setEnableSettingsFileCreationPromptOnStartup: (value: boolean) => Promise): Promise { +export async function checkForExistingAppHostPathInWorkspace(appHostDiscoveryService: AppHostDiscoveryService, getEnableSettingsFileCreationPromptOnStartup: () => boolean, setEnableSettingsFileCreationPromptOnStartup: (value: boolean) => Promise): Promise { extensionLogOutputChannel.info('Checking for existing AppHost path in workspace'); const enabled = getEnableSettingsFileCreationPromptOnStartup(); @@ -392,22 +192,17 @@ export async function checkForExistingAppHostPathInWorkspace(terminalProvider: A } const settingsFile = settingsFiles[0]; - extensionLogOutputChannel.info('Searching for AppHost projects using CLI command: aspire ls'); + extensionLogOutputChannel.info('Searching for AppHost projects using shared AppHost discovery'); - let proc: ChildProcessWithoutNullStreams | undefined; - const cliPath = await terminalProvider.getAspireCliExecutablePath(); - const discovery = findAppHostsWithAspireLs(terminalProvider, cliPath, rootFolder); - proc = discovery.process; - discovery.result + appHostDiscoveryService.discover(rootFolder, true) + .then(appHosts => getWorkspaceAppHostProjectSearchResult(rootFolder, appHosts)) .then(result => promptToAddAppHostPathToSettingsFile(result, settingsFileExists, settingsFile, rootFolder, setEnableSettingsFileCreationPromptOnStartup)) .catch(error => { extensionLogOutputChannel.error(`Failed to retrieve AppHost projects: ${error}`); - }) - .finally(() => proc = undefined); + }); return { dispose() { - proc?.kill(); } }; } diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index 53a76643e8e..e2b7a956b55 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -5,7 +5,7 @@ import { spawnCliProcess } from '../debugger/languages/cli'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { extensionLogOutputChannel } from '../utils/logging'; import { appHostDescribeMayNotBeSupported, aspireCliDescribeNotSupported, aspireDescribeMinimumVersion, errorFetchingAppHosts, workspaceViewSelectedMultipleAppHosts, workspaceViewSelectedSingleAppHost } from '../loc/strings'; -import { AppHostCandidate, findAppHostsWithAspireLs, formatAppHostLanguage, isBuildableAppHostCandidate } from '../utils/workspace'; +import { AppHostCandidate, AppHostDiscoveryService, formatAppHostLanguage, getWorkspaceAppHostProjectSearchResult, isBuildableAppHostCandidate } from '../utils/appHostDiscovery'; export interface ResourceUrlJson { name: string | null; @@ -134,7 +134,9 @@ export class AppHostDataRepository { private _workspaceAppHostDescription: string | undefined; private _workspaceAppHostDiscoveryComplete = false; private _workspaceAppHostDiscoveryUsesWorkspaceRoot = false; - private _getAppHostsProcess: ChildProcessWithoutNullStreams | undefined; + private readonly _appHostDiscoveryChangeDisposable: vscode.Disposable; + private readonly _appHostDiscoveryService: AppHostDiscoveryService; + private readonly _ownsAppHostDiscoveryService: boolean; // ── Error state ── private _describeErrorMessage: string | undefined; @@ -148,7 +150,15 @@ export class AppHostDataRepository { private readonly _configChangeDisposable: vscode.Disposable; private _disposed = false; - constructor(private readonly _terminalProvider: AspireTerminalProvider) { + constructor(private readonly _terminalProvider: AspireTerminalProvider, appHostDiscoveryService?: AppHostDiscoveryService) { + this._appHostDiscoveryService = appHostDiscoveryService ?? new AppHostDiscoveryService(_terminalProvider); + this._ownsAppHostDiscoveryService = appHostDiscoveryService === undefined; + this._appHostDiscoveryChangeDisposable = this._appHostDiscoveryService.onDidChangeCandidates(workspaceFolder => { + const rootFolder = vscode.workspace.workspaceFolders?.[0]; + if (rootFolder?.uri.toString() === workspaceFolder.uri.toString()) { + this._fetchWorkspaceAppHost(); + } + }); this._fetchWorkspaceAppHost(); this._configChangeDisposable = vscode.workspace.onDidChangeConfiguration(e => { if (e.affectsConfiguration('aspire.globalAppHostsPollingInterval') && this._shouldPoll) { @@ -259,13 +269,12 @@ export class AppHostDataRepository { this._disposed = true; this._stopPolling(); this._stopDescribeWatch(); - if (this._getAppHostsProcess) { - const getAppHostsProcess = this._getAppHostsProcess; - this._getAppHostsProcess = undefined; - this._terminateProcess(getAppHostsProcess, 'aspire ls'); - } this._configChangeDisposable.dispose(); + this._appHostDiscoveryChangeDisposable.dispose(); this._onDidChangeData.dispose(); + if (this._ownsAppHostDiscoveryService) { + this._appHostDiscoveryService.dispose(); + } } // ── PS polling lifecycle ── @@ -326,29 +335,16 @@ export class AppHostDataRepository { const rootFolder = workspaceFolders[0]; this._workspaceAppHostDiscoveryUsesWorkspaceRoot = true; - extensionLogOutputChannel.info('Fetching workspace apphost via: aspire ls'); + extensionLogOutputChannel.info('Fetching workspace apphost via shared AppHost discovery'); - this._terminalProvider.getAspireCliExecutablePath().then(cliPath => { + this._appHostDiscoveryService.discover(rootFolder).then(appHosts => { if (this._disposed) { return; } - const discovery = findAppHostsWithAspireLs(this._terminalProvider, cliPath, rootFolder); - this._getAppHostsProcess = discovery.process; - discovery.result.then(result => { - if (this._disposed) { - return; - } - - this._getAppHostsProcess = undefined; - this._workspaceAppHostDiscoveryComplete = true; - this._handleWorkspaceAppHostCandidates(result.app_host_candidates, result.selected_project_file); - }).catch(error => { - this._getAppHostsProcess = undefined; - this._workspaceAppHostDiscoveryComplete = true; - extensionLogOutputChannel.warn(`aspire ls error: ${error}`); - this._syncPolling(); - }); + const result = getWorkspaceAppHostProjectSearchResult(rootFolder, appHosts); + this._workspaceAppHostDiscoveryComplete = true; + this._handleWorkspaceAppHostCandidates(result.app_host_candidates, result.selected_project_file); }).catch(error => { this._workspaceAppHostDiscoveryComplete = true; extensionLogOutputChannel.warn(`Failed to fetch workspace apphost: ${error}`); diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index b026298b55b..002d03e55cb 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -434,11 +434,55 @@ await Parallel.ForEachAsync(candidatesWithHandlers, parallelOptions, async (cand logger.LogDebug("Stopping AppHost discovery early after finding multiple valid AppHost projects."); } + await AddSettingsAppHostCandidateAsync().ConfigureAwait(false); + // This sort is done here to make results deterministic since we get all the app // host information in parallel and the order may vary. appHostProjects.Sort((x, y) => string.Compare(x.AppHostFile.FullName, y.AppHostFile.FullName, StringComparison.Ordinal)); return (appHostProjects, unbuildableSuspectedAppHostProjects, hasUnsupportedProjects); + + async Task AddSettingsAppHostCandidateAsync() + { + var settingsAppHost = await GetAppHostFromSettingsAsync(searchDirectory, searchParentDirectories: true, cancellationToken).ConfigureAwait(false); + if (settingsAppHost is null) + { + return; + } + + var pathComparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (appHostProjects.Any(candidate => string.Equals(candidate.AppHostFile.FullName, settingsAppHost.FullName, pathComparison)) + || unbuildableSuspectedAppHostProjects.Any(candidate => string.Equals(candidate.AppHostFile.FullName, settingsAppHost.FullName, pathComparison))) + { + return; + } + + var handler = projectFactory.TryGetProject(settingsAppHost); + if (handler is null) + { + return; + } + + var validationResult = await handler.ValidateAppHostAsync(settingsAppHost, cancellationToken).ConfigureAwait(false); + if (validationResult.IsValid) + { + var appHostProject = new AppHostProjectCandidate(settingsAppHost, handler.LanguageId); + appHostProjects.Add(appHostProject); + await ReportCandidateFoundAsync(appHostProject, cancellationToken).ConfigureAwait(false); + } + else if (validationResult.IsPossiblyUnbuildable) + { + var appHostProject = new AppHostProjectCandidate(settingsAppHost, handler.LanguageId, AppHostProjectCandidateStatus.PossiblyUnbuildable); + unbuildableSuspectedAppHostProjects.Add(appHostProject); + await ReportCandidateFoundAsync(appHostProject, cancellationToken).ConfigureAwait(false); + } + else if (validationResult.IsUnsupported) + { + hasUnsupportedProjects = true; + } + } } if (displayProgress) diff --git a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs index 26a0394f69c..59a2655e32b 100644 --- a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs @@ -164,6 +164,46 @@ public async Task LsCommand_JsonFormat_WhenNoCandidates_ReturnsEmptyArray() Assert.Equal("", stderrText); } + [Fact] + public async Task LsCommand_JsonFormat_IncludesConfiguredAppHostOutsideWorkingDirectory() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var textWriter = new TestOutputTextWriter(outputHelper); + var workingDirectory = workspace.WorkspaceRoot.CreateSubdirectory("WorkingDir"); + var configuredAppHost = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "ConfiguredAppHost.csproj")); + await File.WriteAllTextAsync(configuredAppHost.FullName, "Not a real apphost"); + await File.WriteAllTextAsync(Path.Combine(workingDirectory.FullName, "aspire.config.json"), JsonSerializer.Serialize(new + { + appHost = new + { + path = "../ConfiguredAppHost.csproj" + } + })); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.WorkingDirectory = workingDirectory; + options.OutputTextWriter = textWriter; + options.AppHostProjectFactory = _ => new TestAppHostProjectFactory(); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("ls --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + + var jsonOutput = string.Join(string.Empty, textWriter.Logs); + var candidateAppHosts = JsonSerializer.Deserialize(jsonOutput, JsonSourceGenerationContext.RelaxedEscaping.ListCandidateAppHostDisplayInfo); + Assert.NotNull(candidateAppHosts); + var candidate = Assert.Single(candidateAppHosts); + Assert.Equal(configuredAppHost.FullName, candidate.Path); + Assert.Equal(KnownLanguageId.CSharp, candidate.Language); + Assert.Equal("buildable", candidate.Status); + } + [Fact] public async Task LsCommand_JsonFormat_OnlyJsonOnStdout_StatusMessagesOnStderr() {