From e517e7e1bf74a86d4b42aae7556c709cf864eb21 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 19 Mar 2026 13:21:22 -0500 Subject: [PATCH 1/4] [dotnet-watch] Use Spectre.Console for target framework selection Replace the basic numeric-key console prompt with Spectre.Console's SelectionPrompt for target framework selection in dotnet-watch. This matches the dotnet-run behavior and provides arrow key navigation, search, and pagination. - Add Spectre.Console dependency to Watch library - Add Spectre.Console.Testing for unit tests - Accept IAnsiConsole in TargetFrameworkSelectionPrompt for testability - Use ShowAsync with CancellationToken for cancellation support - Localize prompt strings in Resources.resx - Rewrite tests to drive the prompt via TestConsole key presses Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 1 + .../Watch/HotReload/HotReloadDotNetWatcher.cs | 4 +- .../Microsoft.DotNet.HotReload.Watch.csproj | 1 + .../UI/TargetFrameworkSelectionPrompt.cs | 32 +++---- src/Dotnet.Watch/dotnet-watch/Program.cs | 4 +- src/Dotnet.Watch/dotnet-watch/Resources.resx | 9 ++ .../dotnet-watch/xlf/Resources.cs.xlf | 15 +++ .../dotnet-watch/xlf/Resources.de.xlf | 15 +++ .../dotnet-watch/xlf/Resources.es.xlf | 15 +++ .../dotnet-watch/xlf/Resources.fr.xlf | 15 +++ .../dotnet-watch/xlf/Resources.it.xlf | 15 +++ .../dotnet-watch/xlf/Resources.ja.xlf | 15 +++ .../dotnet-watch/xlf/Resources.ko.xlf | 15 +++ .../dotnet-watch/xlf/Resources.pl.xlf | 15 +++ .../dotnet-watch/xlf/Resources.pt-BR.xlf | 15 +++ .../dotnet-watch/xlf/Resources.ru.xlf | 15 +++ .../dotnet-watch/xlf/Resources.tr.xlf | 15 +++ .../dotnet-watch/xlf/Resources.zh-Hans.xlf | 15 +++ .../dotnet-watch/xlf/Resources.zh-Hant.xlf | 15 +++ .../TargetFrameworkSelectionPromptTests.cs | 94 +++++++++++++++---- .../dotnet-watch.Tests.csproj | 1 + 21 files changed, 302 insertions(+), 39 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 3e9b3bd272de..0f20e1ccff90 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -117,6 +117,7 @@ + diff --git a/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs index ba1191e5cce1..810376fcc992 100644 --- a/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs @@ -25,7 +25,7 @@ internal sealed class HotReloadDotNetWatcher internal Task? Test_FileChangesCompletedTask { get; set; } - public HotReloadDotNetWatcher(DotNetWatchContext context, IConsole console, IRuntimeProcessLauncherFactory? runtimeProcessLauncherFactory) + public HotReloadDotNetWatcher(DotNetWatchContext context, IConsole console, IRuntimeProcessLauncherFactory? runtimeProcessLauncherFactory, TargetFrameworkSelectionPrompt? targetFrameworkSelectionPrompt = null) { _context = context; _console = console; @@ -41,7 +41,7 @@ public HotReloadDotNetWatcher(DotNetWatchContext context, IConsole console, IRun } _rudeEditRestartPrompt = new RestartPrompt(context.Logger, consoleInput, noPrompt ? true : null); - _targetFrameworkSelectionPrompt = new TargetFrameworkSelectionPrompt(consoleInput); + _targetFrameworkSelectionPrompt = targetFrameworkSelectionPrompt; } _designTimeBuildGraphFactory = new ProjectGraphFactory( diff --git a/src/Dotnet.Watch/Watch/Microsoft.DotNet.HotReload.Watch.csproj b/src/Dotnet.Watch/Watch/Microsoft.DotNet.HotReload.Watch.csproj index 5b46ae0b06e7..70e98e6e1612 100644 --- a/src/Dotnet.Watch/Watch/Microsoft.DotNet.HotReload.Watch.csproj +++ b/src/Dotnet.Watch/Watch/Microsoft.DotNet.HotReload.Watch.csproj @@ -32,6 +32,7 @@ + diff --git a/src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs b/src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs index fd6d17ac4d4f..8e86e5de1f4e 100644 --- a/src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs +++ b/src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs @@ -1,16 +1,18 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Spectre.Console; + namespace Microsoft.DotNet.Watch; -internal sealed class TargetFrameworkSelectionPrompt(ConsoleInputReader inputReader) +internal sealed class TargetFrameworkSelectionPrompt(string title, string moreChoicesText, string searchPlaceholderText, IAnsiConsole? console = null) { public IReadOnlyList? PreviousTargetFrameworks { get; set; } public string? PreviousSelection { get; set; } public async ValueTask SelectAsync(IReadOnlyList targetFrameworks, CancellationToken cancellationToken) { - var orderedTargetFrameworks = targetFrameworks.Order().ToArray(); + var orderedTargetFrameworks = targetFrameworks.Order(StringComparer.OrdinalIgnoreCase).ToArray(); if (PreviousSelection != null && PreviousTargetFrameworks?.SequenceEqual(orderedTargetFrameworks, StringComparer.OrdinalIgnoreCase) == true) { @@ -19,21 +21,15 @@ public async ValueTask SelectAsync(IReadOnlyList targetFramework PreviousTargetFrameworks = orderedTargetFrameworks; - var keyInfo = await inputReader.GetKeyAsync( - $"Select target framework:{Environment.NewLine}{string.Join(Environment.NewLine, targetFrameworks.Select((tfm, i) => $"{i + 1}) {tfm}"))}", - AcceptKey, - cancellationToken); - - _ = TryGetIndex(keyInfo, out var index); - return PreviousSelection = targetFrameworks[index]; - - bool TryGetIndex(ConsoleKeyInfo info, out int index) - { - index = info.KeyChar - '1'; - return index >= 0 && index < targetFrameworks.Count; - } + var prompt = new SelectionPrompt() + .Title($"[cyan]{Markup.Escape(title)}[/]") + .PageSize(10) + .MoreChoicesText($"[gray]({Markup.Escape(moreChoicesText)})[/]") + .AddChoices(targetFrameworks) + .EnableSearch() + .SearchPlaceholderText(searchPlaceholderText); - bool AcceptKey(ConsoleKeyInfo info) - => info is { Modifiers: ConsoleModifiers.None } && TryGetIndex(info, out _); + PreviousSelection = await prompt.ShowAsync(console ?? AnsiConsole.Console, cancellationToken); + return PreviousSelection; } } diff --git a/src/Dotnet.Watch/dotnet-watch/Program.cs b/src/Dotnet.Watch/dotnet-watch/Program.cs index 1b25994261e3..92f9914278aa 100644 --- a/src/Dotnet.Watch/dotnet-watch/Program.cs +++ b/src/Dotnet.Watch/dotnet-watch/Program.cs @@ -265,7 +265,9 @@ internal async Task RunAsync() if (IsHotReloadEnabled()) { - var watcher = new HotReloadDotNetWatcher(context, console, runtimeProcessLauncherFactory: null); + var tfmPrompt = context.Options.NonInteractive ? null + : new TargetFrameworkSelectionPrompt(Resources.SelectTargetFrameworkPrompt, Resources.MoreFrameworksText, Resources.SearchPlaceholderText); + var watcher = new HotReloadDotNetWatcher(context, console, runtimeProcessLauncherFactory: null, tfmPrompt); await watcher.WatchAsync(shutdownHandler.CancellationToken); } else if (mainProjectOptions.Representation.EntryPointFilePath != null) diff --git a/src/Dotnet.Watch/dotnet-watch/Resources.resx b/src/Dotnet.Watch/dotnet-watch/Resources.resx index 89103943c106..9892790f4556 100644 --- a/src/Dotnet.Watch/dotnet-watch/Resources.resx +++ b/src/Dotnet.Watch/dotnet-watch/Resources.resx @@ -138,6 +138,15 @@ Warning NETSDK1174: The abbreviation of -p for --project is deprecated. Please use --project. {Locked="-p"}{Locked="--project"} + + Select the target framework to run: + + + Move up and down to reveal more frameworks + + + Type to search + Runs dotnet-watch in non-interactive mode. This option is only supported when running with Hot Reload enabled. diff --git a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.cs.xlf b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.cs.xlf index 1628e14ae227..fc6a8ee1f26b 100644 --- a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.cs.xlf +++ b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.cs.xlf @@ -142,6 +142,21 @@ Examples: + + Move up and down to reveal more frameworks + Move up and down to reveal more frameworks + + + + Type to search + Type to search + + + + Select the target framework to run: + Select the target framework to run: + + The specified path '{0}' is invalid: {1} Zadaná cesta {0} je neplatná: {1} diff --git a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.de.xlf b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.de.xlf index 903537d57e2b..90af2e2f4e24 100644 --- a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.de.xlf +++ b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.de.xlf @@ -142,6 +142,21 @@ Beispiele: + + Move up and down to reveal more frameworks + Move up and down to reveal more frameworks + + + + Type to search + Type to search + + + + Select the target framework to run: + Select the target framework to run: + + The specified path '{0}' is invalid: {1} Der angegebene Pfad „{0}“ ist ungültig: {1} diff --git a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.es.xlf b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.es.xlf index d808857128e5..ef8f6975d7b7 100644 --- a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.es.xlf +++ b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.es.xlf @@ -142,6 +142,21 @@ Ejemplos: + + Move up and down to reveal more frameworks + Move up and down to reveal more frameworks + + + + Type to search + Type to search + + + + Select the target framework to run: + Select the target framework to run: + + The specified path '{0}' is invalid: {1} La ruta de acceso especificada ''{0}'' no es válida: {1} diff --git a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.fr.xlf b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.fr.xlf index 6957d76140e9..968277ea2d55 100644 --- a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.fr.xlf +++ b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.fr.xlf @@ -142,6 +142,21 @@ Exemples : + + Move up and down to reveal more frameworks + Move up and down to reveal more frameworks + + + + Type to search + Type to search + + + + Select the target framework to run: + Select the target framework to run: + + The specified path '{0}' is invalid: {1} Le chemin d’accès spécifié « {0} » n’est pas valide : {1} diff --git a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.it.xlf b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.it.xlf index 95d0f981056d..1b3db736000b 100644 --- a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.it.xlf +++ b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.it.xlf @@ -142,6 +142,21 @@ Esempi: + + Move up and down to reveal more frameworks + Move up and down to reveal more frameworks + + + + Type to search + Type to search + + + + Select the target framework to run: + Select the target framework to run: + + The specified path '{0}' is invalid: {1} Il percorso specificato '{0}' non è valido: {1} diff --git a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.ja.xlf b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.ja.xlf index a73fc27148c5..56e3d1a25211 100644 --- a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.ja.xlf +++ b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.ja.xlf @@ -142,6 +142,21 @@ Examples: + + Move up and down to reveal more frameworks + Move up and down to reveal more frameworks + + + + Type to search + Type to search + + + + Select the target framework to run: + Select the target framework to run: + + The specified path '{0}' is invalid: {1} 指定されたパス '{0}' は無効です: {1} diff --git a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.ko.xlf b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.ko.xlf index 438aaae2de6b..2840cc97f47f 100644 --- a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.ko.xlf +++ b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.ko.xlf @@ -142,6 +142,21 @@ Examples: + + Move up and down to reveal more frameworks + Move up and down to reveal more frameworks + + + + Type to search + Type to search + + + + Select the target framework to run: + Select the target framework to run: + + The specified path '{0}' is invalid: {1} 지정한 경로 '{0}'이(가) 잘못되었습니다. {1}. diff --git a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.pl.xlf b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.pl.xlf index da5a4d916ea3..ed38de448c21 100644 --- a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.pl.xlf +++ b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.pl.xlf @@ -142,6 +142,21 @@ Przykłady: + + Move up and down to reveal more frameworks + Move up and down to reveal more frameworks + + + + Type to search + Type to search + + + + Select the target framework to run: + Select the target framework to run: + + The specified path '{0}' is invalid: {1} Określona ścieżka „{0}” jest nieprawidłowa: {1} diff --git a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.pt-BR.xlf b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.pt-BR.xlf index 350dd86e376b..2a00c37d7e55 100644 --- a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.pt-BR.xlf +++ b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.pt-BR.xlf @@ -142,6 +142,21 @@ Exemplos: + + Move up and down to reveal more frameworks + Move up and down to reveal more frameworks + + + + Type to search + Type to search + + + + Select the target framework to run: + Select the target framework to run: + + The specified path '{0}' is invalid: {1} O caminho especificado "{0}" é inválido: {1} diff --git a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.ru.xlf b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.ru.xlf index 1d6c0f4a7395..01463726aac0 100644 --- a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.ru.xlf +++ b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.ru.xlf @@ -142,6 +142,21 @@ Examples: + + Move up and down to reveal more frameworks + Move up and down to reveal more frameworks + + + + Type to search + Type to search + + + + Select the target framework to run: + Select the target framework to run: + + The specified path '{0}' is invalid: {1} Указанный путь "{0}" недопустим: {1} diff --git a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.tr.xlf b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.tr.xlf index 736d74b9ebad..a986584893cb 100644 --- a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.tr.xlf +++ b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.tr.xlf @@ -142,6 +142,21 @@ Açıklamalar: + + Move up and down to reveal more frameworks + Move up and down to reveal more frameworks + + + + Type to search + Type to search + + + + Select the target framework to run: + Select the target framework to run: + + The specified path '{0}' is invalid: {1} Belirtilen '{0}' yolu geçersiz: {1} diff --git a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.zh-Hans.xlf b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.zh-Hans.xlf index 15eeab84f477..edb79267b452 100644 --- a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.zh-Hans.xlf +++ b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.zh-Hans.xlf @@ -143,6 +143,21 @@ Examples: + + Move up and down to reveal more frameworks + Move up and down to reveal more frameworks + + + + Type to search + Type to search + + + + Select the target framework to run: + Select the target framework to run: + + The specified path '{0}' is invalid: {1} 指定的路径“{0}”无效: {1} diff --git a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.zh-Hant.xlf b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.zh-Hant.xlf index aba90cffedeb..eb8d920c1ca3 100644 --- a/src/Dotnet.Watch/dotnet-watch/xlf/Resources.zh-Hant.xlf +++ b/src/Dotnet.Watch/dotnet-watch/xlf/Resources.zh-Hant.xlf @@ -142,6 +142,21 @@ Examples: + + Move up and down to reveal more frameworks + Move up and down to reveal more frameworks + + + + Type to search + Type to search + + + + Select the target framework to run: + Select the target framework to run: + + The specified path '{0}' is invalid: {1} 指定的路徑 '{0}' 無效: {1} diff --git a/test/dotnet-watch.Tests/HotReload/TargetFrameworkSelectionPromptTests.cs b/test/dotnet-watch.Tests/HotReload/TargetFrameworkSelectionPromptTests.cs index ac59577f9b5f..337b622c9e9d 100644 --- a/test/dotnet-watch.Tests/HotReload/TargetFrameworkSelectionPromptTests.cs +++ b/test/dotnet-watch.Tests/HotReload/TargetFrameworkSelectionPromptTests.cs @@ -1,37 +1,95 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.Extensions.Logging; +using SpectreTestConsole = Spectre.Console.Testing.TestConsole; namespace Microsoft.DotNet.Watch.UnitTests; -public class TargetFrameworkSelectionPromptTests(ITestOutputHelper output) +public class TargetFrameworkSelectionPromptTests { [Theory] [CombinatorialData] - public async Task PreviousSelection([CombinatorialRange(0, count: 3)] int index) + public async Task SelectsFrameworkByArrowKeysAndEnter([CombinatorialRange(0, count: 3)] int index) { - var console = new TestConsole(output); - var consoleInput = new ConsoleInputReader(console, LogLevel.Debug, suppressEmojis: false); - var prompt = new TargetFrameworkSelectionPrompt(consoleInput); + var console = new SpectreTestConsole(); + console.Profile.Capabilities.Interactive = true; + + // Press DownArrow 'index' times to move to the desired item, then Enter to select + for (var i = 0; i < index; i++) + { + console.Input.PushKey(ConsoleKey.DownArrow); + } + console.Input.PushKey(ConsoleKey.Enter); + + var frameworks = new[] { "net7.0", "net8.0", "net9.0" }; + var prompt = new TargetFrameworkSelectionPrompt("Select:", "more", "search", console); + + var result = await prompt.SelectAsync(frameworks, CancellationToken.None); + Assert.Equal(frameworks[index], result); + Assert.Equal(frameworks[index], prompt.PreviousSelection); + } + + [Theory] + [CombinatorialData] + public async Task PreviousSelectionIsReusedWhenFrameworksUnchanged([CombinatorialRange(0, count: 3)] int index) + { + var console = new SpectreTestConsole(); + console.Profile.Capabilities.Interactive = true; + + // First selection via key presses + for (var i = 0; i < index; i++) + { + console.Input.PushKey(ConsoleKey.DownArrow); + } + console.Input.PushKey(ConsoleKey.Enter); var frameworks = new[] { "net7.0", "net8.0", "net9.0" }; - var expectedTfm = frameworks[index]; + var prompt = new TargetFrameworkSelectionPrompt("Select:", "more", "search", console); - // first selection: - console.QueuedKeyPresses.Add(new ConsoleKeyInfo((char)('1' + index), ConsoleKey.D1 + index, shift: false, alt: false, control: false)); + var first = await prompt.SelectAsync(frameworks, CancellationToken.None); + Assert.Equal(frameworks[index], first); - Assert.Equal(expectedTfm, await prompt.SelectAsync(frameworks, CancellationToken.None)); - Assert.Equal(expectedTfm, prompt.PreviousSelection); - console.QueuedKeyPresses.Clear(); + // Same frameworks (reordered, different casing) should reuse previous selection without prompting + var second = await prompt.SelectAsync(["NET9.0", "net7.0", "net8.0"], CancellationToken.None); + Assert.Equal(first, second); + } + + [Fact] + public async Task PromptsAgainWhenFrameworksChange() + { + var console = new SpectreTestConsole(); + console.Profile.Capabilities.Interactive = true; + + // First selection: pick first item + console.Input.PushKey(ConsoleKey.Enter); + // Second selection (after frameworks change): pick second item + console.Input.PushKey(ConsoleKey.DownArrow); + console.Input.PushKey(ConsoleKey.Enter); + + var prompt = new TargetFrameworkSelectionPrompt("Select:", "more", "search", console); + + var first = await prompt.SelectAsync(["net7.0", "net8.0", "net9.0"], CancellationToken.None); + Assert.Equal("net7.0", first); + + // Different set of frameworks — should prompt again + var second = await prompt.SelectAsync(["net9.0", "net10.0"], CancellationToken.None); + Assert.Equal("net10.0", second); + } + + [Fact] + public async Task SelectsFrameworkBySearchText() + { + var console = new SpectreTestConsole(); + console.Profile.Capabilities.Interactive = true; - // should use previous selection: - Assert.Equal(expectedTfm, await prompt.SelectAsync(["NET9.0", "net7.0", "net8.0"], CancellationToken.None)); + // Type "net9.0" to filter, then Enter to select the match + console.Input.PushText("net9.0"); + console.Input.PushKey(ConsoleKey.Enter); - // second selection: - console.QueuedKeyPresses.Add(new ConsoleKeyInfo('3', ConsoleKey.D3, shift: false, alt: false, control: false)); + var frameworks = new[] { "net7.0", "net8.0", "net9.0", "net10.0" }; + var prompt = new TargetFrameworkSelectionPrompt("Select:", "more", "search", console); - // should prompt again: - Assert.Equal("net10.0", await prompt.SelectAsync(["net9.0", "net7.0", "net10.0"], CancellationToken.None)); + var result = await prompt.SelectAsync(frameworks, CancellationToken.None); + Assert.Equal("net9.0", result); } } diff --git a/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj b/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj index 0b9bf77c0747..b62eaf549aae 100644 --- a/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj +++ b/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj @@ -23,6 +23,7 @@ + From a06943fd19a27614b2276dd9ab23cb69c5799711 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 19 Mar 2026 16:24:35 -0500 Subject: [PATCH 2/4] Make TargetFrameworkSelectionPrompt abstract, move Spectre.Console to dotnet-watch Address review feedback: make TargetFrameworkSelectionPrompt an abstract class in the Watch library (no Spectre dependency) with a derived SpectreTargetFrameworkSelectionPrompt in dotnet-watch. This avoids adding the Spectre.Console dependency to the Watch library, which is also used by Aspire as a headless server. Also make the targetFrameworkSelectionPrompt constructor parameter non-optional, passing null explicitly where needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Server/AspireWatcherLauncher.cs | 2 +- .../Watch/HotReload/HotReloadDotNetWatcher.cs | 2 +- .../Microsoft.DotNet.HotReload.Watch.csproj | 1 - .../UI/TargetFrameworkSelectionPrompt.cs | 17 ++++---------- src/Dotnet.Watch/dotnet-watch/Program.cs | 2 +- .../SpectreTargetFrameworkSelectionPrompt.cs | 22 +++++++++++++++++++ .../dotnet-watch/dotnet-watch.csproj | 1 + .../HotReload/BuildProjectsTests.cs | 2 +- .../TargetFrameworkSelectionPromptTests.cs | 8 +++---- .../TestUtilities/DotNetWatchTestBase.cs | 2 +- 10 files changed, 36 insertions(+), 23 deletions(-) create mode 100644 src/Dotnet.Watch/dotnet-watch/Watch/SpectreTargetFrameworkSelectionPrompt.cs diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/AspireWatcherLauncher.cs b/src/Dotnet.Watch/Watch.Aspire/Server/AspireWatcherLauncher.cs index abeac2e71a3e..e95e07914929 100644 --- a/src/Dotnet.Watch/Watch.Aspire/Server/AspireWatcherLauncher.cs +++ b/src/Dotnet.Watch/Watch.Aspire/Server/AspireWatcherLauncher.cs @@ -40,7 +40,7 @@ protected async Task LaunchWatcherAsync( try { - var watcher = new HotReloadDotNetWatcher(context, Console, processLauncherFactory); + var watcher = new HotReloadDotNetWatcher(context, Console, processLauncherFactory, targetFrameworkSelectionPrompt: null); await watcher.WatchAsync(cancellationSource.Token); } catch (OperationCanceledException) when (shutdownHandler.CancellationToken.IsCancellationRequested) diff --git a/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs index 810376fcc992..9ac8aacf40c7 100644 --- a/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs @@ -25,7 +25,7 @@ internal sealed class HotReloadDotNetWatcher internal Task? Test_FileChangesCompletedTask { get; set; } - public HotReloadDotNetWatcher(DotNetWatchContext context, IConsole console, IRuntimeProcessLauncherFactory? runtimeProcessLauncherFactory, TargetFrameworkSelectionPrompt? targetFrameworkSelectionPrompt = null) + public HotReloadDotNetWatcher(DotNetWatchContext context, IConsole console, IRuntimeProcessLauncherFactory? runtimeProcessLauncherFactory, TargetFrameworkSelectionPrompt? targetFrameworkSelectionPrompt) { _context = context; _console = console; diff --git a/src/Dotnet.Watch/Watch/Microsoft.DotNet.HotReload.Watch.csproj b/src/Dotnet.Watch/Watch/Microsoft.DotNet.HotReload.Watch.csproj index 70e98e6e1612..5b46ae0b06e7 100644 --- a/src/Dotnet.Watch/Watch/Microsoft.DotNet.HotReload.Watch.csproj +++ b/src/Dotnet.Watch/Watch/Microsoft.DotNet.HotReload.Watch.csproj @@ -32,7 +32,6 @@ - diff --git a/src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs b/src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs index 8e86e5de1f4e..45b720020029 100644 --- a/src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs +++ b/src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs @@ -1,11 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Spectre.Console; - namespace Microsoft.DotNet.Watch; -internal sealed class TargetFrameworkSelectionPrompt(string title, string moreChoicesText, string searchPlaceholderText, IAnsiConsole? console = null) +internal abstract class TargetFrameworkSelectionPrompt { public IReadOnlyList? PreviousTargetFrameworks { get; set; } public string? PreviousSelection { get; set; } @@ -20,16 +18,9 @@ public async ValueTask SelectAsync(IReadOnlyList targetFramework } PreviousTargetFrameworks = orderedTargetFrameworks; - - var prompt = new SelectionPrompt() - .Title($"[cyan]{Markup.Escape(title)}[/]") - .PageSize(10) - .MoreChoicesText($"[gray]({Markup.Escape(moreChoicesText)})[/]") - .AddChoices(targetFrameworks) - .EnableSearch() - .SearchPlaceholderText(searchPlaceholderText); - - PreviousSelection = await prompt.ShowAsync(console ?? AnsiConsole.Console, cancellationToken); + PreviousSelection = await PromptAsync(targetFrameworks, cancellationToken); return PreviousSelection; } + + protected abstract Task PromptAsync(IReadOnlyList targetFrameworks, CancellationToken cancellationToken); } diff --git a/src/Dotnet.Watch/dotnet-watch/Program.cs b/src/Dotnet.Watch/dotnet-watch/Program.cs index 92f9914278aa..0cc94443297d 100644 --- a/src/Dotnet.Watch/dotnet-watch/Program.cs +++ b/src/Dotnet.Watch/dotnet-watch/Program.cs @@ -266,7 +266,7 @@ internal async Task RunAsync() if (IsHotReloadEnabled()) { var tfmPrompt = context.Options.NonInteractive ? null - : new TargetFrameworkSelectionPrompt(Resources.SelectTargetFrameworkPrompt, Resources.MoreFrameworksText, Resources.SearchPlaceholderText); + : new SpectreTargetFrameworkSelectionPrompt(); var watcher = new HotReloadDotNetWatcher(context, console, runtimeProcessLauncherFactory: null, tfmPrompt); await watcher.WatchAsync(shutdownHandler.CancellationToken); } diff --git a/src/Dotnet.Watch/dotnet-watch/Watch/SpectreTargetFrameworkSelectionPrompt.cs b/src/Dotnet.Watch/dotnet-watch/Watch/SpectreTargetFrameworkSelectionPrompt.cs new file mode 100644 index 000000000000..fd896e0f4b0a --- /dev/null +++ b/src/Dotnet.Watch/dotnet-watch/Watch/SpectreTargetFrameworkSelectionPrompt.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Spectre.Console; + +namespace Microsoft.DotNet.Watch; + +internal sealed class SpectreTargetFrameworkSelectionPrompt(IAnsiConsole? console = null) : TargetFrameworkSelectionPrompt +{ + protected override Task PromptAsync(IReadOnlyList targetFrameworks, CancellationToken cancellationToken) + { + var prompt = new SelectionPrompt() + .Title($"[cyan]{Markup.Escape(Resources.SelectTargetFrameworkPrompt)}[/]") + .PageSize(10) + .MoreChoicesText($"[gray]({Markup.Escape(Resources.MoreFrameworksText)})[/]") + .AddChoices(targetFrameworks) + .EnableSearch() + .SearchPlaceholderText(Resources.SearchPlaceholderText); + + return prompt.ShowAsync(console ?? AnsiConsole.Console, cancellationToken); + } +} diff --git a/src/Dotnet.Watch/dotnet-watch/dotnet-watch.csproj b/src/Dotnet.Watch/dotnet-watch/dotnet-watch.csproj index 5d5968b4f949..ccde55be8567 100644 --- a/src/Dotnet.Watch/dotnet-watch/dotnet-watch.csproj +++ b/src/Dotnet.Watch/dotnet-watch/dotnet-watch.csproj @@ -30,6 +30,7 @@ + diff --git a/test/dotnet-watch.Tests/HotReload/BuildProjectsTests.cs b/test/dotnet-watch.Tests/HotReload/BuildProjectsTests.cs index 2d8fa5ebc422..3f73a014018a 100644 --- a/test/dotnet-watch.Tests/HotReload/BuildProjectsTests.cs +++ b/test/dotnet-watch.Tests/HotReload/BuildProjectsTests.cs @@ -50,7 +50,7 @@ public TestContext(ITestOutputHelper output, ImmutableArray Date: Thu, 19 Mar 2026 16:32:16 -0500 Subject: [PATCH 3/4] Update MauiBlazor test to use Spectre.Console search Type the target framework name via stdin to search and select it, replacing the old numeric key approach. Add KeyPressedAnsiConsole/KeyPressedInput to bridge IConsole.KeyPressed events to Spectre.Console's IAnsiConsoleInput when stdin is redirected. This lets PhysicalConsole.ListenToStandardInputAsync remain the single stdin reader, with Spectre receiving keys through the same pipeline. Also extend PhysicalConsole's stdin key mapping to handle '.', '-', Enter, and use ConsoleKey.D0-D9 (matching Spectre's expectations). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Dotnet.Watch/Watch/UI/PhysicalConsole.cs | 3 + .../UI/TargetFrameworkSelectionPrompt.cs | 4 +- src/Dotnet.Watch/dotnet-watch/Program.cs | 4 +- .../SpectreTargetFrameworkSelectionPrompt.cs | 83 ++++++++++++++++++- .../HotReload/MauiHotReloadTests.cs | 10 ++- 5 files changed, 97 insertions(+), 7 deletions(-) diff --git a/src/Dotnet.Watch/Watch/UI/PhysicalConsole.cs b/src/Dotnet.Watch/Watch/UI/PhysicalConsole.cs index c835611b5d72..e499829d46d0 100644 --- a/src/Dotnet.Watch/Watch/UI/PhysicalConsole.cs +++ b/src/Dotnet.Watch/Watch/UI/PhysicalConsole.cs @@ -62,9 +62,12 @@ private async Task ListenToStandardInputAsync() { CtrlC => new ConsoleKeyInfo('C', ConsoleKey.C, shift: false, alt: false, control: true), CtrlR => new ConsoleKeyInfo('R', ConsoleKey.R, shift: false, alt: false, control: true), + '\r' or '\n' => new ConsoleKeyInfo('\r', ConsoleKey.Enter, shift: false, alt: false, control: false), >= 'a' and <= 'z' => new ConsoleKeyInfo(c, ConsoleKey.A + (c - 'a'), shift: false, alt: false, control: false), >= 'A' and <= 'Z' => new ConsoleKeyInfo(c, ConsoleKey.A + (c - 'A'), shift: true, alt: false, control: false), >= '0' and <= '9' => new ConsoleKeyInfo(c, ConsoleKey.NumPad0 + (c - '0'), shift: false, alt: false, control: false), + '.' => new ConsoleKeyInfo('.', ConsoleKey.OemPeriod, shift: false, alt: false, control: false), + '-' => new ConsoleKeyInfo('-', ConsoleKey.OemMinus, shift: false, alt: false, control: false), _ => default }; diff --git a/src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs b/src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs index 45b720020029..1d7a801ce347 100644 --- a/src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs +++ b/src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs @@ -3,7 +3,7 @@ namespace Microsoft.DotNet.Watch; -internal abstract class TargetFrameworkSelectionPrompt +internal abstract class TargetFrameworkSelectionPrompt : IDisposable { public IReadOnlyList? PreviousTargetFrameworks { get; set; } public string? PreviousSelection { get; set; } @@ -23,4 +23,6 @@ public async ValueTask SelectAsync(IReadOnlyList targetFramework } protected abstract Task PromptAsync(IReadOnlyList targetFrameworks, CancellationToken cancellationToken); + + public virtual void Dispose() { } } diff --git a/src/Dotnet.Watch/dotnet-watch/Program.cs b/src/Dotnet.Watch/dotnet-watch/Program.cs index 0cc94443297d..9dc9b9c41d84 100644 --- a/src/Dotnet.Watch/dotnet-watch/Program.cs +++ b/src/Dotnet.Watch/dotnet-watch/Program.cs @@ -265,8 +265,8 @@ internal async Task RunAsync() if (IsHotReloadEnabled()) { - var tfmPrompt = context.Options.NonInteractive ? null - : new SpectreTargetFrameworkSelectionPrompt(); + using var tfmPrompt = context.Options.NonInteractive ? null + : new SpectreTargetFrameworkSelectionPrompt(console); var watcher = new HotReloadDotNetWatcher(context, console, runtimeProcessLauncherFactory: null, tfmPrompt); await watcher.WatchAsync(shutdownHandler.CancellationToken); } diff --git a/src/Dotnet.Watch/dotnet-watch/Watch/SpectreTargetFrameworkSelectionPrompt.cs b/src/Dotnet.Watch/dotnet-watch/Watch/SpectreTargetFrameworkSelectionPrompt.cs index fd896e0f4b0a..6d68048d8413 100644 --- a/src/Dotnet.Watch/dotnet-watch/Watch/SpectreTargetFrameworkSelectionPrompt.cs +++ b/src/Dotnet.Watch/dotnet-watch/Watch/SpectreTargetFrameworkSelectionPrompt.cs @@ -1,12 +1,21 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Threading.Channels; using Spectre.Console; namespace Microsoft.DotNet.Watch; -internal sealed class SpectreTargetFrameworkSelectionPrompt(IAnsiConsole? console = null) : TargetFrameworkSelectionPrompt +internal sealed class SpectreTargetFrameworkSelectionPrompt(IAnsiConsole console) : TargetFrameworkSelectionPrompt { + public SpectreTargetFrameworkSelectionPrompt(IConsole watchConsole) + : this(CreateConsole(watchConsole)) + { + } + + public override void Dispose() + => (console as IDisposable)?.Dispose(); + protected override Task PromptAsync(IReadOnlyList targetFrameworks, CancellationToken cancellationToken) { var prompt = new SelectionPrompt() @@ -17,6 +26,76 @@ protected override Task PromptAsync(IReadOnlyList targetFramewor .EnableSearch() .SearchPlaceholderText(Resources.SearchPlaceholderText); - return prompt.ShowAsync(console ?? AnsiConsole.Console, cancellationToken); + return prompt.ShowAsync(console, cancellationToken); + } + + private static IAnsiConsole CreateConsole(IConsole watchConsole) + { + if (!Console.IsInputRedirected) + { + return AnsiConsole.Console; + } + + // When stdin is redirected (e.g. in integration tests), Spectre.Console detects + // non-interactive mode and refuses to prompt. Create a console with forced + // interactivity that reads keys from IConsole.KeyPressed (fed by + // PhysicalConsole.ListenToStandardInputAsync). + var ansiConsole = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + Interactive = InteractionSupport.Yes, + }); + ansiConsole.Profile.Capabilities.Interactive = true; + ansiConsole.Profile.Capabilities.Ansi = true; + return new KeyPressedAnsiConsole(ansiConsole, watchConsole); + } + + /// + /// Wraps an to read input from events + /// instead of . + /// + private sealed class KeyPressedAnsiConsole(IAnsiConsole inner, IConsole watchConsole) : IAnsiConsole, IDisposable + { + private readonly KeyPressedInput _input = new(watchConsole); + + public Profile Profile => inner.Profile; + public IAnsiConsoleCursor Cursor => inner.Cursor; + public IAnsiConsoleInput Input => _input; + public IExclusivityMode ExclusivityMode => inner.ExclusivityMode; + public Spectre.Console.Rendering.RenderPipeline Pipeline => inner.Pipeline; + public void Clear(bool home) => inner.Clear(home); + public void Write(Spectre.Console.Rendering.IRenderable renderable) => inner.Write(renderable); + public void Dispose() => _input.Dispose(); + } + + /// + /// Bridges events to Spectre.Console's + /// using a channel. + /// + private sealed class KeyPressedInput : IAnsiConsoleInput, IDisposable + { + private readonly Channel _channel = Channel.CreateUnbounded(); + private readonly IConsole _console; + + public KeyPressedInput(IConsole console) + { + _console = console; + _console.KeyPressed += OnKeyPressed; + } + + private void OnKeyPressed(ConsoleKeyInfo key) + => _channel.Writer.TryWrite(key); + + public bool IsKeyAvailable() + => _channel.Reader.TryPeek(out _); + + public ConsoleKeyInfo? ReadKey(bool intercept) + => ReadKeyAsync(intercept, CancellationToken.None).GetAwaiter().GetResult(); + + public async Task ReadKeyAsync(bool intercept, CancellationToken cancellationToken) + => await _channel.Reader.ReadAsync(cancellationToken); + + public void Dispose() + => _console.KeyPressed -= OnKeyPressed; } } diff --git a/test/dotnet-watch.Tests/HotReload/MauiHotReloadTests.cs b/test/dotnet-watch.Tests/HotReload/MauiHotReloadTests.cs index 129dbb6bb11c..d4c3c12ed2c6 100644 --- a/test/dotnet-watch.Tests/HotReload/MauiHotReloadTests.cs +++ b/test/dotnet-watch.Tests/HotReload/MauiHotReloadTests.cs @@ -32,8 +32,14 @@ public async Task MauiBlazor(bool selectTfm) if (selectTfm) { - await App.WaitUntilOutputContains("❔ Select target framework"); - App.SendKey(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? '2' : '1'); + await App.WaitUntilOutputContains(Resources.SelectTargetFrameworkPrompt); + + // Type the target framework to search and select it via Spectre.Console's search + foreach (var c in tfm) + { + App.SendKey(c); + } + App.SendKey('\r'); } await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); From 210dc166380ec704d61a2be3328facbc1d214b15 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 20 Mar 2026 14:05:23 -0500 Subject: [PATCH 4/4] Move SpectreTargetFrameworkSelectionPrompt to dotnet-watch/UI The Watch directory under dotnet-watch is for legacy --no-hot-reload mode. Move the Spectre prompt implementation to a UI directory instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../{Watch => UI}/SpectreTargetFrameworkSelectionPrompt.cs | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/Dotnet.Watch/dotnet-watch/{Watch => UI}/SpectreTargetFrameworkSelectionPrompt.cs (100%) diff --git a/src/Dotnet.Watch/dotnet-watch/Watch/SpectreTargetFrameworkSelectionPrompt.cs b/src/Dotnet.Watch/dotnet-watch/UI/SpectreTargetFrameworkSelectionPrompt.cs similarity index 100% rename from src/Dotnet.Watch/dotnet-watch/Watch/SpectreTargetFrameworkSelectionPrompt.cs rename to src/Dotnet.Watch/dotnet-watch/UI/SpectreTargetFrameworkSelectionPrompt.cs