From 0d8fa08a8174e207a62391c14e7bfd832f34ed5c Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Fri, 29 May 2026 16:06:29 -0700 Subject: [PATCH 1/7] Show idle AppHosts in Aspire pane with Run/Debug context menu --- extension/loc/xlf/aspire-vscode.xlf | 29 +-- extension/package.json | 61 +++++- extension/package.nls.json | 19 +- .../src/editor/AspireEditorCommandProvider.ts | 21 +- extension/src/extension.ts | 19 +- extension/src/loc/strings.ts | 2 + extension/src/server/interactionService.ts | 2 +- .../src/services/AppHostLaunchService.ts | 92 +++++++++ .../src/test/appHostLaunchService.test.ts | 100 ++++++++++ extension/src/test/appHostTreeView.test.ts | 183 +++++++++++++++--- .../src/test/aspireCodeLensProvider.test.ts | 3 +- .../test/aspireEditorCommandProvider.test.ts | 43 +++- extension/src/test/packageManifest.test.ts | 31 ++- extension/src/views/AppHostDataRepository.ts | 4 +- .../src/views/AspireAppHostTreeProvider.ts | 165 ++++++++++++++-- 15 files changed, 671 insertions(+), 103 deletions(-) create mode 100644 extension/src/services/AppHostLaunchService.ts create mode 100644 extension/src/test/appHostLaunchService.test.ts diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index d8c04afb042..224d39e9be1 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) ... @@ -467,7 +470,7 @@ 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,7 +500,7 @@ Show colored status dots in the editor gutter next to resource declarations in AppHost files. - Show workspace AppHost + Show workspace AppHosts Start @@ -527,7 +530,7 @@ 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..f38a55b1e28 100644 --- a/extension/package.json +++ b/extension/package.json @@ -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)" }, @@ -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" }, { @@ -591,8 +621,13 @@ "group": "navigation" }, { - "command": "aspire-vscode.refreshRunningAppHosts", - "when": "view == 'aspire-vscode.runningAppHosts'", + "command": "aspire-vscode.globalRefreshAppHosts", + "when": "view == 'aspire-vscode.runningAppHosts' && aspire.viewMode == 'global'", + "group": "navigation" + }, + { + "command": "aspire-vscode.refreshAppHosts", + "when": "view == 'aspire-vscode.runningAppHosts' && aspire.viewMode != 'global'", "group": "navigation" } ], @@ -609,17 +644,27 @@ }, { "command": "aspire-vscode.openAppHostSource", - "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost$|workspaceResources(:|$))/", + "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost$|workspaceResources:hasAppHost|workspaceAppHost)$/", "group": "1_open@1" }, + { + "command": "aspire-vscode.runAppHost", + "when": "view == aspire-vscode.runningAppHosts && viewItem == workspaceAppHost", + "group": "2_actions@1" + }, + { + "command": "aspire-vscode.debugAppHost", + "when": "view == aspire-vscode.runningAppHosts && viewItem == workspaceAppHost", + "group": "2_actions@2" + }, { "command": "aspire-vscode.stopAppHost", - "when": "view == aspire-vscode.runningAppHosts && viewItem == appHost", + "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost$|workspaceResources:hasAppHost)$/", "group": "2_actions@1" }, { "command": "aspire-vscode.copyAppHostPath", - "when": "view == aspire-vscode.runningAppHosts && viewItem == appHost", + "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost$|workspaceResources:hasAppHost|workspaceAppHost)$/", "group": "3_clipboard@1" }, { diff --git a/extension/package.nls.json b/extension/package.nls.json index 32ae40fa38b..2f0dfdb8139 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.runningAppHosts.name": "AppHosts", + "views.runningAppHosts.loading": "Searching for AppHosts...", + "views.runningAppHosts.welcome": "No Aspire AppHosts detected in this workspace.\n[Refresh](command:aspire-vscode.refreshAppHosts)", + "views.runningAppHosts.globalWelcome": "No running Aspire AppHosts detected on this machine.\n[Refresh](command:aspire-vscode.globalRefreshAppHosts)", + "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.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", 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..13fba334fb3 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,7 +94,7 @@ 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 appHostTreeProvider = new AspireAppHostTreeProvider(dataRepository, terminalProvider, appHostLaunchService, context.globalState); const appHostTreeView = vscode.window.createTreeView('aspire-vscode.runningAppHosts', { 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)); @@ -136,7 +143,7 @@ export async function activate(context: vscode.ExtensionContext) { // 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..47bbcd80977 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -76,6 +76,8 @@ 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 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..388726faafe --- /dev/null +++ b/extension/src/services/AppHostLaunchService.ts @@ -0,0 +1,92 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { AspireCommandType } 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 + this._launchingPaths.add(getComparisonKey(path.resolve(appHostPath))); + this._onDidChangeLaunchingState.fire(); + + const config: vscode.DebugConfiguration = { + type: 'aspire', + name: `Aspire ${command}: ${vscode.workspace.asRelativePath(appHostPath)}`, + request: 'launch', + program: appHostPath, + command: command, + noDebug: noDebug + }; + + if (doStep) { + config.step = doStep; + } + + await vscode.debug.startDebugging(undefined, config); + } +} diff --git a/extension/src/test/appHostLaunchService.test.ts b/extension/src/test/appHostLaunchService.test.ts new file mode 100644 index 00000000000..f64a9e7c742 --- /dev/null +++ b/extension/src/test/appHostLaunchService.test.ts @@ -0,0 +1,100 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +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 vscode.DebugConfiguration; + 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 vscode.DebugConfiguration; + 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); + }); +}); diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index d7789a1e03b..8a01e32b086 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,153 @@ 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 [appHostItem] = 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(appHostItem, 'Expected a non-running workspace AppHost candidate item'); + assert.strictEqual(appHostItem.label, 'AppHost.csproj'); + assert.strictEqual(appHostItem.contextValue, 'workspaceAppHost'); + 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 appHostItems = provider.getChildren(); + + 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 [item] = provider.getChildren(); + + 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(); + + // First item is the running AppHost (WorkspaceResourcesItem), second is the group + assert.strictEqual(topLevelItems.length, 2); + assert.ok(topLevelItems[0].contextValue?.startsWith('workspaceResources')); + assert.strictEqual(topLevelItems[1].contextValue, 'workspaceAppHostsGroup'); + + // Group contains the idle AppHost + const groupChildren = provider.getChildren(topLevelItems[1]); + assert.strictEqual(groupChildren.length, 1); + assert.strictEqual(groupChildren[0].contextValue, 'workspaceAppHost'); + provider.dispose(); + }); + + test('runAppHost shows warning when element is undefined', () => { + const provider = makeTreeProvider([], 'workspace'); + const stub = sinon.stub(vscode.window, 'showWarningMessage'); + + 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 and pass it to runAppHost + const [item] = provider.getChildren(); + 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(); }); @@ -719,9 +857,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 +885,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 +915,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 +925,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 +954,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 +982,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 +1020,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 +1126,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 +1153,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 +1281,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 +1313,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..a035bd45a4c 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[]; }; @@ -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, "aspire.viewMode != 'global'"); assertContains(switchToWorkspace?.when, "view == 'aspire-vscode.runningAppHosts'"); assertContains(switchToWorkspace?.when, "aspire.viewMode == 'global'"); - assertContains(refreshRunningAppHosts?.when, "view == 'aspire-vscode.runningAppHosts'"); + assertContains(globalRefreshAppHosts?.when, "view == 'aspire-vscode.runningAppHosts'"); + }); + + 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.runningAppHosts"); + assertContains(runAppHost?.when, 'viewItem == workspaceAppHost'); + assertContains(debugAppHost?.when, "view == aspire-vscode.runningAppHosts"); + 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..f3fbf7b9465 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 { diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index e9816c2df31..dfa43f7fc12 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -11,6 +11,7 @@ import { selectCommandPlaceholder, selectDashboardPlaceholder, workspaceAppHostLabel, + workspaceAppHostsGroupLabel, resourceCountDescription, tooltipType, tooltipState, @@ -25,6 +26,7 @@ import { resourceDescriptionHealth, resourceDescriptionExitCode, logFileLabel, + appHostStartingDescription, } from '../loc/strings'; import { isLinkableUrl } from '../utils/urlSchemes'; import { @@ -37,8 +39,9 @@ import { } 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 | HealthChecksGroupItem | HealthCheckItem | LogFileItem; function sortResources(resources: ResourceJson[]): ResourceJson[] { return [...resources].sort((a, b) => { @@ -101,6 +104,42 @@ class WorkspaceResourcesItem extends vscode.TreeItem { } } +class WorkspaceAppHostItem extends vscode.TreeItem { + constructor( + public readonly appHostPath: string, + appHostName?: string, + appHostDescription?: string, + launching?: boolean + ) { + super(appHostName ?? workspaceAppHostLabel, vscode.TreeItemCollapsibleState.None); + 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 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 EndpointUrlItem extends vscode.TreeItem { constructor(public readonly url: string, displayName: string) { super(displayName, vscode.TreeItemCollapsibleState.None); @@ -357,6 +396,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider(); private _treeView: vscode.TreeView | undefined; @@ -366,9 +406,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 +459,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 runningAppHostsByPath = new Map( + this._repository.appHosts.map(appHost => [getComparisonKey(path.resolve(appHost.appHostPath)), appHost] as const) + ); + 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: TreeElement[] = []; + const workspaceItems: WorkspaceAppHostItem[] = []; + + for (let i = 0; i < workspaceCandidatePaths.length; i++) { + const candidatePath = workspaceCandidatePaths[i]; + const runningAppHost = runningAppHostsByPath.get(getComparisonKey(path.resolve(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) { + return [...runningItems, new WorkspaceAppHostsGroupItem(workspaceItems)]; + } + // When nothing is running, show workspace items flat (no group wrapper) + return [...runningItems, ...workspaceItems]; } + + // Single candidate, running — show flat WorkspaceResourcesItem const resources = workspaceResources.length > 0 ? workspaceResources : [...workspaceAppHost?.resources ?? []]; @@ -552,6 +655,10 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { - 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 +939,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 { From 0e268c4d43c755028fc72c1afe0b99c9e517a567 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Fri, 29 May 2026 16:25:19 -0700 Subject: [PATCH 2/7] Rename view ID from runningAppHosts to appHosts --- extension/loc/xlf/aspire-vscode.xlf | 10 ++-- extension/package.json | 66 +++++++++++----------- extension/package.nls.json | 10 ++-- extension/src/extension.ts | 2 +- extension/src/test/packageManifest.test.ts | 16 +++--- 5 files changed, 52 insertions(+), 52 deletions(-) diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 224d39e9be1..bec65686862 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -10,7 +10,7 @@ Additional arguments to pass to the Aspire CLI command - + AppHosts @@ -346,7 +346,7 @@ No AppHost found in the current workspace. - + No Aspire AppHosts detected in this workspace. [Refresh](command:aspire-vscode.refreshAppHosts) @@ -355,7 +355,7 @@ No output from msbuild. - + No running Aspire AppHosts detected on this machine. [Refresh](command:aspire-vscode.globalRefreshAppHosts) @@ -469,7 +469,7 @@ 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 AppHosts... @@ -529,7 +529,7 @@ 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.globalRefreshAppHosts) diff --git a/extension/package.json b/extension/package.json index f38a55b1e28..23ec50cc442 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%", + "view": "aspire-vscode.appHosts", + "contents": "%views.appHosts.welcome%", "when": "aspire.noRunningAppHosts && !aspire.fetchAppHostsError && !aspire.loading && aspire.viewMode != 'global'" }, { - "view": "aspire-vscode.runningAppHosts", - "contents": "%views.runningAppHosts.globalWelcome%", + "view": "aspire-vscode.appHosts", + "contents": "%views.appHosts.globalWelcome%", "when": "aspire.noRunningAppHosts && !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" } ], @@ -612,119 +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.globalRefreshAppHosts", - "when": "view == 'aspire-vscode.runningAppHosts' && aspire.viewMode == 'global'", + "when": "view == 'aspire-vscode.appHosts' && aspire.viewMode == 'global'", "group": "navigation" }, { "command": "aspire-vscode.refreshAppHosts", - "when": "view == 'aspire-vscode.runningAppHosts' && aspire.viewMode != 'global'", + "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:hasAppHost|workspaceAppHost)$/", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^(appHost$|workspaceResources:hasAppHost|workspaceAppHost)$/", "group": "1_open@1" }, { "command": "aspire-vscode.runAppHost", - "when": "view == aspire-vscode.runningAppHosts && viewItem == workspaceAppHost", + "when": "view == aspire-vscode.appHosts && viewItem == workspaceAppHost", "group": "2_actions@1" }, { "command": "aspire-vscode.debugAppHost", - "when": "view == aspire-vscode.runningAppHosts && viewItem == workspaceAppHost", + "when": "view == aspire-vscode.appHosts && viewItem == workspaceAppHost", "group": "2_actions@2" }, { "command": "aspire-vscode.stopAppHost", - "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost$|workspaceResources:hasAppHost)$/", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^(appHost$|workspaceResources:hasAppHost)$/", "group": "2_actions@1" }, { "command": "aspire-vscode.copyAppHostPath", - "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost$|workspaceResources:hasAppHost|workspaceAppHost)$/", + "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" } ] diff --git a/extension/package.nls.json b/extension/package.nls.json index 2f0dfdb8139..65e36485d2f 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -142,11 +142,11 @@ "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": "AppHosts", - "views.runningAppHosts.loading": "Searching for AppHosts...", - "views.runningAppHosts.welcome": "No Aspire AppHosts detected in this workspace.\n[Refresh](command:aspire-vscode.refreshAppHosts)", - "views.runningAppHosts.globalWelcome": "No running Aspire AppHosts detected on this machine.\n[Refresh](command:aspire-vscode.globalRefreshAppHosts)", - "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.globalRefreshAppHosts)", + "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", diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 13fba334fb3..f49294a7560 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -95,7 +95,7 @@ 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, appHostLaunchService, context.globalState); - const appHostTreeView = vscode.window.createTreeView('aspire-vscode.runningAppHosts', { + const appHostTreeView = vscode.window.createTreeView('aspire-vscode.appHosts', { treeDataProvider: appHostTreeProvider, showCollapseAll: true, }); diff --git a/extension/src/test/packageManifest.test.ts b/extension/src/test/packageManifest.test.ts index a035bd45a4c..f5f39308bf7 100644 --- a/extension/src/test/packageManifest.test.ts +++ b/extension/src/test/packageManifest.test.ts @@ -47,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'"); @@ -64,11 +64,11 @@ suite('extension/package.json', () => { const switchToWorkspace = titleMenus.find(item => item.command === 'aspire-vscode.switchToWorkspaceView'); 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(globalRefreshAppHosts?.when, "view == 'aspire-vscode.runningAppHosts'"); + assertContains(globalRefreshAppHosts?.when, "view == 'aspire-vscode.appHosts'"); }); test('workspace non-running apphost context actions include run and debug', () => { @@ -78,9 +78,9 @@ suite('extension/package.json', () => { 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.runningAppHosts"); + assertContains(runAppHost?.when, "view == aspire-vscode.appHosts"); assertContains(runAppHost?.when, 'viewItem == workspaceAppHost'); - assertContains(debugAppHost?.when, "view == aspire-vscode.runningAppHosts"); + assertContains(debugAppHost?.when, "view == aspire-vscode.appHosts"); assertContains(debugAppHost?.when, 'viewItem == workspaceAppHost'); }); From 2b80102deeb6b1bd03498c4802030fa451cd6140 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Fri, 29 May 2026 16:31:08 -0700 Subject: [PATCH 3/7] Rename context key from noRunningAppHosts to noAppHosts --- extension/package.json | 6 +++--- extension/src/extension.ts | 2 +- extension/src/test/appHostDataRepository.test.ts | 6 +++--- extension/src/views/AppHostDataRepository.ts | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/extension/package.json b/extension/package.json index 23ec50cc442..42b208f87ec 100644 --- a/extension/package.json +++ b/extension/package.json @@ -80,12 +80,12 @@ { "view": "aspire-vscode.appHosts", "contents": "%views.appHosts.welcome%", - "when": "aspire.noRunningAppHosts && !aspire.fetchAppHostsError && !aspire.loading && aspire.viewMode != 'global'" + "when": "aspire.noAppHosts && !aspire.fetchAppHostsError && !aspire.loading && aspire.viewMode != 'global'" }, { "view": "aspire-vscode.appHosts", "contents": "%views.appHosts.globalWelcome%", - "when": "aspire.noRunningAppHosts && !aspire.fetchAppHostsError && !aspire.loading && aspire.viewMode == 'global'" + "when": "aspire.noAppHosts && !aspire.fetchAppHostsError && !aspire.loading && aspire.viewMode == 'global'" }, { "view": "aspire-vscode.appHosts", @@ -510,7 +510,7 @@ }, { "command": "aspire-vscode.openDashboard", - "when": "!aspire.noRunningAppHosts" + "when": "!aspire.noAppHosts" }, { "command": "aspire-vscode.stopAppHost", diff --git a/extension/src/extension.ts b/extension/src/extension.ts index f49294a7560..661e31307a0 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -137,7 +137,7 @@ 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.noRunningAppHosts', true); + vscode.commands.executeCommand('setContext', 'aspire.noAppHosts', true); vscode.commands.executeCommand('setContext', 'aspire.loading', true); // Activate the data repository. Workspace describe watching and global polling begin when the panel is visible. diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index 145f7f5ba27..9da39ea7491 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(); @@ -1070,7 +1070,7 @@ suite('AppHostDataRepository', () => { assert.strictEqual(loadingContextCalls.at(-1)?.args[2], false); 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], true); } finally { repository.dispose(); @@ -1201,7 +1201,7 @@ suite('AppHostDataRepository', () => { assert.strictEqual(repository.appHosts.length, 0); 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], true); } finally { repository.dispose(); diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index f3fbf7b9465..819242d8256 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -837,7 +837,7 @@ 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); + vscode.commands.executeCommand('setContext', 'aspire.noAppHosts', !hasWorkspaceAppHost && !hasResources && !hasRunningAppHosts); const clearLoading = options?.clearLoading ?? (hasResources || hasWorkspaceAppHost || hasRunningAppHosts); if (this._loadingWorkspace && clearLoading) { this._loadingWorkspace = false; @@ -983,7 +983,7 @@ export class AppHostDataRepository { if (this._viewMode === 'global' && this._loadingGlobal) { this._loadingGlobal = false; this._updateLoadingContext(); - vscode.commands.executeCommand('setContext', 'aspire.noRunningAppHosts', this._appHosts.length === 0); + vscode.commands.executeCommand('setContext', 'aspire.noAppHosts', this._appHosts.length === 0); } } @@ -1083,7 +1083,7 @@ export class AppHostDataRepository { } if (changed) { - vscode.commands.executeCommand('setContext', 'aspire.noRunningAppHosts', appHosts.length === 0); + vscode.commands.executeCommand('setContext', 'aspire.noAppHosts', appHosts.length === 0); this._onDidChangeData.fire(); } } catch (e) { From 0e312f97b65dafb45494441af8a25d28af18b02c Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Fri, 29 May 2026 16:52:05 -0700 Subject: [PATCH 4/7] Keep panel visible when stopped AppHost has workspace candidates When an AppHost stops, the noAppHosts context key now considers workspace candidates. This ensures the panel shows idle AppHosts instead of the empty welcome view after a running AppHost is stopped. --- extension/src/views/AppHostDataRepository.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index 819242d8256..d57a47794e1 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -837,8 +837,9 @@ 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.noAppHosts', !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); + const clearLoading = options?.clearLoading ?? (hasResources || hasWorkspaceAppHost || hasRunningAppHosts || hasWorkspaceCandidates); if (this._loadingWorkspace && clearLoading) { this._loadingWorkspace = false; this._updateLoadingContext(); From 93421701233a89c5b55a89d610904135b4a06fde Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Fri, 29 May 2026 17:25:24 -0700 Subject: [PATCH 5/7] Fix noAppHosts assertions: workspace candidates keep panel visible The _updateWorkspaceContext change (0e312f97b) added !hasWorkspaceCandidates to the noAppHosts condition, meaning the panel stays visible when idle AppHosts are discovered. Two tests asserted noAppHosts=true after describe exit, but the legacy format candidate is treated as buildable (toAppHostCandidate defaults null status to 'buildable'), so workspace candidates persist and noAppHosts is correctly false. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/src/test/appHostDataRepository.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index 9da39ea7491..c723ddecfc3 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -1069,9 +1069,10 @@ 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], true); + assert.strictEqual(noRunningContextCalls.at(-1)?.args[2], false); } finally { repository.dispose(); executeCommandStub.restore(); @@ -1200,9 +1201,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.noAppHosts'); - assert.strictEqual(noRunningContextCalls.at(-1)?.args[2], true); + assert.strictEqual(noRunningContextCalls.at(-1)?.args[2], false); } finally { repository.dispose(); executeCommandStub.restore(); From 7026f831eba96a589e323cc9bebd7781400750ec Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 29 May 2026 23:49:02 -0400 Subject: [PATCH 6/7] Make workspace AppHosts expandable with launch actions --- extension/src/loc/strings.ts | 5 + .../src/services/AppHostLaunchService.ts | 8 +- .../src/test/appHostLaunchService.test.ts | 5 +- extension/src/test/appHostTreeView.test.ts | 46 ++++++--- .../src/views/AspireAppHostTreeProvider.ts | 98 +++++++++++++++++-- 5 files changed, 136 insertions(+), 26 deletions(-) diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 47bbcd80977..d6ef6429640 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -77,6 +77,11 @@ export const resourceCommandMaxLength = (length: number) => vscode.l10n.t('Value 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); diff --git a/extension/src/services/AppHostLaunchService.ts b/extension/src/services/AppHostLaunchService.ts index 388726faafe..b4edb0dde81 100644 --- a/extension/src/services/AppHostLaunchService.ts +++ b/extension/src/services/AppHostLaunchService.ts @@ -1,6 +1,6 @@ import * as path from 'path'; import * as vscode from 'vscode'; -import { AspireCommandType } from '../dcp/types'; +import { AspireCommandType, AspireExtendedDebugConfiguration } from '../dcp/types'; function getComparisonKey(value: string): string { return process.platform === 'win32' ? value.toLowerCase() : value; @@ -74,13 +74,13 @@ export class AppHostLaunchService implements vscode.Disposable { this._launchingPaths.add(getComparisonKey(path.resolve(appHostPath))); this._onDidChangeLaunchingState.fire(); - const config: vscode.DebugConfiguration = { + const config: AspireExtendedDebugConfiguration = { type: 'aspire', name: `Aspire ${command}: ${vscode.workspace.asRelativePath(appHostPath)}`, request: 'launch', program: appHostPath, - command: command, - noDebug: noDebug + command, + noDebug }; if (doStep) { diff --git a/extension/src/test/appHostLaunchService.test.ts b/extension/src/test/appHostLaunchService.test.ts index f64a9e7c742..4890f16c79e 100644 --- a/extension/src/test/appHostLaunchService.test.ts +++ b/extension/src/test/appHostLaunchService.test.ts @@ -1,6 +1,7 @@ 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', () => { @@ -40,7 +41,7 @@ suite('AppHostLaunchService', () => { await service.launch('/repo/AppHost.csproj', 'run', false); assert.ok(startDebuggingStub.calledOnce); - const config = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; + 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'); @@ -52,7 +53,7 @@ suite('AppHostLaunchService', () => { 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 vscode.DebugConfiguration; + const config = startDebuggingStub.firstCall.args[1] as AspireExtendedDebugConfiguration; assert.strictEqual(config.command, 'do'); assert.strictEqual(config.step, 'deploy'); }); diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 8a01e32b086..36eb3a10c6c 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -703,12 +703,21 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { } as unknown as AppHostDataRepository; const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); - const [appHostItem] = provider.getChildren(); + const [groupItem] = provider.getChildren(); const result = provider.findAppHostElement('/repo/AppHost/AppHost.csproj'); - assert.ok(appHostItem, 'Expected a non-running workspace AppHost candidate item'); + 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(); }); @@ -730,7 +739,11 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { } as unknown as AppHostDataRepository; const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); - const appHostItems = provider.getChildren(); + 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', @@ -764,7 +777,9 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { } as unknown as AppHostDataRepository; const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); - const [item] = provider.getChildren(); + 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'); @@ -789,15 +804,21 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { const topLevelItems = provider.getChildren(); - // First item is the running AppHost (WorkspaceResourcesItem), second is the group + // 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.ok(topLevelItems[0].contextValue?.startsWith('workspaceResources')); + assert.strictEqual(topLevelItems[0].contextValue, 'runningAppHostsGroup'); assert.strictEqual(topLevelItems[1].contextValue, 'workspaceAppHostsGroup'); - // Group contains the idle AppHost - const groupChildren = provider.getChildren(topLevelItems[1]); - assert.strictEqual(groupChildren.length, 1); - assert.strictEqual(groupChildren[0].contextValue, 'workspaceAppHost'); + // 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(); }); @@ -828,8 +849,9 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { } as unknown as AppHostDataRepository; const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); - // Get the workspace item and pass it to runAppHost - const [item] = provider.getChildren(); + // Get the workspace item from inside the Workspace AppHosts group and pass it to runAppHost + const [groupItem] = provider.getChildren(); + const [item] = provider.getChildren(groupItem); provider.runAppHost(item as any, false); assert.ok(launchStub.calledOnce, 'Expected launch to be called'); diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index dfa43f7fc12..0d94c671c72 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -12,6 +12,11 @@ import { selectDashboardPlaceholder, workspaceAppHostLabel, workspaceAppHostsGroupLabel, + runningAppHostsGroupLabel, + appHostOpenSourceActionLabel, + appHostRunActionLabel, + appHostDebugActionLabel, + appHostPathLabel, resourceCountDescription, tooltipType, tooltipState, @@ -41,7 +46,7 @@ import { collectResourceCommandArguments, ResourceCommandArgumentValue } from '. import { createResourceCommandArgumentLoader } from './ResourceCommandArgumentsLoader'; import { AppHostLaunchService } from '../services/AppHostLaunchService'; -type TreeElement = AppHostItem | EndpointUrlItem | ResourcesGroupItem | ResourceItem | WorkspaceResourcesItem | WorkspaceAppHostItem | WorkspaceAppHostsGroupItem | 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) => { @@ -109,9 +114,9 @@ class WorkspaceAppHostItem extends vscode.TreeItem { public readonly appHostPath: string, appHostName?: string, appHostDescription?: string, - launching?: boolean + public readonly launching?: boolean ) { - super(appHostName ?? workspaceAppHostLabel, vscode.TreeItemCollapsibleState.None); + super(appHostName ?? workspaceAppHostLabel, vscode.TreeItemCollapsibleState.Collapsed); this.id = `workspace-apphost:${getComparisonKey(path.resolve(appHostPath))}`; if (launching) { @@ -130,6 +135,40 @@ class WorkspaceAppHostItem extends vscode.TreeItem { } } +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); @@ -140,6 +179,16 @@ class WorkspaceAppHostsGroupItem extends vscode.TreeItem { } } +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); @@ -500,7 +549,17 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider 0 && runningItems.length > 0) { - return [...runningItems, new WorkspaceAppHostsGroupItem(workspaceItems)]; + // 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, show workspace items flat (no group wrapper) - return [...runningItems, ...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 @@ -659,6 +726,21 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider Date: Sat, 30 May 2026 00:11:05 -0400 Subject: [PATCH 7/7] Address review feedback for PR #17506 Fix #1: Rename command IDs in package.json menus and walkthrough so they target the new aspire-vscode.runAppHostCommand and aspire-vscode.debugAppHostCommand registrations introduced in this PR. Without this the editor title bar, explorer context menu, and Get Started walkthrough Run/Debug buttons silently no-op. Fix #2: Wrap vscode.debug.startDebugging in try/catch in AppHostLaunchService.launch so a 'false' return value (debug adapter rejected) or thrown error clears the launching state. Otherwise the tree item is stuck showing the 'Starting...' spinner forever and the user cannot retry. Fix #3: Make AspireAppHostTreeProvider.runAppHost async and await launch so launch failures surface via showErrorMessage instead of being dropped as unhandled promise rejections. Fix #4: In workspace mode with multiple candidate AppHost paths, match running AppHosts to candidates by directory equivalence (isMatchingAppHostPath) rather than exact path. This is the same matching used elsewhere in AppHostDataRepository when correlating 'aspire ps' output to candidate paths, so canonicalization differences (case, separators, trailing slashes) no longer cause a running AppHost to display as idle. Fix #5: Introduce aspire.noRunningAppHosts context key so the Open Dashboard palette command is only enabled when at least one AppHost is actually running. Previously the palette appeared when only idle candidates were known and then silently no-oped. Fix #6: Widen the workspaceResources contextValue regex in package.json so the read-only 'Open AppHost Source' and 'Copy AppHost Path' actions appear on bare 'workspaceResources' items, not only on 'workspaceResources:hasAppHost'. The destructive 'Stop AppHost' menu remains gated on :hasAppHost. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/loc/xlf/aspire-vscode.xlf | 2 +- extension/package.json | 18 ++--- extension/package.nls.json | 2 +- extension/src/extension.ts | 1 + .../src/services/AppHostLaunchService.ts | 16 +++- .../src/test/appHostDataRepository.test.ts | 8 ++ .../src/test/appHostLaunchService.test.ts | 20 +++++ extension/src/test/appHostTreeView.test.ts | 77 ++++++++++++++++++- extension/src/views/AppHostDataRepository.ts | 10 ++- .../src/views/AspireAppHostTreeProvider.ts | 21 +++-- 10 files changed, 152 insertions(+), 23 deletions(-) diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index bec65686862..c09465209c1 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -506,7 +506,7 @@ 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 diff --git a/extension/package.json b/extension/package.json index 42b208f87ec..6b41f9d0888 100644 --- a/extension/package.json +++ b/extension/package.json @@ -461,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" } @@ -510,7 +510,7 @@ }, { "command": "aspire-vscode.openDashboard", - "when": "!aspire.noAppHosts" + "when": "!aspire.noRunningAppHosts" }, { "command": "aspire-vscode.stopAppHost", @@ -644,7 +644,7 @@ }, { "command": "aspire-vscode.openAppHostSource", - "when": "view == aspire-vscode.appHosts && viewItem =~ /^(appHost$|workspaceResources:hasAppHost|workspaceAppHost)$/", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^(appHost$|workspaceResources(:hasAppHost)?|workspaceAppHost)$/", "group": "1_open@1" }, { @@ -664,7 +664,7 @@ }, { "command": "aspire-vscode.copyAppHostPath", - "when": "view == aspire-vscode.appHosts && viewItem =~ /^(appHost$|workspaceResources:hasAppHost|workspaceAppHost)$/", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^(appHost$|workspaceResources(:hasAppHost)?|workspaceAppHost)$/", "group": "3_clipboard@1" }, { @@ -900,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 65e36485d2f..22b70038bab 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -186,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/extension.ts b/extension/src/extension.ts index 661e31307a0..04576787891 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -138,6 +138,7 @@ export async function activate(context: vscode.ExtensionContext) { // 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. diff --git a/extension/src/services/AppHostLaunchService.ts b/extension/src/services/AppHostLaunchService.ts index b4edb0dde81..ea19403cb3b 100644 --- a/extension/src/services/AppHostLaunchService.ts +++ b/extension/src/services/AppHostLaunchService.ts @@ -70,7 +70,11 @@ export class AppHostLaunchService implements vscode.Disposable { * @param doStep Optional step name for the 'do' command. */ async launch(appHostPath: string, command: AspireCommandType, noDebug: boolean, doStep?: string): Promise { - // Track launching state + // 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(); @@ -87,6 +91,14 @@ export class AppHostLaunchService implements vscode.Disposable { config.step = doStep; } - await vscode.debug.startDebugging(undefined, config); + 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 c723ddecfc3..fbe6b0102cc 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -1073,6 +1073,14 @@ suite('AppHostDataRepository', () => { 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(noLiveAppHostsCalls.at(-1)?.args[2], true); } finally { repository.dispose(); executeCommandStub.restore(); diff --git a/extension/src/test/appHostLaunchService.test.ts b/extension/src/test/appHostLaunchService.test.ts index 4890f16c79e..356dd2f2509 100644 --- a/extension/src/test/appHostLaunchService.test.ts +++ b/extension/src/test/appHostLaunchService.test.ts @@ -98,4 +98,24 @@ suite('AppHostLaunchService', () => { 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 36eb3a10c6c..ec01f3b6e4b 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -822,11 +822,51 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { provider.dispose(); }); - test('runAppHost shows warning when element is undefined', () => { + 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'); - provider.runAppHost(undefined, true); + await provider.runAppHost(undefined, true); assert.ok(stub.calledOnce, 'Expected a warning message'); stub.restore(); @@ -852,7 +892,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { // Get the workspace item from inside the Workspace AppHosts group and pass it to runAppHost const [groupItem] = provider.getChildren(); const [item] = provider.getChildren(groupItem); - provider.runAppHost(item as any, false); + await provider.runAppHost(item as any, false); assert.ok(launchStub.calledOnce, 'Expected launch to be called'); assert.strictEqual(launchStub.firstCall.args[0], appHostPath); @@ -862,6 +902,37 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { 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(); + }); + test('workspace mode renders a running AppHost with no resources', () => { const hostPath = '/repo/AppHost/AppHost.csproj'; const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index d57a47794e1..04b55bb7481 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -839,6 +839,12 @@ export class AppHostDataRepository { const hasRunningAppHosts = this._appHosts.length > 0; 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; @@ -985,6 +991,7 @@ export class AppHostDataRepository { 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); } } @@ -1085,6 +1092,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(); } } catch (e) { @@ -1429,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 0d94c671c72..9b329af17b5 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -32,6 +32,7 @@ import { resourceDescriptionExitCode, logFileLabel, appHostStartingDescription, + errorMessage, } from '../loc/strings'; import { isLinkableUrl } from '../utils/urlSchemes'; import { @@ -40,6 +41,7 @@ import { ResourceCommandArgumentInputJson, ResourceJson, ViewMode, + isMatchingAppHostPath, shortenPaths, } from './AppHostDataRepository'; import { collectResourceCommandArguments, ResourceCommandArgumentValue } from './ResourceCommandArguments'; @@ -647,9 +649,6 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider 1 || (workspaceResources.length === 0 && !workspaceAppHost)) { const selectedAppHostPath = workspaceAppHost?.appHostPath ?? this._repository.workspaceAppHostPath; - const runningAppHostsByPath = new Map( - this._repository.appHosts.map(appHost => [getComparisonKey(path.resolve(appHost.appHostPath)), appHost] as const) - ); const labels = shortenPaths(workspaceCandidatePaths); // When multiple workspace AppHosts are running, use global-style AppHostItem (nested view). @@ -659,7 +658,13 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider isMatchingAppHostPath(appHost.appHostPath, candidatePath) + ); const launching = this._launchService.isLaunching(candidatePath); if (!runningAppHost) { @@ -916,14 +921,18 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { const appHostPath = element?.appHostPath; if (!appHostPath) { vscode.window.showWarningMessage(appHostSourceNotFound); return; } - this._launchService.launch(appHostPath, 'run', noDebug); + try { + await this._launchService.launch(appHostPath, 'run', noDebug); + } catch (err) { + vscode.window.showErrorMessage(errorMessage(err)); + } } stopAppHost(element: AppHostItem | WorkspaceResourcesItem): void {