diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index bbe98c2cc15..09d7dbe9ed0 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -292,5 +292,11 @@ Yes + + Select directory + + + Select file + \ No newline at end of file diff --git a/extension/package.nls.json b/extension/package.nls.json index 03c1794715e..50e29832bcf 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -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" } diff --git a/extension/src/capabilities.ts b/extension/src/capabilities.ts index 32f5831fd63..f1441c1e16a 100644 --- a/extension/src/capabilities.ts +++ b/extension/src/capabilities.ts @@ -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 @@ -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"); diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 1b02e953ff7..81844681653 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -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'); diff --git a/extension/src/server/interactionService.ts b/extension/src/server/interactionService.ts index b3ab6cb3d92..57379e71087 100644 --- a/extension/src/server/interactionService.ts +++ b/extension/src/server/interactionService.ts @@ -2,7 +2,7 @@ import { MessageConnection } from 'vscode-jsonrpc'; import * as vscode from 'vscode'; import * as fs from 'fs/promises'; import { getRelativePathToWorkspace, isFolderOpenInWorkspace } from '../utils/workspace'; -import { yesLabel, noLabel, directLink, codespacesLink, openAspireDashboard, failedToShowPromptEmpty, incompatibleAppHostError, aspireHostingSdkVersion, aspireCliVersion, requiredCapability, fieldRequired, aspireDebugSessionNotInitialized, errorMessage, failedToStartDebugSession, dashboard, codespaces } 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'; @@ -16,6 +16,7 @@ export interface IInteractionService { showStatus: (statusText: string | null) => void; promptForString: (promptText: string, defaultValue: string | null, required: boolean, rpcClient: ICliRpcClient) => Promise; promptForSecretString: (promptText: string, required: boolean, rpcClient: ICliRpcClient) => Promise; + promptForFilePath: (promptText: string, defaultValue: string | null, directory: boolean) => Promise; confirm: (promptText: string, defaultValue: boolean) => Promise; promptForSelection: (promptText: string, choices: string[]) => Promise; promptForSelections: (promptText: string, choices: string[]) => Promise; @@ -169,6 +170,28 @@ export class InteractionService implements IInteractionService { return input ?? null; } + async promptForFilePath(promptText: string, defaultValue: string | null, directory: boolean): Promise { + 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 { extensionLogOutputChannel.info(`Confirming: ${promptText} with default value: ${defaultValue}`); const yes = yesLabel; @@ -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))); diff --git a/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs b/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs index eefff9a8b0f..d98fad345d7 100644 --- a/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs @@ -36,6 +36,7 @@ internal interface IExtensionBackchannel Task ConfirmAsync(string promptText, bool defaultValue, CancellationToken cancellationToken); Task PromptForStringAsync(string promptText, string? defaultValue, Func? validator, bool required, CancellationToken cancellationToken); Task PromptForSecretStringAsync(string promptText, Func? validator, bool required, CancellationToken cancellationToken); + Task PromptForFilePathAsync(string promptText, string? defaultValue, bool directory, CancellationToken cancellationToken); Task OpenEditorAsync(string path, CancellationToken cancellationToken); Task LogMessageAsync(LogLevel logLevel, string message, CancellationToken cancellationToken); Task GetCapabilitiesAsync(CancellationToken cancellationToken); @@ -543,6 +544,24 @@ public async Task PromptForSecretStringAsync(string promptText, Func 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( + "promptForFilePath", + [_token, promptText, defaultValue, directory], + cancellationToken); + + return result; + } + public async Task OpenEditorAsync(string path, CancellationToken cancellationToken) { await ConnectAsync(cancellationToken); diff --git a/src/Aspire.Cli/Commands/AgentInitCommand.cs b/src/Aspire.Cli/Commands/AgentInitCommand.cs index b4f1f905ce2..ef86b025c0b 100644 --- a/src/Aspire.Cli/Commands/AgentInitCommand.cs +++ b/src/Aspire.Cli/Commands/AgentInitCommand.cs @@ -69,7 +69,7 @@ protected override async Task 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 => @@ -86,6 +86,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ValidationResult.Success(); }, + directory: true, cancellationToken: cancellationToken); var workspaceRoot = new DirectoryInfo(workspaceRootPath); diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index e7296794ed2..59f76faa2ed 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -456,9 +456,10 @@ public virtual async Task 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 ); } diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index c423415d6ac..c3e9188f252 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -156,6 +156,11 @@ public async Task PromptForStringAsync(string promptText, string? defaul return await _outConsole.PromptAsync(prompt, cancellationToken); } + public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) + { + return PromptForStringAsync(promptText, defaultValue, validator, isSecret: false, required, cancellationToken); + } + public async Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull { ArgumentNullException.ThrowIfNull(promptText, nameof(promptText)); diff --git a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs index 270f405e69d..f73d543b21f 100644 --- a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs @@ -133,6 +133,58 @@ await _extensionTaskChannel.Writer.WriteAsync(async () => } } + public async Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? 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(); + + 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 ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default) { if (_extensionPromptEnabled) diff --git a/src/Aspire.Cli/Interaction/IInteractionService.cs b/src/Aspire.Cli/Interaction/IInteractionService.cs index a7727ffba66..69f7c1a7bd4 100644 --- a/src/Aspire.Cli/Interaction/IInteractionService.cs +++ b/src/Aspire.Cli/Interaction/IInteractionService.cs @@ -12,6 +12,7 @@ internal interface IInteractionService Task ShowStatusAsync(string statusText, Func> action, KnownEmoji? emoji = null, bool allowMarkup = false); void ShowStatus(string statusText, Action action, KnownEmoji? emoji = null, bool allowMarkup = false); Task PromptForStringAsync(string promptText, string? defaultValue = null, Func? validator = null, bool isSecret = false, bool required = false, CancellationToken cancellationToken = default); + Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default); public Task ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default); Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull; Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull; diff --git a/src/Aspire.Cli/Projects/ProjectUpdater.cs b/src/Aspire.Cli/Projects/ProjectUpdater.cs index 549a9018511..0ce2ffb7a30 100644 --- a/src/Aspire.Cli/Projects/ProjectUpdater.cs +++ b/src/Aspire.Cli/Projects/ProjectUpdater.cs @@ -106,11 +106,11 @@ public async Task 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); diff --git a/src/Aspire.Cli/Utils/ExtensionHelper.cs b/src/Aspire.Cli/Utils/ExtensionHelper.cs index a2834eb7838..0ac0170359f 100644 --- a/src/Aspire.Cli/Utils/ExtensionHelper.cs +++ b/src/Aspire.Cli/Utils/ExtensionHelper.cs @@ -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"; } diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 9685e5a68f6..6bc8bece413 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -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 ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default) => Task.FromResult(true); - public void DisplaySubtleMessage(string message, bool allowMarkup = false) { } + public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? 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) { } diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 7a0ca45abde..3e659b2fe27 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -887,6 +887,9 @@ public Task PromptForStringAsync(string promptText, string? defaultValue return Task.FromResult(defaultValue ?? string.Empty); } + public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) + => PromptForStringAsync(promptText, defaultValue, validator, isSecret: false, required, cancellationToken); + public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull { if (_shouldCancel || cancellationToken.IsCancellationRequested) diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 2c228f9f058..80e0d0c5e58 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -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 PromptForStringAsync(string promptText, string? defaultValue = null, Func? validator = null, bool isSecret = false, bool required = false, CancellationToken cancellationToken = default) => _innerService.PromptForStringAsync(promptText, defaultValue, validator, isSecret, required, cancellationToken); + public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) + => _innerService.PromptForFilePathAsync(promptText, defaultValue, validator, directory, required, cancellationToken); public Task ConfirmAsync(string promptText, bool defaultValue = true, CancellationToken cancellationToken = default) => _innerService.ConfirmAsync(promptText, defaultValue, cancellationToken); public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index 78f7b45af99..f95b721dfe8 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -463,6 +463,9 @@ public Task> PromptForSelectionsAsync(string promptText, IEn public Task PromptForStringAsync(string promptText, string? defaultValue = null, Func? validator = null, bool isSecret = false, bool required = false, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task ConfirmAsync(string prompt, bool defaultAnswer, CancellationToken cancellationToken) => throw new NotImplementedException(); diff --git a/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs index 54fe0cd51a6..0bac2dbca30 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs @@ -41,6 +41,11 @@ public Task PromptForStringAsync(string promptText, string? defaultValue return Task.FromResult(defaultValue ?? string.Empty); } + public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) + { + return PromptForStringAsync(promptText, defaultValue, validator, isSecret: false, required, cancellationToken); + } + public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull { if (!choices.Any()) diff --git a/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs index 9decc14937a..caf5010a7c9 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs @@ -56,6 +56,9 @@ internal sealed class TestExtensionBackchannel : IExtensionBackchannel public TaskCompletionSource? PromptForSecretStringAsyncCalled { get; set; } public Func?, bool, Task>? PromptForSecretStringAsyncCallback { get; set; } + public TaskCompletionSource? PromptForFilePathAsyncCalled { get; set; } + public Func>? PromptForFilePathAsyncCallback { get; set; } + public TaskCompletionSource? OpenEditorAsyncCalled { get; set; } public Func? OpenEditorAsyncCallback { get; set; } @@ -149,6 +152,14 @@ public Task ShowStatusAsync(string? status, CancellationToken cancellationToken) return ShowStatusAsyncCallback?.Invoke(status) ?? Task.CompletedTask; } + public Task PromptForFilePathAsync(string promptText, string? defaultValue, bool directory, CancellationToken cancellationToken) + { + PromptForFilePathAsyncCalled?.SetResult(); + return PromptForFilePathAsyncCallback != null + ? PromptForFilePathAsyncCallback.Invoke(promptText, defaultValue, directory) + : Task.FromResult(defaultValue); + } + public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken) where T : notnull { PromptForSelectionAsyncCalled?.SetResult(); diff --git a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs index 349b1025a77..069a27d9d52 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs @@ -39,6 +39,11 @@ public Task PromptForStringAsync(string promptText, string? defaultValue return Task.FromResult(defaultValue ?? string.Empty); } + public Task PromptForFilePathAsync(string promptText, string? defaultValue = null, Func? validator = null, bool directory = false, bool required = false, CancellationToken cancellationToken = default) + { + return PromptForStringAsync(promptText, defaultValue, validator, isSecret: false, required, cancellationToken); + } + public Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull { if (!choices.Any())