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.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 ba1191e5cce1..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)
+ public HotReloadDotNetWatcher(DotNetWatchContext context, IConsole console, IRuntimeProcessLauncherFactory? runtimeProcessLauncherFactory, TargetFrameworkSelectionPrompt? targetFrameworkSelectionPrompt)
{
_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/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 fd6d17ac4d4f..1d7a801ce347 100644
--- a/src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs
+++ b/src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs
@@ -1,16 +1,16 @@
-// 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.
namespace Microsoft.DotNet.Watch;
-internal sealed class TargetFrameworkSelectionPrompt(ConsoleInputReader inputReader)
+internal abstract class TargetFrameworkSelectionPrompt : IDisposable
{
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)
{
@@ -18,22 +18,11 @@ public async ValueTask SelectAsync(IReadOnlyList targetFramework
}
PreviousTargetFrameworks = orderedTargetFrameworks;
+ PreviousSelection = await PromptAsync(targetFrameworks, cancellationToken);
+ return PreviousSelection;
+ }
- 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;
- }
+ protected abstract Task PromptAsync(IReadOnlyList targetFrameworks, CancellationToken cancellationToken);
- bool AcceptKey(ConsoleKeyInfo info)
- => info is { Modifiers: ConsoleModifiers.None } && TryGetIndex(info, out _);
- }
+ public virtual void Dispose() { }
}
diff --git a/src/Dotnet.Watch/dotnet-watch/Program.cs b/src/Dotnet.Watch/dotnet-watch/Program.cs
index 1b25994261e3..9dc9b9c41d84 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);
+ using var tfmPrompt = context.Options.NonInteractive ? null
+ : new SpectreTargetFrameworkSelectionPrompt(console);
+ 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/UI/SpectreTargetFrameworkSelectionPrompt.cs b/src/Dotnet.Watch/dotnet-watch/UI/SpectreTargetFrameworkSelectionPrompt.cs
new file mode 100644
index 000000000000..6d68048d8413
--- /dev/null
+++ b/src/Dotnet.Watch/dotnet-watch/UI/SpectreTargetFrameworkSelectionPrompt.cs
@@ -0,0 +1,101 @@
+// 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) : 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()
+ .Title($"[cyan]{Markup.Escape(Resources.SelectTargetFrameworkPrompt)}[/]")
+ .PageSize(10)
+ .MoreChoicesText($"[gray]({Markup.Escape(Resources.MoreFrameworksText)})[/]")
+ .AddChoices(targetFrameworks)
+ .EnableSearch()
+ .SearchPlaceholderText(Resources.SearchPlaceholderText);
+
+ 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/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/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/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
+