diff --git a/extension/README.md b/extension/README.md index c068d7a96d6..21444ab588e 100644 --- a/extension/README.md +++ b/extension/README.md @@ -190,7 +190,7 @@ You can configure the extension under **Settings → Aspire**, or jump there wit |---------|---------|-------------| | `aspire.aspireCliExecutablePath` | `""` | Path to the Aspire CLI. Leave empty to use `aspire` from PATH. | | `aspire.dashboardBrowser` | `openExternalBrowser` | Which browser to open the dashboard in — system default, or Chrome/Edge/Firefox as a debug session | -| `aspire.enableAspireDashboardAutoLaunch` | `true` | Open the dashboard automatically when you start debugging | +| `aspire.enableAspireDashboardAutoLaunch` | `launch` | Controls what happens with the dashboard when debugging starts: `launch` (auto-open), `notification` (show link), or `off` | | `aspire.registerMcpServerInWorkspace` | `false` | Register the Aspire MCP server (see [below](#mcp-server-integration)) | There are more settings for things like verbose logging, startup prompts, and polling intervals — run **Aspire: Extension settings** from the Command Palette to see them all. diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 5ad10af80d2..bd6672f38fd 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -52,6 +52,9 @@ Authorization header must start with 'Bearer '. + + Automatically open the dashboard in the browser. + Build failed for project {0} with error: {1}. @@ -82,6 +85,9 @@ Configure launch.json file + + Controls what happens with the Aspire Dashboard when debugging starts. + Create a new project @@ -109,6 +115,9 @@ Dismiss + + Do nothing — the dashboard URL is still printed in the terminal. + Do you want to select the default apphost for this workspace? @@ -124,9 +133,6 @@ Enable apphost discovery on extension activation and prompt to setup .aspire/settings.json.appHostPath if it does not exist in the workspace. - - Enable automatic launch of the Aspire Dashboard when using the Aspire debugger. - Enable console debug logging for Aspire CLI commands executed by the extension. @@ -197,7 +203,7 @@ Include environment variables when logging debug session configurations. This can help diagnose environment-related issues but may expose sensitive information in logs. - Initialize Aspire + Initialize Aspire in an existing codebase Install Aspire CLI (daily) @@ -223,6 +229,9 @@ Launch Aspire Dashboard + + Settings + Launch Chrome as a debug session (auto-closes when debugging ends). @@ -338,7 +347,7 @@ Run {0} - Running apphosts + Running AppHosts 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) @@ -355,6 +364,9 @@ Select the default apphost to launch when starting an Aspire debug session + + Show a notification with a link to open the dashboard. + Show global apphosts @@ -388,6 +400,9 @@ The apphost is not compatible. Consider upgrading the apphost or Aspire CLI. + + The apphost process has terminated. To view console output, select the apphost session from the debug console dropdown. + The browser to use when auto-launching the Aspire Dashboard. diff --git a/extension/package.json b/extension/package.json index 0c1355e59d2..c5a37e9d557 100644 --- a/extension/package.json +++ b/extension/package.json @@ -470,9 +470,15 @@ "scope": "window" }, "aspire.enableAspireDashboardAutoLaunch": { - "type": "boolean", - "default": true, + "type": "string", + "enum": ["launch", "notification", "off"], + "default": "launch", "description": "%configuration.aspire.enableAspireDashboardAutoLaunch%", + "enumDescriptions": [ + "%configuration.aspire.enableAspireDashboardAutoLaunch.launch%", + "%configuration.aspire.enableAspireDashboardAutoLaunch.notification%", + "%configuration.aspire.enableAspireDashboardAutoLaunch.off%" + ], "scope": "window" }, "aspire.dashboardBrowser": { diff --git a/extension/package.nls.json b/extension/package.nls.json index 58dbc1b38dc..886b11ae286 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -24,7 +24,10 @@ "configuration.aspire.aspireCliExecutablePath": "The path to the Aspire CLI executable. If not set, the extension will attempt to use 'aspire' from the system PATH.", "configuration.aspire.enableAspireCliDebugLogging": "Enable console debug logging for Aspire CLI commands executed by the extension.", "configuration.aspire.enableAspireDcpDebugLogging": "Enable Developer Control Plane (DCP) debug logging for Aspire applications. Logs will be stored in the workspace's .aspire/dcp/logs-{debugSessionId} folder.", - "configuration.aspire.enableAspireDashboardAutoLaunch": "Enable automatic launch of the Aspire Dashboard when using the Aspire debugger.", + "configuration.aspire.enableAspireDashboardAutoLaunch": "Controls what happens with the Aspire Dashboard when debugging starts.", + "configuration.aspire.enableAspireDashboardAutoLaunch.launch": "Automatically open the dashboard in the browser.", + "configuration.aspire.enableAspireDashboardAutoLaunch.notification": "Show a notification with a link to open the dashboard.", + "configuration.aspire.enableAspireDashboardAutoLaunch.off": "Do nothing — the dashboard URL is still printed in the terminal.", "configuration.aspire.dashboardBrowser": "The browser to use when auto-launching the Aspire Dashboard.", "configuration.aspire.dashboardBrowser.openExternalBrowser": "Use the system default browser (cannot auto-close).", "configuration.aspire.dashboardBrowser.debugChrome": "Launch Chrome as a debug session (auto-closes when debugging ends).", diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 4b48f955263..d65d03d95f7 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -9,6 +9,7 @@ export const codespacesUrl = (url: string) => vscode.l10n.t('Codespaces: {0}', u export const directLink = vscode.l10n.t('Open local URL'); export const codespacesLink = vscode.l10n.t('Open codespaces URL'); export const openAspireDashboard = vscode.l10n.t('Launch Aspire Dashboard'); +export const settingsLabel = vscode.l10n.t('Settings'); export const aspireDashboard = vscode.l10n.t('Aspire Dashboard'); export const noWorkspaceOpen = vscode.l10n.t('No workspace is open. Please open a folder or workspace before running this command.'); export const failedToShowPromptEmpty = vscode.l10n.t('Failed to show prompt, text was empty.'); diff --git a/extension/src/server/interactionService.ts b/extension/src/server/interactionService.ts index a546a1401b6..7cdbd04e08a 100644 --- a/extension/src/server/interactionService.ts +++ b/extension/src/server/interactionService.ts @@ -2,7 +2,7 @@ import { MessageConnection } from 'vscode-jsonrpc'; import * as vscode from 'vscode'; import * as fs from 'fs/promises'; import { getRelativePathToWorkspace, isFolderOpenInWorkspace } from '../utils/workspace'; -import { yesLabel, noLabel, directLink, codespacesLink, openAspireDashboard, failedToShowPromptEmpty, incompatibleAppHostError, aspireHostingSdkVersion, aspireCliVersion, requiredCapability, fieldRequired, aspireDebugSessionNotInitialized, errorMessage, failedToStartDebugSession, dashboard, codespaces, selectDirectoryTitle, selectFileTitle } from '../loc/strings'; +import { yesLabel, noLabel, directLink, codespacesLink, openAspireDashboard, settingsLabel, failedToShowPromptEmpty, incompatibleAppHostError, aspireHostingSdkVersion, aspireCliVersion, requiredCapability, fieldRequired, aspireDebugSessionNotInitialized, errorMessage, failedToStartDebugSession, dashboard, codespaces, selectDirectoryTitle, selectFileTitle } from '../loc/strings'; import { ICliRpcClient } from './rpcClient'; import { ProgressNotifier } from './progressNotifier'; import { applyTextStyle, formatText } from '../utils/strings'; @@ -334,11 +334,27 @@ export class InteractionService implements IInteractionService { this.writeDebugSessionMessage(codespacesUrl, true, AnsiColors.Blue); } - // If aspire.enableAspireDashboardAutoLaunch is true, the dashboard will be launched automatically and we do not need - // to show an information message. + // Refresh the Aspire panel so it picks up dashboard URLs for the running app host + vscode.commands.executeCommand('aspire-vscode.refreshRunningAppHosts'); + + // If aspire.enableAspireDashboardAutoLaunch is 'launch', the dashboard will be launched automatically. + // If 'notification', a notification is shown with a link. If 'off', do nothing. const aspireConfig = vscode.workspace.getConfiguration('aspire'); - const enableDashboardAutoLaunch = aspireConfig.get('enableAspireDashboardAutoLaunch', true); - if (enableDashboardAutoLaunch) { + const rawDashboardAutoLaunch = aspireConfig.get('enableAspireDashboardAutoLaunch', 'launch'); + + // Handle legacy boolean values from before this setting was changed to an enum + let dashboardAutoLaunch: 'launch' | 'notification' | 'off'; + if (rawDashboardAutoLaunch === true) { + dashboardAutoLaunch = 'launch'; + } else if (rawDashboardAutoLaunch === false) { + dashboardAutoLaunch = 'notification'; + } else if (rawDashboardAutoLaunch === 'launch' || rawDashboardAutoLaunch === 'notification' || rawDashboardAutoLaunch === 'off') { + dashboardAutoLaunch = rawDashboardAutoLaunch; + } else { + dashboardAutoLaunch = 'launch'; + } + + if (dashboardAutoLaunch === 'launch') { // Open the dashboard URL in the configured browser. Prefer codespaces URL if available. const urlToOpen = codespacesUrl || baseUrl; const debugSession = this._getAspireDebugSession(); @@ -349,6 +365,10 @@ export class InteractionService implements IInteractionService { return; } + if (dashboardAutoLaunch === 'off') { + return; + } + const actions: vscode.MessageItem[] = [ { title: directLink } ]; @@ -357,6 +377,8 @@ export class InteractionService implements IInteractionService { actions.push({ title: codespacesLink }); } + actions.push({ title: settingsLabel }); + // Delay 1 second to allow a slight pause between progress notification and message setTimeout(() => { // Don't await - fire and forget to avoid blocking @@ -376,6 +398,9 @@ export class InteractionService implements IInteractionService { else if (selected.title === codespacesLink && codespacesUrl) { vscode.env.openExternal(vscode.Uri.parse(codespacesUrl)); } + else if (selected.title === settingsLabel) { + vscode.commands.executeCommand('workbench.action.openSettings', 'aspire.enableAspireDashboardAutoLaunch'); + } }); }, 1000); } diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 2fa15746210..43aad8b5ae0 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -4,7 +4,7 @@ import { getResourceContextValue, getResourceIcon } from '../views/AspireAppHost import type { ResourceJson } from '../views/AppHostDataRepository'; function makeResource(overrides: Partial = {}): ResourceJson { - return { + const base: ResourceJson = { name: 'my-service', displayName: null, resourceType: 'Project', @@ -14,8 +14,9 @@ function makeResource(overrides: Partial = {}): ResourceJson { dashboardUrl: null, urls: null, commands: null, - ...overrides, + properties: null, }; + return { ...base, ...overrides } as ResourceJson; } suite('shortenPath', () => { @@ -97,6 +98,7 @@ suite('getResourceContextValue', () => { })); assert.strictEqual(result, 'resource:canRestart'); }); + }); suite('getResourceIcon', () => { diff --git a/extension/src/test/rpc/interactionServiceTests.test.ts b/extension/src/test/rpc/interactionServiceTests.test.ts index f2b073aa7c9..e8de2f7b703 100644 --- a/extension/src/test/rpc/interactionServiceTests.test.ts +++ b/extension/src/test/rpc/interactionServiceTests.test.ts @@ -189,11 +189,11 @@ suite('InteractionService endpoints', () => { stub.restore(); }); - test("displayDashboardUrls writes URLs to output channel and shows info message when autoLaunch disabled", async () => { + test("displayDashboardUrls writes URLs to output channel and shows info message when autoLaunch is notification", async () => { const stub = sinon.stub(extensionLogOutputChannel, 'info'); const showInformationMessageStub = sinon.stub(vscode.window, 'showInformationMessage').resolves(); const getConfigurationStub = sinon.stub(vscode.workspace, 'getConfiguration').returns({ - get: (key: string, defaultValue?: any) => key === 'enableAspireDashboardAutoLaunch' ? false : defaultValue + get: (key: string, defaultValue?: any) => key === 'enableAspireDashboardAutoLaunch' ? 'notification' : defaultValue } as any); const testInfo = await createTestRpcServer(); @@ -212,17 +212,17 @@ suite('InteractionService endpoints', () => { assert.ok(outputLines.some(line => line.includes(baseUrl)), 'Output should contain base URL'); assert.ok(outputLines.some(line => line.includes(codespacesUrl)), 'Output should contain codespaces URL'); - assert.equal(showInformationMessageStub.callCount, 1, 'Should show info message when autoLaunch is disabled'); + assert.equal(showInformationMessageStub.callCount, 1, 'Should show info message when autoLaunch is notification'); stub.restore(); showInformationMessageStub.restore(); getConfigurationStub.restore(); }); - test("displayDashboardUrls writes URLs but does not show info message when autoLaunch enabled", async () => { + test("displayDashboardUrls writes URLs but does not show info message when autoLaunch is launch", async () => { const stub = sinon.stub(extensionLogOutputChannel, 'info'); const showInformationMessageStub = sinon.stub(vscode.window, 'showInformationMessage').resolves(); const getConfigurationStub = sinon.stub(vscode.workspace, 'getConfiguration').returns({ - get: (key: string, defaultValue?: any) => key === 'enableAspireDashboardAutoLaunch' ? true : defaultValue + get: (key: string, defaultValue?: any) => key === 'enableAspireDashboardAutoLaunch' ? 'launch' : defaultValue } as any); const testInfo = await createTestRpcServer(); @@ -239,7 +239,7 @@ suite('InteractionService endpoints', () => { // No need to wait since no setTimeout should be called when autoLaunch is enabled assert.ok(outputLines.some(line => line.includes(baseUrl)), 'Output should contain base URL'); assert.ok(outputLines.some(line => line.includes(codespacesUrl)), 'Output should contain codespaces URL'); - assert.equal(showInformationMessageStub.callCount, 0, 'Should not show info message when autoLaunch is enabled'); + assert.equal(showInformationMessageStub.callCount, 0, 'Should not show info message when autoLaunch is launch'); stub.restore(); showInformationMessageStub.restore(); getConfigurationStub.restore(); diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index 97098feb359..94b830d7a50 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -27,6 +27,7 @@ export interface ResourceJson { dashboardUrl: string | null; urls: ResourceUrlJson[] | null; commands: Record | null; + properties: Record | null; } export interface AppHostDisplayInfo { @@ -64,7 +65,6 @@ export class AppHostDataRepository { private _describeRestarting = false; private _describeRestartDelay = 5000; private _describeRestartTimer: ReturnType | undefined; - private static readonly _maxDescribeRestartDelay = 60000; // ── Global mode state (ps polling) ── private _appHosts: AppHostDisplayInfo[] = []; @@ -273,7 +273,7 @@ export class AppHostDataRepository { // Auto-restart with exponential backoff const delay = this._describeRestartDelay; - this._describeRestartDelay = Math.min(this._describeRestartDelay * 2, AppHostDataRepository._maxDescribeRestartDelay); + this._describeRestartDelay = Math.min(this._describeRestartDelay * 2, this._getPollingIntervalMs()); extensionLogOutputChannel.info(`Restarting describe --follow in ${delay}ms`); this._describeRestartTimer = setTimeout(() => { this._describeRestartTimer = undefined; diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index bc80b18ff78..b98fc02ef5d 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -25,6 +25,14 @@ import { type TreeElement = AppHostItem | DetailItem | ResourcesGroupItem | ResourceItem | WorkspaceResourcesItem; +function sortResources(resources: ResourceJson[]): ResourceJson[] { + return [...resources].sort((a, b) => { + const nameA = (a.displayName ?? a.name).toLowerCase(); + const nameB = (b.displayName ?? b.name).toLowerCase(); + return nameA.localeCompare(nameB); + }); +} + function appHostIcon(path?: string): vscode.ThemeIcon { const icon = path?.endsWith('.csproj') ? 'server-process' : 'file-code'; return new vscode.ThemeIcon(icon, new vscode.ThemeColor('aspire.brandPurple')); @@ -71,12 +79,19 @@ class ResourcesGroupItem extends vscode.TreeItem { } } +function getParentResourceName(resource: ResourceJson): string | null { + return resource.properties?.['resource.parentName'] ?? null; +} + class ResourceItem extends vscode.TreeItem { - constructor(public readonly resource: ResourceJson, public readonly appHostPid: number | null) { + constructor(public readonly resource: ResourceJson, public readonly appHostPid: number | null, hasChildren: boolean) { const state = resource.state ?? ''; const label = state ? resourceStateLabel(resource.displayName ?? resource.name, state) : (resource.displayName ?? resource.name); const hasUrls = resource.urls && resource.urls.filter(u => !u.isInternal).length > 0; - super(label, hasUrls ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None); + const collapsible = hasChildren + ? vscode.TreeItemCollapsibleState.Expanded + : hasUrls ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None; + super(label, collapsible); this.id = appHostPid !== null ? `resource:${appHostPid}:${resource.name}` : `resource:workspace:${resource.name}`; this.iconPath = getResourceIcon(resource); this.description = resource.resourceType; @@ -220,14 +235,17 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider !getParentResourceName(r)); + for (const resource of sortResources(topLevel)) { + const hasChildren = element.resources.some(r => getParentResourceName(r) === resource.name); + items.push(new ResourceItem(resource, null, hasChildren)); } return items; } if (element instanceof ResourceItem) { - return this._getUrlChildren(element); + return this._getResourceChildren(element, [...this._repository.workspaceResources]); } return []; @@ -277,16 +295,39 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider new ResourceItem(r, element.appHostPid)); + const topLevel = element.resources.filter(r => !getParentResourceName(r)); + return sortResources(topLevel).map(r => { + const hasChildren = element.resources.some(c => getParentResourceName(c) === r.name); + return new ResourceItem(r, element.appHostPid, hasChildren); + }); } if (element instanceof ResourceItem) { - return this._getUrlChildren(element); + const allResources = this._repository.viewMode === 'workspace' + ? [...this._repository.workspaceResources] + : this._repository.appHosts.find(a => a.appHostPid === element.appHostPid)?.resources ?? []; + return this._getResourceChildren(element, allResources); } return []; } + private _getResourceChildren(element: ResourceItem, allResources: readonly ResourceJson[]): TreeElement[] { + const items: TreeElement[] = []; + + // Add child resources + const children = allResources.filter(r => getParentResourceName(r) === element.resource.name); + for (const child of sortResources(children)) { + const hasChildren = allResources.some(r => getParentResourceName(r) === child.name); + items.push(new ResourceItem(child, element.appHostPid, hasChildren)); + } + + // Add URL children + items.push(...this._getUrlChildren(element)); + + return items; + } + private _getUrlChildren(element: ResourceItem): TreeElement[] { const urls = element.resource.urls?.filter(u => !u.isInternal) ?? []; return urls.map(url => new DetailItem( diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index c8a02f12456..6a766f82ec7 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -252,7 +252,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // Now wait for the backchannel to be established var backchannel = await InteractionService.ShowStatusAsync( - isExtensionHost ? InteractionServiceStrings.BuildingAppHost : RunCommandStrings.ConnectingToAppHost, + RunCommandStrings.ConnectingToAppHost, async () => await backchannelCompletionSource.Task.WaitAsync(cancellationToken)); // Set up log capture - writes to unified CLI log file