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 +