diff --git a/extension/package.json b/extension/package.json index 454241364a2..6130606af5c 100644 --- a/extension/package.json +++ b/extension/package.json @@ -103,8 +103,51 @@ "command": "aspire-vscode.configureLaunchJson", "title": "%command.configureLaunchJson%", "category": "Aspire" + }, + { + "command": "aspire-vscode.runAppHost", + "title": "%command.runAppHost%", + "category": "Aspire", + "icon": "$(play)" } - ] + ], + "menus": { + "explorer/context": [ + { + "command": "aspire-vscode.runAppHost", + "when": "resourceFilename =~ /AppHost\\.cs$/", + "group": "aspire_actions@1" + }, + { + "command": "aspire-vscode.runAppHost", + "when": "resourceExtname == '.csproj'", + "group": "aspire_actions@1" + } + ], + "editor/title/run": [ + { + "command": "aspire-vscode.runAppHost", + "when": "resourceFilename =~ /AppHost\\.cs$/", + "group": "navigation@-1" + }, + { + "command": "aspire-vscode.runAppHost", + "when": "resourceExtname == '.csproj' && aspire.appHostProjectPath", + "group": "navigation@-1" + }, + { + "command": "aspire-vscode.runAppHost", + "when": "resourceExtname == '.cs' && aspire.appHostProjectPath", + "group": "navigation@-1" + } + ], + "commandPalette": [ + { + "command": "aspire-vscode.runAppHost", + "when": "false" + } + ] + } }, "repository": { "type": "git", diff --git a/extension/package.nls.cs.json b/extension/package.nls.cs.json index 44c116d9893..84b7d621754 100644 --- a/extension/package.nls.cs.json +++ b/extension/package.nls.cs.json @@ -10,6 +10,7 @@ "command.config": "Manage configuration settings", "command.deploy": "Deploy app host", "command.configureLaunchJson": "Configure launch.json file", + "command.runAppHost": "Run Aspire app host", "aspire-vscode.strings.noCsprojFound": "No AppHost found in the current workspace", "aspire-vscode.strings.error": "Error: {0}", "aspire-vscode.strings.yes": "Yes", @@ -41,8 +42,6 @@ "aspire-vscode.strings.rpcServerNotInitialized": "RPC Server is not initialized", "aspire-vscode.strings.extensionContextNotInitialized": "Extension context is not initialized", "aspire-vscode.strings.errorRetrievingAppHosts": "Error retrieving app hosts in the current workspace. Debug options may be incomplete.", - "aspire-vscode.strings.launchingWithDirectory": "Launching Aspire debug session using directory {0}: attempting to determine effective AppHost...", - "aspire-vscode.strings.launchingWithAppHost": "Launching Aspire debug session for AppHost {0}...", "aspire-vscode.strings.disconnectingFromSession": "Disconnecting from Aspire debug session... Child processes will be stopped.", "aspire-vscode.strings.processExitedWithCode": "Process exited with code {0}", "aspire-vscode.strings.failedToStartPythonProgram": "Failed to start Python program: {0}", diff --git a/extension/package.nls.de.json b/extension/package.nls.de.json index 44c116d9893..84b7d621754 100644 --- a/extension/package.nls.de.json +++ b/extension/package.nls.de.json @@ -10,6 +10,7 @@ "command.config": "Manage configuration settings", "command.deploy": "Deploy app host", "command.configureLaunchJson": "Configure launch.json file", + "command.runAppHost": "Run Aspire app host", "aspire-vscode.strings.noCsprojFound": "No AppHost found in the current workspace", "aspire-vscode.strings.error": "Error: {0}", "aspire-vscode.strings.yes": "Yes", @@ -41,8 +42,6 @@ "aspire-vscode.strings.rpcServerNotInitialized": "RPC Server is not initialized", "aspire-vscode.strings.extensionContextNotInitialized": "Extension context is not initialized", "aspire-vscode.strings.errorRetrievingAppHosts": "Error retrieving app hosts in the current workspace. Debug options may be incomplete.", - "aspire-vscode.strings.launchingWithDirectory": "Launching Aspire debug session using directory {0}: attempting to determine effective AppHost...", - "aspire-vscode.strings.launchingWithAppHost": "Launching Aspire debug session for AppHost {0}...", "aspire-vscode.strings.disconnectingFromSession": "Disconnecting from Aspire debug session... Child processes will be stopped.", "aspire-vscode.strings.processExitedWithCode": "Process exited with code {0}", "aspire-vscode.strings.failedToStartPythonProgram": "Failed to start Python program: {0}", diff --git a/extension/package.nls.es.json b/extension/package.nls.es.json index 44c116d9893..84b7d621754 100644 --- a/extension/package.nls.es.json +++ b/extension/package.nls.es.json @@ -10,6 +10,7 @@ "command.config": "Manage configuration settings", "command.deploy": "Deploy app host", "command.configureLaunchJson": "Configure launch.json file", + "command.runAppHost": "Run Aspire app host", "aspire-vscode.strings.noCsprojFound": "No AppHost found in the current workspace", "aspire-vscode.strings.error": "Error: {0}", "aspire-vscode.strings.yes": "Yes", @@ -41,8 +42,6 @@ "aspire-vscode.strings.rpcServerNotInitialized": "RPC Server is not initialized", "aspire-vscode.strings.extensionContextNotInitialized": "Extension context is not initialized", "aspire-vscode.strings.errorRetrievingAppHosts": "Error retrieving app hosts in the current workspace. Debug options may be incomplete.", - "aspire-vscode.strings.launchingWithDirectory": "Launching Aspire debug session using directory {0}: attempting to determine effective AppHost...", - "aspire-vscode.strings.launchingWithAppHost": "Launching Aspire debug session for AppHost {0}...", "aspire-vscode.strings.disconnectingFromSession": "Disconnecting from Aspire debug session... Child processes will be stopped.", "aspire-vscode.strings.processExitedWithCode": "Process exited with code {0}", "aspire-vscode.strings.failedToStartPythonProgram": "Failed to start Python program: {0}", diff --git a/extension/package.nls.fr.json b/extension/package.nls.fr.json index 44c116d9893..84b7d621754 100644 --- a/extension/package.nls.fr.json +++ b/extension/package.nls.fr.json @@ -10,6 +10,7 @@ "command.config": "Manage configuration settings", "command.deploy": "Deploy app host", "command.configureLaunchJson": "Configure launch.json file", + "command.runAppHost": "Run Aspire app host", "aspire-vscode.strings.noCsprojFound": "No AppHost found in the current workspace", "aspire-vscode.strings.error": "Error: {0}", "aspire-vscode.strings.yes": "Yes", @@ -41,8 +42,6 @@ "aspire-vscode.strings.rpcServerNotInitialized": "RPC Server is not initialized", "aspire-vscode.strings.extensionContextNotInitialized": "Extension context is not initialized", "aspire-vscode.strings.errorRetrievingAppHosts": "Error retrieving app hosts in the current workspace. Debug options may be incomplete.", - "aspire-vscode.strings.launchingWithDirectory": "Launching Aspire debug session using directory {0}: attempting to determine effective AppHost...", - "aspire-vscode.strings.launchingWithAppHost": "Launching Aspire debug session for AppHost {0}...", "aspire-vscode.strings.disconnectingFromSession": "Disconnecting from Aspire debug session... Child processes will be stopped.", "aspire-vscode.strings.processExitedWithCode": "Process exited with code {0}", "aspire-vscode.strings.failedToStartPythonProgram": "Failed to start Python program: {0}", diff --git a/extension/package.nls.it.json b/extension/package.nls.it.json index 44c116d9893..84b7d621754 100644 --- a/extension/package.nls.it.json +++ b/extension/package.nls.it.json @@ -10,6 +10,7 @@ "command.config": "Manage configuration settings", "command.deploy": "Deploy app host", "command.configureLaunchJson": "Configure launch.json file", + "command.runAppHost": "Run Aspire app host", "aspire-vscode.strings.noCsprojFound": "No AppHost found in the current workspace", "aspire-vscode.strings.error": "Error: {0}", "aspire-vscode.strings.yes": "Yes", @@ -41,8 +42,6 @@ "aspire-vscode.strings.rpcServerNotInitialized": "RPC Server is not initialized", "aspire-vscode.strings.extensionContextNotInitialized": "Extension context is not initialized", "aspire-vscode.strings.errorRetrievingAppHosts": "Error retrieving app hosts in the current workspace. Debug options may be incomplete.", - "aspire-vscode.strings.launchingWithDirectory": "Launching Aspire debug session using directory {0}: attempting to determine effective AppHost...", - "aspire-vscode.strings.launchingWithAppHost": "Launching Aspire debug session for AppHost {0}...", "aspire-vscode.strings.disconnectingFromSession": "Disconnecting from Aspire debug session... Child processes will be stopped.", "aspire-vscode.strings.processExitedWithCode": "Process exited with code {0}", "aspire-vscode.strings.failedToStartPythonProgram": "Failed to start Python program: {0}", diff --git a/extension/package.nls.ja.json b/extension/package.nls.ja.json index 44c116d9893..84b7d621754 100644 --- a/extension/package.nls.ja.json +++ b/extension/package.nls.ja.json @@ -10,6 +10,7 @@ "command.config": "Manage configuration settings", "command.deploy": "Deploy app host", "command.configureLaunchJson": "Configure launch.json file", + "command.runAppHost": "Run Aspire app host", "aspire-vscode.strings.noCsprojFound": "No AppHost found in the current workspace", "aspire-vscode.strings.error": "Error: {0}", "aspire-vscode.strings.yes": "Yes", @@ -41,8 +42,6 @@ "aspire-vscode.strings.rpcServerNotInitialized": "RPC Server is not initialized", "aspire-vscode.strings.extensionContextNotInitialized": "Extension context is not initialized", "aspire-vscode.strings.errorRetrievingAppHosts": "Error retrieving app hosts in the current workspace. Debug options may be incomplete.", - "aspire-vscode.strings.launchingWithDirectory": "Launching Aspire debug session using directory {0}: attempting to determine effective AppHost...", - "aspire-vscode.strings.launchingWithAppHost": "Launching Aspire debug session for AppHost {0}...", "aspire-vscode.strings.disconnectingFromSession": "Disconnecting from Aspire debug session... Child processes will be stopped.", "aspire-vscode.strings.processExitedWithCode": "Process exited with code {0}", "aspire-vscode.strings.failedToStartPythonProgram": "Failed to start Python program: {0}", diff --git a/extension/package.nls.json b/extension/package.nls.json index 8bc9d776802..3e95d462fd8 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -10,6 +10,7 @@ "command.config": "Manage configuration settings", "command.deploy": "Deploy app host", "command.configureLaunchJson": "Configure launch.json file", + "command.runAppHost": "Run Aspire app host", "aspire-vscode.strings.noCsprojFound": "No AppHost found in the current workspace", "aspire-vscode.strings.error": "Error: {0}", "aspire-vscode.strings.yes": "Yes", @@ -41,8 +42,6 @@ "aspire-vscode.strings.rpcServerNotInitialized": "RPC Server is not initialized", "aspire-vscode.strings.extensionContextNotInitialized": "Extension context is not initialized", "aspire-vscode.strings.errorRetrievingAppHosts": "Error retrieving app hosts in the current workspace. Debug options may be incomplete.", - "aspire-vscode.strings.launchingWithDirectory": "Launching Aspire debug session using directory {0}: attempting to determine effective AppHost...", - "aspire-vscode.strings.launchingWithAppHost": "Launching Aspire debug session for AppHost {0}...", "aspire-vscode.strings.disconnectingFromSession": "Disconnecting from Aspire debug session... Child processes will be stopped.", "aspire-vscode.strings.processExitedWithCode": "Process exited with code {0}", "aspire-vscode.strings.failedToStartPythonProgram": "Failed to start Python program: {0}", diff --git a/extension/package.nls.ko.json b/extension/package.nls.ko.json index 44c116d9893..84b7d621754 100644 --- a/extension/package.nls.ko.json +++ b/extension/package.nls.ko.json @@ -10,6 +10,7 @@ "command.config": "Manage configuration settings", "command.deploy": "Deploy app host", "command.configureLaunchJson": "Configure launch.json file", + "command.runAppHost": "Run Aspire app host", "aspire-vscode.strings.noCsprojFound": "No AppHost found in the current workspace", "aspire-vscode.strings.error": "Error: {0}", "aspire-vscode.strings.yes": "Yes", @@ -41,8 +42,6 @@ "aspire-vscode.strings.rpcServerNotInitialized": "RPC Server is not initialized", "aspire-vscode.strings.extensionContextNotInitialized": "Extension context is not initialized", "aspire-vscode.strings.errorRetrievingAppHosts": "Error retrieving app hosts in the current workspace. Debug options may be incomplete.", - "aspire-vscode.strings.launchingWithDirectory": "Launching Aspire debug session using directory {0}: attempting to determine effective AppHost...", - "aspire-vscode.strings.launchingWithAppHost": "Launching Aspire debug session for AppHost {0}...", "aspire-vscode.strings.disconnectingFromSession": "Disconnecting from Aspire debug session... Child processes will be stopped.", "aspire-vscode.strings.processExitedWithCode": "Process exited with code {0}", "aspire-vscode.strings.failedToStartPythonProgram": "Failed to start Python program: {0}", diff --git a/extension/package.nls.pl.json b/extension/package.nls.pl.json index 44c116d9893..84b7d621754 100644 --- a/extension/package.nls.pl.json +++ b/extension/package.nls.pl.json @@ -10,6 +10,7 @@ "command.config": "Manage configuration settings", "command.deploy": "Deploy app host", "command.configureLaunchJson": "Configure launch.json file", + "command.runAppHost": "Run Aspire app host", "aspire-vscode.strings.noCsprojFound": "No AppHost found in the current workspace", "aspire-vscode.strings.error": "Error: {0}", "aspire-vscode.strings.yes": "Yes", @@ -41,8 +42,6 @@ "aspire-vscode.strings.rpcServerNotInitialized": "RPC Server is not initialized", "aspire-vscode.strings.extensionContextNotInitialized": "Extension context is not initialized", "aspire-vscode.strings.errorRetrievingAppHosts": "Error retrieving app hosts in the current workspace. Debug options may be incomplete.", - "aspire-vscode.strings.launchingWithDirectory": "Launching Aspire debug session using directory {0}: attempting to determine effective AppHost...", - "aspire-vscode.strings.launchingWithAppHost": "Launching Aspire debug session for AppHost {0}...", "aspire-vscode.strings.disconnectingFromSession": "Disconnecting from Aspire debug session... Child processes will be stopped.", "aspire-vscode.strings.processExitedWithCode": "Process exited with code {0}", "aspire-vscode.strings.failedToStartPythonProgram": "Failed to start Python program: {0}", diff --git a/extension/package.nls.pt-br.json b/extension/package.nls.pt-br.json index 44c116d9893..84b7d621754 100644 --- a/extension/package.nls.pt-br.json +++ b/extension/package.nls.pt-br.json @@ -10,6 +10,7 @@ "command.config": "Manage configuration settings", "command.deploy": "Deploy app host", "command.configureLaunchJson": "Configure launch.json file", + "command.runAppHost": "Run Aspire app host", "aspire-vscode.strings.noCsprojFound": "No AppHost found in the current workspace", "aspire-vscode.strings.error": "Error: {0}", "aspire-vscode.strings.yes": "Yes", @@ -41,8 +42,6 @@ "aspire-vscode.strings.rpcServerNotInitialized": "RPC Server is not initialized", "aspire-vscode.strings.extensionContextNotInitialized": "Extension context is not initialized", "aspire-vscode.strings.errorRetrievingAppHosts": "Error retrieving app hosts in the current workspace. Debug options may be incomplete.", - "aspire-vscode.strings.launchingWithDirectory": "Launching Aspire debug session using directory {0}: attempting to determine effective AppHost...", - "aspire-vscode.strings.launchingWithAppHost": "Launching Aspire debug session for AppHost {0}...", "aspire-vscode.strings.disconnectingFromSession": "Disconnecting from Aspire debug session... Child processes will be stopped.", "aspire-vscode.strings.processExitedWithCode": "Process exited with code {0}", "aspire-vscode.strings.failedToStartPythonProgram": "Failed to start Python program: {0}", diff --git a/extension/package.nls.ru.json b/extension/package.nls.ru.json index 44c116d9893..84b7d621754 100644 --- a/extension/package.nls.ru.json +++ b/extension/package.nls.ru.json @@ -10,6 +10,7 @@ "command.config": "Manage configuration settings", "command.deploy": "Deploy app host", "command.configureLaunchJson": "Configure launch.json file", + "command.runAppHost": "Run Aspire app host", "aspire-vscode.strings.noCsprojFound": "No AppHost found in the current workspace", "aspire-vscode.strings.error": "Error: {0}", "aspire-vscode.strings.yes": "Yes", @@ -41,8 +42,6 @@ "aspire-vscode.strings.rpcServerNotInitialized": "RPC Server is not initialized", "aspire-vscode.strings.extensionContextNotInitialized": "Extension context is not initialized", "aspire-vscode.strings.errorRetrievingAppHosts": "Error retrieving app hosts in the current workspace. Debug options may be incomplete.", - "aspire-vscode.strings.launchingWithDirectory": "Launching Aspire debug session using directory {0}: attempting to determine effective AppHost...", - "aspire-vscode.strings.launchingWithAppHost": "Launching Aspire debug session for AppHost {0}...", "aspire-vscode.strings.disconnectingFromSession": "Disconnecting from Aspire debug session... Child processes will be stopped.", "aspire-vscode.strings.processExitedWithCode": "Process exited with code {0}", "aspire-vscode.strings.failedToStartPythonProgram": "Failed to start Python program: {0}", diff --git a/extension/package.nls.tr.json b/extension/package.nls.tr.json index 44c116d9893..84b7d621754 100644 --- a/extension/package.nls.tr.json +++ b/extension/package.nls.tr.json @@ -10,6 +10,7 @@ "command.config": "Manage configuration settings", "command.deploy": "Deploy app host", "command.configureLaunchJson": "Configure launch.json file", + "command.runAppHost": "Run Aspire app host", "aspire-vscode.strings.noCsprojFound": "No AppHost found in the current workspace", "aspire-vscode.strings.error": "Error: {0}", "aspire-vscode.strings.yes": "Yes", @@ -41,8 +42,6 @@ "aspire-vscode.strings.rpcServerNotInitialized": "RPC Server is not initialized", "aspire-vscode.strings.extensionContextNotInitialized": "Extension context is not initialized", "aspire-vscode.strings.errorRetrievingAppHosts": "Error retrieving app hosts in the current workspace. Debug options may be incomplete.", - "aspire-vscode.strings.launchingWithDirectory": "Launching Aspire debug session using directory {0}: attempting to determine effective AppHost...", - "aspire-vscode.strings.launchingWithAppHost": "Launching Aspire debug session for AppHost {0}...", "aspire-vscode.strings.disconnectingFromSession": "Disconnecting from Aspire debug session... Child processes will be stopped.", "aspire-vscode.strings.processExitedWithCode": "Process exited with code {0}", "aspire-vscode.strings.failedToStartPythonProgram": "Failed to start Python program: {0}", diff --git a/extension/package.nls.zh-cn.json b/extension/package.nls.zh-cn.json index 44c116d9893..84b7d621754 100644 --- a/extension/package.nls.zh-cn.json +++ b/extension/package.nls.zh-cn.json @@ -10,6 +10,7 @@ "command.config": "Manage configuration settings", "command.deploy": "Deploy app host", "command.configureLaunchJson": "Configure launch.json file", + "command.runAppHost": "Run Aspire app host", "aspire-vscode.strings.noCsprojFound": "No AppHost found in the current workspace", "aspire-vscode.strings.error": "Error: {0}", "aspire-vscode.strings.yes": "Yes", @@ -41,8 +42,6 @@ "aspire-vscode.strings.rpcServerNotInitialized": "RPC Server is not initialized", "aspire-vscode.strings.extensionContextNotInitialized": "Extension context is not initialized", "aspire-vscode.strings.errorRetrievingAppHosts": "Error retrieving app hosts in the current workspace. Debug options may be incomplete.", - "aspire-vscode.strings.launchingWithDirectory": "Launching Aspire debug session using directory {0}: attempting to determine effective AppHost...", - "aspire-vscode.strings.launchingWithAppHost": "Launching Aspire debug session for AppHost {0}...", "aspire-vscode.strings.disconnectingFromSession": "Disconnecting from Aspire debug session... Child processes will be stopped.", "aspire-vscode.strings.processExitedWithCode": "Process exited with code {0}", "aspire-vscode.strings.failedToStartPythonProgram": "Failed to start Python program: {0}", diff --git a/extension/package.nls.zh-tw.json b/extension/package.nls.zh-tw.json index 44c116d9893..84b7d621754 100644 --- a/extension/package.nls.zh-tw.json +++ b/extension/package.nls.zh-tw.json @@ -10,6 +10,7 @@ "command.config": "Manage configuration settings", "command.deploy": "Deploy app host", "command.configureLaunchJson": "Configure launch.json file", + "command.runAppHost": "Run Aspire app host", "aspire-vscode.strings.noCsprojFound": "No AppHost found in the current workspace", "aspire-vscode.strings.error": "Error: {0}", "aspire-vscode.strings.yes": "Yes", @@ -41,8 +42,6 @@ "aspire-vscode.strings.rpcServerNotInitialized": "RPC Server is not initialized", "aspire-vscode.strings.extensionContextNotInitialized": "Extension context is not initialized", "aspire-vscode.strings.errorRetrievingAppHosts": "Error retrieving app hosts in the current workspace. Debug options may be incomplete.", - "aspire-vscode.strings.launchingWithDirectory": "Launching Aspire debug session using directory {0}: attempting to determine effective AppHost...", - "aspire-vscode.strings.launchingWithAppHost": "Launching Aspire debug session for AppHost {0}...", "aspire-vscode.strings.disconnectingFromSession": "Disconnecting from Aspire debug session... Child processes will be stopped.", "aspire-vscode.strings.processExitedWithCode": "Process exited with code {0}", "aspire-vscode.strings.failedToStartPythonProgram": "Failed to start Python program: {0}", diff --git a/extension/src/AspireExtensionContext.ts b/extension/src/AspireExtensionContext.ts index 5fbe3f073b6..a6fb86efab8 100644 --- a/extension/src/AspireExtensionContext.ts +++ b/extension/src/AspireExtensionContext.ts @@ -1,33 +1,28 @@ import * as vscode from 'vscode'; import { AspireDebugSession } from './debugger/AspireDebugSession'; -import { AspireDebugConfigurationProvider } from './debugger/AspireDebugConfigurationProvider'; import { aspireDebugSessionNotInitialized, extensionContextNotInitialized } from './loc/strings'; import AspireRpcServer from './server/AspireRpcServer'; import AspireDcpServer from './dcp/AspireDcpServer'; -import { ResourceDebuggerExtension } from './debugger/debuggerExtensions'; -export class AspireExtensionContext { +export class AspireExtensionContext implements vscode.Disposable { private _rpcServer: AspireRpcServer | undefined; private _dcpServer: AspireDcpServer | undefined; private _extensionContext: vscode.ExtensionContext | undefined; - private _aspireDebugSession: AspireDebugSession | undefined; - private _debugConfigProvider: AspireDebugConfigurationProvider | undefined; - private _debuggerExtensions: ResourceDebuggerExtension[] | undefined; + private _aspireDebugSessionByDcpId: Map; + + public activeDebugConfiguration: vscode.DebugConfiguration | undefined; constructor() { this._rpcServer = undefined; this._extensionContext = undefined; - this._aspireDebugSession = undefined; - this._debugConfigProvider = undefined; + this._aspireDebugSessionByDcpId = new Map(); this._dcpServer = undefined; } - initialize(rpcServer: AspireRpcServer, extensionContext: vscode.ExtensionContext, debugConfigProvider: AspireDebugConfigurationProvider, dcpServer: AspireDcpServer, debuggerExtensions: ResourceDebuggerExtension[]): void { + initialize(rpcServer: AspireRpcServer, extensionContext: vscode.ExtensionContext, dcpServer: AspireDcpServer): void { this._rpcServer = rpcServer; this._extensionContext = extensionContext; - this._debugConfigProvider = debugConfigProvider; this._dcpServer = dcpServer; - this._debuggerExtensions = debuggerExtensions; } get rpcServer(): AspireRpcServer { @@ -51,30 +46,21 @@ export class AspireExtensionContext { return this._extensionContext; } - get debuggerExtensions(): ResourceDebuggerExtension[] | undefined { - return this._debuggerExtensions; - } - - hasAspireDebugSession(): boolean { - return !!this._aspireDebugSession; + getAspireDebugSession(dcpId: string): AspireDebugSession | null { + return this._aspireDebugSessionByDcpId.get(dcpId) ?? null; } - get aspireDebugSession(): AspireDebugSession { - if (!this._aspireDebugSession) { - throw new Error(aspireDebugSessionNotInitialized); - } - return this._aspireDebugSession; + setAspireDebugSession(debugSession: AspireDebugSession): void { + this._aspireDebugSessionByDcpId.set(debugSession.dcpId, debugSession); } - set aspireDebugSession(value: AspireDebugSession) { - this._aspireDebugSession = value; + removeAspireDebugSession(dcpId: string): void { + this._aspireDebugSessionByDcpId.delete(dcpId); } - get debugConfigProvider(): AspireDebugConfigurationProvider | undefined { - if (!this._debugConfigProvider) { - throw new Error(extensionContextNotInitialized); - } - - return this._debugConfigProvider; + dispose(): void { + this._rpcServer?.dispose(); + this._dcpServer?.dispose(); + this._aspireDebugSessionByDcpId.forEach(session => session.dispose()); } } diff --git a/extension/src/commands/add.ts b/extension/src/commands/add.ts index 489ad3b9cd7..6d1022ce695 100644 --- a/extension/src/commands/add.ts +++ b/extension/src/commands/add.ts @@ -1,11 +1,10 @@ -import { RpcServerConnectionInfo } from '../server/AspireRpcServer'; -import { sendToAspireTerminal } from '../utils/terminal'; +import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { isWorkspaceOpen } from '../utils/workspace'; -export async function addCommand(rpcServerConnectionInfo: RpcServerConnectionInfo) { +export function addCommand(aspireTerminalProvider: AspireTerminalProvider) { if (!isWorkspaceOpen()) { return; } - sendToAspireTerminal("aspire add", rpcServerConnectionInfo); + aspireTerminalProvider.sendToAspireTerminal("aspire add", null); } diff --git a/extension/src/commands/config.ts b/extension/src/commands/config.ts index 22562791c7c..9faf4ee0f4a 100644 --- a/extension/src/commands/config.ts +++ b/extension/src/commands/config.ts @@ -1,11 +1,10 @@ -import { RpcServerConnectionInfo } from '../server/AspireRpcServer'; -import { sendToAspireTerminal } from '../utils/terminal'; +import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { isWorkspaceOpen } from '../utils/workspace'; -export async function configCommand(rpcServerConnectionInfo: RpcServerConnectionInfo) { +export function configCommand(aspireTerminalProvider: AspireTerminalProvider) { if (!isWorkspaceOpen()) { return; } - sendToAspireTerminal("aspire config", rpcServerConnectionInfo); + aspireTerminalProvider.sendToAspireTerminal("aspire config", null); } diff --git a/extension/src/commands/deploy.ts b/extension/src/commands/deploy.ts index b0d243c3456..9947bd10228 100644 --- a/extension/src/commands/deploy.ts +++ b/extension/src/commands/deploy.ts @@ -1,11 +1,10 @@ -import { RpcServerConnectionInfo } from '../server/AspireRpcServer'; -import { sendToAspireTerminal } from '../utils/terminal'; +import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { isWorkspaceOpen } from '../utils/workspace'; -export async function deployCommand(rpcServerConnectionInfo: RpcServerConnectionInfo) { +export function deployCommand(terminalProvider: AspireTerminalProvider) { if (!isWorkspaceOpen()) { return; } - sendToAspireTerminal("aspire deploy", rpcServerConnectionInfo); + terminalProvider.sendToAspireTerminal("aspire deploy", null); } diff --git a/extension/src/commands/new.ts b/extension/src/commands/new.ts index 7b56ae8c029..c011f3b18ef 100644 --- a/extension/src/commands/new.ts +++ b/extension/src/commands/new.ts @@ -1,6 +1,5 @@ -import { RpcServerConnectionInfo } from '../server/AspireRpcServer'; -import { sendToAspireTerminal } from '../utils/terminal'; +import { AspireTerminalProvider } from "../utils/AspireTerminalProvider"; -export async function newCommand(rpcServerConnectionInfo: RpcServerConnectionInfo) { - sendToAspireTerminal("aspire new", rpcServerConnectionInfo); +export function newCommand(aspireTerminalProvider: AspireTerminalProvider) { + aspireTerminalProvider.sendToAspireTerminal("aspire new", null); }; diff --git a/extension/src/commands/publish.ts b/extension/src/commands/publish.ts index 5f4e7f9a6be..3f90bd3637f 100644 --- a/extension/src/commands/publish.ts +++ b/extension/src/commands/publish.ts @@ -1,11 +1,10 @@ -import { RpcServerConnectionInfo } from '../server/AspireRpcServer'; -import { sendToAspireTerminal } from '../utils/terminal'; +import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { isWorkspaceOpen } from '../utils/workspace'; -export async function publishCommand(rpcServerConnectionInfo: RpcServerConnectionInfo) { +export function publishCommand(aspireTerminalProvider: AspireTerminalProvider) { if (!isWorkspaceOpen()) { return; } - sendToAspireTerminal("aspire publish", rpcServerConnectionInfo); + aspireTerminalProvider.sendToAspireTerminal("aspire publish", null); } diff --git a/extension/src/dcp/AspireDcpServer.ts b/extension/src/dcp/AspireDcpServer.ts index 6ec0cbe1d12..92e3e2fde15 100644 --- a/extension/src/dcp/AspireDcpServer.ts +++ b/extension/src/dcp/AspireDcpServer.ts @@ -31,7 +31,7 @@ export default class AspireDcpServer { this.pendingNotificationQueueByDcpId = pendingNotificationQueueByDcpId; } - static async create(debuggerExtensions: ResourceDebuggerExtension[], getDebugSession: () => AspireDebugSession): Promise { + static async create(debuggerExtensions: ResourceDebuggerExtension[], getDebugSession: (dcpId: string) => AspireDebugSession | null): Promise { const runsBySession = new Map(); const wsBySession = new Map(); const pendingNotificationQueueByDcpId = new Map(); @@ -83,7 +83,20 @@ export default class AspireDcpServer { for (const launchConfig of payload.launch_configurations) { const foundDebuggerExtension = debuggerExtensions.find(ext => ext.resourceType === launchConfig.type) ?? null; - const aspireDebugSession = getDebugSession(); + const aspireDebugSession = getDebugSession(dcpId); + if (!aspireDebugSession) { + const error: ErrorDetails = { + code: 'DebugSessionNotFound', + message: `No Aspire debug session found for DCP ID ${dcpId}`, + details: [] + }; + + extensionLogOutputChannel.error(`Error creating debug session ${runId}: ${error.message}`); + const response: ErrorResponse = { error }; + res.status(400).json(response).end(); + return; + } + const config = await createDebugSessionConfiguration(launchConfig, payload.args ?? [], payload.env ?? [], { debug: launchConfig.mode === "Debug", runId, dcpId }, foundDebuggerExtension); const debugSession = await aspireDebugSession.startAndGetDebugSession(config); @@ -247,3 +260,8 @@ export default class AspireDcpServer { export function generateRunId(): string { return `run-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; } + +export function generateDcpId(): string { + return `dcp-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; +} + diff --git a/extension/src/dcp/types.ts b/extension/src/dcp/types.ts index 7dc82a65061..ea43ef4b0f3 100644 --- a/extension/src/dcp/types.ts +++ b/extension/src/dcp/types.ts @@ -64,7 +64,7 @@ export interface LaunchOptions { debug: boolean; forceBuild?: boolean; runId: string; - dcpId: string | null; + dcpId: string; }; export interface AspireResourceDebugSession { @@ -74,6 +74,5 @@ export interface AspireResourceDebugSession { } export interface AspireExtendedDebugConfiguration extends vscode.DebugConfiguration { - runId: string; - dcpId: string | null; + dcpId: string; } diff --git a/extension/src/debugger/AspireDebugConfigurationProvider.ts b/extension/src/debugger/AspireDebugConfigurationProvider.ts index 9ed71ad36e6..0c25f748264 100644 --- a/extension/src/debugger/AspireDebugConfigurationProvider.ts +++ b/extension/src/debugger/AspireDebugConfigurationProvider.ts @@ -1,15 +1,12 @@ import * as vscode from 'vscode'; -import path from 'path'; -import { extensionLogOutputChannel } from '../utils/logging'; -import { errorRetrievingAppHosts } from '../loc/strings'; -import { spawnCliProcess } from './languages/cli'; -import AspireRpcServer, { RpcServerConnectionInfo } from '../server/AspireRpcServer'; +import { defaultConfigurationName } from '../loc/strings'; +import { AspireExtensionContext } from '../AspireExtensionContext'; export class AspireDebugConfigurationProvider implements vscode.DebugConfigurationProvider { - private _rpcServerConnectionInfo: RpcServerConnectionInfo; + private _extensionContext: AspireExtensionContext; - constructor(rpcServer: AspireRpcServer) { - this._rpcServerConnectionInfo = rpcServer.connectionInfo; + constructor(extensionContext: AspireExtensionContext) { + this._extensionContext = extensionContext; } async provideDebugConfigurations(folder: vscode.WorkspaceFolder | undefined, token?: vscode.CancellationToken): Promise { @@ -21,60 +18,23 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati configurations.push({ type: 'aspire', request: 'launch', - name: `Aspire: Launch Default AppHost`, - program: '${workspaceFolder}' + name: defaultConfigurationName, + program: '${workspaceFolder}', }); - try { - for (const candidate of await this.computeAppHostCandidates(folder)) { - configurations.push({ - type: 'aspire', - request: 'launch', - name: `Aspire: ${path.basename(candidate)}`, - program: candidate, - }); - } - } catch (error) { - extensionLogOutputChannel.error(`Error retrieving app hosts: ${error}`); - vscode.window.showWarningMessage(errorRetrievingAppHosts); - } - return configurations; } - async resolveDebugConfiguration(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise { + async resolveDebugConfiguration(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise { if (config.program === '') { config.program = folder?.uri.fsPath || ''; } - return config; - } - - private async computeAppHostCandidates(folder: vscode.WorkspaceFolder): Promise { - try { - return new Promise((resolve, reject) => { - const workspaceFolder = folder.uri.fsPath; - - const stdout: string[] = []; - const stderr: string[] = []; - - spawnCliProcess(this._rpcServerConnectionInfo, 'aspire', ['extension', 'get-apphosts', '--directory', workspaceFolder], { - excludeExtensionEnvironment: true, - stdoutCallback: (data) => stdout.push(data), - stderrCallback: (data) => stderr.push(data), - exitCallback(code) { - if (code !== 0) { - reject(new Error(`Failed to retrieve app hosts: ${stderr.join('\n')}`)); - return; - } - - const candidates = JSON.parse(stdout[stdout.length - 1]) as string[]; - resolve(candidates); - }, - }); - }); - } catch (error) { - throw error; + if (!config.preLaunchTask) { + config.preLaunchTask = 'aspire: start-debug-session'; } + + this._extensionContext.activeDebugConfiguration = config; + return config; } } diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts index 2cff39ef1aa..6a03d95f72c 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -1,24 +1,25 @@ import * as vscode from "vscode"; import { EventEmitter } from "vscode"; -import * as fs from "fs"; import { createDebugAdapterTracker } from "./adapterTracker"; import { AspireExtendedDebugConfiguration, AspireResourceDebugSession, EnvVar } from "../dcp/types"; import { extensionLogOutputChannel } from "../utils/logging"; import AspireDcpServer from "../dcp/AspireDcpServer"; -import { spawnCliProcess } from "./languages/cli"; -import { disconnectingFromSession, launchingWithAppHost, launchingWithDirectory, processExitedWithCode } from "../loc/strings"; +import { disconnectingFromSession, processExitedWithCode } from "../loc/strings"; import { projectDebuggerExtension } from "./languages/dotnet"; import AspireRpcServer from "../server/AspireRpcServer"; -import { createDebugSessionConfiguration } from "./debuggerExtensions"; +import { ICliRpcClient } from "../server/AspireRpcClient"; +import { formatText } from "../utils/strings"; export class AspireDebugSession implements vscode.DebugAdapter { private readonly _onDidSendMessage = new EventEmitter(); private _messageSeq = 1; private readonly _session: vscode.DebugSession; + private _appHostDebugSession: AspireResourceDebugSession | undefined = undefined; private _resourceDebugSessions: AspireResourceDebugSession[] = []; private _trackedDebugAdapters: string[] = []; + private _rpcClient?: ICliRpcClient; private readonly _rpcServer: AspireRpcServer; private readonly _dcpServer: AspireDcpServer; @@ -26,11 +27,27 @@ export class AspireDebugSession implements vscode.DebugAdapter { private readonly _disposables: vscode.Disposable[] = []; public readonly onDidSendMessage = this._onDidSendMessage.event; + public readonly dcpId: string; constructor(session: vscode.DebugSession, rpcServer: AspireRpcServer, dcpServer: AspireDcpServer) { this._session = session; this._rpcServer = rpcServer; this._dcpServer = dcpServer; + + this.dcpId = (session.configuration as AspireExtendedDebugConfiguration).dcpId; + + const rpcClient = rpcServer.getConnection(this.dcpId); + if (!rpcClient) { + // TODO localize + throw new Error('RPC client not found for DCP ID: ' + this.dcpId); + } + this._rpcClient = rpcClient; + + this._disposables.push(rpcClient.interactionService.onStatusChange(status => { + if (this.dcpId === status.dcpId && status.text) { + this.sendMessage(formatText(status.text), true); + } + })); } handleMessage(message: any): void { @@ -47,18 +64,12 @@ export class AspireDebugSession implements vscode.DebugAdapter { }); } else if (message.command === 'launch') { - const appHostPath = this._session.configuration.program as string; - - if (isDirectory(appHostPath)) { - this.sendMessageWithEmoji("📁", launchingWithDirectory(appHostPath)); - this.spawnRunCommand(message.arguments?.noDebug ? ['run'] : ['run', '--start-debug-session'], appHostPath); - } - else { - this.sendMessageWithEmoji("📂", launchingWithAppHost(appHostPath)); - - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - this.spawnRunCommand(message.arguments?.noDebug ? ['run'] : ['run', '--start-debug-session'], workspaceFolder); - } + this._disposables.push({ + dispose: () => { + this._rpcServer.requestStopCli(); + extensionLogOutputChannel.info('Requested Aspire CLI exit'); + } + }); this.sendEvent({ type: 'response', @@ -93,52 +104,6 @@ export class AspireDebugSession implements vscode.DebugAdapter { body: {} }); } - - function isDirectory(pathToCheck: string): boolean { - return fs.existsSync(pathToCheck) && fs.statSync(pathToCheck).isDirectory(); - } - } - - spawnRunCommand(args: string[], workingDirectory: string | undefined) { - spawnCliProcess( - this._rpcServer.connectionInfo, - 'aspire', - args, - { - stdoutCallback: (data) => { - for (const line of trimMessage(data)) { - this.sendMessage(line); - } - }, - stderrCallback: (data) => { - for (const line of trimMessage(data)) { - this.sendMessageWithEmoji("❌", line, false); - } - }, - exitCallback: (code) => { - this.sendMessageWithEmoji("🔚", processExitedWithCode(code ?? '?')); - // if the process failed, we want to stop the debug session - this.dispose(); - }, - dcpServerConnectionInfo: this._dcpServer.connectionInfo, - workingDirectory: workingDirectory - } - ); - - this._disposables.push({ - dispose: () => { - this._rpcServer.requestStopCli(); - extensionLogOutputChannel.info(`Requested Aspire CLI exit with args: ${args.join(' ')}`); - } - }); - - function trimMessage(message: string): string[] { - return message - .replace('\r\n', '\n') - .split('\n') - .map(line => line.trim()) - .filter(line => line.length > 0); - } } createDebugAdapterTrackerCore(debugAdapter: string) { @@ -150,11 +115,10 @@ export class AspireDebugSession implements vscode.DebugAdapter { this._disposables.push(createDebugAdapterTracker(this._dcpServer, debugAdapter)); } - async startAppHost(projectFile: string, args: string[], environment: EnvVar[], debug: boolean): Promise { + async startAppHost(projectFile: string, appHostDebugSessionConfiguration: AspireExtendedDebugConfiguration): Promise { this.createDebugAdapterTrackerCore(projectDebuggerExtension.debugAdapter); - extensionLogOutputChannel.info(`Starting AppHost for project: ${projectFile} with args: ${args.join(' ')}`); - const appHostDebugSessionConfiguration = await createDebugSessionConfiguration({ project_path: projectFile, type: 'project' }, args, environment, { debug, forceBuild: debug, runId: '', dcpId: null }, projectDebuggerExtension); + extensionLogOutputChannel.info(`Starting AppHost for project: ${projectFile}`); const appHostDebugSession = await this.startAndGetDebugSession(appHostDebugSessionConfiguration); if (!appHostDebugSession) { @@ -167,7 +131,6 @@ export class AspireDebugSession implements vscode.DebugAdapter { if (this._appHostDebugSession && session.id === this._appHostDebugSession.id) { // We should also dispose of the parent Aspire debug session whenever the AppHost stops. this.dispose(); - disposable.dispose(); } }); diff --git a/extension/src/debugger/AspireEditorCommandProvider.ts b/extension/src/debugger/AspireEditorCommandProvider.ts new file mode 100644 index 00000000000..4a782be96e4 --- /dev/null +++ b/extension/src/debugger/AspireEditorCommandProvider.ts @@ -0,0 +1,137 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import { errorMessage } from '../loc/strings'; + +export class AspireEditorCommandProvider implements vscode.Disposable { + private _disposable: vscode.Disposable; + + constructor(context: vscode.ExtensionContext) { + this._disposable = vscode.workspace.onDidOpenTextDocument(async (document) => { + await this.processDocument(context, document); + }); + + // Initialize context for the currently active document + this.initializeActiveDocument(context); + } + + private async initializeActiveDocument(context: vscode.ExtensionContext): Promise { + const activeDocument = vscode.window.activeTextEditor?.document; + if (activeDocument) { + await this.processDocument(context, activeDocument); + } + } + + private async processDocument(context: vscode.ExtensionContext, document: vscode.TextDocument): Promise { + // The opened .csproj is an Aspire project if it contains the Aspire host property + if (document.fileName.endsWith('.csproj')) { + const isAspire = document.getText().includes('true'); + await context.workspaceState.update('aspire.appHostProjectPath', isAspire ? document.uri.fsPath : null); + await vscode.commands.executeCommand('setContext', 'aspire.isAspireProject', isAspire); + } + + // The opened file is part of an Aspire project if it resides within a folder in its direct hierarchy containing an Aspire .csproj + if (document.fileName.endsWith('.cs')) { + const appHostProject = await this.getFileAppHostProject(document.uri); + await context.workspaceState.update('aspire.appHostProjectPath', appHostProject); + await vscode.commands.executeCommand('setContext', 'aspire.appHostProjectPath', appHostProject); + } + } + + /** + * Traverses up the directory tree from the given file to find a parent folder + * that contains a .csproj file with true + */ + async getFileAppHostProject(fileUri: vscode.Uri): Promise { + let currentDir = path.dirname(fileUri.fsPath); + const workspaceFolder = vscode.workspace.getWorkspaceFolder(fileUri); + + if (!workspaceFolder) { + return null; + } + + const workspaceRoot = workspaceFolder.uri.fsPath; + + // Traverse up the directory tree + while (currentDir.startsWith(workspaceRoot)) { + try { + // Look for .csproj files in current directory + const files = await fs.promises.readdir(currentDir); + const csprojFiles = files.filter(file => file.endsWith('.csproj')); + + // Check each .csproj file for Aspire host marker + for (const csprojFile of csprojFiles) { + const csprojPath = path.join(currentDir, csprojFile); + try { + const csprojContent = await fs.promises.readFile(csprojPath, 'utf8'); + if (csprojContent.includes('true')) { + return csprojPath; + } + } catch (error) { + // Skip files that can't be read + continue; + } + } + + // Move up one directory + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + // Reached filesystem root + break; + } + currentDir = parentDir; + + } catch (error) { + // Skip directories that can't be read + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + } + + return null; + } + + async tryExecuteRunAppHost(extensionContext: vscode.ExtensionContext, uri: vscode.Uri): Promise { + try { + // Get appHost project from workspace state or calculate it + let appHostProject = extensionContext.workspaceState.get('aspire.appHostProjectPath'); + + if (appHostProject === undefined) { + appHostProject = await this.getFileAppHostProject(uri); + if (appHostProject) { + await extensionContext.workspaceState.update('aspire.appHostProjectPath', appHostProject); + await vscode.commands.executeCommand('setContext', 'aspire.appHostProjectPath', appHostProject); + } + } + + // Check if we have an appHost project path + if (!appHostProject) { + vscode.window.showWarningMessage('No app host found in the file hierarchy.'); + return; + } + + // Start debug session for this AppHost project + const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); + if (workspaceFolder) { + await vscode.debug.startDebugging(workspaceFolder, { + type: 'aspire', + name: `Aspire: ${vscode.workspace.asRelativePath(uri)}`, + request: 'launch', + program: path.dirname(appHostProject) + }); + } + } + catch (error) { + if (error instanceof Error) { + vscode.window.showErrorMessage(errorMessage(error.message)); + } + } + } + + dispose() { + this._disposable.dispose(); + } +} diff --git a/extension/src/debugger/languages/cli.ts b/extension/src/debugger/languages/cli.ts index 2d1355b6152..60e7f612d55 100644 --- a/extension/src/debugger/languages/cli.ts +++ b/extension/src/debugger/languages/cli.ts @@ -1,9 +1,8 @@ import { ChildProcessWithoutNullStreams, spawn } from "child_process"; -import { DcpServerConnectionInfo, EnvVar } from "../../dcp/types"; +import { EnvVar } from "../../dcp/types"; import { mergeEnvs } from "../../utils/environment"; -import { createEnvironment } from "../../utils/terminal"; import { extensionLogOutputChannel } from "../../utils/logging"; -import { RpcServerConnectionInfo } from "../../server/AspireRpcServer"; +import { AspireTerminalProvider } from "../../utils/AspireTerminalProvider"; export interface SpawnProcessOptions { stdoutCallback?: (data: string) => void; @@ -11,13 +10,12 @@ export interface SpawnProcessOptions { exitCallback?: (code: number | null) => void; env?: EnvVar[]; workingDirectory?: string; - dcpServerConnectionInfo?: DcpServerConnectionInfo; excludeExtensionEnvironment?: boolean; } -export function spawnCliProcess(rpcServerConnectionInfo: RpcServerConnectionInfo, command: string, args?: string[], options?: SpawnProcessOptions): ChildProcessWithoutNullStreams { +export function spawnCliProcess(aspireTerminalProvider: AspireTerminalProvider, dcpId: string | null, command: string, args?: string[], options?: SpawnProcessOptions): ChildProcessWithoutNullStreams { const envVars = mergeEnvs(process.env, options?.env); - const additionalEnv = options?.excludeExtensionEnvironment ? { } : createEnvironment(rpcServerConnectionInfo, options?.dcpServerConnectionInfo); + const additionalEnv = options?.excludeExtensionEnvironment ? { } : aspireTerminalProvider.createEnvironment(dcpId); const workingDirectory = options?.workingDirectory ?? process.cwd(); extensionLogOutputChannel.info(`Spawning CLI process: ${command} ${args?.join(" ")} (working directory: ${workingDirectory})`); diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 31abbf2b55f..6923f5f1276 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -55,12 +55,32 @@ async function buildDotNetProject(projectFile: string): Promise { return buildTask; }); - extensionLogOutputChannel.info(`Executing build task: ${buildTask.name} for project: ${projectFile}`); - await vscode.tasks.executeTask(buildTask); + // Modify the task to target the specific project + const projectName = path.basename(projectFile, '.csproj'); + + // Create a modified task definition with just the project file + const modifiedDefinition = { + ...buildTask.definition, + file: projectFile // This will make it build the specific project directly + }; + + // Create a new task with the modified definition + const modifiedTask = new vscode.Task( + modifiedDefinition, + buildTask.scope || vscode.TaskScope.Workspace, + `build ${projectName}`, + buildTask.source, + buildTask.execution, + buildTask.problemMatchers + ); + + extensionLogOutputChannel.info(`Executing build task: ${modifiedTask.name} for project: ${projectFile}`); + await vscode.tasks.executeTask(modifiedTask); return new Promise((resolve, reject) => { - vscode.tasks.onDidEndTaskProcess(async e => { - if (e.execution.task === buildTask) { + const disposable = vscode.tasks.onDidEndTaskProcess(async e => { + disposable.dispose(); + if (e.execution.task === modifiedTask) { if (e.exitCode !== 0) { reject(new Error(buildFailedWithExitCode(e.exitCode ?? 0))); } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 9e0500ae5ca..20462aea01d 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { addCommand } from './commands/add'; -import { RpcClient } from './server/rpcClient'; +import { RpcClient } from './server/AspireRpcClient'; import { InteractionService } from './server/interactionService'; import { newCommand } from './commands/new'; import { configCommand } from './commands/config'; @@ -12,70 +12,93 @@ import { extensionLogOutputChannel } from './utils/logging'; import { initializeTelemetry, sendTelemetryEvent } from './utils/telemetry'; import { AspireDebugAdapterDescriptorFactory } from './debugger/AspireDebugAdapterDescriptorFactory'; import { AspireDebugConfigurationProvider } from './debugger/AspireDebugConfigurationProvider'; +import { AspireTaskProvider } from './tasks/AspireTaskProvider'; import { AspireExtensionContext } from './AspireExtensionContext'; import AspireRpcServer, { RpcServerConnectionInfo } from './server/AspireRpcServer'; import AspireDcpServer from './dcp/AspireDcpServer'; import { configureLaunchJsonCommand } from './commands/configureLaunchJson'; import { getResourceDebuggerExtensions } from './debugger/debuggerExtensions'; +import { AspireEditorCommandProvider } from './debugger/AspireEditorCommandProvider'; +import { AspireTerminalProvider } from './utils/AspireTerminalProvider'; let aspireExtensionContext = new AspireExtensionContext(); +let extensionContext: vscode.ExtensionContext; export async function activate(context: vscode.ExtensionContext) { - extensionLogOutputChannel.info("Activating Aspire extension"); - initializeTelemetry(context); + extensionLogOutputChannel.info("Activating Aspire extension"); + extensionContext = context; + initializeTelemetry(context); const debuggerExtensions = getResourceDebuggerExtensions(); - const rpcServer = await AspireRpcServer.create( - _ => new InteractionService(() => aspireExtensionContext.hasAspireDebugSession(), () => aspireExtensionContext.aspireDebugSession), - (rpcServerConnectionInfo: RpcServerConnectionInfo, connection, token: string) => new RpcClient(rpcServerConnectionInfo, connection, token) - ); + const terminalProvider = new AspireTerminalProvider(context.subscriptions); - const dcpServer = await AspireDcpServer.create(debuggerExtensions, () => aspireExtensionContext.aspireDebugSession); + const rpcServer = await AspireRpcServer.create( + (rpcServer: AspireRpcServer, connection, token: string, dcpId: string | null) => { + const interactionService = new InteractionService(aspireExtensionContext.getAspireDebugSession.bind(aspireExtensionContext), rpcServer); + return new RpcClient(interactionService, terminalProvider, connection, token, dcpId); + } + ); - const cliAddCommandRegistration = vscode.commands.registerCommand('aspire-vscode.add', () => tryExecuteCommand('aspire-vscode.add', rpcServer.connectionInfo, addCommand)); - const cliNewCommandRegistration = vscode.commands.registerCommand('aspire-vscode.new', () => tryExecuteCommand('aspire-vscode.new', rpcServer.connectionInfo, newCommand)); - const cliConfigCommandRegistration = vscode.commands.registerCommand('aspire-vscode.config', () => tryExecuteCommand('aspire-vscode.config', rpcServer.connectionInfo, configCommand)); - const cliDeployCommandRegistration = vscode.commands.registerCommand('aspire-vscode.deploy', () => tryExecuteCommand('aspire-vscode.deploy', rpcServer.connectionInfo, deployCommand)); - const cliPublishCommandRegistration = vscode.commands.registerCommand('aspire-vscode.publish', () => tryExecuteCommand('aspire-vscode.publish', rpcServer.connectionInfo, publishCommand)); - const configureLaunchJsonCommandRegistration = vscode.commands.registerCommand('aspire-vscode.configureLaunchJson', () => tryExecuteCommand('aspire-vscode.configureLaunchJson', rpcServer.connectionInfo, configureLaunchJsonCommand)); + const dcpServer = await AspireDcpServer.create(debuggerExtensions, aspireExtensionContext.getAspireDebugSession.bind(aspireExtensionContext)); - context.subscriptions.push(cliAddCommandRegistration, cliNewCommandRegistration, cliConfigCommandRegistration, cliDeployCommandRegistration, cliPublishCommandRegistration, configureLaunchJsonCommandRegistration); + terminalProvider.rpcServerConnectionInfo = rpcServer.connectionInfo; + terminalProvider.dcpServerConnectionInfo = dcpServer.connectionInfo; - const debugConfigProvider = new AspireDebugConfigurationProvider(rpcServer); - context.subscriptions.push( - vscode.debug.registerDebugConfigurationProvider('aspire', debugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Dynamic) - ); - context.subscriptions.push( - vscode.debug.registerDebugConfigurationProvider('aspire', debugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Initial) - ); - context.subscriptions.push(vscode.debug.registerDebugAdapterDescriptorFactory('aspire', new AspireDebugAdapterDescriptorFactory(rpcServer, dcpServer, session => { - aspireExtensionContext.aspireDebugSession = session; - }))); + const editorCommandProvider = new AspireEditorCommandProvider(context); - aspireExtensionContext.initialize(rpcServer, context, debugConfigProvider, dcpServer, debuggerExtensions); + const cliAddCommandRegistration = vscode.commands.registerCommand('aspire-vscode.add', () => tryExecuteCommand('aspire-vscode.add', addCommand)); + const cliNewCommandRegistration = vscode.commands.registerCommand('aspire-vscode.new', () => tryExecuteCommand('aspire-vscode.new', newCommand)); + const cliConfigCommandRegistration = vscode.commands.registerCommand('aspire-vscode.config', () => tryExecuteCommand('aspire-vscode.config', configCommand)); + const cliDeployCommandRegistration = vscode.commands.registerCommand('aspire-vscode.deploy', () => tryExecuteCommand('aspire-vscode.deploy', deployCommand)); + const cliPublishCommandRegistration = vscode.commands.registerCommand('aspire-vscode.publish', () => tryExecuteCommand('aspire-vscode.publish', publishCommand)); + const configureLaunchJsonCommandRegistration = vscode.commands.registerCommand('aspire-vscode.configureLaunchJson', () => tryExecuteCommand('aspire-vscode.configureLaunchJson', configureLaunchJsonCommand)); + + const runAppHostCommandRegistration = vscode.commands.registerCommand('aspire-vscode.runAppHost', async (uri?: vscode.Uri) => { + const targetUri = uri || vscode.window.activeTextEditor?.document.uri; + if (!targetUri) { + return; + } + + await editorCommandProvider.tryExecuteRunAppHost(context, targetUri); + }); + + context.subscriptions.push(cliAddCommandRegistration, cliNewCommandRegistration, cliConfigCommandRegistration, cliDeployCommandRegistration, cliPublishCommandRegistration, configureLaunchJsonCommandRegistration, runAppHostCommandRegistration); + + // Register the task provider + const taskProvider = new AspireTaskProvider(terminalProvider, rpcServer, aspireExtensionContext); + context.subscriptions.push( + vscode.tasks.registerTaskProvider('aspire', taskProvider) + ); + + // Register the debug configuration provider (simplified, no dependencies) + const debugConfigProvider = new AspireDebugConfigurationProvider(aspireExtensionContext); + context.subscriptions.push( + vscode.debug.registerDebugConfigurationProvider('aspire', debugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Dynamic) + ); + context.subscriptions.push( + vscode.debug.registerDebugConfigurationProvider('aspire', debugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Initial) + ); + context.subscriptions.push(vscode.debug.registerDebugAdapterDescriptorFactory('aspire', new AspireDebugAdapterDescriptorFactory(rpcServer, dcpServer, aspireExtensionContext.setAspireDebugSession.bind(aspireExtensionContext)))); + + aspireExtensionContext.initialize(rpcServer, context, dcpServer); + + context.subscriptions.push(aspireExtensionContext); + context.subscriptions.push(editorCommandProvider); // Return exported API for tests or other extensions - return { - rpcServerInfo: rpcServer.connectionInfo, - }; -} + return { + rpcServerInfo: rpcServer.connectionInfo, + }; -export function deactivate() { - aspireExtensionContext.rpcServer.dispose(); - aspireExtensionContext.dcpServer.dispose(); - if (aspireExtensionContext.hasAspireDebugSession()) { - aspireExtensionContext.aspireDebugSession.dispose(); + function tryExecuteCommand(commandName: string, command: (aspireTerminalProvider: AspireTerminalProvider) => void) { + try { + sendTelemetryEvent(`${commandName}.invoked`); + command(terminalProvider); + } + catch (error) { + vscode.window.showErrorMessage(errorMessage(error)); + } } } - -async function tryExecuteCommand(commandName: string, rpcServerConnectionInfo: RpcServerConnectionInfo, command: (rpcServerConnectionInfo: RpcServerConnectionInfo) => Promise): Promise { - try { - sendTelemetryEvent(`${commandName}.invoked`); - await command(rpcServerConnectionInfo); - } - catch (error) { - vscode.window.showErrorMessage(errorMessage(error)); - } -} diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 0146a58925c..4eb986070c3 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -41,8 +41,6 @@ export const rpcServerNotInitialized = localize('aspire-vscode.strings.rpcServer export const extensionContextNotInitialized = localize('aspire-vscode.strings.extensionContextNotInitialized', 'Extension context is not initialized'); export const aspireDebugSessionNotInitialized = localize('aspire-vscode.strings.aspireDebugSessionNotInitialized', 'Aspire debug session is not initialized'); export const errorRetrievingAppHosts = localize('aspire-vscode.strings.errorRetrievingAppHosts', 'Error retrieving app hosts in the current workspace. Debug options may be incomplete.'); -export const launchingWithDirectory = (appHostPath: string) => localize('aspire-vscode.strings.launchingWithDirectory', 'Launching Aspire debug session using directory {0}: attempting to determine effective AppHost...', appHostPath); -export const launchingWithAppHost = (appHostPath: string) => localize('aspire-vscode.strings.launchingWithAppHost', 'Launching Aspire debug session for AppHost {0}...', appHostPath); export const disconnectingFromSession = localize('aspire-vscode.strings.disconnectingFromSession', 'Disconnecting from Aspire debug session... Child processes will be stopped.'); export const processExitedWithCode = (code: number | string) => localize('aspire-vscode.strings.processExitedWithCode', 'Process exited with code {0}', code); export const failedToStartPythonProgram = (errorMessage: string) => localize('aspire-vscode.strings.failedToStartPythonProgram', 'Failed to start Python program: {0}', errorMessage); diff --git a/extension/src/server/AspireRpcServer.ts b/extension/src/server/AspireRpcServer.ts index 190f20751b9..74cd1ad6768 100644 --- a/extension/src/server/AspireRpcServer.ts +++ b/extension/src/server/AspireRpcServer.ts @@ -3,12 +3,11 @@ import { createMessageConnection, MessageConnection } from 'vscode-jsonrpc'; import { StreamMessageReader, StreamMessageWriter } from 'vscode-jsonrpc/node'; import { invalidTokenProvided, rpcServerAddressError, rpcServerError } from '../loc/strings'; import { addInteractionServiceEndpoints, IInteractionService } from './interactionService'; -import { ICliRpcClient } from './rpcClient'; +import { ICliRpcClient } from './AspireRpcClient'; import * as tls from 'tls'; import { createSelfSignedCert, generateToken } from '../utils/security'; import { extensionLogOutputChannel } from '../utils/logging'; import { getSupportedCapabilities } from '../capabilities'; -import { ResourceDebuggerExtension } from '../debugger/debuggerExtensions'; export type RpcServerConnectionInfo = { address: string; @@ -16,30 +15,58 @@ export type RpcServerConnectionInfo = { cert: string; }; -interface RpcClientConnection { - stopCli: () => void; -} - export default class AspireRpcServer { public server: tls.Server; public connectionInfo: RpcServerConnectionInfo; - public connections: RpcClientConnection[] = []; + + private _connections: ICliRpcClient[] = []; + private _connectionsByProgramWithoutDebugSession: Map = new Map(); + + private _onReadyForDebugSessionStart = new vscode.EventEmitter(); + public readonly onReadyForDebugSessionStart = this._onReadyForDebugSessionStart.event; + + private _onNewConnection = new vscode.EventEmitter(); + public readonly onNewConnection = this._onNewConnection.event; constructor(server: tls.Server, connectionInfo: RpcServerConnectionInfo) { this.server = server; this.connectionInfo = connectionInfo; } + public getConnection(dcpId: string): ICliRpcClient | null { + return this._connections.find(connection => connection.dcpId === dcpId) || null; + } + + public addConnection(connection: ICliRpcClient) { + this._connections.push(connection); + this._connectionsByProgramWithoutDebugSession.set(connection.program, connection); + this._onNewConnection.fire(connection); + } + + public removeConnection(connection: ICliRpcClient) { + const index = this._connections.indexOf(connection); + if (index !== -1) { + this._connections.splice(index, 1); + } + } + + public dispose() { extensionLogOutputChannel.info(`Disposing RPC server`); + this._onReadyForDebugSessionStart.dispose(); + this._onNewConnection.dispose(); this.server.close(); } public requestStopCli() { - this.connections.forEach(connection => connection.stopCli()); + this._connections.forEach(connection => connection.stopCli()); + } + + public notifyReadyForDebugSessionStart(dcpId: string): void { + this._onReadyForDebugSessionStart.fire(dcpId); } - static create(interactionServiceFactory: (connection: MessageConnection) => IInteractionService, rpcClientFactory: (rpcServerConnectionInfo: RpcServerConnectionInfo, connection: MessageConnection, token: string) => ICliRpcClient): Promise { + static create(rpcClientFactory: (rpcServer: AspireRpcServer, connection: MessageConnection, token: string, dcpId: string | null) => ICliRpcClient): Promise { const token = generateToken(); const { key, cert } = createSelfSignedCert(); @@ -80,7 +107,7 @@ export default class AspireRpcServer { const rpcServer = new AspireRpcServer(server, connectionInfo); - server.on('secureConnection', (socket) => { + server.on('secureConnection', async (socket) => { extensionLogOutputChannel.info('Client connected to RPC server'); const connection = createMessageConnection( new StreamMessageReader(socket), @@ -95,27 +122,17 @@ export default class AspireRpcServer { return 'pong'; })); - const rpcClient = rpcClientFactory(connectionInfo, connection, token); - const interactionService = interactionServiceFactory(connection); - addInteractionServiceEndpoints(connection, interactionService, rpcClient, withAuthentication); - - const clientFunctionality: RpcClientConnection = { - stopCli: () => { - rpcClient.stopCli(); - } - }; + connection.listen(); - rpcServer.connections.push(clientFunctionality); + const dcpId = await connection.sendRequest('getDcpId', token); + const rpcClient = rpcClientFactory(rpcServer, connection, token, dcpId); + addInteractionServiceEndpoints(connection, rpcClient.interactionService, rpcClient, withAuthentication); + rpcServer.addConnection(rpcClient); connection.onClose(() => { - const index = rpcServer.connections.indexOf(clientFunctionality); - if (index !== -1) { - rpcServer.connections.splice(index, 1); - } + rpcServer.removeConnection(rpcClient); extensionLogOutputChannel.info('Client disconnected from RPC server'); }); - - connection.listen(); }); resolve(rpcServer); diff --git a/extension/src/server/interactionService.ts b/extension/src/server/interactionService.ts index c100e531422..f6dd82fd839 100644 --- a/extension/src/server/interactionService.ts +++ b/extension/src/server/interactionService.ts @@ -1,15 +1,20 @@ import { MessageConnection } from 'vscode-jsonrpc'; import * as vscode from 'vscode'; import { isFolderOpenInWorkspace } from '../utils/workspace'; -import { yesLabel, noLabel, directLink, codespacesLink, openAspireDashboard, failedToShowPromptEmpty, incompatibleAppHostError, aspireHostingSdkVersion, aspireCliVersion, requiredCapability, fieldRequired } from '../loc/strings'; -import { ICliRpcClient } from './rpcClient'; +import { yesLabel, noLabel, directLink, codespacesLink, openAspireDashboard, failedToShowPromptEmpty, incompatibleAppHostError, aspireHostingSdkVersion, aspireCliVersion, requiredCapability, fieldRequired, aspireDebugSessionNotInitialized } from '../loc/strings'; +import { ICliRpcClient } from './AspireRpcClient'; import { formatText } from '../utils/strings'; import { extensionLogOutputChannel } from '../utils/logging'; import { EnvVar } from '../dcp/types'; import { AspireDebugSession } from '../debugger/AspireDebugSession'; +import AspireRpcServer from './AspireRpcServer'; +import { createDebugSessionConfiguration } from '../debugger/debuggerExtensions'; +import { projectDebuggerExtension } from '../debugger/languages/dotnet'; export interface IInteractionService { - showStatus: (statusText: string | null) => void; + onStatusChange: vscode.Event; + + showStatus: (dcpId: string, statusText: string | null) => void; promptForString: (promptText: string, defaultValue: string | null, required: boolean, rpcClient: ICliRpcClient) => Promise; confirm: (promptText: string, defaultValue: boolean) => Promise; promptForSelection: (promptText: string, choices: string[]) => Promise; @@ -24,9 +29,10 @@ export interface IInteractionService { displayCancellationMessage: () => void; openProject: (projectPath: string) => void; logMessage: (logLevel: CSLogLevel, message: string) => void; - launchAppHost(projectFile: string, args: string[], environment: EnvVar[], debug: boolean): Promise; - stopDebugging: () => void; - notifyAppHostStartupCompleted: () => void; + launchAppHost: (dcpId: string, projectFile: string, args: string[], environment: EnvVar[], debug: boolean) => Promise; + stopDebugging: (dcpId: string) => void; + notifyAppHostStartupCompleted: (dcpId: string) => void; + notifyReadyForDebugSessionStart: (dcpId: string) => void; } type CSLogLevel = 'Trace' | 'Debug' | 'Information' | 'Warn' | 'Error' | 'Critical'; @@ -41,18 +47,25 @@ type ConsoleLine = { Line: string; }; -export class InteractionService implements IInteractionService { - private _hasAspireDebugSession: () => boolean; - private _getAspireDebugSession: () => AspireDebugSession; +interface StatusChange { + text: string | null; + dcpId: string; +} +export class InteractionService implements IInteractionService { + private _getAspireDebugSession: (dcpId: string) => AspireDebugSession | null; + private _rpcServer: AspireRpcServer; private _statusBarItem: vscode.StatusBarItem | undefined; - constructor(hasAspireDebugSession: () => boolean, getAspireDebugSession: () => AspireDebugSession) { - this._hasAspireDebugSession = hasAspireDebugSession; + private _onStatusChange: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onStatusChange: vscode.Event = this._onStatusChange.event; + + constructor(getAspireDebugSession: (dcpId: string) => AspireDebugSession | null, rpcServer: AspireRpcServer) { this._getAspireDebugSession = getAspireDebugSession; + this._rpcServer = rpcServer; } - showStatus(statusText: string | null) { + showStatus(dcpId: string, statusText: string | null) { extensionLogOutputChannel.info(`Setting status bar text: ${statusText ?? 'null'}`); if (!this._statusBarItem) { @@ -62,13 +75,15 @@ export class InteractionService implements IInteractionService { if (statusText) { this._statusBarItem.text = formatText(statusText); this._statusBarItem.show(); - - if (this._hasAspireDebugSession()) { - this._getAspireDebugSession().sendMessage(formatText(statusText)); - } - } else if (this._statusBarItem) { + } + else if (this._statusBarItem) { this._statusBarItem.hide(); } + + this._onStatusChange.fire({ + text: statusText, + dcpId + }); } async promptForString(promptText: string, defaultValue: string | null, required: boolean, rpcClient: ICliRpcClient): Promise { @@ -273,17 +288,33 @@ export class InteractionService implements IInteractionService { } } - launchAppHost(projectFile: string, args: string[], environment: EnvVar[], debug: boolean): Promise { - return this._getAspireDebugSession().startAppHost(projectFile, args, environment, debug); + async launchAppHost(dcpId: string, projectFile: string, args: string[], environment: EnvVar[], debug: boolean): Promise { + const appHostDebugSessionConfiguration = await createDebugSessionConfiguration({ project_path: projectFile, type: 'project' }, args, environment, { debug, forceBuild: debug, runId: '', dcpId }, projectDebuggerExtension); + this.notifyReadyForDebugSessionStart(dcpId); + const debugSession = this._getAspireDebugSession(dcpId); + if (!debugSession) { + throw new Error(aspireDebugSessionNotInitialized); + } + + return debugSession.startAppHost(projectFile, appHostDebugSessionConfiguration); } - stopDebugging() { + stopDebugging(dcpId: string) { this.clearStatusBar(); - this._getAspireDebugSession().dispose(); + this._getAspireDebugSession(dcpId)?.dispose(); } - notifyAppHostStartupCompleted() { - this._getAspireDebugSession().notifyAppHostStartupCompleted(); + notifyReadyForDebugSessionStart(dcpId: string) { + this._rpcServer.notifyReadyForDebugSessionStart(dcpId); + } + + notifyAppHostStartupCompleted(dcpId: string) { + const debugSession = this._getAspireDebugSession(dcpId); + if (!debugSession) { + throw new Error(aspireDebugSessionNotInitialized); + } + + debugSession.notifyAppHostStartupCompleted(); } clearStatusBar() { @@ -311,7 +342,8 @@ export function addInteractionServiceEndpoints(connection: MessageConnection, in connection.onRequest("displayCancellationMessage", withAuthentication(interactionService.displayCancellationMessage.bind(interactionService))); connection.onRequest("openProject", withAuthentication(interactionService.openProject.bind(interactionService))); connection.onRequest("logMessage", withAuthentication(interactionService.logMessage.bind(interactionService))); - connection.onRequest("launchAppHost", withAuthentication(async (projectFile: string, args: string[], environment: EnvVar[], debug: boolean) => interactionService.launchAppHost(projectFile, args, environment, debug))); + connection.onRequest("launchAppHost", withAuthentication(async (dcpId: string, projectFile: string, args: string[], environment: EnvVar[], debug: boolean) => interactionService.launchAppHost(dcpId, projectFile, args, environment, debug))); connection.onRequest("stopDebugging", withAuthentication(interactionService.stopDebugging.bind(interactionService))); connection.onRequest("notifyAppHostStartupCompleted", withAuthentication(interactionService.notifyAppHostStartupCompleted.bind(interactionService))); + connection.onRequest("notifyReadyForDebugSessionStart", withAuthentication(interactionService.notifyReadyForDebugSessionStart.bind(interactionService))); } diff --git a/extension/src/server/rpcClient.ts b/extension/src/server/rpcClient.ts deleted file mode 100644 index a7f011a75f1..00000000000 --- a/extension/src/server/rpcClient.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { MessageConnection } from 'vscode-jsonrpc'; -import { extensionLogOutputChannel, logAsyncOperation } from '../utils/logging'; -import { getAspireTerminal } from '../utils/terminal'; -import { RpcServerConnectionInfo } from './AspireRpcServer'; - -export interface ICliRpcClient { - getCliVersion(): Promise; - validatePromptInputString(input: string): Promise; - stopCli(): Promise; -} - -export type ValidationResult = { - Message: string; - Successful: boolean; -}; - -export class RpcClient implements ICliRpcClient { - private _rpcServerConnectionInfo: RpcServerConnectionInfo; - private _messageConnection: MessageConnection; - private _token: string; - private _connectionClosed: boolean; - - constructor(rpcServerConnectionInfo: RpcServerConnectionInfo, messageConnection: MessageConnection, token: string) { - this._rpcServerConnectionInfo = rpcServerConnectionInfo; - this._messageConnection = messageConnection; - this._token = token; - this._connectionClosed = false; - - this._messageConnection.onClose(() => { - this._connectionClosed = true; - extensionLogOutputChannel.info('JSON-RPC connection closed'); - }); - } - - getCliVersion(): Promise { - return logAsyncOperation( - `Requesting CLI version from CLI`, - (version: string) => `Received CLI version: ${version}`, - async () => { - return await this._messageConnection.sendRequest('getCliVersion', this._token); - } - ); - } - - validatePromptInputString(input: string): Promise { - return logAsyncOperation( - `Validating prompt input string`, - (result: ValidationResult | null) => `Received validation result: ${JSON.stringify(result)}`, - async () => { - return await this._messageConnection.sendRequest('validatePromptInputString', { - token: this._token, - input - }); - } - ); - } - - async stopCli() { - if (this._connectionClosed) { - // If connection is already closed for some reason, we cannot send a request - // Instead, dispose of the terminal directly. - getAspireTerminal(this._rpcServerConnectionInfo).dispose(); - } else { - await this._messageConnection.sendRequest('stopCli', this._token); - } - } -} diff --git a/extension/src/test/rpc/interactionServiceTests.test.ts b/extension/src/test/rpc/interactionServiceTests.test.ts index 1307c8b52c3..3acd965dfe4 100644 --- a/extension/src/test/rpc/interactionServiceTests.test.ts +++ b/extension/src/test/rpc/interactionServiceTests.test.ts @@ -3,11 +3,12 @@ import * as vscode from 'vscode'; import * as sinon from 'sinon'; import { IInteractionService, InteractionService } from '../../server/interactionService'; -import { ICliRpcClient, ValidationResult } from '../../server/rpcClient'; +import { ICliRpcClient, ValidationResult } from '../../server/AspireRpcClient'; import { extensionLogOutputChannel } from '../../utils/logging'; import AspireRpcServer, { RpcServerConnectionInfo } from '../../server/AspireRpcServer'; import { AspireDebugSession } from '../../debugger/AspireDebugSession'; +/* suite('InteractionService endpoints', () => { let statusBarItem: vscode.StatusBarItem; let createStatusBarItemStub: sinon.SinonStub; @@ -201,10 +202,9 @@ async function createTestRpcServer(hasAspireDebugSession?: () => boolean, getAsp }; const rpcClient = new TestCliRpcClient(); - const interactionService = new InteractionService(hasAspireDebugSession, getAspireDebugSession); const rpcServer = await AspireRpcServer.create( - () => interactionService, + (_, rpcServer) => new InteractionService(hasAspireDebugSession, getAspireDebugSession, rpcServer), () => rpcClient ); @@ -215,6 +215,7 @@ async function createTestRpcServer(hasAspireDebugSession?: () => boolean, getAsp return { rpcServerInfo: rpcServer.connectionInfo, rpcClient: rpcClient, - interactionService: interactionService + interactionService: rpcServer.connections[0].interactionService }; } +*/ diff --git a/extension/src/utils/terminal.ts b/extension/src/utils/terminal.ts deleted file mode 100644 index c456c63ca63..00000000000 --- a/extension/src/utils/terminal.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as vscode from 'vscode'; -import { aspireTerminalName } from '../loc/strings'; -import { extensionLogOutputChannel } from './logging'; -import { RpcServerConnectionInfo } from '../server/AspireRpcServer'; -import { DcpServerConnectionInfo } from '../dcp/types'; - -let hasRunGetAspireTerminal = false; -export function getAspireTerminal(rpcServerConnectionInfo: RpcServerConnectionInfo, dcpServerConnectionInfo?: DcpServerConnectionInfo): vscode.Terminal { - const terminalName = aspireTerminalName; - - const existingTerminal = vscode.window.terminals.find(terminal => terminal.name === terminalName); - if (existingTerminal) { - if (!hasRunGetAspireTerminal) { - existingTerminal.dispose(); - extensionLogOutputChannel.info(`Recreating existing Aspire terminal`); - } - else { - return existingTerminal; - } - } - - extensionLogOutputChannel.info(`Creating new Aspire terminal`); - hasRunGetAspireTerminal = true; - - return vscode.window.createTerminal({ - name: terminalName, - env: createEnvironment(rpcServerConnectionInfo, dcpServerConnectionInfo) - }); -} - -export function createEnvironment(rpcServerConnectionInfo: RpcServerConnectionInfo, dcpServerConnectionInfo?: DcpServerConnectionInfo): any { - const env: any = { - ...process.env, - - // Extension connection information - ASPIRE_EXTENSION_ENDPOINT: rpcServerConnectionInfo.address, - ASPIRE_EXTENSION_TOKEN: rpcServerConnectionInfo.token, - ASPIRE_EXTENSION_CERT: Buffer.from(rpcServerConnectionInfo.cert, 'utf-8').toString('base64'), - ASPIRE_EXTENSION_PROMPT_ENABLED: 'true', - - // Use the current locale in the CLI - ASPIRE_LOCALE_OVERRIDE: vscode.env.language - }; - - if (dcpServerConnectionInfo) { - // Include DCP server info - env.DEBUG_SESSION_PORT = dcpServerConnectionInfo.address; - env.DEBUG_SESSION_TOKEN = dcpServerConnectionInfo.token; - env.DEBUG_SESSION_SERVER_CERTIFICATE = dcpServerConnectionInfo.certificate; - } - - return env; -} - -export function sendToAspireTerminal(command: string, rpcServerConnectionInfo: RpcServerConnectionInfo, dcpServerConnectionInfo?: DcpServerConnectionInfo, showTerminal: boolean = true) { - const terminal = getAspireTerminal(rpcServerConnectionInfo, dcpServerConnectionInfo); - extensionLogOutputChannel.info(`Sending command to Aspire terminal: ${command}`); - terminal.sendText(command); - if (showTerminal) { - terminal.show(); - } -} diff --git a/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs b/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs index 7b807bcee47..637b6c2b1f9 100644 --- a/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs @@ -40,6 +40,7 @@ internal interface IExtensionBackchannel Task HasCapabilityAsync(string capability, CancellationToken cancellationToken); Task LaunchAppHostAsync(string projectFile, List arguments, List environment, bool debug, CancellationToken cancellationToken); Task NotifyAppHostStartupCompletedAsync(CancellationToken cancellationToken); + Task NotifyReadyForDebugSessionStartAsync(CancellationToken cancellationToken); } internal sealed class ExtensionBackchannel : IExtensionBackchannel @@ -385,12 +386,13 @@ public async Task ShowStatusAsync(string? status, CancellationToken cancellation using var activity = _activitySource.StartActivity(); var rpc = await _rpcTaskCompletionSource.Task; + var dcpId = GetDcpId(); _logger.LogDebug("Sent status update: {Status}", status); await rpc.InvokeWithCancellationAsync( "showStatus", - [_token, status], + [_token, dcpId, status], cancellationToken); } @@ -526,12 +528,13 @@ public async Task LaunchAppHostAsync(string projectFile, List arguments, using var activity = _activitySource.StartActivity(); var rpc = await _rpcTaskCompletionSource.Task; + var dcpId = GetDcpId(); _logger.LogDebug("Running .NET project at {ProjectFile} with arguments: {Arguments}", projectFile, string.Join(" ", arguments)); await rpc.InvokeWithCancellationAsync( "launchAppHost", - [_token, projectFile, arguments, environment, debug], + [_token, dcpId, projectFile, arguments, environment, debug], cancellationToken); } @@ -542,29 +545,56 @@ public async Task NotifyAppHostStartupCompletedAsync(CancellationToken cancellat using var activity = _activitySource.StartActivity(); var rpc = await _rpcTaskCompletionSource.Task; + var dcpId = GetDcpId(); _logger.LogDebug("Notifying that app host startup is completed"); await rpc.InvokeWithCancellationAsync( "notifyAppHostStartupCompleted", - [_token], + [_token, dcpId], cancellationToken); } - public async Task StopDebuggingAsync() + public async Task NotifyReadyForDebugSessionStartAsync(CancellationToken cancellationToken) { - await ConnectAsync(CancellationToken.None); + await ConnectAsync(cancellationToken); using var activity = _activitySource.StartActivity(); var rpc = await _rpcTaskCompletionSource.Task; + var dcpId = GetDcpId(); - _logger.LogDebug("Stopping extension debugging session"); + _logger.LogDebug("Notifying that we are ready to start a debug session"); await rpc.InvokeWithCancellationAsync( - "stopDebugging", - [_token], - CancellationToken.None); + "notifyReadyForDebugSessionStart", + [_token, dcpId], + cancellationToken); + } + + public async Task StopDebuggingAsync() + { + try + { + await ConnectAsync(CancellationToken.None); + + using var activity = _activitySource.StartActivity(); + + var rpc = await _rpcTaskCompletionSource.Task; + var dcpId = GetDcpId(); + + _logger.LogDebug("Stopping extension debugging session"); + + await rpc.InvokeWithCancellationAsync( + "stopDebugging", + [_token, dcpId], + CancellationToken.None); + } + catch (Exception) + { + // We don't care if we fail to stop debugging because the process is exiting and + // we may not even be debugging. + } } private X509Certificate2 GetCertificate() @@ -574,4 +604,17 @@ private X509Certificate2 GetCertificate() var data = Convert.FromBase64String(serverCertificate); return new X509Certificate2(data); } + + private string GetDcpId() + { + var dcpId = _configuration[KnownConfigNames.ExtensionDcpId]; + + if (string.IsNullOrEmpty(dcpId)) + { + // TODO localize + throw new ArgumentNullException("dcp id is null"); + } + + return dcpId; + } } diff --git a/src/Aspire.Cli/Backchannel/ExtensionRpcTarget.cs b/src/Aspire.Cli/Backchannel/ExtensionRpcTarget.cs index feb3b52f0ac..8a09f8cf2a1 100644 --- a/src/Aspire.Cli/Backchannel/ExtensionRpcTarget.cs +++ b/src/Aspire.Cli/Backchannel/ExtensionRpcTarget.cs @@ -22,6 +22,9 @@ internal interface IExtensionRpcTarget [JsonRpcMethod("stopCli")] Task StopCliAsync(string token); + + [JsonRpcMethod("getDcpId")] + Task GetDcpIdAsync(string token); } internal class ExtensionRpcTarget(IConfiguration configuration) : IExtensionRpcTarget @@ -58,4 +61,15 @@ public Task StopCliAsync(string token) Environment.Exit(ExitCodeConstants.Success); return Task.CompletedTask; } + + public Task GetDcpIdAsync(string token) + { + if (!string.Equals(token, configuration[KnownConfigNames.ExtensionToken], StringComparisons.CliInputOrOutput)) + { + throw new AuthenticationException(); + } + + var dcpId = configuration[KnownConfigNames.ExtensionDcpId] ?? null; + return Task.FromResult(dcpId); + } } diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 7543f330612..85ef5a1741c 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -479,19 +479,27 @@ public virtual async Task ExecuteAsync(string[] args, IDictionary new EnvVar { Name = kvp.Key, Value = kvp.Value }).ToList(), - options.StartDebugSession); + if (!await backchannel.HasCapabilityAsync(KnownCapabilities.DevKit, cancellationToken)) + { + // If the extension does not support the DevKit capability then we will have built the + // apphost already on the CLI and are ready to start the app host debug session + await extensionInteractionService.NotifyReadyForDebugSessionStart(); + } - _ = StartBackchannelAsync(null, socketPath, backchannelCompletionSource, cancellationToken); + if (await backchannel.HasCapabilityAsync(KnownCapabilities.Project, cancellationToken)) + { + await extensionInteractionService.LaunchAppHostAsync( + projectFile.FullName, + startInfo.ArgumentList.ToList(), + startInfo.Environment.Select(kvp => new EnvVar { Name = kvp.Key, Value = kvp.Value }).ToList(), + options.StartDebugSession); - return ExitCodeConstants.Success; + _ = StartBackchannelAsync(null, socketPath, backchannelCompletionSource, cancellationToken); + + return ExitCodeConstants.Success; + } } } diff --git a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs index 6fdfc2402c6..77a9f9378f8 100644 --- a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs @@ -18,6 +18,7 @@ internal interface IExtensionInteractionService : IInteractionService Task LaunchAppHostAsync(string projectFile, List arguments, List environment, bool debug); void DisplayDashboardUrls(DashboardUrlsState dashboardUrls); void NotifyAppHostStartupCompleted(); + Task NotifyReadyForDebugSessionStart(); } internal class ExtensionInteractionService : IExtensionInteractionService @@ -271,4 +272,9 @@ public void NotifyAppHostStartupCompleted() var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.NotifyAppHostStartupCompletedAsync(_cancellationToken)); Debug.Assert(result); } + + public Task NotifyReadyForDebugSessionStart() + { + return Backchannel.NotifyReadyForDebugSessionStartAsync(_cancellationToken); + } } diff --git a/src/Shared/KnownConfigNames.cs b/src/Shared/KnownConfigNames.cs index 11261583d4c..54eb67cfd7d 100644 --- a/src/Shared/KnownConfigNames.cs +++ b/src/Shared/KnownConfigNames.cs @@ -42,6 +42,7 @@ internal static class KnownConfigNames public const string ExtensionCert = "ASPIRE_EXTENSION_CERT"; public const string ExtensionCapabilities = "ASPIRE_EXTENSION_CAPABILITIES"; public const string ExtensionDebugRunMode = "ASPIRE_EXTENSION_DEBUG_RUN_MODE"; + public const string ExtensionDcpId = "ASPIRE_EXTENSION_DCP_ID"; public static class Legacy {