Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 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.

4 changes: 3 additions & 1 deletion extension/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,7 @@
"aspire-vscode.strings.cliNotAvailable": "Aspire CLI is not available on PATH. Please install it and restart VS Code.",
"aspire-vscode.strings.cliFoundAtDefaultPath": "Aspire CLI found at {0}. The extension will use this path.",
"aspire-vscode.strings.openCliInstallInstructions": "See CLI installation instructions",
"aspire-vscode.strings.dismissLabel": "Dismiss"
"aspire-vscode.strings.dismissLabel": "Dismiss",
"aspire-vscode.strings.selectDirectoryTitle": "Select directory",
"aspire-vscode.strings.selectFileTitle": "Select file"
}
3 changes: 2 additions & 1 deletion extension/src/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type Capability =
| 'prompting' // Support using VS Code to capture user input instead of CLI
| 'baseline.v1'
| 'secret-prompts.v1'
| 'file-pickers.v1'
| 'build-dotnet-using-cli' // Support building .NET projects using the CLI
| 'devkit' // Support for .NET DevKit extension (old, used for determining whether to build .NET projects in extension)
| 'ms-dotnettools.csdevkit' // Older AppHost versions used this extension identifier instead of devkit
Expand Down Expand Up @@ -33,7 +34,7 @@ export function isPythonInstalled() {
}

export function getSupportedCapabilities(): Capabilities {
const capabilities: Capabilities = ['prompting', 'baseline.v1', 'secret-prompts.v1', 'build-dotnet-using-cli'];
const capabilities: Capabilities = ['prompting', 'baseline.v1', 'secret-prompts.v1', 'file-pickers.v1', 'build-dotnet-using-cli'];

if (isCsDevKitInstalled()) {
capabilities.push("devkit");
Expand Down
2 changes: 2 additions & 0 deletions extension/src/loc/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,5 @@ export const dismissLabel = vscode.l10n.t('Dismiss');
export const openCliInstallInstructions = vscode.l10n.t('See CLI installation instructions');
export const cliNotAvailable = vscode.l10n.t('Aspire CLI is not available on PATH. Please install it and restart VS Code.');
export const cliFoundAtDefaultPath = (path: string) => vscode.l10n.t('Aspire CLI found at {0}. The extension will use this path.', path);
export const selectDirectoryTitle = vscode.l10n.t('Select directory');
export const selectFileTitle = vscode.l10n.t('Select file');
26 changes: 25 additions & 1 deletion extension/src/server/interactionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from '../loc/strings';
import { yesLabel, noLabel, directLink, codespacesLink, openAspireDashboard, 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';
Expand All @@ -16,6 +16,7 @@ export interface IInteractionService {
showStatus: (statusText: string | null) => void;
promptForString: (promptText: string, defaultValue: string | null, required: boolean, rpcClient: ICliRpcClient) => Promise<string | null>;
promptForSecretString: (promptText: string, required: boolean, rpcClient: ICliRpcClient) => Promise<string | null>;
promptForFilePath: (promptText: string, defaultValue: string | null, directory: boolean) => Promise<string | null>;
confirm: (promptText: string, defaultValue: boolean) => Promise<boolean | null>;
promptForSelection: (promptText: string, choices: string[]) => Promise<string | null>;
promptForSelections: (promptText: string, choices: string[]) => Promise<string[] | null>;
Expand Down Expand Up @@ -169,6 +170,28 @@ export class InteractionService implements IInteractionService {
return input ?? null;
}

async promptForFilePath(promptText: string, defaultValue: string | null, directory: boolean): Promise<string | null> {
extensionLogOutputChannel.info(`Prompting for file path: ${promptText}, directory: ${directory}, default: ${defaultValue ?? 'null'}`);

const defaultUri = defaultValue ? vscode.Uri.file(defaultValue) : undefined;
const openLabel = directory ? selectDirectoryTitle : selectFileTitle;

const result = await vscode.window.showOpenDialog({
canSelectFiles: !directory,
canSelectFolders: directory,
canSelectMany: false,
defaultUri,
openLabel,
title: formatText(promptText),
});

if (!result || result.length === 0) {
return null;
}

return result[0].fsPath;
}

async confirm(promptText: string, defaultValue: boolean): Promise<boolean | null> {
extensionLogOutputChannel.info(`Confirming: ${promptText} with default value: ${defaultValue}`);
const yes = yesLabel;
Expand Down Expand Up @@ -481,6 +504,7 @@ export function addInteractionServiceEndpoints(connection: MessageConnection, in
connection.onRequest("showStatus", middleware('showStatus', interactionService.showStatus.bind(interactionService)));
connection.onRequest("promptForString", middleware('promptForString', async (promptText: string, defaultValue: string | null, required: boolean) => interactionService.promptForString(promptText, defaultValue, required, rpcClient)));
connection.onRequest("promptForSecretString", middleware('promptForSecretString', async (promptText: string, required: boolean) => interactionService.promptForSecretString(promptText, required, rpcClient)));
connection.onRequest("promptForFilePath", middleware('promptForFilePath', async (promptText: string, defaultValue: string | null, directory: boolean) => interactionService.promptForFilePath(promptText, defaultValue, directory)));
connection.onRequest("confirm", middleware('confirm', interactionService.confirm.bind(interactionService)));
connection.onRequest("promptForSelection", middleware('promptForSelection', interactionService.promptForSelection.bind(interactionService)));
connection.onRequest("promptForSelections", middleware('promptForSelections', interactionService.promptForSelections.bind(interactionService)));
Expand Down
19 changes: 19 additions & 0 deletions src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ internal interface IExtensionBackchannel
Task<bool> ConfirmAsync(string promptText, bool defaultValue, CancellationToken cancellationToken);
Task<string> PromptForStringAsync(string promptText, string? defaultValue, Func<string, ValidationResult>? validator, bool required, CancellationToken cancellationToken);
Task<string> PromptForSecretStringAsync(string promptText, Func<string, ValidationResult>? validator, bool required, CancellationToken cancellationToken);
Task<string?> PromptForFilePathAsync(string promptText, string? defaultValue, bool directory, CancellationToken cancellationToken);
Task OpenEditorAsync(string path, CancellationToken cancellationToken);
Task LogMessageAsync(LogLevel logLevel, string message, CancellationToken cancellationToken);
Task<string[]> GetCapabilitiesAsync(CancellationToken cancellationToken);
Expand Down Expand Up @@ -543,6 +544,24 @@ public async Task<string> PromptForSecretStringAsync(string promptText, Func<str
return result;
}

public async Task<string?> PromptForFilePathAsync(string promptText, string? defaultValue, bool directory, CancellationToken cancellationToken)
{
await ConnectAsync(cancellationToken);

using var activity = _activitySource.StartActivity();

var rpc = await _rpcTaskCompletionSource.Task;

_logger.LogDebug("Prompting for file path with text: {PromptText}, default value: {DefaultValue}, directory: {Directory}", promptText, defaultValue, directory);

var result = await rpc.InvokeWithCancellationAsync<string?>(
"promptForFilePath",
[_token, promptText, defaultValue, directory],
cancellationToken);

return result;
}

public async Task OpenEditorAsync(string path, CancellationToken cancellationToken)
{
await ConnectAsync(cancellationToken);
Expand Down
3 changes: 2 additions & 1 deletion src/Aspire.Cli/Commands/AgentInitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
var defaultWorkspaceRoot = gitRoot ?? ExecutionContext.WorkingDirectory;

// Prompt the user for the workspace root
var workspaceRootPath = await _interactionService.PromptForStringAsync(
var workspaceRootPath = await _interactionService.PromptForFilePathAsync(
McpCommandStrings.InitCommand_WorkspaceRootPrompt,
defaultValue: defaultWorkspaceRoot.FullName,
validator: path =>
Expand All @@ -86,6 +86,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell

return ValidationResult.Success();
},
directory: true,
cancellationToken: cancellationToken);

var workspaceRoot = new DirectoryInfo(workspaceRootPath);
Expand Down
3 changes: 2 additions & 1 deletion src/Aspire.Cli/Commands/NewCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -456,9 +456,10 @@ public virtual async Task<string> PromptForOutputPath(string path, CancellationT
{
// Escape markup characters in the path to prevent Spectre.Console from trying to parse them as markup
// when displaying it as the default value in the prompt
return await interactionService.PromptForStringAsync(
return await interactionService.PromptForFilePathAsync(
NewCommandStrings.EnterTheOutputPath,
defaultValue: path.EscapeMarkup(),
directory: true,
cancellationToken: cancellationToken
);
}
Expand Down
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Interaction/ConsoleInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ public async Task<string> PromptForStringAsync(string promptText, string? defaul
return await _outConsole.PromptAsync(prompt, cancellationToken);
}

public Task<string> PromptForFilePathAsync(string promptText, string? defaultValue = null, Func<string, ValidationResult>? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default)
{
return PromptForStringAsync(promptText, defaultValue, validator, isSecret: false, required, cancellationToken);
}

public async Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T> choices, Func<T, string> choiceFormatter, CancellationToken cancellationToken = default) where T : notnull
{
ArgumentNullException.ThrowIfNull(promptText, nameof(promptText));
Expand Down
52 changes: 52 additions & 0 deletions src/Aspire.Cli/Interaction/ExtensionInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,58 @@ await _extensionTaskChannel.Writer.WriteAsync(async () =>
}
}

public async Task<string> PromptForFilePathAsync(string promptText, string? defaultValue = null, Func<string, ValidationResult>? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default)
{
if (_extensionPromptEnabled)
{
var hasFilePickersCapability = await Backchannel.HasCapabilityAsync(KnownCapabilities.FilePickers, _cancellationToken).ConfigureAwait(false);

if (hasFilePickersCapability)
{
var tcs = new TaskCompletionSource<string?>();

await _extensionTaskChannel.Writer.WriteAsync(async () =>
{
try
{
var result = await Backchannel.PromptForFilePathAsync(promptText.RemoveSpectreFormatting(), defaultValue, directory, _cancellationToken).ConfigureAwait(false);
tcs.SetResult(result);
}
catch (Exception ex)
{
tcs.SetException(ex);
}
}, cancellationToken).ConfigureAwait(false);

var picked = await tcs.Task.ConfigureAwait(false);

if (picked is null)
{
throw new ExtensionOperationCanceledException(promptText);
}

if (validator is not null)
{
var validationResult = validator(picked);

if (!validationResult.Successful)
{
var errorMessage = validationResult.Message ?? "Invalid selection.";
DisplayError(errorMessage);
throw new InvalidOperationException(errorMessage);
}
}

return picked;
}

// Fall back to string prompt for older extensions without file picker support
return await PromptForStringAsync(promptText, defaultValue, validator, isSecret: false, required, cancellationToken).ConfigureAwait(false);
}

return await _consoleInteractionService.PromptForFilePathAsync(promptText, defaultValue, validator, directory, required, cancellationToken).ConfigureAwait(false);
}

public async Task<bool> ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default)
{
if (_extensionPromptEnabled)
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Cli/Interaction/IInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ internal interface IInteractionService
Task<T> ShowStatusAsync<T>(string statusText, Func<Task<T>> action, KnownEmoji? emoji = null, bool allowMarkup = false);
void ShowStatus(string statusText, Action action, KnownEmoji? emoji = null, bool allowMarkup = false);
Task<string> PromptForStringAsync(string promptText, string? defaultValue = null, Func<string, ValidationResult>? validator = null, bool isSecret = false, bool required = false, CancellationToken cancellationToken = default);
Task<string> PromptForFilePathAsync(string promptText, string? defaultValue = null, Func<string, ValidationResult>? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default);
public Task<bool> ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default);
Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T> choices, Func<T, string> choiceFormatter, CancellationToken cancellationToken = default) where T : notnull;
Task<IReadOnlyList<T>> PromptForSelectionsAsync<T>(string promptText, IEnumerable<T> choices, Func<T, string> choiceFormatter, CancellationToken cancellationToken = default) where T : notnull;
Expand Down
4 changes: 2 additions & 2 deletions src/Aspire.Cli/Projects/ProjectUpdater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,11 @@ public async Task<ProjectUpdateResult> UpdateProjectAsync(FileInfo projectFile,

interactionService.DisplayEmptyLine();

var selectedPathForNewNuGetConfigFile = await interactionService.PromptForStringAsync(
var selectedPathForNewNuGetConfigFile = await interactionService.PromptForFilePathAsync(
promptText: UpdateCommandStrings.WhichDirectoryNuGetConfigPrompt,
defaultValue: recommendedNuGetConfigFileDirectory.EscapeMarkup(),
validator: null,
isSecret: false,
directory: true,
required: true,
cancellationToken: cancellationToken);

Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Cli/Utils/ExtensionHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ internal static class KnownCapabilities
public const string BuildDotnetUsingCli = "build-dotnet-using-cli";
public const string Baseline = "baseline.v1";
public const string SecretPrompts = "secret-prompts.v1";
public const string FilePickers = "file-pickers.v1";
}
4 changes: 3 additions & 1 deletion tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1341,7 +1341,9 @@ public void DisplaySuccess(string message, bool allowMarkup = false) { }
public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { }
public void DisplayCancellationMessage() { }
public Task<bool> ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default) => Task.FromResult(true);
public void DisplaySubtleMessage(string message, bool allowMarkup = false) { }
public Task<string> PromptForFilePathAsync(string promptText, string? defaultValue = null, Func<string, ValidationResult>? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default)
=> PromptForStringAsync(promptText, defaultValue, validator, isSecret: false, required, cancellationToken);
public void DisplaySubtleMessage(string message, bool escapeMarkup = true) { }
public void DisplayEmptyLine() { }
public void DisplayPlainText(string text) { }
public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,9 @@ public Task<string> PromptForStringAsync(string promptText, string? defaultValue
return Task.FromResult(defaultValue ?? string.Empty);
}

public Task<string> PromptForFilePathAsync(string promptText, string? defaultValue = null, Func<string, ValidationResult>? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default)
=> PromptForStringAsync(promptText, defaultValue, validator, isSecret: false, required, cancellationToken);

public Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T> choices, Func<T, string> choiceFormatter, CancellationToken cancellationToken = default) where T : notnull
{
if (_shouldCancel || cancellationToken.IsCancellationRequested)
Expand Down
2 changes: 2 additions & 0 deletions tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,8 @@ public CancellationTrackingInteractionService(IInteractionService innerService)
public void ShowStatus(string statusText, Action action, KnownEmoji? emoji = null, bool allowMarkup = false) => _innerService.ShowStatus(statusText, action, emoji, allowMarkup);
public Task<string> PromptForStringAsync(string promptText, string? defaultValue = null, Func<string, ValidationResult>? validator = null, bool isSecret = false, bool required = false, CancellationToken cancellationToken = default)
=> _innerService.PromptForStringAsync(promptText, defaultValue, validator, isSecret, required, cancellationToken);
public Task<string> PromptForFilePathAsync(string promptText, string? defaultValue = null, Func<string, ValidationResult>? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default)
=> _innerService.PromptForFilePathAsync(promptText, defaultValue, validator, directory, required, cancellationToken);
public Task<bool> ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default)
=> _innerService.ConfirmAsync(promptText, defaultValue, cancellationToken);
public Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T> choices, Func<T, string> choiceFormatter, CancellationToken cancellationToken = default) where T : notnull
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,9 @@ public Task<IReadOnlyList<T>> PromptForSelectionsAsync<T>(string promptText, IEn
public Task<string> PromptForStringAsync(string promptText, string? defaultValue = null, Func<string, ValidationResult>? validator = null, bool isSecret = false, bool required = false, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();

public Task<string> PromptForFilePathAsync(string promptText, string? defaultValue = null, Func<string, ValidationResult>? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();

public Task<bool> ConfirmAsync(string prompt, bool defaultAnswer, CancellationToken cancellationToken)
=> throw new NotImplementedException();

Expand Down
Loading
Loading