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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
<PackageVersion Include="runtime.linux-x64.Microsoft.NETCore.DotNetHostResolver" Version="$(MicrosoftNETCoreDotNetHostResolverPackageVersion)" />
<PackageVersion Include="runtime.osx-x64.Microsoft.NETCore.DotNetHostResolver" Version="$(MicrosoftNETCoreDotNetHostResolverPackageVersion)" />
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
<PackageVersion Include="Spectre.Console.Testing" Version="0.54.0" />
<PackageVersion Include="StyleCop.Analyzers" Version="$(StyleCopAnalyzersPackageVersion)" />
<PackageVersion Include="System.CodeDom" Version="$(SystemCodeDomPackageVersion)" />
<PackageVersion Include="System.CommandLine" Version="$(SystemCommandLineVersion)" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ protected async Task<int> 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)
Expand Down
4 changes: 2 additions & 2 deletions src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions src/Dotnet.Watch/Watch/UI/PhysicalConsole.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

Expand Down
27 changes: 8 additions & 19 deletions src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs
Original file line number Diff line number Diff line change
@@ -1,39 +1,28 @@
// 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<string>? PreviousTargetFrameworks { get; set; }
public string? PreviousSelection { get; set; }

public async ValueTask<string> SelectAsync(IReadOnlyList<string> targetFrameworks, CancellationToken cancellationToken)
{
var orderedTargetFrameworks = targetFrameworks.Order().ToArray();
var orderedTargetFrameworks = targetFrameworks.Order(StringComparer.OrdinalIgnoreCase).ToArray();

if (PreviousSelection != null && PreviousTargetFrameworks?.SequenceEqual(orderedTargetFrameworks, StringComparer.OrdinalIgnoreCase) == true)
{
return PreviousSelection;
}

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<string> PromptAsync(IReadOnlyList<string> targetFrameworks, CancellationToken cancellationToken);

bool AcceptKey(ConsoleKeyInfo info)
=> info is { Modifiers: ConsoleModifiers.None } && TryGetIndex(info, out _);
}
public virtual void Dispose() { }
}
4 changes: 3 additions & 1 deletion src/Dotnet.Watch/dotnet-watch/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,9 @@ internal async Task<int> 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)
Expand Down
9 changes: 9 additions & 0 deletions src/Dotnet.Watch/dotnet-watch/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,15 @@
<value>Warning NETSDK1174: The abbreviation of -p for --project is deprecated. Please use --project.</value>
<comment>{Locked="-p"}{Locked="--project"}</comment>
</data>
<data name="SelectTargetFrameworkPrompt" xml:space="preserve">
<value>Select the target framework to run:</value>
</data>
<data name="MoreFrameworksText" xml:space="preserve">
<value>Move up and down to reveal more frameworks</value>
</data>
<data name="SearchPlaceholderText" xml:space="preserve">
<value>Type to search</value>
</data>
<data name="Help_NonInteractive" xml:space="preserve">
<value>
Runs dotnet-watch in non-interactive mode. This option is only supported when running with Hot Reload enabled.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string> PromptAsync(IReadOnlyList<string> targetFrameworks, CancellationToken cancellationToken)
{
var prompt = new SelectionPrompt<string>()
.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);
}

/// <summary>
/// Wraps an <see cref="IAnsiConsole"/> to read input from <see cref="IConsole.KeyPressed"/> events
/// instead of <see cref="System.Console.ReadKey"/>.
/// </summary>
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();
}

/// <summary>
/// Bridges <see cref="IConsole.KeyPressed"/> events to Spectre.Console's
/// <see cref="IAnsiConsoleInput"/> using a channel.
/// </summary>
private sealed class KeyPressedInput : IAnsiConsoleInput, IDisposable
{
private readonly Channel<ConsoleKeyInfo> _channel = Channel.CreateUnbounded<ConsoleKeyInfo>();
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<ConsoleKeyInfo?> ReadKeyAsync(bool intercept, CancellationToken cancellationToken)
=> await _channel.Reader.ReadAsync(cancellationToken);

public void Dispose()
=> _console.KeyPressed -= OnKeyPressed;
}
}
1 change: 1 addition & 0 deletions src/Dotnet.Watch/dotnet-watch/dotnet-watch.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Build.Locator" />
<PackageReference Include="Microsoft.Build" ExcludeAssets="runtime" PrivateAssets="All" />
<PackageReference Include="Spectre.Console" />
</ItemGroup>

<ItemGroup>
Expand Down
15 changes: 15 additions & 0 deletions src/Dotnet.Watch/dotnet-watch/xlf/Resources.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions src/Dotnet.Watch/dotnet-watch/xlf/Resources.de.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions src/Dotnet.Watch/dotnet-watch/xlf/Resources.es.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions src/Dotnet.Watch/dotnet-watch/xlf/Resources.fr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions src/Dotnet.Watch/dotnet-watch/xlf/Resources.it.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions src/Dotnet.Watch/dotnet-watch/xlf/Resources.ja.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions src/Dotnet.Watch/dotnet-watch/xlf/Resources.ko.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading