Skip to content
3 changes: 3 additions & 0 deletions extension/loc/xlf/aspire-vscode.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions extension/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 21 additions & 2 deletions extension/src/editor/AspireCodeLensProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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';
Expand All @@ -28,6 +29,7 @@ import {
codeLensCommand,
codeLensOpenDashboard,
codeLensViewAppHostLogs,
codeLensResourceValueMissing,
} from '../loc/strings';

export class AspireCodeLensProvider implements vscode.CodeLensProvider {
Expand Down Expand Up @@ -251,7 +253,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})` : ''}`;
Comment thread
ellahathaway marked this conversation as resolved.
const reports = resource.healthReports;
if (reports && healthStatus && healthStatus !== HealthStatus.Healthy) {
const failing = Object.entries(reports).filter(([, r]) => r.status !== HealthStatus.Healthy);
Expand All @@ -267,6 +269,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);
Comment thread
ellahathaway marked this conversation as resolved.
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) {
Expand Down Expand Up @@ -310,7 +324,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);
Expand Down Expand Up @@ -367,6 +384,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;
}
Expand Down
11 changes: 11 additions & 0 deletions extension/src/editor/resourceConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
2 changes: 2 additions & 0 deletions extension/src/loc/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -164,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');
Expand Down
174 changes: 174 additions & 0 deletions extension/src/test/appHostTreeView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -1663,6 +1783,60 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => {
assert.notStrictEqual(resultA, resultB, 'Expected distinct items for distinct AppHosts');
provider.dispose();
});

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, registrationOrder: 0 },
'custom-action': { displayName: 'Custom action', description: null, registrationOrder: 1 },
'delete-parameter': { displayName: 'Delete parameter', description: null, registrationOrder: 2 },
},
});
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', () => {
Expand Down
Loading
Loading