diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index d8c04afb042..c09465209c1 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -10,6 +10,9 @@ Additional arguments to pass to the Aspire CLI command + + AppHosts + Aspire @@ -140,7 +143,7 @@ Dashboard - Debug Aspire AppHost + Debug AppHost Debug Aspire pipeline step @@ -343,17 +346,17 @@ No AppHost found in the current workspace. + + No Aspire AppHosts detected in this workspace. [Refresh](command:aspire-vscode.refreshAppHosts) + No C# Dev Kit build task found, defaulting to dotnet CLI. Maybe the workspace hasn't finished loading? No output from msbuild. - - No running Aspire AppHost detected in this workspace. [Refresh](command:aspire-vscode.refreshRunningAppHosts) - - - No running Aspire AppHosts detected on this machine. [Refresh](command:aspire-vscode.refreshRunningAppHosts) + + No running Aspire AppHosts detected on this machine. [Refresh](command:aspire-vscode.globalRefreshAppHosts) No watch task found. Please ensure a watch task is defined in your workspace. @@ -427,7 +430,10 @@ RPC server is not initialized. - + + Refresh AppHosts + + Refresh running AppHosts @@ -443,7 +449,7 @@ Reveal resource in Aspire panel - Run Aspire AppHost + Run AppHost Run your app @@ -454,9 +460,6 @@ Run {0} - - Running AppHosts - Running aspire restore ({0}/{1} projects) ... @@ -466,8 +469,8 @@ Scaffold a new Aspire project from a starter template. The template includes an AppHost orchestrator, a sample API, and a web frontend. [Create new project](command:aspire-vscode.new) - - Searching for running AppHosts... + + Searching for AppHosts... See CLI installation instructions @@ -497,13 +500,13 @@ Show colored status dots in the editor gutter next to resource declarations in AppHost files. - Show workspace AppHost + Show workspace AppHosts Start - Start your Aspire app to launch all services and open the real-time dashboard. [Run AppHost](command:aspire-vscode.runAppHost) [Debug AppHost](command:aspire-vscode.debugAppHost) + Start your Aspire app to launch all services and open the real-time dashboard. [Run AppHost](command:aspire-vscode.runAppHostCommand) [Debug AppHost](command:aspire-vscode.debugAppHostCommand) Stop @@ -526,8 +529,8 @@ The Aspire Dashboard shows your resources, endpoints, logs, traces, and metrics — all in one place. [Open dashboard](command:aspire-vscode.openDashboard) - - The Aspire panel workspace view requires Aspire CLI 13.2.0 or newer and an AppHost that references Aspire.Hosting 13.2.0 or newer. Install or update the Aspire CLI and update your AppHost package to get started. [Update Aspire CLI](command:aspire-vscode.updateSelf) [Refresh](command:aspire-vscode.refreshRunningAppHosts) + + The Aspire panel workspace view requires Aspire CLI 13.2.0 or newer and an AppHost that references Aspire.Hosting 13.2.0 or newer. Install or update the Aspire CLI and update your AppHost package to get started. [Update Aspire CLI](command:aspire-vscode.updateSelf) [Refresh](command:aspire-vscode.globalRefreshAppHosts) The browser to use when auto-launching the Aspire Dashboard. diff --git a/extension/package.json b/extension/package.json index 095c39616c3..6b41f9d0888 100644 --- a/extension/package.json +++ b/extension/package.json @@ -66,30 +66,30 @@ "views": { "aspire-panel": [ { - "id": "aspire-vscode.runningAppHosts", - "name": "%views.runningAppHosts.name%" + "id": "aspire-vscode.appHosts", + "name": "%views.appHosts.name%" } ] }, "viewsWelcome": [ { - "view": "aspire-vscode.runningAppHosts", - "contents": "%views.runningAppHosts.loading%", + "view": "aspire-vscode.appHosts", + "contents": "%views.appHosts.loading%", "when": "aspire.loading" }, { - "view": "aspire-vscode.runningAppHosts", - "contents": "%views.runningAppHosts.welcome%", - "when": "aspire.noRunningAppHosts && !aspire.fetchAppHostsError && !aspire.loading && aspire.viewMode != 'global'" + "view": "aspire-vscode.appHosts", + "contents": "%views.appHosts.welcome%", + "when": "aspire.noAppHosts && !aspire.fetchAppHostsError && !aspire.loading && aspire.viewMode != 'global'" }, { - "view": "aspire-vscode.runningAppHosts", - "contents": "%views.runningAppHosts.globalWelcome%", - "when": "aspire.noRunningAppHosts && !aspire.fetchAppHostsError && !aspire.loading && aspire.viewMode == 'global'" + "view": "aspire-vscode.appHosts", + "contents": "%views.appHosts.globalWelcome%", + "when": "aspire.noAppHosts && !aspire.fetchAppHostsError && !aspire.loading && aspire.viewMode == 'global'" }, { - "view": "aspire-vscode.runningAppHosts", - "contents": "%views.runningAppHosts.errorWelcome%", + "view": "aspire-vscode.appHosts", + "contents": "%views.appHosts.errorWelcome%", "when": "aspire.fetchAppHostsError && !aspire.loading" } ], @@ -261,8 +261,26 @@ "icon": "$(debug-all)" }, { - "command": "aspire-vscode.refreshRunningAppHosts", - "title": "%command.refreshRunningAppHosts%", + "command": "aspire-vscode.runAppHostCommand", + "title": "%command.runAppHost%", + "category": "Aspire", + "icon": "$(run-all)" + }, + { + "command": "aspire-vscode.debugAppHostCommand", + "title": "%command.debugAppHost%", + "category": "Aspire", + "icon": "$(debug-all)" + }, + { + "command": "aspire-vscode.globalRefreshAppHosts", + "title": "%command.globalRefreshAppHosts%", + "category": "Aspire", + "icon": "$(refresh)" + }, + { + "command": "aspire-vscode.refreshAppHosts", + "title": "%command.refreshAppHosts%", "category": "Aspire", "icon": "$(refresh)" }, @@ -443,24 +461,24 @@ "menus": { "explorer/context": [ { - "command": "aspire-vscode.runAppHost", + "command": "aspire-vscode.runAppHostCommand", "when": "resourceFilename =~ /apphost\\.(cs|ts|js)$/i", "group": "aspire_actions@1" }, { - "command": "aspire-vscode.debugAppHost", + "command": "aspire-vscode.debugAppHostCommand", "when": "resourceFilename =~ /apphost\\.(cs|ts|js)$/i", "group": "aspire_actions@2" } ], "editor/title/run": [ { - "command": "aspire-vscode.runAppHost", + "command": "aspire-vscode.runAppHostCommand", "when": "(aspire.fileIsAppHost || aspire.workspaceHasAppHost) && aspire.editorSupportsRunDebug", "group": "navigation@-4" }, { - "command": "aspire-vscode.debugAppHost", + "command": "aspire-vscode.debugAppHostCommand", "when": "(aspire.fileIsAppHost || aspire.workspaceHasAppHost) && aspire.editorSupportsRunDebug", "group": "navigation@-3" } @@ -475,7 +493,19 @@ "when": "false" }, { - "command": "aspire-vscode.refreshRunningAppHosts", + "command": "aspire-vscode.runAppHostCommand", + "when": "false" + }, + { + "command": "aspire-vscode.debugAppHostCommand", + "when": "false" + }, + { + "command": "aspire-vscode.globalRefreshAppHosts", + "when": "false" + }, + { + "command": "aspire-vscode.refreshAppHosts", "when": "false" }, { @@ -582,104 +612,119 @@ "view/title": [ { "command": "aspire-vscode.switchToGlobalView", - "when": "view == 'aspire-vscode.runningAppHosts' && aspire.viewMode != 'global'", + "when": "view == 'aspire-vscode.appHosts' && aspire.viewMode != 'global'", "group": "navigation" }, { "command": "aspire-vscode.switchToWorkspaceView", - "when": "view == 'aspire-vscode.runningAppHosts' && aspire.viewMode == 'global'", + "when": "view == 'aspire-vscode.appHosts' && aspire.viewMode == 'global'", "group": "navigation" }, { - "command": "aspire-vscode.refreshRunningAppHosts", - "when": "view == 'aspire-vscode.runningAppHosts'", + "command": "aspire-vscode.globalRefreshAppHosts", + "when": "view == 'aspire-vscode.appHosts' && aspire.viewMode == 'global'", + "group": "navigation" + }, + { + "command": "aspire-vscode.refreshAppHosts", + "when": "view == 'aspire-vscode.appHosts' && aspire.viewMode != 'global'", "group": "navigation" } ], "view/item/context": [ { "command": "aspire-vscode.openDashboard", - "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost$|workspaceResources(:|$))/", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^(appHost$|workspaceResources(:|$))/", "group": "inline" }, { "command": "aspire-vscode.expandAll", - "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost$|workspaceResources(:|$))/", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^(appHost$|workspaceResources(:|$))/", "group": "inline" }, { "command": "aspire-vscode.openAppHostSource", - "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost$|workspaceResources(:|$))/", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^(appHost$|workspaceResources(:hasAppHost)?|workspaceAppHost)$/", "group": "1_open@1" }, + { + "command": "aspire-vscode.runAppHost", + "when": "view == aspire-vscode.appHosts && viewItem == workspaceAppHost", + "group": "2_actions@1" + }, + { + "command": "aspire-vscode.debugAppHost", + "when": "view == aspire-vscode.appHosts && viewItem == workspaceAppHost", + "group": "2_actions@2" + }, { "command": "aspire-vscode.stopAppHost", - "when": "view == aspire-vscode.runningAppHosts && viewItem == appHost", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^(appHost$|workspaceResources:hasAppHost)$/", "group": "2_actions@1" }, { "command": "aspire-vscode.copyAppHostPath", - "when": "view == aspire-vscode.runningAppHosts && viewItem == appHost", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^(appHost$|workspaceResources(:hasAppHost)?|workspaceAppHost)$/", "group": "3_clipboard@1" }, { "command": "aspire-vscode.stopResource", - "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^resource.*:canStop/", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^resource.*:canStop/", "group": "2_actions@1" }, { "command": "aspire-vscode.startResource", - "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^resource.*:canStart/", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^resource.*:canStart/", "group": "2_actions@2" }, { "command": "aspire-vscode.restartResource", - "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^resource.*:canRestart/", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^resource.*:canRestart/", "group": "2_actions@3" }, { "command": "aspire-vscode.executeResourceCommand", - "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^resource(:|$)/", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^resource(:|$)/", "group": "2_actions@4" }, { "command": "aspire-vscode.viewResourceLogs", - "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^resource(:|$)/", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^resource(:|$)/", "group": "3_info@1" }, { "command": "aspire-vscode.openInExternalBrowser", - "when": "view == aspire-vscode.runningAppHosts && viewItem == endpointUrl", + "when": "view == aspire-vscode.appHosts && viewItem == endpointUrl", "group": "1_open@1" }, { "command": "aspire-vscode.openInIntegratedBrowser", - "when": "view == aspire-vscode.runningAppHosts && viewItem == endpointUrl", + "when": "view == aspire-vscode.appHosts && viewItem == endpointUrl", "group": "1_open@2" }, { "command": "aspire-vscode.copyEndpointUrl", - "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^endpointUrl/", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^endpointUrl/", "group": "3_clipboard@1" }, { "command": "aspire-vscode.copyResourceName", - "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^resource(:|$)/", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^resource(:|$)/", "group": "3_info@2" }, { "command": "aspire-vscode.viewAppHostSource", - "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost|workspaceResources:hasAppHost)$/", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^(appHost|workspaceResources:hasAppHost)$/", "group": "1_open@2" }, { "command": "aspire-vscode.viewAppHostLogFile", - "when": "view == aspire-vscode.runningAppHosts && viewItem == logFileItem", + "when": "view == aspire-vscode.appHosts && viewItem == logFileItem", "group": "1_open@1" }, { "command": "aspire-vscode.copyLogFilePath", - "when": "view == aspire-vscode.runningAppHosts && viewItem == logFileItem", + "when": "view == aspire-vscode.appHosts && viewItem == logFileItem", "group": "3_clipboard@1" } ] @@ -855,8 +900,8 @@ "markdown": "walkthrough/runApp.md" }, "completionEvents": [ - "onCommand:aspire-vscode.runAppHost", - "onCommand:aspire-vscode.debugAppHost" + "onCommand:aspire-vscode.runAppHostCommand", + "onCommand:aspire-vscode.debugAppHostCommand" ] }, { diff --git a/extension/package.nls.json b/extension/package.nls.json index 32ae40fa38b..22b70038bab 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -46,8 +46,8 @@ "configuration.aspire.enableCodeLens": "Show CodeLens actions (state, restart, stop, logs) inline above resource declarations in AppHost files.", "configuration.aspire.enableGutterDecorations": "Show colored status dots in the editor gutter next to resource declarations in AppHost files.", "configuration.aspire.enableAutoRestore": "Automatically run 'aspire restore' when the workspace opens and whenever aspire.config.json changes (e.g. after switching git branches). Keeps integration packages in sync and prevents editor errors.", - "command.runAppHost": "Run Aspire AppHost", - "command.debugAppHost": "Debug Aspire AppHost", + "command.runAppHost": "Run AppHost", + "command.debugAppHost": "Debug AppHost", "aspire-vscode.strings.noCsprojFound": "No AppHost found in the current workspace.", "aspire-vscode.strings.error": "Error: {0}", "aspire-vscode.strings.yes": "Yes", @@ -142,12 +142,13 @@ "aspire-vscode.strings.logFilePathInvalid": "Could not determine the AppHost log file to open.", "aspire-vscode.strings.logFileOpenFailed": "Failed to open AppHost log file {0}: {1}", "viewsContainers.aspirePanel.title": "Aspire", - "views.runningAppHosts.name": "Running AppHosts", - "views.runningAppHosts.loading": "Searching for running AppHosts...", - "views.runningAppHosts.welcome": "No running Aspire AppHost detected in this workspace.\n[Refresh](command:aspire-vscode.refreshRunningAppHosts)", - "views.runningAppHosts.globalWelcome": "No running Aspire AppHosts detected on this machine.\n[Refresh](command:aspire-vscode.refreshRunningAppHosts)", - "views.runningAppHosts.errorWelcome": "The Aspire panel workspace view requires Aspire CLI 13.2.0 or newer and an AppHost that references Aspire.Hosting 13.2.0 or newer. Install or update the Aspire CLI and update your AppHost package to get started.\n[Update Aspire CLI](command:aspire-vscode.updateSelf)\n[Refresh](command:aspire-vscode.refreshRunningAppHosts)", - "command.refreshRunningAppHosts": "Refresh running AppHosts", + "views.appHosts.name": "AppHosts", + "views.appHosts.loading": "Searching for AppHosts...", + "views.appHosts.welcome": "No Aspire AppHosts detected in this workspace.\n[Refresh](command:aspire-vscode.refreshAppHosts)", + "views.appHosts.globalWelcome": "No running Aspire AppHosts detected on this machine.\n[Refresh](command:aspire-vscode.globalRefreshAppHosts)", + "views.appHosts.errorWelcome": "The Aspire panel workspace view requires Aspire CLI 13.2.0 or newer and an AppHost that references Aspire.Hosting 13.2.0 or newer. Install or update the Aspire CLI and update your AppHost package to get started.\n[Update Aspire CLI](command:aspire-vscode.updateSelf)\n[Refresh](command:aspire-vscode.globalRefreshAppHosts)", + "command.refreshAppHosts": "Refresh AppHosts", + "command.globalRefreshAppHosts": "Refresh running AppHosts", "command.openDashboard": "Open Aspire Dashboard", "command.openAppHostSource": "Open AppHost source", "command.stopAppHost": "Stop", @@ -163,7 +164,7 @@ "command.copyResourceName": "Copy resource name", "command.viewAppHostSource": "View source", "command.switchToGlobalView": "Show all running AppHosts", - "command.switchToWorkspaceView": "Show workspace AppHost", + "command.switchToWorkspaceView": "Show workspace AppHosts", "command.installCliStable": "Install Aspire CLI (stable)", "command.installCliDaily": "Install Aspire CLI (daily)", "command.verifyCliInstalled": "Verify Aspire CLI installation", @@ -185,7 +186,7 @@ "walkthrough.getStarted.createProject.title": "Create a new project", "walkthrough.getStarted.createProject.description": "Scaffold a new Aspire project from a starter template. The template includes an AppHost orchestrator, a sample API, and a web frontend.\n\n[Create new project](command:aspire-vscode.new)", "walkthrough.getStarted.runApp.title": "Run your app", - "walkthrough.getStarted.runApp.description": "Start your Aspire app to launch all services and open the real-time dashboard.\n\n[Run AppHost](command:aspire-vscode.runAppHost)\n\n[Debug AppHost](command:aspire-vscode.debugAppHost)", + "walkthrough.getStarted.runApp.description": "Start your Aspire app to launch all services and open the real-time dashboard.\n\n[Run AppHost](command:aspire-vscode.runAppHostCommand)\n\n[Debug AppHost](command:aspire-vscode.debugAppHostCommand)", "walkthrough.getStarted.dashboard.title": "Explore the dashboard", "walkthrough.getStarted.dashboard.description": "The Aspire Dashboard shows your resources, endpoints, logs, traces, and metrics — all in one place.\n\n[Open dashboard](command:aspire-vscode.openDashboard)", "walkthrough.getStarted.nextSteps.title": "Next steps", diff --git a/extension/src/editor/AspireEditorCommandProvider.ts b/extension/src/editor/AspireEditorCommandProvider.ts index 5c041cdfade..2e8f531e99a 100644 --- a/extension/src/editor/AspireEditorCommandProvider.ts +++ b/extension/src/editor/AspireEditorCommandProvider.ts @@ -6,11 +6,15 @@ import { AspireCommandType } from '../dcp/types'; import { AppHostDiscoveryService, getDebugTargetForCandidate, selectWorkspaceAppHostPath } from '../utils/appHostDiscovery'; import type { CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; import { extensionLogOutputChannel } from '../utils/logging'; +import { AppHostLaunchService } from '../services/AppHostLaunchService'; export class AspireEditorCommandProvider implements vscode.Disposable { private _disposables: vscode.Disposable[] = []; - constructor(private readonly _appHostDiscoveryService: AppHostDiscoveryService) { + constructor( + private readonly _appHostDiscoveryService: AppHostDiscoveryService, + private readonly _launchService: AppHostLaunchService, + ) { this._disposables.push(vscode.workspace.onDidChangeWorkspaceFolders(event => { void this.updateWorkspaceAppHostContext(); })); @@ -137,20 +141,7 @@ export class AspireEditorCommandProvider implements vscode.Disposable { return; } - const config: vscode.DebugConfiguration = { - type: 'aspire', - name: `Aspire ${aspireCommand}: ${vscode.workspace.asRelativePath(appHostToRun)}`, - request: 'launch', - program: appHostToRun, - command: aspireCommand, - noDebug: noDebug - }; - - if (doStep) { - config.step = doStep; - } - - await vscode.debug.startDebugging(undefined, config); + await this._launchService.launch(appHostToRun, aspireCommand, noDebug, doStep); } dispose() { diff --git a/extension/src/extension.ts b/extension/src/extension.ts index eb7ab99ee8b..04576787891 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -39,6 +39,7 @@ import { collectResourceCommandArguments } from './views/ResourceCommandArgument import { createResourceCommandArgumentLoader } from './views/ResourceCommandArgumentsLoader'; import { ResourceCommandJson } from './views/AppHostDataRepository'; import { AppHostDiscoveryService } from './utils/appHostDiscovery'; +import { AppHostLaunchService } from './services/AppHostLaunchService'; let aspireExtensionContext = new AspireExtensionContext(); @@ -65,7 +66,10 @@ export async function activate(context: vscode.ExtensionContext) { const appHostDiscoveryService = new AppHostDiscoveryService(terminalProvider); context.subscriptions.push(appHostDiscoveryService); - const editorCommandProvider = new AspireEditorCommandProvider(appHostDiscoveryService); + const appHostLaunchService = new AppHostLaunchService(); + context.subscriptions.push(appHostLaunchService); + + const editorCommandProvider = new AspireEditorCommandProvider(appHostDiscoveryService, appHostLaunchService); 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)); @@ -80,8 +84,8 @@ export async function activate(context: vscode.ExtensionContext) { const settingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.settings', () => tryExecuteCommand('aspire-vscode.settings', terminalProvider, settingsCommand)); const openLocalSettingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openLocalSettings', () => tryExecuteCommand('aspire-vscode.openLocalSettings', terminalProvider, openLocalSettingsCommand)); const openGlobalSettingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openGlobalSettings', () => tryExecuteCommand('aspire-vscode.openGlobalSettings', terminalProvider, openGlobalSettingsCommand)); - const runAppHostCommandRegistration = vscode.commands.registerCommand('aspire-vscode.runAppHost', () => editorCommandProvider.tryExecuteRunAppHost(true)); - const debugAppHostCommandRegistration = vscode.commands.registerCommand('aspire-vscode.debugAppHost', () => editorCommandProvider.tryExecuteRunAppHost(false)); + const runAppHostCommandRegistration = vscode.commands.registerCommand('aspire-vscode.runAppHostCommand', () => editorCommandProvider.tryExecuteRunAppHost(true)); + const debugAppHostCommandRegistration = vscode.commands.registerCommand('aspire-vscode.debugAppHostCommand', () => editorCommandProvider.tryExecuteRunAppHost(false)); // Walkthrough commands (no CLI check - CLI may not be installed yet) const installCliStableRegistration = vscode.commands.registerCommand('aspire-vscode.installCliStable', installCliStableCommand); @@ -90,8 +94,8 @@ export async function activate(context: vscode.ExtensionContext) { // Aspire panel - running app hosts tree view const dataRepository = new AppHostDataRepository(terminalProvider, appHostDiscoveryService); - const appHostTreeProvider = new AspireAppHostTreeProvider(dataRepository, terminalProvider, context.globalState); - const appHostTreeView = vscode.window.createTreeView('aspire-vscode.runningAppHosts', { + const appHostTreeProvider = new AspireAppHostTreeProvider(dataRepository, terminalProvider, appHostLaunchService, context.globalState); + const appHostTreeView = vscode.window.createTreeView('aspire-vscode.appHosts', { treeDataProvider: appHostTreeProvider, showCollapseAll: true, }); @@ -108,12 +112,15 @@ export async function activate(context: vscode.ExtensionContext) { const appHostFilePresenceWatcher = new AppHostFilePresenceWatcher(dataRepository); context.subscriptions.push(appHostFilePresenceWatcher); - const refreshRunningAppHostsRegistration = vscode.commands.registerCommand('aspire-vscode.refreshRunningAppHosts', () => dataRepository.refresh()); + const globalRefreshAppHostsRegistration = vscode.commands.registerCommand('aspire-vscode.globalRefreshAppHosts', () => dataRepository.refresh()); + const refreshAppHostsRegistration = vscode.commands.registerCommand('aspire-vscode.refreshAppHosts', () => dataRepository.refresh()); const switchToGlobalViewRegistration = vscode.commands.registerCommand('aspire-vscode.switchToGlobalView', () => dataRepository.setViewMode('global')); const switchToWorkspaceViewRegistration = vscode.commands.registerCommand('aspire-vscode.switchToWorkspaceView', () => dataRepository.setViewMode('workspace')); const openDashboardRegistration = vscode.commands.registerCommand('aspire-vscode.openDashboard', (element) => appHostTreeProvider.openDashboard(element)); const openAppHostSourceRegistration = vscode.commands.registerCommand('aspire-vscode.openAppHostSource', (element) => appHostTreeProvider.openAppHostSource(element)); const stopAppHostRegistration = vscode.commands.registerCommand('aspire-vscode.stopAppHost', (element) => appHostTreeProvider.stopAppHost(element)); + const runAppHostRegistration = vscode.commands.registerCommand('aspire-vscode.runAppHost', (element) => appHostTreeProvider.runAppHost(element, true)); + const debugAppHostRegistration = vscode.commands.registerCommand('aspire-vscode.debugAppHost', (element) => appHostTreeProvider.runAppHost(element, false)); const stopResourceRegistration = vscode.commands.registerCommand('aspire-vscode.stopResource', (element) => appHostTreeProvider.stopResource(element)); const startResourceRegistration = vscode.commands.registerCommand('aspire-vscode.startResource', (element) => appHostTreeProvider.startResource(element)); const restartResourceRegistration = vscode.commands.registerCommand('aspire-vscode.restartResource', (element) => appHostTreeProvider.restartResource(element)); @@ -130,13 +137,14 @@ export async function activate(context: vscode.ExtensionContext) { const expandAllRegistration = vscode.commands.registerCommand('aspire-vscode.expandAll', (element) => appHostTreeProvider.expandAll(element)); // Set initial context for welcome view + vscode.commands.executeCommand('setContext', 'aspire.noAppHosts', true); vscode.commands.executeCommand('setContext', 'aspire.noRunningAppHosts', true); vscode.commands.executeCommand('setContext', 'aspire.loading', true); // Activate the data repository. Workspace describe watching and global polling begin when the panel is visible. dataRepository.activate(); - context.subscriptions.push(appHostTreeView, refreshRunningAppHostsRegistration, switchToGlobalViewRegistration, switchToWorkspaceViewRegistration, openDashboardRegistration, openAppHostSourceRegistration, stopAppHostRegistration, stopResourceRegistration, startResourceRegistration, restartResourceRegistration, viewResourceLogsRegistration, executeResourceCommandRegistration, copyEndpointUrlRegistration, openInExternalBrowserRegistration, openInIntegratedBrowserRegistration, copyResourceNameRegistration, copyAppHostPathRegistration, viewAppHostSourceRegistration, viewAppHostLogFileRegistration, copyLogFilePathRegistration, expandAllRegistration, { dispose: () => { appHostTreeProvider.dispose(); dataRepository.dispose(); } }); + context.subscriptions.push(appHostTreeView, globalRefreshAppHostsRegistration, refreshAppHostsRegistration, switchToGlobalViewRegistration, switchToWorkspaceViewRegistration, openDashboardRegistration, openAppHostSourceRegistration, stopAppHostRegistration, runAppHostRegistration, debugAppHostRegistration, stopResourceRegistration, startResourceRegistration, restartResourceRegistration, viewResourceLogsRegistration, executeResourceCommandRegistration, copyEndpointUrlRegistration, openInExternalBrowserRegistration, openInIntegratedBrowserRegistration, copyResourceNameRegistration, copyAppHostPathRegistration, viewAppHostSourceRegistration, viewAppHostLogFileRegistration, copyLogFilePathRegistration, expandAllRegistration, { dispose: () => { appHostTreeProvider.dispose(); dataRepository.dispose(); } }); // CodeLens provider — shows Debug on pipeline steps, resource state on resources const codeLensProvider = new AspireCodeLensProvider(appHostTreeProvider, dataRepository); diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index f62bb069c06..d6ef6429640 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -76,6 +76,13 @@ export const resourceCommandInvalidNumber = vscode.l10n.t('Enter a number using export const resourceCommandMaxLength = (length: number) => vscode.l10n.t('Value must be {0} characters or fewer.', length); export const selectDashboardPlaceholder = vscode.l10n.t('Select a dashboard to open'); export const workspaceAppHostLabel = vscode.l10n.t('Workspace AppHost'); +export const workspaceAppHostsGroupLabel = vscode.l10n.t('Workspace AppHosts'); +export const runningAppHostsGroupLabel = vscode.l10n.t('Running AppHosts'); +export const appHostOpenSourceActionLabel = vscode.l10n.t('Open AppHost source'); +export const appHostRunActionLabel = vscode.l10n.t('Run AppHost'); +export const appHostDebugActionLabel = vscode.l10n.t('Debug AppHost'); +export const appHostPathLabel = vscode.l10n.t('Path'); +export const appHostStartingDescription = vscode.l10n.t('Starting...'); export const resourceCountDescription = (count: number) => vscode.l10n.t('({0} resources)', count); export const appHostCandidateDescription = (language: string, status: string) => vscode.l10n.t('{0} · {1}', language, status); export const workspaceViewSelectedSingleAppHost = (language?: string) => language diff --git a/extension/src/server/interactionService.ts b/extension/src/server/interactionService.ts index 1b992602e8b..37a9dc9e8f7 100644 --- a/extension/src/server/interactionService.ts +++ b/extension/src/server/interactionService.ts @@ -339,7 +339,7 @@ export class InteractionService implements IInteractionService { } // Refresh the Aspire panel so it picks up dashboard URLs for the running app host - vscode.commands.executeCommand('aspire-vscode.refreshRunningAppHosts'); + vscode.commands.executeCommand('aspire-vscode.refreshAppHosts'); // If aspire.enableAspireDashboardAutoLaunch is 'launch', the dashboard will be launched automatically. // If 'notification', a notification is shown with a link. If 'off', do nothing. diff --git a/extension/src/services/AppHostLaunchService.ts b/extension/src/services/AppHostLaunchService.ts new file mode 100644 index 00000000000..ea19403cb3b --- /dev/null +++ b/extension/src/services/AppHostLaunchService.ts @@ -0,0 +1,104 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { AspireCommandType, AspireExtendedDebugConfiguration } from '../dcp/types'; + +function getComparisonKey(value: string): string { + return process.platform === 'win32' ? value.toLowerCase() : value; +} + +/** + * Centralizes all Aspire AppHost launch operations that require a resolved + * AppHost path. Both the editor command provider (which discovers the path) + * and the tree provider (which extracts it from a tree item) delegate here. + * + * Also tracks which AppHost paths are currently in a "launching" state + * (between the user clicking Run/Debug and the AppHost appearing in the + * running list or the debug session terminating). + */ +export class AppHostLaunchService implements vscode.Disposable { + private readonly _launchingPaths = new Set(); + + private readonly _onDidChangeLaunchingState = new vscode.EventEmitter(); + readonly onDidChangeLaunchingState = this._onDidChangeLaunchingState.event; + + private readonly _debugSessionSubscription: vscode.Disposable; + + constructor() { + // When a debug session terminates, clear launching state for that AppHost + // so the tree reverts from "Starting..." if the launch failed or was cancelled. + this._debugSessionSubscription = vscode.debug.onDidTerminateDebugSession(session => { + const appHostPath = session.configuration?.program; + if (appHostPath && session.configuration?.type === 'aspire') { + const key = getComparisonKey(path.resolve(appHostPath)); + if (this._launchingPaths.delete(key)) { + this._onDidChangeLaunchingState.fire(); + } + } + }); + } + + dispose(): void { + this._debugSessionSubscription.dispose(); + this._onDidChangeLaunchingState.dispose(); + } + + /** + * Returns whether the given AppHost path is currently in a launching state. + */ + isLaunching(appHostPath: string): boolean { + return this._launchingPaths.has(getComparisonKey(path.resolve(appHostPath))); + } + + /** + * Clears launching state for the given AppHost path (e.g., when it + * appears in the running AppHosts list). + */ + clearLaunching(appHostPath: string): void { + const key = getComparisonKey(path.resolve(appHostPath)); + if (this._launchingPaths.delete(key)) { + this._onDidChangeLaunchingState.fire(); + } + } + + /** + * Launches an Aspire debug session for the given AppHost path. + * Automatically marks the path as "launching" until it either appears + * in the running list or the debug session terminates. + * @param appHostPath Absolute path to the AppHost project. + * @param command The Aspire CLI command to execute (run, deploy, publish, do). + * @param noDebug When true, launches without the debugger attached. + * @param doStep Optional step name for the 'do' command. + */ + async launch(appHostPath: string, command: AspireCommandType, noDebug: boolean, doStep?: string): Promise { + // Track launching state before awaiting startDebugging so the tree shows "Starting..." + // immediately. We must clear this state if startDebugging returns false (debug adapter + // rejected, no provider matched, user cancelled) or throws — otherwise no terminate + // event will fire and the tree item stays stuck on the spinner indefinitely. + // See https://code.visualstudio.com/api/references/vscode-api#debug.startDebugging + this._launchingPaths.add(getComparisonKey(path.resolve(appHostPath))); + this._onDidChangeLaunchingState.fire(); + + const config: AspireExtendedDebugConfiguration = { + type: 'aspire', + name: `Aspire ${command}: ${vscode.workspace.asRelativePath(appHostPath)}`, + request: 'launch', + program: appHostPath, + command, + noDebug + }; + + if (doStep) { + config.step = doStep; + } + + try { + const started = await vscode.debug.startDebugging(undefined, config); + if (!started) { + this.clearLaunching(appHostPath); + } + } catch (err) { + this.clearLaunching(appHostPath); + throw err; + } + } +} diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index 145f7f5ba27..fbe6b0102cc 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -1003,7 +1003,7 @@ suite('AppHostDataRepository', () => { ])); const noRunningContextCalls = executeCommandStub.getCalls().filter(call => - call.args[0] === 'setContext' && call.args[1] === 'aspire.noRunningAppHosts'); + call.args[0] === 'setContext' && call.args[1] === 'aspire.noAppHosts'); assert.strictEqual(noRunningContextCalls.at(-1)?.args[2], false); } finally { repository.dispose(); @@ -1069,9 +1069,18 @@ suite('AppHostDataRepository', () => { call.args[0] === 'setContext' && call.args[1] === 'aspire.loading'); assert.strictEqual(loadingContextCalls.at(-1)?.args[2], false); + // noAppHosts is false because workspace candidates are still present (idle AppHosts) const noRunningContextCalls = executeCommandStub.getCalls().filter(call => + call.args[0] === 'setContext' && call.args[1] === 'aspire.noAppHosts'); + assert.strictEqual(noRunningContextCalls.at(-1)?.args[2], false); + + // noRunningAppHosts is true because aspire ps returned no running AppHosts. + // This distinguishes "discovered candidates exist" from "any AppHost is actually + // running" — the Open Dashboard palette entry should be hidden in this state + // because no live dashboard URL is available. + const noLiveAppHostsCalls = executeCommandStub.getCalls().filter(call => call.args[0] === 'setContext' && call.args[1] === 'aspire.noRunningAppHosts'); - assert.strictEqual(noRunningContextCalls.at(-1)?.args[2], true); + assert.strictEqual(noLiveAppHostsCalls.at(-1)?.args[2], true); } finally { repository.dispose(); executeCommandStub.restore(); @@ -1200,9 +1209,10 @@ suite('AppHostDataRepository', () => { assert.strictEqual(repository.workspaceAppHost, undefined); assert.strictEqual(repository.appHosts.length, 0); + // noAppHosts is false because workspace candidates are still present (idle AppHosts) const noRunningContextCalls = executeCommandStub.getCalls().filter(call => - call.args[0] === 'setContext' && call.args[1] === 'aspire.noRunningAppHosts'); - assert.strictEqual(noRunningContextCalls.at(-1)?.args[2], true); + call.args[0] === 'setContext' && call.args[1] === 'aspire.noAppHosts'); + assert.strictEqual(noRunningContextCalls.at(-1)?.args[2], false); } finally { repository.dispose(); executeCommandStub.restore(); diff --git a/extension/src/test/appHostLaunchService.test.ts b/extension/src/test/appHostLaunchService.test.ts new file mode 100644 index 00000000000..356dd2f2509 --- /dev/null +++ b/extension/src/test/appHostLaunchService.test.ts @@ -0,0 +1,121 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { AspireExtendedDebugConfiguration } from '../dcp/types'; +import { AppHostLaunchService } from '../services/AppHostLaunchService'; + +suite('AppHostLaunchService', () => { + let service: AppHostLaunchService; + let startDebuggingStub: sinon.SinonStub; + + setup(() => { + service = new AppHostLaunchService(); + startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + }); + + teardown(() => { + service.dispose(); + startDebuggingStub.restore(); + }); + + test('isLaunching returns false before launch', () => { + assert.strictEqual(service.isLaunching('/repo/AppHost.csproj'), false); + }); + + test('launch marks path as launching', async () => { + await service.launch('/repo/AppHost.csproj', 'run', true); + + assert.strictEqual(service.isLaunching('/repo/AppHost.csproj'), true); + }); + + test('launch fires onDidChangeLaunchingState event', async () => { + let fired = false; + service.onDidChangeLaunchingState(() => { fired = true; }); + + await service.launch('/repo/AppHost.csproj', 'run', true); + + assert.strictEqual(fired, true); + }); + + test('launch starts a debug session with correct configuration', async () => { + await service.launch('/repo/AppHost.csproj', 'run', false); + + assert.ok(startDebuggingStub.calledOnce); + const config = startDebuggingStub.firstCall.args[1] as AspireExtendedDebugConfiguration; + assert.strictEqual(config.type, 'aspire'); + assert.strictEqual(config.request, 'launch'); + assert.strictEqual(config.program, '/repo/AppHost.csproj'); + assert.strictEqual(config.command, 'run'); + assert.strictEqual(config.noDebug, false); + assert.strictEqual(config.step, undefined); + }); + + test('launch includes step when doStep is provided', async () => { + await service.launch('/repo/AppHost.csproj', 'do', true, 'deploy'); + + const config = startDebuggingStub.firstCall.args[1] as AspireExtendedDebugConfiguration; + assert.strictEqual(config.command, 'do'); + assert.strictEqual(config.step, 'deploy'); + }); + + test('clearLaunching removes the path from launching state', async () => { + await service.launch('/repo/AppHost.csproj', 'run', true); + assert.strictEqual(service.isLaunching('/repo/AppHost.csproj'), true); + + service.clearLaunching('/repo/AppHost.csproj'); + + assert.strictEqual(service.isLaunching('/repo/AppHost.csproj'), false); + }); + + test('clearLaunching fires onDidChangeLaunchingState event', async () => { + await service.launch('/repo/AppHost.csproj', 'run', true); + + let fired = false; + service.onDidChangeLaunchingState(() => { fired = true; }); + service.clearLaunching('/repo/AppHost.csproj'); + + assert.strictEqual(fired, true); + }); + + test('clearLaunching does not fire event when path was not launching', () => { + let fired = false; + service.onDidChangeLaunchingState(() => { fired = true; }); + + service.clearLaunching('/repo/nonexistent.csproj'); + + assert.strictEqual(fired, false); + }); + + test('multiple paths can be tracked independently', async () => { + await service.launch('/repo/AppHost1.csproj', 'run', true); + await service.launch('/repo/AppHost2.csproj', 'run', true); + + assert.strictEqual(service.isLaunching('/repo/AppHost1.csproj'), true); + assert.strictEqual(service.isLaunching('/repo/AppHost2.csproj'), true); + + service.clearLaunching('/repo/AppHost1.csproj'); + + assert.strictEqual(service.isLaunching('/repo/AppHost1.csproj'), false); + assert.strictEqual(service.isLaunching('/repo/AppHost2.csproj'), true); + }); + + test('launch clears launching state when startDebugging returns false', async () => { + // vscode.debug.startDebugging returns Promise and resolves false when + // the debug adapter rejects or no provider matches — no terminate event is + // emitted in that case. Without explicit cleanup the tree item would be stuck + // showing the "Starting..." spinner forever. + startDebuggingStub.resolves(false); + + await service.launch('/repo/AppHost.csproj', 'run', true); + + assert.strictEqual(service.isLaunching('/repo/AppHost.csproj'), false); + }); + + test('launch clears launching state and rethrows when startDebugging throws', async () => { + startDebuggingStub.rejects(new Error('boom')); + + await assert.rejects(service.launch('/repo/AppHost.csproj', 'run', true), /boom/); + + assert.strictEqual(service.isLaunching('/repo/AppHost.csproj'), false); + }); +}); diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index d7789a1e03b..ec01f3b6e4b 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -8,6 +8,7 @@ import { AspireAppHostTreeProvider, getResourceContextValue, getResourceIcon, re import type { AppHostDisplayInfo, ResourceJson, ViewMode } from '../views/AppHostDataRepository'; import { ResourceState, HealthStatus, StateStyle } from '../editor/resourceConstants'; import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import { AppHostLaunchService } from '../services/AppHostLaunchService'; function makeResource(overrides: Partial = {}): ResourceJson { const base: ResourceJson = { @@ -42,6 +43,10 @@ function makeAppHost(overrides: Partial = {}): AppHostDispla }; } +function makeLaunchService(): AppHostLaunchService { + return new AppHostLaunchService(); +} + function makeTerminalProvider(): AspireTerminalProvider { return { getAspireCliExecutablePath: async () => 'aspire', @@ -57,12 +62,13 @@ function makeTreeProvider(appHosts: readonly AppHostDisplayInfo[], viewMode: Vie appHosts, workspaceResources: [], workspaceAppHostPath: undefined, + workspaceAppHostCandidatePaths: [], workspaceAppHostName: undefined, workspaceAppHostDescription, onDidChangeData, } as unknown as AppHostDataRepository; - return new AspireAppHostTreeProvider(repository, makeTerminalProvider()); + return new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); } function makeWorkspaceTreeProvider(workspaceAppHostDescription: string): AspireAppHostTreeProvider { @@ -72,12 +78,13 @@ function makeWorkspaceTreeProvider(workspaceAppHostDescription: string): AspireA appHosts: [], workspaceResources: [makeResource()], workspaceAppHostPath: '/workspace/apps/Store/AppHost.csproj', + workspaceAppHostCandidatePaths: ['/workspace/apps/Store/AppHost.csproj'], workspaceAppHostName: 'AppHost.csproj', workspaceAppHostDescription, onDidChangeData, } as unknown as AppHostDataRepository; - return new AspireAppHostTreeProvider(repository, makeTerminalProvider()); + return new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); } async function flushPromises(): Promise { @@ -655,7 +662,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); const result = provider.findAppHostElement(hostPath); @@ -675,7 +682,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); const result = provider.findAppHostElement('/repo/AppHost/AppHost.cs'); @@ -683,22 +690,246 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { provider.dispose(); }); - test('returns undefined for workspace mode without resources', () => { + test('workspace mode renders selected non-running AppHost candidate without resources', () => { const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); const repository = { viewMode: 'workspace' as ViewMode, appHosts: [], workspaceResources: [], workspaceAppHostPath: '/repo/AppHost/AppHost.csproj', + workspaceAppHostCandidatePaths: ['/repo/AppHost/AppHost.csproj'], workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const [groupItem] = provider.getChildren(); const result = provider.findAppHostElement('/repo/AppHost/AppHost.csproj'); - // Workspace tree builds no top-level item when there are no resources, so no match. - assert.strictEqual(result, undefined); + assert.ok(groupItem, 'Expected the Workspace AppHosts group'); + assert.strictEqual(groupItem.contextValue, 'workspaceAppHostsGroup'); + const [appHostItem] = provider.getChildren(groupItem); + assert.strictEqual(appHostItem.label, 'AppHost.csproj'); + assert.strictEqual(appHostItem.contextValue, 'workspaceAppHost'); + assert.strictEqual(appHostItem.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed); + assert.deepStrictEqual(provider.getChildren(appHostItem).map(item => item.contextValue), [ + 'workspaceAppHostAction:openSource', + 'workspaceAppHostAction:run', + 'workspaceAppHostAction:debug', + 'workspaceAppHostPath', + ]); + assert.ok(result, 'Expected to find the workspace AppHost candidate'); + provider.dispose(); + }); + + test('workspace mode renders non-running AppHost candidates from aspire ls', () => { + const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); + const repository = { + viewMode: 'workspace' as ViewMode, + appHosts: [], + workspaceResources: [], + workspaceAppHostPath: undefined, + workspaceAppHostCandidatePaths: [ + '/repo/apps/Store/AppHost.csproj', + '/repo/samples/Store/AppHost.csproj', + ], + workspaceAppHostDescription: 'Workspace view selected because aspire ls found 2 buildable AppHosts.', + workspaceAppHostName: undefined, + onDidChangeData, + } as unknown as AppHostDataRepository; + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + + const topLevel = provider.getChildren(); + assert.strictEqual(topLevel.length, 1); + assert.strictEqual(topLevel[0].contextValue, 'workspaceAppHostsGroup'); + + const appHostItems = provider.getChildren(topLevel[0]); + + assert.deepStrictEqual(appHostItems.map(item => item.label), [ + 'apps/Store/AppHost.csproj', + 'samples/Store/AppHost.csproj', + ]); + assert.deepStrictEqual(appHostItems.map(item => item.contextValue), [ + 'workspaceAppHost', + 'workspaceAppHost', + ]); + provider.dispose(); + }); + + test('workspace mode renders launching AppHost with spinner and no context menu', () => { + const appHostPath = '/repo/AppHost/AppHost.csproj'; + const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); + const launchService = makeLaunchService(); + + // Simulate the path being in launching state by calling launch (stub startDebugging) + const stub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + launchService.launch(appHostPath, 'run', true); + stub.restore(); + + const repository = { + viewMode: 'workspace' as ViewMode, + appHosts: [], + workspaceResources: [], + workspaceAppHostPath: undefined, + workspaceAppHostCandidatePaths: [appHostPath], + workspaceAppHostName: undefined, + onDidChangeData, + } as unknown as AppHostDataRepository; + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + + const [groupItem] = provider.getChildren(); + assert.strictEqual(groupItem?.contextValue, 'workspaceAppHostsGroup'); + const [item] = provider.getChildren(groupItem); + + assert.ok(item, 'Expected a launching workspace AppHost item'); + assert.strictEqual(item.contextValue, 'workspaceAppHostLaunching'); + assert.deepStrictEqual((item.iconPath as vscode.ThemeIcon).id, 'loading~spin'); + provider.dispose(); + }); + + test('workspace mode groups idle AppHosts under WorkspaceAppHostsGroupItem when running AppHost exists', () => { + const runningPath = '/repo/apps/Store/AppHost.csproj'; + const idlePath = '/repo/apps/Backend/AppHost.csproj'; + const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); + const repository = { + viewMode: 'workspace' as ViewMode, + appHosts: [makeAppHost({ appHostPath: runningPath, appHostPid: 1234, cliPid: 5678, resources: [makeResource()] })], + workspaceResources: [], + workspaceAppHostPath: runningPath, + workspaceAppHostCandidatePaths: [runningPath, idlePath], + workspaceAppHostName: undefined, + onDidChangeData, + } as unknown as AppHostDataRepository; + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + + const topLevelItems = provider.getChildren(); + + // When both running and idle AppHosts exist, both sets are wrapped in sibling + // groups so they nest at the same depth and read symmetrically in the tree. + assert.strictEqual(topLevelItems.length, 2); + assert.strictEqual(topLevelItems[0].contextValue, 'runningAppHostsGroup'); + assert.strictEqual(topLevelItems[1].contextValue, 'workspaceAppHostsGroup'); + + // Running group contains the running AppHost (rendered as WorkspaceResourcesItem) + const runningChildren = provider.getChildren(topLevelItems[0]); + assert.strictEqual(runningChildren.length, 1); + assert.ok(runningChildren[0].contextValue?.startsWith('workspaceResources')); + + // Workspace group contains the idle AppHost + const idleChildren = provider.getChildren(topLevelItems[1]); + assert.strictEqual(idleChildren.length, 1); + assert.strictEqual(idleChildren[0].contextValue, 'workspaceAppHost'); + provider.dispose(); + }); + + test('workspace mode matches running AppHost to candidate by directory when paths differ', () => { + // aspire ls returns the project file (.csproj) while aspire ps can report the + // AppHost source file (Program.cs) in the same directory. These paths are not + // equal, but the tree should still pair them as the SAME AppHost via the + // directory-equivalence fallback in isMatchingAppHostPath. + const candidateCsproj = '/repo/apps/Store/AppHost.csproj'; + const runningSourceFile = '/repo/apps/Store/Program.cs'; + const idlePath = '/repo/apps/Backend/AppHost.csproj'; + const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); + const repository = { + viewMode: 'workspace' as ViewMode, + appHosts: [makeAppHost({ appHostPath: runningSourceFile, appHostPid: 1234, cliPid: 5678, resources: [makeResource()] })], + workspaceResources: [], + workspaceAppHostPath: candidateCsproj, + workspaceAppHostCandidatePaths: [candidateCsproj, idlePath], + workspaceAppHostName: undefined, + onDidChangeData, + } as unknown as AppHostDataRepository; + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + + const topLevelItems = provider.getChildren(); + + // Both groups must appear: the .csproj candidate should be recognized as running + // (rendered in the running group), and the unrelated idle candidate stays in + // the workspace group. Without directory-equivalence matching, the .csproj + // candidate would be misclassified as idle. + assert.strictEqual(topLevelItems.length, 2); + assert.strictEqual(topLevelItems[0].contextValue, 'runningAppHostsGroup'); + assert.strictEqual(topLevelItems[1].contextValue, 'workspaceAppHostsGroup'); + + const runningChildren = provider.getChildren(topLevelItems[0]); + assert.strictEqual(runningChildren.length, 1); + assert.ok(runningChildren[0].contextValue?.startsWith('workspaceResources')); + + const idleChildren = provider.getChildren(topLevelItems[1]); + assert.strictEqual(idleChildren.length, 1); + assert.strictEqual(idleChildren[0].contextValue, 'workspaceAppHost'); + provider.dispose(); + }); + + test('runAppHost shows warning when element is undefined', async () => { + const provider = makeTreeProvider([], 'workspace'); + const stub = sinon.stub(vscode.window, 'showWarningMessage'); + + await provider.runAppHost(undefined, true); + + assert.ok(stub.calledOnce, 'Expected a warning message'); + stub.restore(); + provider.dispose(); + }); + + test('runAppHost delegates to launch service with correct path', async () => { + const appHostPath = '/repo/AppHost/AppHost.csproj'; + const launchService = makeLaunchService(); + const launchStub = sinon.stub(launchService, 'launch').resolves(); + const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); + const repository = { + viewMode: 'workspace' as ViewMode, + appHosts: [], + workspaceResources: [], + workspaceAppHostPath: undefined, + workspaceAppHostCandidatePaths: [appHostPath], + workspaceAppHostName: undefined, + onDidChangeData, + } as unknown as AppHostDataRepository; + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + + // Get the workspace item from inside the Workspace AppHosts group and pass it to runAppHost + const [groupItem] = provider.getChildren(); + const [item] = provider.getChildren(groupItem); + await provider.runAppHost(item as any, false); + + assert.ok(launchStub.calledOnce, 'Expected launch to be called'); + assert.strictEqual(launchStub.firstCall.args[0], appHostPath); + assert.strictEqual(launchStub.firstCall.args[1], 'run'); + assert.strictEqual(launchStub.firstCall.args[2], false); + launchStub.restore(); + provider.dispose(); + }); + + test('runAppHost surfaces launch errors via showErrorMessage', async () => { + // The previous fire-and-forget call discarded rejections — they surfaced as + // unhandled promise rejections with no user feedback. The async variant must + // catch and report so the user knows the launch failed. + const appHostPath = '/repo/AppHost/AppHost.csproj'; + const launchService = makeLaunchService(); + const launchStub = sinon.stub(launchService, 'launch').rejects(new Error('startDebugging blew up')); + const errorStub = sinon.stub(vscode.window, 'showErrorMessage'); + const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); + const repository = { + viewMode: 'workspace' as ViewMode, + appHosts: [], + workspaceResources: [], + workspaceAppHostPath: undefined, + workspaceAppHostCandidatePaths: [appHostPath], + workspaceAppHostName: undefined, + onDidChangeData, + } as unknown as AppHostDataRepository; + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + + const [groupItem] = provider.getChildren(); + const [item] = provider.getChildren(groupItem); + await provider.runAppHost(item as any, false); + + assert.ok(launchStub.calledOnce, 'Expected launch to be called'); + assert.ok(errorStub.calledOnce, 'Expected showErrorMessage to be called when launch rejects'); + launchStub.restore(); + errorStub.restore(); provider.dispose(); }); @@ -719,9 +950,10 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { }), workspaceAppHostPath: hostPath, workspaceAppHostName: 'AppHost.csproj', + workspaceAppHostCandidatePaths: [hostPath], onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); const [appHostItem] = provider.getChildren(); const appHostChildren = provider.getChildren(appHostItem); @@ -746,12 +978,12 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceResources: [], workspaceAppHost: undefined, workspaceAppHostPath: undefined, - hasMultipleWorkspaceAppHosts: true, workspaceAppHostName: undefined, + workspaceAppHostCandidatePaths: ['/repo/apps/Store/AppHost.csproj', '/repo/samples/Store/AppHost.csproj'], workspaceAppHostDescription: 'Workspace view selected because aspire ls found 2 buildable AppHosts.', onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); const appHostItems = provider.getChildren(); @@ -776,8 +1008,8 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceResources: [], workspaceAppHost: makeAppHost({ appHostPath: selectedHostPath, appHostPid: 1234, resources: [] }), workspaceAppHostPath: selectedHostPath, - hasMultipleWorkspaceAppHosts: true, workspaceAppHostName: 'apps/Store/AppHost.csproj', + workspaceAppHostCandidatePaths: [selectedHostPath, otherHostPath], workspaceAppHostDescription: 'Workspace view selected because aspire ls found 2 buildable AppHosts.', onDidChangeData, } as unknown as AppHostDataRepository; @@ -786,7 +1018,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { createEnvironment: () => ({}), sendAspireCommandToAspireTerminal: (command: string) => commands.push(command), } as unknown as AspireTerminalProvider; - const provider = new AspireAppHostTreeProvider(repository, terminalProvider); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService()); const otherAppHostItem = provider.getChildren()[1]; const resourcesGroup = provider.getChildren(otherAppHostItem).find(child => child.label === 'Resources'); @@ -815,12 +1047,12 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceResources: [makeResource({ name: 'api', displayName: 'api' })], workspaceAppHost: makeAppHost({ appHostPath: selectedHostPath, appHostPid: 1234, resources: undefined }), workspaceAppHostPath: selectedHostPath, - hasMultipleWorkspaceAppHosts: true, workspaceAppHostName: 'AppHost.csproj', + workspaceAppHostCandidatePaths: [selectedHostPath, '/repo/samples/Store/AppHost.csproj'], workspaceAppHostDescription: 'Workspace view selected because aspire ls found 2 buildable AppHosts.', onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); const [selectedAppHostItem] = provider.getChildren(); const selectedChildren = provider.getChildren(selectedAppHostItem); @@ -843,12 +1075,12 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceResources: [makeResource({ name: 'api', displayName: 'api' })], workspaceAppHost: makeAppHost({ appHostPath: selectedHostPath, appHostPid: 1234, resources: [] }), workspaceAppHostPath: selectedHostPath, - hasMultipleWorkspaceAppHosts: true, workspaceAppHostName: 'AppHost.csproj', + workspaceAppHostCandidatePaths: [selectedHostPath, '/repo/samples/Store/AppHost.csproj'], workspaceAppHostDescription: 'Workspace view selected because aspire ls found 2 buildable AppHosts.', onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); const [selectedAppHostItem] = provider.getChildren(); const selectedChildren = provider.getChildren(selectedAppHostItem); @@ -881,7 +1113,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: 'AppHost.csproj', onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); const [appHostItem] = provider.getChildren(); const appHostChildren = provider.getChildren(appHostItem); @@ -987,7 +1219,7 @@ suite('LogFileItem in tree', () => { workspaceAppHostName: 'AppHost.csproj', onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); const [appHostItem] = provider.getChildren(); const children = provider.getChildren(appHostItem); @@ -1014,7 +1246,7 @@ suite('LogFileItem in tree', () => { workspaceAppHostName: 'AppHost.csproj', onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); const [appHostItem] = provider.getChildren(); const children = provider.getChildren(appHostItem); @@ -1142,7 +1374,7 @@ suite('viewAppHostSource', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); const fakeDoc = { uri: vscode.Uri.parse('aspire-source:AppHost-999.json') } as vscode.TextDocument; sandbox.stub(vscode.workspace, 'openTextDocument').resolves(fakeDoc); @@ -1174,7 +1406,7 @@ suite('viewAppHostSource', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); const registerStub = sandbox.stub(vscode.workspace, 'registerTextDocumentContentProvider').returns({ dispose: () => { } }); const fakeDoc = { uri: vscode.Uri.parse('aspire-source:AppHost-999.json') } as vscode.TextDocument; sandbox.stub(vscode.workspace, 'openTextDocument').resolves(fakeDoc); diff --git a/extension/src/test/aspireCodeLensProvider.test.ts b/extension/src/test/aspireCodeLensProvider.test.ts index 6c02ca4a150..beada8fca3d 100644 --- a/extension/src/test/aspireCodeLensProvider.test.ts +++ b/extension/src/test/aspireCodeLensProvider.test.ts @@ -12,6 +12,7 @@ import { codeLensCommand } from '../loc/strings'; import { AspireAppHostTreeProvider } from '../views/AspireAppHostTreeProvider'; import { AppHostDataRepository, AppHostDisplayInfo, ResourceJson } from '../views/AppHostDataRepository'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import { AppHostLaunchService } from '../services/AppHostLaunchService'; // Import parsers so they self-register before the provider consults them. import '../editor/parsers/csharpAppHostParser'; import '../editor/parsers/jsTsAppHostParser'; @@ -123,7 +124,7 @@ function createHarness(opts: { const subs: vscode.Disposable[] = []; const terminalProvider = new AspireTerminalProvider(subs); const repository = new AppHostDataRepository(terminalProvider); - const treeProvider = new AspireAppHostTreeProvider(repository, terminalProvider); + const treeProvider = new AspireAppHostTreeProvider(repository, terminalProvider, new AppHostLaunchService()); const appHostsStub = sinon.stub(repository, 'appHosts').get(() => opts.appHosts ?? []); const workspaceResourcesStub = sinon.stub(repository, 'workspaceResources').get(() => opts.workspaceResources ?? []); diff --git a/extension/src/test/aspireEditorCommandProvider.test.ts b/extension/src/test/aspireEditorCommandProvider.test.ts index bede4044c7b..4e5d125bc9f 100644 --- a/extension/src/test/aspireEditorCommandProvider.test.ts +++ b/extension/src/test/aspireEditorCommandProvider.test.ts @@ -8,6 +8,7 @@ import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; import { AppHostDiscoveryService } from '../utils/appHostDiscovery'; +import { AppHostLaunchService } from '../services/AppHostLaunchService'; function createEditor(filePath: string): vscode.TextEditor { return { @@ -28,6 +29,8 @@ suite('AspireEditorCommandProvider', () => { let onDidChangeWorkspaceFoldersStub: sinon.SinonStub; let onDidChangeActiveTextEditorStub: sinon.SinonStub; let executeCommandStub: sinon.SinonStub; + let startDebuggingStub: sinon.SinonStub; + let showErrorMessageStub: sinon.SinonStub; setup(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-editor-command-provider-')); @@ -45,9 +48,13 @@ suite('AspireEditorCommandProvider', () => { 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); + startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + showErrorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); }); teardown(() => { + showErrorMessageStub.restore(); + startDebuggingStub.restore(); executeCommandStub.restore(); onDidChangeActiveTextEditorStub.restore(); onDidChangeWorkspaceFoldersStub.restore(); @@ -67,7 +74,7 @@ suite('AspireEditorCommandProvider', () => { fs.writeFileSync(projectPath, ''); activeEditor = createEditor(programPath); - const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(projectPath)); + const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(projectPath), new AppHostLaunchService()); try { assert.strictEqual(await provider.getAppHostPath(), projectPath); } @@ -81,7 +88,7 @@ suite('AspireEditorCommandProvider', () => { fs.writeFileSync(appHostPath, '#:sdk Aspire.AppHost.Sdk\nvar builder = DistributedApplication.CreateBuilder(args);'); activeEditor = createEditor(appHostPath); - const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath)); + const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath), new AppHostLaunchService()); try { assert.strictEqual(await provider.getAppHostPath(), appHostPath); } @@ -95,7 +102,7 @@ suite('AspireEditorCommandProvider', () => { fs.writeFileSync(appHostPath, 'import { createBuilder } from "./.aspire/modules/aspire";'); activeEditor = createEditor(appHostPath); - const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath, 'typescript/nodejs')); + const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath, 'typescript/nodejs'), new AppHostLaunchService()); try { assert.strictEqual(await provider.getAppHostPath(), appHostPath); } @@ -109,7 +116,7 @@ suite('AspireEditorCommandProvider', () => { fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);'); activeEditor = createEditor(programPath); - const provider = new AspireEditorCommandProvider(createFailingAppHostDiscoveryService()); + const provider = new AspireEditorCommandProvider(createFailingAppHostDiscoveryService(), new AppHostLaunchService()); try { await provider.processDocument(activeEditor.document); @@ -126,7 +133,7 @@ suite('AspireEditorCommandProvider', () => { fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);'); activeEditor = createEditor(programPath); - const provider = new AspireEditorCommandProvider(createFailingAppHostDiscoveryService()); + const provider = new AspireEditorCommandProvider(createFailingAppHostDiscoveryService(), new AppHostLaunchService()); try { assert.strictEqual(await provider.getAppHostPath(), null); } @@ -134,6 +141,32 @@ suite('AspireEditorCommandProvider', () => { provider.dispose(); } }); + + test('run command uses resolved AppHost path from discovery', async () => { + const appHostDirectory = path.join(tempDir, 'ResolvedAppHost'); + fs.mkdirSync(appHostDirectory); + + const appHostPath = path.join(appHostDirectory, 'ResolvedAppHost.csproj'); + const programPath = path.join(appHostDirectory, 'Program.cs'); + fs.writeFileSync(appHostPath, ''); + fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);'); + activeEditor = createEditor(programPath); + + const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath), new AppHostLaunchService()); + try { + await provider.tryExecuteRunAppHost(true); + + assert.ok(startDebuggingStub.calledOnce); + const launchConfiguration = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; + assert.strictEqual(launchConfiguration.program, appHostPath); + assert.strictEqual(launchConfiguration.command, 'run'); + assert.strictEqual(launchConfiguration.noDebug, true); + assert.strictEqual(showErrorMessageStub.called, false); + } + finally { + provider.dispose(); + } + }); }); function createAppHostDiscoveryService(resolvedPath: string, language = 'csharp'): AppHostDiscoveryService { diff --git a/extension/src/test/packageManifest.test.ts b/extension/src/test/packageManifest.test.ts index 212d5f5d026..f5f39308bf7 100644 --- a/extension/src/test/packageManifest.test.ts +++ b/extension/src/test/packageManifest.test.ts @@ -29,6 +29,7 @@ type ExtensionManifest = { viewsWelcome?: Array<{ view?: string; contents?: string; when?: string }>; menus?: { 'view/title'?: ManifestMenuItem[]; + 'view/item/context'?: ManifestMenuItem[]; }; debuggers?: DebuggerContribution[]; }; @@ -46,10 +47,10 @@ function assertContains(whenClause: string | undefined, fragment: string): void suite('extension/package.json', () => { test('running apphosts welcome states use string view mode checks', () => { const manifest = readManifest(); - const runningAppHostsWelcome = manifest.contributes.viewsWelcome?.filter(item => item.view === 'aspire-vscode.runningAppHosts') ?? []; + const runningAppHostsWelcome = manifest.contributes.viewsWelcome?.filter(item => item.view === 'aspire-vscode.appHosts') ?? []; - const workspaceWelcome = runningAppHostsWelcome.find(item => item.contents === '%views.runningAppHosts.welcome%'); - const globalWelcome = runningAppHostsWelcome.find(item => item.contents === '%views.runningAppHosts.globalWelcome%'); + const workspaceWelcome = runningAppHostsWelcome.find(item => item.contents === '%views.appHosts.welcome%'); + const globalWelcome = runningAppHostsWelcome.find(item => item.contents === '%views.appHosts.globalWelcome%'); assertContains(workspaceWelcome?.when, "aspire.viewMode != 'global'"); assertContains(globalWelcome?.when, "aspire.viewMode == 'global'"); @@ -61,13 +62,39 @@ suite('extension/package.json', () => { const switchToGlobal = titleMenus.find(item => item.command === 'aspire-vscode.switchToGlobalView'); const switchToWorkspace = titleMenus.find(item => item.command === 'aspire-vscode.switchToWorkspaceView'); - const refreshRunningAppHosts = titleMenus.find(item => item.command === 'aspire-vscode.refreshRunningAppHosts'); + const globalRefreshAppHosts = titleMenus.find(item => item.command === 'aspire-vscode.globalRefreshAppHosts'); - assertContains(switchToGlobal?.when, "view == 'aspire-vscode.runningAppHosts'"); + assertContains(switchToGlobal?.when, "view == 'aspire-vscode.appHosts'"); assertContains(switchToGlobal?.when, "aspire.viewMode != 'global'"); - assertContains(switchToWorkspace?.when, "view == 'aspire-vscode.runningAppHosts'"); + assertContains(switchToWorkspace?.when, "view == 'aspire-vscode.appHosts'"); assertContains(switchToWorkspace?.when, "aspire.viewMode == 'global'"); - assertContains(refreshRunningAppHosts?.when, "view == 'aspire-vscode.runningAppHosts'"); + assertContains(globalRefreshAppHosts?.when, "view == 'aspire-vscode.appHosts'"); + }); + + test('workspace non-running apphost context actions include run and debug', () => { + const manifest = readManifest(); + const contextMenus = manifest.contributes.menus?.['view/item/context'] ?? []; + + const runAppHost = contextMenus.find(item => item.command === 'aspire-vscode.runAppHost'); + const debugAppHost = contextMenus.find(item => item.command === 'aspire-vscode.debugAppHost'); + + assertContains(runAppHost?.when, "view == aspire-vscode.appHosts"); + assertContains(runAppHost?.when, 'viewItem == workspaceAppHost'); + assertContains(debugAppHost?.when, "view == aspire-vscode.appHosts"); + assertContains(debugAppHost?.when, 'viewItem == workspaceAppHost'); + }); + + test('running apphost context actions only target running apphost contexts', () => { + const manifest = readManifest(); + const contextMenus = manifest.contributes.menus?.['view/item/context'] ?? []; + + const openDashboard = contextMenus.find(item => item.command === 'aspire-vscode.openDashboard'); + const expandAll = contextMenus.find(item => item.command === 'aspire-vscode.expandAll'); + const openAppHostSource = contextMenus.find(item => item.command === 'aspire-vscode.openAppHostSource'); + + assertContains(openDashboard?.when, 'workspaceResources'); + assertContains(expandAll?.when, 'workspaceResources'); + assertContains(openAppHostSource?.when, 'workspaceResources'); }); test('aspire launch configuration declares an env property as a string-valued object', () => { diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index 4bb6b4e351a..04b55bb7481 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -224,8 +224,8 @@ export class AppHostDataRepository { return this._workspaceAppHostPath; } - get hasMultipleWorkspaceAppHosts(): boolean { - return this._workspaceAppHostCandidatePaths.length > 1; + get workspaceAppHostCandidatePaths(): readonly string[] { + return this._workspaceAppHostCandidatePaths; } get workspaceAppHostDescription(): string | undefined { @@ -837,8 +837,15 @@ export class AppHostDataRepository { const hasWorkspaceAppHost = this._workspaceAppHost !== undefined; const hasResources = this._workspaceResources.size > 0; const hasRunningAppHosts = this._appHosts.length > 0; - vscode.commands.executeCommand('setContext', 'aspire.noRunningAppHosts', !hasWorkspaceAppHost && !hasResources && !hasRunningAppHosts); - const clearLoading = options?.clearLoading ?? (hasResources || hasWorkspaceAppHost || hasRunningAppHosts); + const hasWorkspaceCandidates = this._workspaceAppHostCandidatePaths.length > 0; + vscode.commands.executeCommand('setContext', 'aspire.noAppHosts', !hasWorkspaceAppHost && !hasResources && !hasRunningAppHosts && !hasWorkspaceCandidates); + // `aspire.noRunningAppHosts` gates the Open Dashboard command palette entry, + // which requires a live dashboard URL. Keep this distinct from `noAppHosts` + // (which also considers discovered idle candidates) so the palette entry + // doesn't appear when only idle candidates exist — invoking it would silently + // no-op because no dashboard URL is available. + vscode.commands.executeCommand('setContext', 'aspire.noRunningAppHosts', !hasRunningAppHosts); + const clearLoading = options?.clearLoading ?? (hasResources || hasWorkspaceAppHost || hasRunningAppHosts || hasWorkspaceCandidates); if (this._loadingWorkspace && clearLoading) { this._loadingWorkspace = false; this._updateLoadingContext(); @@ -983,6 +990,7 @@ export class AppHostDataRepository { if (this._viewMode === 'global' && this._loadingGlobal) { this._loadingGlobal = false; this._updateLoadingContext(); + vscode.commands.executeCommand('setContext', 'aspire.noAppHosts', this._appHosts.length === 0); vscode.commands.executeCommand('setContext', 'aspire.noRunningAppHosts', this._appHosts.length === 0); } } @@ -1083,6 +1091,7 @@ export class AppHostDataRepository { } if (changed) { + vscode.commands.executeCommand('setContext', 'aspire.noAppHosts', appHosts.length === 0); vscode.commands.executeCommand('setContext', 'aspire.noRunningAppHosts', appHosts.length === 0); this._onDidChangeData.fire(); } @@ -1428,7 +1437,7 @@ function isDescribeUnsupportedOutput(nonJsonLines: readonly string[], stderr: st || output.includes('is not a recognized command'); } -function isMatchingAppHostPath(left: string | undefined, right: string | undefined): boolean { +export function isMatchingAppHostPath(left: string | undefined, right: string | undefined): boolean { if (!left || !right) { return false; } diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index e9816c2df31..9b329af17b5 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -11,6 +11,12 @@ import { selectCommandPlaceholder, selectDashboardPlaceholder, workspaceAppHostLabel, + workspaceAppHostsGroupLabel, + runningAppHostsGroupLabel, + appHostOpenSourceActionLabel, + appHostRunActionLabel, + appHostDebugActionLabel, + appHostPathLabel, resourceCountDescription, tooltipType, tooltipState, @@ -25,6 +31,8 @@ import { resourceDescriptionHealth, resourceDescriptionExitCode, logFileLabel, + appHostStartingDescription, + errorMessage, } from '../loc/strings'; import { isLinkableUrl } from '../utils/urlSchemes'; import { @@ -33,12 +41,14 @@ import { ResourceCommandArgumentInputJson, ResourceJson, ViewMode, + isMatchingAppHostPath, shortenPaths, } from './AppHostDataRepository'; import { collectResourceCommandArguments, ResourceCommandArgumentValue } from './ResourceCommandArguments'; import { createResourceCommandArgumentLoader } from './ResourceCommandArgumentsLoader'; +import { AppHostLaunchService } from '../services/AppHostLaunchService'; -type TreeElement = AppHostItem | EndpointUrlItem | ResourcesGroupItem | ResourceItem | WorkspaceResourcesItem | HealthChecksGroupItem | HealthCheckItem | LogFileItem; +type TreeElement = AppHostItem | EndpointUrlItem | ResourcesGroupItem | ResourceItem | WorkspaceResourcesItem | WorkspaceAppHostItem | WorkspaceAppHostsGroupItem | RunningAppHostsGroupItem | WorkspaceAppHostActionItem | WorkspaceAppHostPathItem | HealthChecksGroupItem | HealthCheckItem | LogFileItem; function sortResources(resources: ResourceJson[]): ResourceJson[] { return [...resources].sort((a, b) => { @@ -101,6 +111,86 @@ class WorkspaceResourcesItem extends vscode.TreeItem { } } +class WorkspaceAppHostItem extends vscode.TreeItem { + constructor( + public readonly appHostPath: string, + appHostName?: string, + appHostDescription?: string, + public readonly launching?: boolean + ) { + super(appHostName ?? workspaceAppHostLabel, vscode.TreeItemCollapsibleState.Collapsed); + this.id = `workspace-apphost:${getComparisonKey(path.resolve(appHostPath))}`; + + if (launching) { + this.iconPath = new vscode.ThemeIcon('loading~spin'); + this.description = appHostStartingDescription; + this.contextValue = 'workspaceAppHostLaunching'; + } else { + this.iconPath = new vscode.ThemeIcon( + appHostPath.endsWith('.csproj') ? 'server-process' : 'file-code', + new vscode.ThemeColor('disabledForeground') + ); + this.contextValue = 'workspaceAppHost'; + } + + this.tooltip = appHostDescription; + } +} + +class WorkspaceAppHostActionItem extends vscode.TreeItem { + constructor(parent: WorkspaceAppHostItem, action: 'openSource' | 'run' | 'debug') { + const label = action === 'openSource' + ? appHostOpenSourceActionLabel + : action === 'run' + ? appHostRunActionLabel + : appHostDebugActionLabel; + super(label, vscode.TreeItemCollapsibleState.None); + this.id = `${parent.id}:action:${action}`; + this.iconPath = new vscode.ThemeIcon(action === 'debug' ? 'debug-alt' : action === 'run' ? 'play' : 'go-to-file'); + this.contextValue = `workspaceAppHostAction:${action}`; + this.command = { + command: action === 'openSource' + ? 'aspire-vscode.openAppHostSource' + : action === 'run' + ? 'aspire-vscode.runAppHost' + : 'aspire-vscode.debugAppHost', + title: label, + arguments: [parent] + }; + } +} + +class WorkspaceAppHostPathItem extends vscode.TreeItem { + constructor(parent: WorkspaceAppHostItem) { + super(appHostPathLabel, vscode.TreeItemCollapsibleState.None); + this.id = `${parent.id}:path`; + this.iconPath = new vscode.ThemeIcon('file-directory'); + this.contextValue = 'workspaceAppHostPath'; + this.description = parent.appHostPath; + this.tooltip = parent.appHostPath; + } +} + +class WorkspaceAppHostsGroupItem extends vscode.TreeItem { + constructor(public readonly appHosts: WorkspaceAppHostItem[]) { + super(workspaceAppHostsGroupLabel, vscode.TreeItemCollapsibleState.Expanded); + this.id = 'workspace-apphosts-group'; + this.iconPath = new vscode.ThemeIcon('folder'); + this.contextValue = 'workspaceAppHostsGroup'; + this.description = `(${appHosts.length})`; + } +} + +class RunningAppHostsGroupItem extends vscode.TreeItem { + constructor(public readonly runningAppHosts: ReadonlyArray) { + super(runningAppHostsGroupLabel, vscode.TreeItemCollapsibleState.Expanded); + this.id = 'running-apphosts-group'; + this.iconPath = new vscode.ThemeIcon('folder-active', new vscode.ThemeColor('aspire.brandPurple')); + this.contextValue = 'runningAppHostsGroup'; + this.description = `(${runningAppHosts.length})`; + } +} + class EndpointUrlItem extends vscode.TreeItem { constructor(public readonly url: string, displayName: string) { super(displayName, vscode.TreeItemCollapsibleState.None); @@ -357,6 +447,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider(); private _treeView: vscode.TreeView | undefined; @@ -366,9 +457,16 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { + this._clearLaunchingPathsForRunningAppHosts(); + this._onDidChangeTreeData.fire(); + }); + + // When the launch service's launching state changes, refresh the tree. + this._launchingSubscription = this._launchService.onDidChangeLaunchingState(() => { this._onDidChangeTreeData.fire(); }); } @@ -412,6 +510,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider 0) { - const appHosts = this._repository.appHosts.map(appHost => { - const selectedAppHostPath = workspaceAppHost?.appHostPath ?? this._repository.workspaceAppHostPath; - if (workspaceResources.length > 0 && selectedAppHostPath && isSamePath(appHost.appHostPath, selectedAppHostPath) && hasNoResources(appHost.resources)) { - return { ...appHost, resources: workspaceResources }; + const workspaceCandidatePaths = this._repository.workspaceAppHostCandidatePaths ?? []; + + if (workspaceCandidatePaths.length > 1 || (workspaceResources.length === 0 && !workspaceAppHost)) { + const selectedAppHostPath = workspaceAppHost?.appHostPath ?? this._repository.workspaceAppHostPath; + const labels = shortenPaths(workspaceCandidatePaths); + + // When multiple workspace AppHosts are running, use global-style AppHostItem (nested view). + // When only one is running, use flat WorkspaceResourcesItem. + const runningItems: (AppHostItem | WorkspaceResourcesItem)[] = []; + const workspaceItems: WorkspaceAppHostItem[] = []; + + for (let i = 0; i < workspaceCandidatePaths.length; i++) { + const candidatePath = workspaceCandidatePaths[i]; + // Use directory-equivalent matching (not exact path) because `aspire ls` + // resolves to a `.csproj` while `aspire ps` can report the AppHost source file + // (e.g. Program.cs) in the same directory. AppHostDataRepository uses the same + // helper when filtering running AppHosts into _appHosts. + const runningAppHost = this._repository.appHosts.find( + appHost => isMatchingAppHostPath(appHost.appHostPath, candidatePath) + ); + const launching = this._launchService.isLaunching(candidatePath); + + if (!runningAppHost) { + workspaceItems.push(new WorkspaceAppHostItem(candidatePath, labels[i], vscode.workspace.asRelativePath(candidatePath), launching)); + continue; } - return appHost; - }); - const labels = shortenPaths(appHosts.map(appHost => appHost.appHostPath)); - return appHosts.map((appHost, index) => new AppHostItem(appHost, labels[index], this._repository.workspaceAppHostDescription)); - } - if (workspaceResources.length === 0 && !workspaceAppHost) { - return []; + // Merge workspace resources into the running AppHost if it's the selected one + // and its own resource list is empty (resources arrive via DCP separately). + const appHost = workspaceResources.length > 0 + && selectedAppHostPath + && isSamePath(runningAppHost.appHostPath, selectedAppHostPath) + && hasNoResources(runningAppHost.resources) + ? { ...runningAppHost, resources: workspaceResources } + : runningAppHost; + + if (runningItems.length > 0) { + // Multiple running — use global-style AppHostItem (nested view) + runningItems.push(new AppHostItem(appHost, labels[i], this._repository.workspaceAppHostDescription)); + } else { + const resources = [...appHost.resources ?? []]; + const rawDashboardUrl = appHost.dashboardUrl ?? resources.find(r => r.dashboardUrl)?.dashboardUrl ?? null; + const dashboardUrl = rawDashboardUrl ? stripResourceSuffix(rawDashboardUrl) : null; + runningItems.push(new WorkspaceResourcesItem(resources, dashboardUrl, appHost.appHostPath, appHost, labels[i], this._repository.workspaceAppHostDescription)); + } + } + + // If multiple ended up running, convert the first to AppHostItem too + if (runningItems.length > 1 && runningItems[0] instanceof WorkspaceResourcesItem) { + const first = runningItems[0]; + const appHost = first.appHost!; + runningItems[0] = new AppHostItem(appHost, first.label as string, this._repository.workspaceAppHostDescription); + } + + if (workspaceItems.length > 0 && runningItems.length > 0) { + // Wrap running items in a sibling group so both sets share the same + // indentation depth and the visual hierarchy reads symmetrically. + const runningGroup = new RunningAppHostsGroupItem(runningItems); + return [runningGroup, new WorkspaceAppHostsGroupItem(workspaceItems)]; + } + // When nothing is running, still wrap idle items in the group so they + // render under the "Workspace AppHosts" header. This keeps the tree shape + // consistent with the mixed case and avoids loose root-level items. + if (workspaceItems.length > 0) { + return [new WorkspaceAppHostsGroupItem(workspaceItems)]; + } + return [...runningItems]; } + + // Single candidate, running — show flat WorkspaceResourcesItem const resources = workspaceResources.length > 0 ? workspaceResources : [...workspaceAppHost?.resources ?? []]; @@ -552,6 +727,25 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { + const appHostPath = element?.appHostPath; + if (!appHostPath) { + vscode.window.showWarningMessage(appHostSourceNotFound); + return; + } + + try { + await this._launchService.launch(appHostPath, 'run', noDebug); + } catch (err) { + vscode.window.showErrorMessage(errorMessage(err)); + } } - async openAppHostSource(element?: AppHostItem | WorkspaceResourcesItem): Promise { - if (!element || !(element instanceof AppHostItem || element instanceof WorkspaceResourcesItem)) { + stopAppHost(element: AppHostItem | WorkspaceResourcesItem): void { + const appHostPath = element instanceof AppHostItem ? element.appHost.appHostPath : element.appHostPath; + if (!appHostPath) { + vscode.window.showWarningMessage(appHostSourceNotFound); + return; + } + this._terminalProvider.sendAspireCommandToAspireTerminal(`stop --apphost "${appHostPath}"`); + } + + async openAppHostSource(element?: AppHostItem | WorkspaceResourcesItem | WorkspaceAppHostItem): Promise { + if (!element || !(element instanceof AppHostItem || element instanceof WorkspaceResourcesItem || element instanceof WorkspaceAppHostItem)) { vscode.window.showWarningMessage(appHostSourceNotFound); return; } @@ -817,8 +1030,13 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { - await vscode.env.clipboard.writeText(element.appHost.appHostPath); + async copyAppHostPath(element: AppHostItem | WorkspaceResourcesItem | WorkspaceAppHostItem): Promise { + const appHostPath = element instanceof AppHostItem ? element.appHost.appHostPath : element.appHostPath; + if (!appHostPath) { + vscode.window.showWarningMessage(appHostSourceNotFound); + return; + } + await vscode.env.clipboard.writeText(appHostPath); } async viewAppHostLogFile(element: unknown): Promise {