From 18434291e6faba03d1e3f305aa11e07c0fc909f0 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Wed, 3 Jun 2026 12:22:22 -0700 Subject: [PATCH 1/8] Improve parameter display in VS Code extension tree Show parameter values in the resource tree consistently with the dashboard: mask secrets with a fixed 8-bullet string, truncate long non-secret values, and surface a "Value missing" state. Preserve AppHost command registration order in the CLI include-disabled stream so set-parameter shows before delete-parameter. Fixes #17193 --- extension/loc/xlf/aspire-vscode.xlf | 3 + extension/package.nls.json | 1 + extension/src/editor/resourceConstants.ts | 11 ++ extension/src/loc/strings.ts | 1 + extension/src/test/appHostTreeView.test.ts | 174 ++++++++++++++++++ .../src/views/AspireAppHostTreeProvider.ts | 58 +++++- .../Backchannel/ResourceSnapshotMapper.cs | 17 +- .../ResourceSnapshotMapperTests.cs | 59 +++++- 8 files changed, 317 insertions(+), 7 deletions(-) diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 4d7463a1fa9..ec4054f656f 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -613,6 +613,9 @@ Use the system default browser (cannot auto-close). + + Value missing + Value must be {0} characters or fewer. diff --git a/extension/package.nls.json b/extension/package.nls.json index 18f406c0b1e..41949188fde 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -211,6 +211,7 @@ "aspire-vscode.strings.resourceCommandDontShowAgain": "Don't show again", "aspire-vscode.strings.resourceCommandInvalidNumber": "Enter a number using invariant culture, for example 1, -1.5, or 1e3.", "aspire-vscode.strings.resourceCommandMaxLength": "Value must be {0} characters or fewer.", + "aspire-vscode.strings.parameterValueMissing": "Value missing", "aspire-vscode.strings.resourceCommandDisabledDescription": "(disabled)", "aspire-vscode.strings.workspaceAppHostLabel": "Workspace AppHost", "aspire-vscode.strings.workspaceAppHostsGroupLabel": "Workspace AppHosts", diff --git a/extension/src/editor/resourceConstants.ts b/extension/src/editor/resourceConstants.ts index d09ce17a62a..9873dd49183 100644 --- a/extension/src/editor/resourceConstants.ts +++ b/extension/src/editor/resourceConstants.ts @@ -12,6 +12,7 @@ export const ResourceState = { Exited: 'Exited', FailedToStart: 'FailedToStart', RuntimeUnhealthy: 'RuntimeUnhealthy', + ValueMissing: 'ValueMissing', } as const; // Health status values returned by the Aspire runtime @@ -39,3 +40,13 @@ export type ResourceStateValue = typeof ResourceState[keyof typeof ResourceState export type HealthStatusValue = typeof HealthStatus[keyof typeof HealthStatus]; export type StateStyleValue = typeof StateStyle[keyof typeof StateStyle]; export type ResourceTypeValue = typeof ResourceType[keyof typeof ResourceType]; + +// Resource command names exposed by the dashboard/CLI +export const CommandName = { + SetParameter: 'set-parameter', +} as const; + +// Well-known resource property names. +export const ParameterPropertyName = { + Value: 'Value', +} as const; diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 157f4ef44f8..42a7db5c056 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -100,6 +100,7 @@ export const healthChecksLabel = vscode.l10n.t('Health Checks'); export const healthCheckDescription = (status: string) => vscode.l10n.t('Status: {0}', status); export const resourceDescriptionHealth = (passed: number, total: number) => vscode.l10n.t('Health: {0}/{1}', passed, total); export const resourceDescriptionExitCode = (exitCode: number) => vscode.l10n.t('Exit Code: {0}', exitCode); +export const parameterValueMissing = vscode.l10n.t('Value missing'); export const failedToStartDebugSession = vscode.l10n.t('Failed to start debug session.'); export const failedToGetConfigInfo = (exitCode: number) => vscode.l10n.t('Failed to get Aspire config info (exit code: {0}). Try updating the Aspire CLI with: aspire update', exitCode); export const failedToParseConfigInfo = (error: any) => vscode.l10n.t('Failed to parse Aspire config info: {0}. Try updating the Aspire CLI with: aspire update', error); diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 7183bf32585..4b86d568c2c 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -7,6 +7,7 @@ import * as configInfoProvider from '../utils/configInfoProvider'; import { AppHostDataRepository, shortenPath, shortenPaths } from '../views/AppHostDataRepository'; import { AspireAppHostTreeProvider, getResourceContextValue, getResourceIcon, getResourceCommandIcon, resolveAppHostSourcePath, buildResourceDescription } from '../views/AspireAppHostTreeProvider'; import type { AppHostDisplayInfo, ResourceJson, ViewMode } from '../views/AppHostDataRepository'; +import { ResourceCommandInputType } from '../views/AppHostDataRepository'; import { ResourceState, HealthStatus, StateStyle } from '../editor/resourceConstants'; import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { quoteShellArg } from '../utils/AspireTerminalProvider'; @@ -977,6 +978,15 @@ suite('getResourceIcon', () => { const icon = getResourceIcon(makeResource({ state: 'SomeUnknownState' })); assert.strictEqual(icon.id, 'circle-filled'); }); + + test('ValueMissing parameter shows warning icon', () => { + const icon = getResourceIcon(makeResource({ + resourceType: 'Parameter', + state: ResourceState.ValueMissing, + })); + + assert.strictEqual(icon.id, 'warning'); + }); }); suite('getResourceCommandIcon', () => { @@ -1049,6 +1059,116 @@ suite('buildResourceDescription', () => { test('empty health reports returns resource type', () => { assert.strictEqual(buildResourceDescription(makeResource({ healthReports: {} })), 'Project'); }); + + test('parameter with missing value shows humanized state and no stale value', () => { + const desc = buildResourceDescription(makeResource({ + resourceType: 'Parameter', + state: ResourceState.ValueMissing, + properties: { Value: 'Parameter value has been deleted' }, + })); + + assert.strictEqual(desc, 'Parameter · Value missing'); + }); + + test('parameter with non-secret value shows value text', () => { + const desc = buildResourceDescription(makeResource({ + resourceType: 'Parameter', + state: ResourceState.Running, + properties: { Value: 'The value' }, + })); + + assert.strictEqual(desc, 'Parameter · Running · The value'); + }); + + test('parameter with secret value shows masked value', () => { + const desc = buildResourceDescription(makeResource({ + resourceType: 'Parameter', + state: ResourceState.Running, + properties: { Value: 'super-secret-value' }, + commands: { + 'set-parameter': { + displayName: 'Set parameter', + description: null, + argumentInputs: [ + { + name: 'Value', + label: null, + description: null, + inputType: ResourceCommandInputType.SecretText, + placeholder: null, + value: null, + options: null, + maxLength: null, + }, + ], + }, + }, + })); + + assert.strictEqual(desc, 'Parameter · Running · ●●●●●●●●'); + assert.ok(!desc.includes('super-secret-value'), 'Expected secret value to be masked'); + }); + + test('parameter value at display limit is not truncated', () => { + const value = 'x'.repeat(80); + const desc = buildResourceDescription(makeResource({ + resourceType: 'Parameter', + state: ResourceState.Running, + properties: { Value: value }, + })); + + assert.strictEqual(desc, `Parameter · Running · ${value}`); + }); + + test('parameter value over display limit is truncated with ellipsis', () => { + const desc = buildResourceDescription(makeResource({ + resourceType: 'Parameter', + state: ResourceState.Running, + properties: { Value: `${'x'.repeat(80)}y` }, + })); + + assert.strictEqual(desc, `Parameter · Running · ${'x'.repeat(79)}…`); + }); + + test('parameter with empty value does not add a blank value segment', () => { + const desc = buildResourceDescription(makeResource({ + resourceType: 'Parameter', + state: ResourceState.Running, + properties: { Value: '' }, + })); + + assert.strictEqual(desc, 'Parameter · Running'); + }); + + test('secret parameter with redacted (null) value shows masked value', () => { + // The backchannel redacts sensitive values to null before they reach the extension, + // so a secret with an actual value arrives as `Value: null`. It must still be masked. + const desc = buildResourceDescription(makeResource({ + resourceType: 'Parameter', + state: ResourceState.Running, + properties: { Value: null }, + commands: { + 'set-parameter': { + displayName: 'Set parameter', + description: null, + argumentInputs: [ + { + name: 'Value', + label: null, + description: null, + inputType: ResourceCommandInputType.SecretText, + placeholder: null, + value: null, + options: null, + maxLength: null, + }, + ], + }, + }, + })); + + assert.strictEqual(desc, 'Parameter · Running · ●●●●●●●●'); + }); }); suite('AspireAppHostTreeProvider.findAppHostElement', () => { @@ -1663,6 +1783,60 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { assert.notStrictEqual(resultA, resultB, 'Expected distinct items for distinct AppHosts'); provider.dispose(); }); + + test('resource command quick pick preserves command order from resource data', async () => { + const sandbox = sinon.createSandbox(); + const resource = makeResource({ + commands: { + 'set-parameter': { displayName: 'Set parameter', description: null }, + 'custom-action': { displayName: 'Custom action', description: null }, + 'delete-parameter': { displayName: 'Delete parameter', description: null }, + }, + }); + const provider = makeTreeProvider([ + makeAppHost({ + resources: [resource], + }), + ]); + + try { + const showQuickPickStub = sandbox.stub(vscode.window, 'showQuickPick').resolves(undefined); + const element = provider.findResourceElement('my-service'); + assert.ok(element, 'Expected to find resource element'); + + await assert.rejects(provider.executeResourceCommand(element as never), /Canceled/); + + const items = showQuickPickStub.getCall(0).args[0] as readonly vscode.QuickPickItem[]; + assert.deepStrictEqual(items.map(item => item.label), [ + 'set-parameter', + 'custom-action', + 'delete-parameter', + ]); + } finally { + sandbox.restore(); + provider.dispose(); + } + }); + + test('parameter missing value tooltip uses humanized state', () => { + const resource = makeResource({ + resourceType: 'Parameter', + state: ResourceState.ValueMissing, + }); + const provider = makeTreeProvider([ + makeAppHost({ + resources: [resource], + }), + ]); + const [appHostItem] = provider.getChildren(); + const resourcesGroup = provider.getChildren(appHostItem).find(child => child.contextValue === 'resourcesGroup'); + assert.ok(resourcesGroup, 'Expected resources group'); + const [resourceItem] = provider.getChildren(resourcesGroup); + const tooltip = resourceItem.tooltip as vscode.MarkdownString; + + assert.ok(tooltip.value.includes('State: Value missing'), tooltip.value); + provider.dispose(); + }); }); suite('LogFileItem in tree', () => { diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index 3cbd93e58f8..0c73ce84bf7 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import { AspireTerminalProvider, quoteShellArg } from '../utils/AspireTerminalProvider'; -import { ResourceState, HealthStatus, StateStyle } from '../editor/resourceConstants'; +import { ResourceState, HealthStatus, StateStyle, ResourceType, CommandName, ParameterPropertyName } from '../editor/resourceConstants'; import { pidDescription, dashboardLabel, @@ -30,6 +30,7 @@ import { healthCheckDescription, resourceDescriptionHealth, resourceDescriptionExitCode, + parameterValueMissing, logFileLabel, commandsLabel, resourceCommandDisabledDescription, @@ -47,6 +48,7 @@ import { isMatchingAppHostPath, shortenPaths, ResourceCommandJson, + ResourceCommandInputType, } from './AppHostDataRepository'; import { collectResourceCommandArguments, ResourceCommandArgumentValue } from './ResourceCommandArguments'; import { createResourceCommandArgumentLoader } from './ResourceCommandArgumentsLoader'; @@ -54,6 +56,11 @@ import { AppHostLaunchService } from '../services/AppHostLaunchService'; type TreeElement = AppHostItem | EndpointUrlItem | ResourcesGroupItem | ResourceItem | WorkspaceResourcesItem | WorkspaceAppHostItem | WorkspaceAppHostsGroupItem | RunningAppHostsGroupItem | WorkspaceAppHostActionItem | WorkspaceAppHostPathItem | HealthChecksGroupItem | HealthCheckItem | LogFileItem | CommandsGroupItem | ResourceCommandItem; +// Trim long parameter values so a single resource row stays readable in the tree. +const maxParameterValueDisplayLength = 80; +// Fixed 8-bullet mask, matching the dashboard's GridValue masking (GetMaskingText(length: 8)). +const maskedParameterValue = '●●●●●●●●'; + function sortResources(resources: ResourceJson[]): ResourceJson[] { return [...resources].sort((a, b) => { const nameA = (a.displayName ?? a.name).toLowerCase(); @@ -426,6 +433,8 @@ export function getResourceIcon(resource: ResourceJson): vscode.ThemeIcon { const state = resource.state; const health = resource.healthStatus; switch (state) { + case ResourceState.ValueMissing: + return new vscode.ThemeIcon('warning', new vscode.ThemeColor('list.warningForeground')); case ResourceState.Running: case ResourceState.Active: if (resource.stateStyle === StateStyle.Error) { @@ -499,7 +508,11 @@ export function buildResourceDescription(resource: ResourceJson): string { const parts: string[] = [resource.resourceType]; const state = resource.state; if (state) { - parts.push(state); + parts.push(getResourceStateDescription(state)); + } + const parameterValue = getParameterValueDescription(resource); + if (parameterValue) { + parts.push(parameterValue); } const reports = resource.healthReports; const exitCode = resource.exitCode; @@ -514,12 +527,51 @@ export function buildResourceDescription(resource: ResourceJson): string { return parts.join(' · '); } +// Humanize the runtime state for display. +function getResourceStateDescription(state: string): string { + return state === ResourceState.ValueMissing ? parameterValueMissing : state; +} + +function getParameterValueDescription(resource: ResourceJson): string | undefined { + if (resource.resourceType !== ResourceType.Parameter || resource.state === ResourceState.ValueMissing) { + return undefined; + } + + // The backchannel redacts secret values to null. Check for the secret before the null/empty + // guard below so the mask isn't lost. + if (Object.prototype.hasOwnProperty.call(resource.properties ?? {}, ParameterPropertyName.Value) && isSecretParameter(resource)) { + return maskedParameterValue; + } + + const value = resource.properties?.[ParameterPropertyName.Value]; + if (typeof value !== 'string' || value.length === 0) { + return undefined; + } + + return truncateParameterValue(value); +} + +function isSecretParameter(resource: ResourceJson): boolean { + const setParameterCommand = resource.commands?.[CommandName.SetParameter]; + return setParameterCommand?.argumentInputs?.some(input => + input.name === ParameterPropertyName.Value && + input.inputType === ResourceCommandInputType.SecretText) ?? false; +} + +function truncateParameterValue(value: string): string { + if (value.length <= maxParameterValueDisplayLength) { + return value; + } + + return `${value.slice(0, maxParameterValueDisplayLength - 1)}…`; +} + function buildResourceTooltip(resource: ResourceJson): vscode.MarkdownString { const md = new vscode.MarkdownString(); md.appendMarkdown(`**${resource.displayName ?? resource.name}**\n\n`); md.appendMarkdown(`${tooltipType(resource.resourceType)}\n\n`); if (resource.state) { - md.appendMarkdown(`${tooltipState(resource.state)}\n\n`); + md.appendMarkdown(`${tooltipState(getResourceStateDescription(resource.state))}\n\n`); } if (resource.healthStatus) { md.appendMarkdown(`${tooltipHealth(resource.healthStatus)}\n\n`); diff --git a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs index 9d955703f0c..305654a4cd4 100644 --- a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs +++ b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs @@ -112,9 +112,20 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl // consumers keep the pre-existing contract. The include-disabled stream is used // by UI consumers that need full command metadata, so it also includes UI-only // commands. Hidden commands are never emitted. - var commands = snapshot.Commands - .Where(c => IsCommandVisibleForConsumer(c.Visibility, includeDisabledCommands) && IsCommandVisibleToConsumer(c.State, includeDisabledCommands)) - .OrderBy(c => c.Name) + // + // Ordering: the default/API stream stays alphabetically sorted to preserve its + // stable contract, while the include-disabled (UI) stream preserves the AppHost + // registration order so it matches the dashboard (for example, set-parameter + // before delete-parameter). See https://github.com/microsoft/aspire/issues/17193. + var visibleCommands = snapshot.Commands + .Where(c => IsCommandVisibleForConsumer(c.Visibility, includeDisabledCommands) && IsCommandVisibleToConsumer(c.State, includeDisabledCommands)); + + if (!includeDisabledCommands) + { + visibleCommands = visibleCommands.OrderBy(c => c.Name); + } + + var commands = visibleCommands .ToDistinctDictionary( c => c.Name, c => new ResourceCommandJson diff --git a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs index 44d6d3de319..b3272392cf3 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs @@ -170,11 +170,68 @@ public void MapToResourceJson_WithIncludeDisabledCommands_IncludesUiOnlyCommands var result = ResourceSnapshotMapper.MapToResourceJson(snapshot, [snapshot], includeDisabledCommands: true); - Assert.Equal(["api-only", "ui-disabled", "ui-only"], result.Commands!.Keys); + // Commands preserve snapshot (registration) order rather than an alphabetical sort. + Assert.Equal(["api-only", "ui-only", "ui-disabled"], result.Commands!.Keys); Assert.Equal(KnownCommandVisibility.UI, result.Commands["ui-only"].Visibility); Assert.Equal(KnownCommandVisibility.UI, result.Commands["ui-disabled"].Visibility); } + [Fact] + public void MapToResourceJson_IncludeDisabledStream_PreservesCommandSnapshotOrder() + { + // The include-disabled (UI) stream preserves the AppHost registration order so the + // VS Code extension matches the dashboard, which shows set-parameter before + // delete-parameter. Commands are provided in a non-alphabetical order so the + // assertion fails if the mapper were to sort them. See + // https://github.com/microsoft/aspire/issues/17193. + var snapshot = new ResourceSnapshot + { + Name = "parameter", + DisplayName = "parameter", + ResourceType = "Parameter", + State = "Running", + Commands = + [ + new ResourceSnapshotCommand { Name = "set-parameter", State = KnownCommandState.Enabled, Description = "Set", Visibility = KnownCommandVisibility.Api }, + new ResourceSnapshotCommand { Name = "custom-action", State = KnownCommandState.Enabled, Description = "Custom", Visibility = KnownCommandVisibility.Api }, + new ResourceSnapshotCommand { Name = "delete-parameter", State = KnownCommandState.Enabled, Description = "Delete", Visibility = KnownCommandVisibility.Api } + ] + }; + + var result = ResourceSnapshotMapper.MapToResourceJson(snapshot, [snapshot], includeDisabledCommands: true); + + Assert.Equal( + ["set-parameter", "custom-action", "delete-parameter"], + result.Commands!.Keys); + } + + [Fact] + public void MapToResourceJson_DefaultStream_SortsCommandsAlphabetically() + { + // The default/API stream keeps its stable alphabetical contract for structured + // consumers (MCP, export, etc.), independent of the registration order used by the + // include-disabled UI stream. See https://github.com/microsoft/aspire/issues/17193. + var snapshot = new ResourceSnapshot + { + Name = "parameter", + DisplayName = "parameter", + ResourceType = "Parameter", + State = "Running", + Commands = + [ + new ResourceSnapshotCommand { Name = "set-parameter", State = KnownCommandState.Enabled, Description = "Set", Visibility = KnownCommandVisibility.Api }, + new ResourceSnapshotCommand { Name = "custom-action", State = KnownCommandState.Enabled, Description = "Custom", Visibility = KnownCommandVisibility.Api }, + new ResourceSnapshotCommand { Name = "delete-parameter", State = KnownCommandState.Enabled, Description = "Delete", Visibility = KnownCommandVisibility.Api } + ] + }; + + var result = ResourceSnapshotMapper.MapToResourceJson(snapshot, [snapshot]); + + Assert.Equal( + ["custom-action", "delete-parameter", "set-parameter"], + result.Commands!.Keys); + } + [Fact] public void MapToResourceJson_WithSecretCommandArgument_OmitsValue() { From dd92d64023f0a959d953a53231cf9ff5068aa73c Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Wed, 3 Jun 2026 13:39:11 -0700 Subject: [PATCH 2/8] Extend parameter display to apphost CodeLens Mirror the tree-view parameter improvements on the inline apphost CodeLens: - ValueMissing renders as "Value missing" with a warning icon. - Add a value lens showing the parameter value (secrets masked, long values truncated to 80 chars) inline next to the state. - Reuse the exported getParameterValueDescription so masking/truncation matches the tree and dashboard. - Preserve command registration order in the tree Commands node (set-parameter before delete-parameter) to match the dashboard. --- .../src/editor/AspireCodeLensProvider.ts | 17 +++- extension/src/loc/strings.ts | 1 + .../src/test/aspireCodeLensProvider.test.ts | 88 ++++++++++++++++++- extension/src/test/codeLens.test.ts | 8 ++ .../src/views/AspireAppHostTreeProvider.ts | 6 +- 5 files changed, 116 insertions(+), 4 deletions(-) diff --git a/extension/src/editor/AspireCodeLensProvider.ts b/extension/src/editor/AspireCodeLensProvider.ts index b7ac3917e64..d99a87ae1c9 100644 --- a/extension/src/editor/AspireCodeLensProvider.ts +++ b/extension/src/editor/AspireCodeLensProvider.ts @@ -3,7 +3,7 @@ import { AppHostResourceParser, getParserForDocument } from './parsers/AppHostRe // Import parsers to trigger self-registration import './parsers/csharpAppHostParser'; import './parsers/jsTsAppHostParser'; -import { AspireAppHostTreeProvider, isCommandVisibleToUi, isEnabledCommand } from '../views/AspireAppHostTreeProvider'; +import { AspireAppHostTreeProvider, isCommandVisibleToUi, isEnabledCommand, getParameterValueDescription } from '../views/AspireAppHostTreeProvider'; import { AppHostDataRepository, ResourceJson, AppHostDisplayInfo, ResourceCommandJson } from '../views/AppHostDataRepository'; import { findResourceState, findWorkspaceResourceState, matchesAppHostPathOrDirectory } from './resourceStateUtils'; import { ResourceState, HealthStatus, StateStyle, ResourceType } from './resourceConstants'; @@ -28,6 +28,7 @@ import { codeLensCommand, codeLensOpenDashboard, codeLensViewAppHostLogs, + codeLensResourceValueMissing, } from '../loc/strings'; export class AspireCodeLensProvider implements vscode.CodeLensProvider { @@ -267,6 +268,18 @@ export class AspireCodeLensProvider implements vscode.CodeLensProvider { arguments: [resource.displayName ?? resource.name, appHost.appHostPath], })); + // Parameter value lens (secrets masked, long values truncated) so the value is + // visible inline next to the state, matching the dashboard and tree view. + const parameterValue = getParameterValueDescription(resource); + if (parameterValue !== undefined) { + lenses.push(new vscode.CodeLens(range, { + title: parameterValue, + command: 'aspire-vscode.codeLensRevealResource', + tooltip: parameterValue, + arguments: [resource.displayName ?? resource.name, appHost.appHostPath], + })); + } + // Action lenses based on available commands const restartCommand = getEnabledCommand(commands, 'restart', 'resource-restart'); if (restartCommand) { @@ -367,6 +380,8 @@ export function getCodeLensStateLabel(state: string, stateStyle: string, exitCod return exitCode != null && exitCode !== 0 ? codeLensResourceStoppedErrorWithExitCode(exitCode) : codeLensResourceStoppedError; } return exitCode != null && exitCode !== 0 ? codeLensResourceStoppedWithExitCode(exitCode) : codeLensResourceStopped; + case ResourceState.ValueMissing: + return codeLensResourceValueMissing; default: return state || codeLensResourceStopped; } diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 42a7db5c056..9d9a1502473 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -165,6 +165,7 @@ export const codeLensResourceStoppedWithExitCode = (exitCode: number) => vscode. export const codeLensResourceStoppedError = vscode.l10n.t('$(error)\u200A Stopped'); export const codeLensResourceStoppedErrorWithExitCode = (exitCode: number) => vscode.l10n.t('$(error)\u200A Stopped (Exit Code: {0})', exitCode); export const codeLensResourceError = vscode.l10n.t('$(error)\u200A Error'); +export const codeLensResourceValueMissing = vscode.l10n.t('$(warning)\u200A Value missing'); export const codeLensRestart = vscode.l10n.t('$(debug-restart)\u200A Restart'); export const codeLensStop = vscode.l10n.t('$(debug-stop)\u200A Stop'); export const codeLensStart = vscode.l10n.t('$(debug-start)\u200A Start'); diff --git a/extension/src/test/aspireCodeLensProvider.test.ts b/extension/src/test/aspireCodeLensProvider.test.ts index 3f403c4dc38..0969eb6e9e8 100644 --- a/extension/src/test/aspireCodeLensProvider.test.ts +++ b/extension/src/test/aspireCodeLensProvider.test.ts @@ -8,7 +8,8 @@ import { AspireCodeLensProvider } from '../editor/AspireCodeLensProvider'; import { AspireGutterDecorationProvider } from '../editor/AspireGutterDecorationProvider'; import * as AppHostResourceParser from '../editor/parsers/AppHostResourceParser'; import { ParsedResource } from '../editor/parsers/AppHostResourceParser'; -import { codeLensCommand } from '../loc/strings'; +import { codeLensCommand, codeLensResourceValueMissing } from '../loc/strings'; +import { ResourceState, ResourceType } from '../editor/resourceConstants'; import { AspireAppHostTreeProvider } from '../views/AspireAppHostTreeProvider'; import { AppHostDataRepository, AppHostDisplayInfo, ResourceJson } from '../views/AppHostDataRepository'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; @@ -987,4 +988,89 @@ suite('AspireCodeLensProvider resource lens anchoring', () => { assert.strictEqual(customLens!.command?.tooltip, 'reset-db'); harness.dispose(); }); + + function makeParameterHarness(overrides: Partial) { + const hostPath = p('repo', 'AppHost', 'apphost.ts'); + const content = [ + 'const builder = await createBuilder();', + 'builder.addParameter("param");', + ].join('\n'); + + const harness = createHarness({ + workspaceAppHostPath: hostPath, + workspaceResources: [makeResource('param', { + resourceType: ResourceType.Parameter, + state: ResourceState.Running, + ...overrides, + } as Partial)], + }); + + return { harness, doc: createMockDocument(content, p('repo', 'AppHost', 'apphost.ts')) }; + } + + const revealLenses = (lenses: vscode.CodeLens[]) => + lenses.filter(l => l.command?.command === 'aspire-vscode.codeLensRevealResource'); + + test('parameter value lens shows a non-secret value', async () => { + const { harness, doc } = makeParameterHarness({ + properties: { Value: 'plain-value' } as any, + commands: { + 'set-parameter': { displayName: 'Set parameter', description: null, argumentInputs: [{ name: 'Value', inputType: 'Text' }] }, + } as any, + }); + + const lenses = await harness.provider.provideCodeLenses(doc, cancellationToken) as vscode.CodeLens[]; + const valueLens = revealLenses(lenses).find(l => l.command?.title === 'plain-value'); + + assert.ok(valueLens, 'expected a value lens showing the parameter value'); + harness.dispose(); + }); + + test('parameter value lens masks secret values', async () => { + const { harness, doc } = makeParameterHarness({ + properties: { Value: 'super-secret-value' } as any, + commands: { + 'set-parameter': { displayName: 'Set parameter', description: null, argumentInputs: [{ name: 'Value', inputType: 'SecretText' }] }, + } as any, + }); + + const lenses = await harness.provider.provideCodeLenses(doc, cancellationToken) as vscode.CodeLens[]; + const titles = revealLenses(lenses).map(l => l.command?.title); + + assert.ok(titles.includes('●●●●●●●●'), 'expected a masked value lens'); + assert.ok(!titles.includes('super-secret-value'), 'secret value must not be displayed'); + harness.dispose(); + }); + + test('parameter value lens truncates long values to 80 characters', async () => { + const longValue = 'a'.repeat(100); + const { harness, doc } = makeParameterHarness({ + properties: { Value: longValue } as any, + commands: { + 'set-parameter': { displayName: 'Set parameter', description: null, argumentInputs: [{ name: 'Value', inputType: 'Text' }] }, + } as any, + }); + + const lenses = await harness.provider.provideCodeLenses(doc, cancellationToken) as vscode.CodeLens[]; + const valueLens = revealLenses(lenses).find(l => typeof l.command?.title === 'string' && l.command.title.endsWith('…')); + + assert.ok(valueLens, 'expected a truncated value lens'); + assert.strictEqual(valueLens!.command!.title!.length, 80); + harness.dispose(); + }); + + test('parameter with missing value shows the warning state lens and no value lens', async () => { + const { harness, doc } = makeParameterHarness({ + state: ResourceState.ValueMissing, + properties: {} as any, + commands: {} as any, + }); + + const lenses = await harness.provider.provideCodeLenses(doc, cancellationToken) as vscode.CodeLens[]; + const reveals = revealLenses(lenses); + + assert.strictEqual(reveals.length, 1, 'expected only the state lens (no value lens) for a missing value'); + assert.strictEqual(reveals[0].command?.title, codeLensResourceValueMissing); + harness.dispose(); + }); }); diff --git a/extension/src/test/codeLens.test.ts b/extension/src/test/codeLens.test.ts index 7a8512e997b..37225569a3e 100644 --- a/extension/src/test/codeLens.test.ts +++ b/extension/src/test/codeLens.test.ts @@ -13,6 +13,7 @@ import { codeLensResourceStoppedError, codeLensResourceStoppedErrorWithExitCode, codeLensResourceError, + codeLensResourceValueMissing, } from '../loc/strings'; import { ResourceState, StateStyle } from '../editor/resourceConstants'; @@ -119,6 +120,13 @@ suite('getCodeLensStateLabel', () => { assert.strictEqual(getCodeLensStateLabel(ResourceState.Finished, ''), codeLensResourceStopped); }); + // --- Parameter ValueMissing state --- + + test('ValueMissing returns the humanized "Value missing" label with a warning icon', () => { + assert.strictEqual(getCodeLensStateLabel(ResourceState.ValueMissing, ''), codeLensResourceValueMissing); + assert.ok(codeLensResourceValueMissing.includes('$(warning)')); + }); + // --- Default / unknown states --- test('unknown state returns the state string itself', () => { diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index 0c73ce84bf7..baede53fa94 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -532,7 +532,7 @@ function getResourceStateDescription(state: string): string { return state === ResourceState.ValueMissing ? parameterValueMissing : state; } -function getParameterValueDescription(resource: ResourceJson): string | undefined { +export function getParameterValueDescription(resource: ResourceJson): string | undefined { if (resource.resourceType !== ResourceType.Parameter || resource.state === ResourceState.ValueMissing) { return undefined; } @@ -1204,8 +1204,10 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider a.localeCompare(b)) .map(([name, cmd]) => new ResourceCommandItem(name, cmd, element.resourceItem, element.id!)); } From 522fd96a336b93c65e528ddedc80f38c7efd1ad2 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Wed, 3 Jun 2026 14:06:25 -0700 Subject: [PATCH 3/8] Fix CodeLens tooltip showing raw 'ValueMissing' instead of humanized text The CodeLens tooltip was using the raw state string (e.g. 'ValueMissing') while the title already used the localized label. Export getResourceStateDescription and use it in the tooltip so it shows 'Value missing' consistently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/src/editor/AspireCodeLensProvider.ts | 4 ++-- extension/src/views/AspireAppHostTreeProvider.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/extension/src/editor/AspireCodeLensProvider.ts b/extension/src/editor/AspireCodeLensProvider.ts index d99a87ae1c9..6e7f0a4ab83 100644 --- a/extension/src/editor/AspireCodeLensProvider.ts +++ b/extension/src/editor/AspireCodeLensProvider.ts @@ -3,7 +3,7 @@ import { AppHostResourceParser, getParserForDocument } from './parsers/AppHostRe // Import parsers to trigger self-registration import './parsers/csharpAppHostParser'; import './parsers/jsTsAppHostParser'; -import { AspireAppHostTreeProvider, isCommandVisibleToUi, isEnabledCommand, getParameterValueDescription } from '../views/AspireAppHostTreeProvider'; +import { AspireAppHostTreeProvider, isCommandVisibleToUi, isEnabledCommand, getParameterValueDescription, getResourceStateDescription } from '../views/AspireAppHostTreeProvider'; import { AppHostDataRepository, ResourceJson, AppHostDisplayInfo, ResourceCommandJson } from '../views/AppHostDataRepository'; import { findResourceState, findWorkspaceResourceState, matchesAppHostPathOrDirectory } from './resourceStateUtils'; import { ResourceState, HealthStatus, StateStyle, ResourceType } from './resourceConstants'; @@ -252,7 +252,7 @@ export class AspireCodeLensProvider implements vscode.CodeLensProvider { } } - let tooltipText = `${resource.displayName ?? resource.name}: ${state}${healthStatus ? ` (${healthStatus})` : ''}`; + let tooltipText = `${resource.displayName ?? resource.name}: ${getResourceStateDescription(state)}${healthStatus ? ` (${healthStatus})` : ''}`; const reports = resource.healthReports; if (reports && healthStatus && healthStatus !== HealthStatus.Healthy) { const failing = Object.entries(reports).filter(([, r]) => r.status !== HealthStatus.Healthy); diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index baede53fa94..8ca798e40f2 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -528,7 +528,7 @@ export function buildResourceDescription(resource: ResourceJson): string { } // Humanize the runtime state for display. -function getResourceStateDescription(state: string): string { +export function getResourceStateDescription(state: string): string { return state === ResourceState.ValueMissing ? parameterValueMissing : state; } From b318ea0e39585631944cbe98e018597a2e26b4ab Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Wed, 3 Jun 2026 14:38:18 -0700 Subject: [PATCH 4/8] Move parameter display helpers to shared util module Move getResourceStateDescription/getParameterValueDescription and the mask/truncation constants out of the AspireAppHostTreeProvider view module into a new extension/src/utils/resourceDisplay.ts, so AspireCodeLensProvider no longer depends on the view layer. Pure move; no behavior change. --- .../src/editor/AspireCodeLensProvider.ts | 3 +- extension/src/utils/resourceDisplay.ts | 47 ++++++++++++++++++ .../src/views/AspireAppHostTreeProvider.ts | 49 +------------------ 3 files changed, 51 insertions(+), 48 deletions(-) create mode 100644 extension/src/utils/resourceDisplay.ts diff --git a/extension/src/editor/AspireCodeLensProvider.ts b/extension/src/editor/AspireCodeLensProvider.ts index 6e7f0a4ab83..44032dfcfd6 100644 --- a/extension/src/editor/AspireCodeLensProvider.ts +++ b/extension/src/editor/AspireCodeLensProvider.ts @@ -3,7 +3,8 @@ import { AppHostResourceParser, getParserForDocument } from './parsers/AppHostRe // Import parsers to trigger self-registration import './parsers/csharpAppHostParser'; import './parsers/jsTsAppHostParser'; -import { AspireAppHostTreeProvider, isCommandVisibleToUi, isEnabledCommand, getParameterValueDescription, getResourceStateDescription } from '../views/AspireAppHostTreeProvider'; +import { AspireAppHostTreeProvider, isCommandVisibleToUi, isEnabledCommand } from '../views/AspireAppHostTreeProvider'; +import { getParameterValueDescription, getResourceStateDescription } from '../utils/resourceDisplay'; import { AppHostDataRepository, ResourceJson, AppHostDisplayInfo, ResourceCommandJson } from '../views/AppHostDataRepository'; import { findResourceState, findWorkspaceResourceState, matchesAppHostPathOrDirectory } from './resourceStateUtils'; import { ResourceState, HealthStatus, StateStyle, ResourceType } from './resourceConstants'; diff --git a/extension/src/utils/resourceDisplay.ts b/extension/src/utils/resourceDisplay.ts new file mode 100644 index 00000000000..7dbd46b349e --- /dev/null +++ b/extension/src/utils/resourceDisplay.ts @@ -0,0 +1,47 @@ +import { ResourceState, ResourceType, CommandName, ParameterPropertyName } from '../editor/resourceConstants'; +import { ResourceJson, ResourceCommandInputType } from '../views/AppHostDataRepository'; +import { parameterValueMissing } from '../loc/strings'; + +// Trim long parameter values so a single resource row stays readable in the tree and CodeLens. +const maxParameterValueDisplayLength = 80; +// Fixed 8-bullet mask, matching the dashboard's GridValue masking (GetMaskingText(length: 8)). +const maskedParameterValue = '●●●●●●●●'; + +// Humanize the runtime state for display. +export function getResourceStateDescription(state: string): string { + return state === ResourceState.ValueMissing ? parameterValueMissing : state; +} + +export function getParameterValueDescription(resource: ResourceJson): string | undefined { + if (resource.resourceType !== ResourceType.Parameter || resource.state === ResourceState.ValueMissing) { + return undefined; + } + + // The backchannel redacts secret values to null. Check for the secret before the null/empty + // guard below so the mask isn't lost. + if (Object.prototype.hasOwnProperty.call(resource.properties ?? {}, ParameterPropertyName.Value) && isSecretParameter(resource)) { + return maskedParameterValue; + } + + const value = resource.properties?.[ParameterPropertyName.Value]; + if (typeof value !== 'string' || value.length === 0) { + return undefined; + } + + return truncateParameterValue(value); +} + +function isSecretParameter(resource: ResourceJson): boolean { + const setParameterCommand = resource.commands?.[CommandName.SetParameter]; + return setParameterCommand?.argumentInputs?.some(input => + input.name === ParameterPropertyName.Value && + input.inputType === ResourceCommandInputType.SecretText) ?? false; +} + +function truncateParameterValue(value: string): string { + if (value.length <= maxParameterValueDisplayLength) { + return value; + } + + return `${value.slice(0, maxParameterValueDisplayLength - 1)}…`; +} diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index 8ca798e40f2..d464e1da179 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -2,7 +2,8 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import { AspireTerminalProvider, quoteShellArg } from '../utils/AspireTerminalProvider'; -import { ResourceState, HealthStatus, StateStyle, ResourceType, CommandName, ParameterPropertyName } from '../editor/resourceConstants'; +import { ResourceState, HealthStatus, StateStyle } from '../editor/resourceConstants'; +import { getParameterValueDescription, getResourceStateDescription } from '../utils/resourceDisplay'; import { pidDescription, dashboardLabel, @@ -30,7 +31,6 @@ import { healthCheckDescription, resourceDescriptionHealth, resourceDescriptionExitCode, - parameterValueMissing, logFileLabel, commandsLabel, resourceCommandDisabledDescription, @@ -48,7 +48,6 @@ import { isMatchingAppHostPath, shortenPaths, ResourceCommandJson, - ResourceCommandInputType, } from './AppHostDataRepository'; import { collectResourceCommandArguments, ResourceCommandArgumentValue } from './ResourceCommandArguments'; import { createResourceCommandArgumentLoader } from './ResourceCommandArgumentsLoader'; @@ -56,11 +55,6 @@ import { AppHostLaunchService } from '../services/AppHostLaunchService'; type TreeElement = AppHostItem | EndpointUrlItem | ResourcesGroupItem | ResourceItem | WorkspaceResourcesItem | WorkspaceAppHostItem | WorkspaceAppHostsGroupItem | RunningAppHostsGroupItem | WorkspaceAppHostActionItem | WorkspaceAppHostPathItem | HealthChecksGroupItem | HealthCheckItem | LogFileItem | CommandsGroupItem | ResourceCommandItem; -// Trim long parameter values so a single resource row stays readable in the tree. -const maxParameterValueDisplayLength = 80; -// Fixed 8-bullet mask, matching the dashboard's GridValue masking (GetMaskingText(length: 8)). -const maskedParameterValue = '●●●●●●●●'; - function sortResources(resources: ResourceJson[]): ResourceJson[] { return [...resources].sort((a, b) => { const nameA = (a.displayName ?? a.name).toLowerCase(); @@ -527,45 +521,6 @@ export function buildResourceDescription(resource: ResourceJson): string { return parts.join(' · '); } -// Humanize the runtime state for display. -export function getResourceStateDescription(state: string): string { - return state === ResourceState.ValueMissing ? parameterValueMissing : state; -} - -export function getParameterValueDescription(resource: ResourceJson): string | undefined { - if (resource.resourceType !== ResourceType.Parameter || resource.state === ResourceState.ValueMissing) { - return undefined; - } - - // The backchannel redacts secret values to null. Check for the secret before the null/empty - // guard below so the mask isn't lost. - if (Object.prototype.hasOwnProperty.call(resource.properties ?? {}, ParameterPropertyName.Value) && isSecretParameter(resource)) { - return maskedParameterValue; - } - - const value = resource.properties?.[ParameterPropertyName.Value]; - if (typeof value !== 'string' || value.length === 0) { - return undefined; - } - - return truncateParameterValue(value); -} - -function isSecretParameter(resource: ResourceJson): boolean { - const setParameterCommand = resource.commands?.[CommandName.SetParameter]; - return setParameterCommand?.argumentInputs?.some(input => - input.name === ParameterPropertyName.Value && - input.inputType === ResourceCommandInputType.SecretText) ?? false; -} - -function truncateParameterValue(value: string): string { - if (value.length <= maxParameterValueDisplayLength) { - return value; - } - - return `${value.slice(0, maxParameterValueDisplayLength - 1)}…`; -} - function buildResourceTooltip(resource: ResourceJson): vscode.MarkdownString { const md = new vscode.MarkdownString(); md.appendMarkdown(`**${resource.displayName ?? resource.name}**\n\n`); From efc3e6ae4ecf6ed86d8c7c853528cd8d0d8d1326 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 4 Jun 2026 18:48:51 -0500 Subject: [PATCH 5/8] Sort resource commands by registration order in VS Code extension Add a RegistrationOrder field to the command JSON contract carrying each command's AppHost registration index, and sort commands in the VS Code extension by (RegistrationOrder, Name). This makes the resource tree, command quick pick, and CodeLens follow the dashboard's registration ordering instead of the alphabetical JSON key order. --- .../src/editor/AspireCodeLensProvider.ts | 6 ++- extension/src/test/appHostTreeView.test.ts | 8 ++-- extension/src/utils/resourceCommands.ts | 14 +++++++ extension/src/views/AppHostDataRepository.ts | 4 ++ .../src/views/AspireAppHostTreeProvider.ts | 5 ++- .../Backchannel/ResourceSnapshotMapper.cs | 42 ++++++++----------- .../Model/Serialization/ResourceJson.cs | 6 +++ .../ResourceSnapshotMapperTests.cs | 33 +++++++++------ 8 files changed, 74 insertions(+), 44 deletions(-) create mode 100644 extension/src/utils/resourceCommands.ts diff --git a/extension/src/editor/AspireCodeLensProvider.ts b/extension/src/editor/AspireCodeLensProvider.ts index 44032dfcfd6..15718f12d96 100644 --- a/extension/src/editor/AspireCodeLensProvider.ts +++ b/extension/src/editor/AspireCodeLensProvider.ts @@ -4,6 +4,7 @@ import { AppHostResourceParser, getParserForDocument } from './parsers/AppHostRe import './parsers/csharpAppHostParser'; import './parsers/jsTsAppHostParser'; import { AspireAppHostTreeProvider, isCommandVisibleToUi, isEnabledCommand } from '../views/AspireAppHostTreeProvider'; +import { compareResourceCommands } from '../utils/resourceCommands'; import { getParameterValueDescription, getResourceStateDescription } from '../utils/resourceDisplay'; import { AppHostDataRepository, ResourceJson, AppHostDisplayInfo, ResourceCommandJson } from '../views/AppHostDataRepository'; import { findResourceState, findWorkspaceResourceState, matchesAppHostPathOrDirectory } from './resourceStateUtils'; @@ -324,7 +325,10 @@ export class AspireCodeLensProvider implements vscode.CodeLensProvider { // Custom commands (non-standard ones like "Reset Database") const standardCommands = new Set(['restart', 'resource-restart', 'stop', 'resource-stop', 'start', 'resource-start']); - for (const [cmdName, cmd] of Object.entries(commands) as [string, ResourceCommandJson][]) { + // Sort by (order, name) so custom command lenses appear in the dashboard registration order. + const customCommands = (Object.entries(commands) as [string, ResourceCommandJson][]) + .sort(compareResourceCommands); + for (const [cmdName, cmd] of customCommands) { if (!standardCommands.has(cmdName) && isEnabledCommand(cmd) && isCommandVisibleToUi(cmd)) { const displayName = getNormalizedCommandText(cmd.displayName); const description = getNormalizedCommandText(cmd.description); diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 4b86d568c2c..d24eb636684 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -1784,13 +1784,13 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { provider.dispose(); }); - test('resource command quick pick preserves command order from resource data', async () => { + test('resource command quick pick orders commands by registration order', async () => { const sandbox = sinon.createSandbox(); const resource = makeResource({ commands: { - 'set-parameter': { displayName: 'Set parameter', description: null }, - 'custom-action': { displayName: 'Custom action', description: null }, - 'delete-parameter': { displayName: 'Delete parameter', description: null }, + 'set-parameter': { displayName: 'Set parameter', description: null, registrationOrder: 0 }, + 'custom-action': { displayName: 'Custom action', description: null, registrationOrder: 1 }, + 'delete-parameter': { displayName: 'Delete parameter', description: null, registrationOrder: 2 }, }, }); const provider = makeTreeProvider([ diff --git a/extension/src/utils/resourceCommands.ts b/extension/src/utils/resourceCommands.ts new file mode 100644 index 00000000000..917d1c9de89 --- /dev/null +++ b/extension/src/utils/resourceCommands.ts @@ -0,0 +1,14 @@ +import type { ResourceCommandJson } from '../views/AppHostDataRepository'; + +/** + * Sorts resource commands by registration order, then name as a tiebreaker, since the CLI keys + * commands alphabetically in JSON. Approximates the dashboard order (highlighted-command floating + * isn't carried through the CLI). See src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs. + */ +export function compareResourceCommands( + [nameA, a]: [string, ResourceCommandJson], + [nameB, b]: [string, ResourceCommandJson]): number { + const orderA = a.registrationOrder ?? 0; + const orderB = b.registrationOrder ?? 0; + return orderA !== orderB ? orderA - orderB : nameA.localeCompare(nameB); +} diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index 1f9392a6a15..f7cca849fb2 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -21,6 +21,10 @@ export interface ResourceCommandJson { description: string | null; visibility?: string | null; state?: string | null; + // Registration order from the AppHost, used to sort commands so the UI matches the dashboard. + // Defaults to 0 when absent (e.g. an older CLI that doesn't emit it). See + // src/Shared/Model/Serialization/ResourceJson.cs (ResourceCommandJson.RegistrationOrder). + registrationOrder?: number | null; argumentInputs?: ResourceCommandArgumentInputJson[] | null; } diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index d464e1da179..b6ced11108a 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -4,6 +4,7 @@ import * as vscode from 'vscode'; import { AspireTerminalProvider, quoteShellArg } from '../utils/AspireTerminalProvider'; import { ResourceState, HealthStatus, StateStyle } from '../editor/resourceConstants'; import { getParameterValueDescription, getResourceStateDescription } from '../utils/resourceDisplay'; +import { compareResourceCommands } from '../utils/resourceCommands'; import { pidDescription, dashboardLabel, @@ -87,7 +88,8 @@ function hasNoResources(resources: readonly ResourceJson[] | null | undefined): function getVisibleCommands(commands: Record): [string, ResourceCommandJson][] { return Object.entries(commands) - .filter(([, command]) => isCommandVisibleToUi(command) && (isEnabledCommand(command) || command.state === 'Disabled')); + .filter(([, command]) => isCommandVisibleToUi(command) && (isEnabledCommand(command) || command.state === 'Disabled')) + .sort(compareResourceCommands); } export function isEnabledCommand(command: ResourceCommandJson | null | undefined): boolean { @@ -1332,6 +1334,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider isCommandVisibleToUi(cmd) && isEnabledCommand(cmd)) + .sort(compareResourceCommands) .map(([name, cmd]) => ({ label: name, description: cmd.description ?? undefined, diff --git a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs index 305654a4cd4..0e13edc9330 100644 --- a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs +++ b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs @@ -108,34 +108,26 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl } } - // By default, include only API-visible enabled commands so existing structured - // consumers keep the pre-existing contract. The include-disabled stream is used - // by UI consumers that need full command metadata, so it also includes UI-only - // commands. Hidden commands are never emitted. - // - // Ordering: the default/API stream stays alphabetically sorted to preserve its - // stable contract, while the include-disabled (UI) stream preserves the AppHost - // registration order so it matches the dashboard (for example, set-parameter - // before delete-parameter). See https://github.com/microsoft/aspire/issues/17193. - var visibleCommands = snapshot.Commands - .Where(c => IsCommandVisibleForConsumer(c.Visibility, includeDisabledCommands) && IsCommandVisibleToConsumer(c.State, includeDisabledCommands)); - - if (!includeDisabledCommands) - { - visibleCommands = visibleCommands.OrderBy(c => c.Name); - } - - var commands = visibleCommands + // Include only API-visible enabled commands by default; the include-disabled stream + // also surfaces UI-only commands for UI consumers. Hidden commands are never emitted. + // Capture each command's registration index (before filtering) and stamp it as + // RegistrationOrder so consumers can sort by (RegistrationOrder, Name); keys are sorted + // alphabetically only for a stable JSON shape. + var commands = snapshot.Commands + .Select((command, index) => (command, index)) + .Where(c => IsCommandVisibleForConsumer(c.command.Visibility, includeDisabledCommands) && IsCommandVisibleToConsumer(c.command.State, includeDisabledCommands)) + .OrderBy(c => c.command.Name) .ToDistinctDictionary( - c => c.Name, + c => c.command.Name, c => new ResourceCommandJson { - DisplayName = string.IsNullOrWhiteSpace(c.DisplayName) ? null : c.DisplayName.Trim(), - Description = c.Description, - Visibility = IsDefaultCommandVisibility(c.Visibility) ? null : c.Visibility, - State = c.State, - ArgumentInputs = c.ArgumentInputs.Length > 0 - ? c.ArgumentInputs.Select(MapCommandArgumentInput).ToArray() + DisplayName = string.IsNullOrWhiteSpace(c.command.DisplayName) ? null : c.command.DisplayName.Trim(), + Description = c.command.Description, + Visibility = IsDefaultCommandVisibility(c.command.Visibility) ? null : c.command.Visibility, + State = c.command.State, + RegistrationOrder = c.index, + ArgumentInputs = c.command.ArgumentInputs.Length > 0 + ? c.command.ArgumentInputs.Select(MapCommandArgumentInput).ToArray() : null }); diff --git a/src/Shared/Model/Serialization/ResourceJson.cs b/src/Shared/Model/Serialization/ResourceJson.cs index b3a059091c1..b421e3fb153 100644 --- a/src/Shared/Model/Serialization/ResourceJson.cs +++ b/src/Shared/Model/Serialization/ResourceJson.cs @@ -242,6 +242,12 @@ internal sealed class ResourceCommandJson [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? State { get; set; } + /// + /// The zero-based index at which the command was registered. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? RegistrationOrder { get; set; } + /// /// The ordered inputs that describe the invocation arguments accepted by the command. /// diff --git a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs index b3272392cf3..3bdcc9eeda3 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs @@ -170,20 +170,16 @@ public void MapToResourceJson_WithIncludeDisabledCommands_IncludesUiOnlyCommands var result = ResourceSnapshotMapper.MapToResourceJson(snapshot, [snapshot], includeDisabledCommands: true); - // Commands preserve snapshot (registration) order rather than an alphabetical sort. - Assert.Equal(["api-only", "ui-only", "ui-disabled"], result.Commands!.Keys); + Assert.Equal(["api-only", "ui-disabled", "ui-only"], result.Commands!.Keys); Assert.Equal(KnownCommandVisibility.UI, result.Commands["ui-only"].Visibility); Assert.Equal(KnownCommandVisibility.UI, result.Commands["ui-disabled"].Visibility); } [Fact] - public void MapToResourceJson_IncludeDisabledStream_PreservesCommandSnapshotOrder() + public void MapToResourceJson_IncludeDisabledStream_StampsRegistrationOrderOnCommands() { - // The include-disabled (UI) stream preserves the AppHost registration order so the - // VS Code extension matches the dashboard, which shows set-parameter before - // delete-parameter. Commands are provided in a non-alphabetical order so the - // assertion fails if the mapper were to sort them. See - // https://github.com/microsoft/aspire/issues/17193. + // Commands are provided in non-alphabetical order so RegistrationOrder must reflect + // registration (set-parameter before delete-parameter), not key order. var snapshot = new ResourceSnapshot { Name = "parameter", @@ -200,17 +196,22 @@ public void MapToResourceJson_IncludeDisabledStream_PreservesCommandSnapshotOrde var result = ResourceSnapshotMapper.MapToResourceJson(snapshot, [snapshot], includeDisabledCommands: true); + // Keys stay alphabetical for a stable JSON shape... Assert.Equal( - ["set-parameter", "custom-action", "delete-parameter"], + ["custom-action", "delete-parameter", "set-parameter"], result.Commands!.Keys); + + // ...while RegistrationOrder reflects the registration order the dashboard uses. + Assert.Equal(0, result.Commands["set-parameter"].RegistrationOrder); + Assert.Equal(1, result.Commands["custom-action"].RegistrationOrder); + Assert.Equal(2, result.Commands["delete-parameter"].RegistrationOrder); } [Fact] - public void MapToResourceJson_DefaultStream_SortsCommandsAlphabetically() + public void MapToResourceJson_DefaultStream_SortsCommandsAlphabeticallyAndStampsRegistrationOrder() { - // The default/API stream keeps its stable alphabetical contract for structured - // consumers (MCP, export, etc.), independent of the registration order used by the - // include-disabled UI stream. See https://github.com/microsoft/aspire/issues/17193. + // The default/API stream keeps alphabetical keys but still stamps RegistrationOrder, + // exactly like the include-disabled stream. var snapshot = new ResourceSnapshot { Name = "parameter", @@ -227,9 +228,15 @@ public void MapToResourceJson_DefaultStream_SortsCommandsAlphabetically() var result = ResourceSnapshotMapper.MapToResourceJson(snapshot, [snapshot]); + // Keys stay alphabetical for a stable JSON shape... Assert.Equal( ["custom-action", "delete-parameter", "set-parameter"], result.Commands!.Keys); + + // ...and RegistrationOrder reflects the registration order even on the default stream. + Assert.Equal(0, result.Commands["set-parameter"].RegistrationOrder); + Assert.Equal(1, result.Commands["custom-action"].RegistrationOrder); + Assert.Equal(2, result.Commands["delete-parameter"].RegistrationOrder); } [Fact] From 32f0081e8b84c98b6cacb1fba542b10d00f7982c Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 4 Jun 2026 18:52:31 -0500 Subject: [PATCH 6/8] Move compareResourceCommands into resourceDisplay Consolidate the resource command comparator alongside the other resource display helpers and remove the standalone resourceCommands.ts module. --- extension/src/editor/AspireCodeLensProvider.ts | 3 +-- extension/src/utils/resourceCommands.ts | 14 -------------- extension/src/utils/resourceDisplay.ts | 15 ++++++++++++++- extension/src/views/AspireAppHostTreeProvider.ts | 3 +-- 4 files changed, 16 insertions(+), 19 deletions(-) delete mode 100644 extension/src/utils/resourceCommands.ts diff --git a/extension/src/editor/AspireCodeLensProvider.ts b/extension/src/editor/AspireCodeLensProvider.ts index 15718f12d96..f74a919e089 100644 --- a/extension/src/editor/AspireCodeLensProvider.ts +++ b/extension/src/editor/AspireCodeLensProvider.ts @@ -4,8 +4,7 @@ import { AppHostResourceParser, getParserForDocument } from './parsers/AppHostRe import './parsers/csharpAppHostParser'; import './parsers/jsTsAppHostParser'; import { AspireAppHostTreeProvider, isCommandVisibleToUi, isEnabledCommand } from '../views/AspireAppHostTreeProvider'; -import { compareResourceCommands } from '../utils/resourceCommands'; -import { getParameterValueDescription, getResourceStateDescription } from '../utils/resourceDisplay'; +import { compareResourceCommands, getParameterValueDescription, getResourceStateDescription } from '../utils/resourceDisplay'; import { AppHostDataRepository, ResourceJson, AppHostDisplayInfo, ResourceCommandJson } from '../views/AppHostDataRepository'; import { findResourceState, findWorkspaceResourceState, matchesAppHostPathOrDirectory } from './resourceStateUtils'; import { ResourceState, HealthStatus, StateStyle, ResourceType } from './resourceConstants'; diff --git a/extension/src/utils/resourceCommands.ts b/extension/src/utils/resourceCommands.ts deleted file mode 100644 index 917d1c9de89..00000000000 --- a/extension/src/utils/resourceCommands.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ResourceCommandJson } from '../views/AppHostDataRepository'; - -/** - * Sorts resource commands by registration order, then name as a tiebreaker, since the CLI keys - * commands alphabetically in JSON. Approximates the dashboard order (highlighted-command floating - * isn't carried through the CLI). See src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs. - */ -export function compareResourceCommands( - [nameA, a]: [string, ResourceCommandJson], - [nameB, b]: [string, ResourceCommandJson]): number { - const orderA = a.registrationOrder ?? 0; - const orderB = b.registrationOrder ?? 0; - return orderA !== orderB ? orderA - orderB : nameA.localeCompare(nameB); -} diff --git a/extension/src/utils/resourceDisplay.ts b/extension/src/utils/resourceDisplay.ts index 7dbd46b349e..0a52a5181b4 100644 --- a/extension/src/utils/resourceDisplay.ts +++ b/extension/src/utils/resourceDisplay.ts @@ -1,7 +1,20 @@ import { ResourceState, ResourceType, CommandName, ParameterPropertyName } from '../editor/resourceConstants'; -import { ResourceJson, ResourceCommandInputType } from '../views/AppHostDataRepository'; +import { ResourceJson, ResourceCommandInputType, ResourceCommandJson } from '../views/AppHostDataRepository'; import { parameterValueMissing } from '../loc/strings'; +/** + * Sorts resource commands by registration order, then name as a tiebreaker, since the CLI keys + * commands alphabetically in JSON. Approximates the dashboard order (highlighted-command floating + * isn't carried through the CLI). See src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs. + */ +export function compareResourceCommands( + [nameA, a]: [string, ResourceCommandJson], + [nameB, b]: [string, ResourceCommandJson]): number { + const orderA = a.registrationOrder ?? 0; + const orderB = b.registrationOrder ?? 0; + return orderA !== orderB ? orderA - orderB : nameA.localeCompare(nameB); +} + // Trim long parameter values so a single resource row stays readable in the tree and CodeLens. const maxParameterValueDisplayLength = 80; // Fixed 8-bullet mask, matching the dashboard's GridValue masking (GetMaskingText(length: 8)). diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index b6ced11108a..514ae55cd0e 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -3,8 +3,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { AspireTerminalProvider, quoteShellArg } from '../utils/AspireTerminalProvider'; import { ResourceState, HealthStatus, StateStyle } from '../editor/resourceConstants'; -import { getParameterValueDescription, getResourceStateDescription } from '../utils/resourceDisplay'; -import { compareResourceCommands } from '../utils/resourceCommands'; +import { compareResourceCommands, getParameterValueDescription, getResourceStateDescription } from '../utils/resourceDisplay'; import { pidDescription, dashboardLabel, From a5bd6147cb1eb3a84cf225d2c3d06e1e8da7dc7a Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 4 Jun 2026 18:53:33 -0500 Subject: [PATCH 7/8] Remove redundant comment on ResourceCommandJson.registrationOrder --- extension/src/views/AppHostDataRepository.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index f7cca849fb2..4657439bfec 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -21,9 +21,6 @@ export interface ResourceCommandJson { description: string | null; visibility?: string | null; state?: string | null; - // Registration order from the AppHost, used to sort commands so the UI matches the dashboard. - // Defaults to 0 when absent (e.g. an older CLI that doesn't emit it). See - // src/Shared/Model/Serialization/ResourceJson.cs (ResourceCommandJson.RegistrationOrder). registrationOrder?: number | null; argumentInputs?: ResourceCommandArgumentInputJson[] | null; } From 978fce18b1b8d2b2b7d64ffe966bcdd49cff21ce Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 4 Jun 2026 18:55:06 -0500 Subject: [PATCH 8/8] Simplify compareResourceCommands comment to match file style --- extension/src/utils/resourceDisplay.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/extension/src/utils/resourceDisplay.ts b/extension/src/utils/resourceDisplay.ts index 0a52a5181b4..0dbab10fa49 100644 --- a/extension/src/utils/resourceDisplay.ts +++ b/extension/src/utils/resourceDisplay.ts @@ -2,11 +2,7 @@ import { ResourceState, ResourceType, CommandName, ParameterPropertyName } from import { ResourceJson, ResourceCommandInputType, ResourceCommandJson } from '../views/AppHostDataRepository'; import { parameterValueMissing } from '../loc/strings'; -/** - * Sorts resource commands by registration order, then name as a tiebreaker, since the CLI keys - * commands alphabetically in JSON. Approximates the dashboard order (highlighted-command floating - * isn't carried through the CLI). See src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs. - */ +// Sort commands by registration order, then name, since the CLI keys them alphabetically in JSON. export function compareResourceCommands( [nameA, a]: [string, ResourceCommandJson], [nameB, b]: [string, ResourceCommandJson]): number {