|
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using System.Globalization;
namespace Microsoft.DotNet.Cli.Commands.Test.Terminal;
/// <summary>
/// Terminal writer that is used when writing ANSI is allowed. It is capable of batching as many updates as possible and writing them at the end,
/// because the terminal is responsible for rendering the colors and control codes.
/// </summary>
internal sealed class AnsiTerminal(IConsole console, string? baseDirectory) : ITerminal
{
/// <summary>
/// File extensions that we will link to directly, all other files
/// are linked to their directory, to avoid opening dlls, or executables.
/// </summary>
private static readonly string[] KnownFileExtensions =
[
// code files
".cs",
".vb",
".fs",
// logs
".log",
".txt",
// reports
".coverage",
".ctrf",
".html",
".junit",
".nunit",
".trx",
".xml",
".xunit",
];
private readonly IConsole _console = console;
private readonly string? _baseDirectory = baseDirectory ?? Directory.GetCurrentDirectory();
// Output ansi code to get spinner on top of a terminal, to indicate in-progress task.
// https://github.com/dotnet/msbuild/issues/8958: iTerm2 treats ;9 code to post a notification instead, so disable progress reporting on Mac.
private readonly bool _useBusyIndicator = !RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
private readonly StringBuilder _stringBuilder = new();
private bool _isBatching;
private AnsiTerminalTestProgressFrame _currentFrame = new(0, 0);
public int Width
=> _console.IsOutputRedirected ? int.MaxValue : _console.BufferWidth;
public int Height
=> _console.IsOutputRedirected ? int.MaxValue : _console.BufferHeight;
public void Append(char value)
{
if (_isBatching)
{
_stringBuilder.Append(value);
}
else
{
_console.Write(value);
}
}
public void Append(string value)
{
if (_isBatching)
{
_stringBuilder.Append(value);
}
else
{
_console.Write(value);
}
}
public void AppendLine()
{
if (_isBatching)
{
_stringBuilder.AppendLine();
}
else
{
_console.WriteLine();
}
}
public void AppendLine(string value)
{
if (_isBatching)
{
_stringBuilder.AppendLine(value);
}
else
{
_console.WriteLine(value);
}
}
public void SetColor(TerminalColor color)
{
string setColor = $"{AnsiCodes.CSI}{(int)color}{AnsiCodes.SetColor}";
if (_isBatching)
{
_stringBuilder.Append(setColor);
}
else
{
_console.Write(setColor);
}
}
public void ResetColor()
{
string resetColor = AnsiCodes.SetDefaultColor;
if (_isBatching)
{
_stringBuilder.Append(resetColor);
}
else
{
_console.Write(resetColor);
}
}
public void ShowCursor()
{
if (_isBatching)
{
_stringBuilder.Append(AnsiCodes.ShowCursor);
}
else
{
_console.Write(AnsiCodes.ShowCursor);
}
}
public void HideCursor()
{
if (_isBatching)
{
_stringBuilder.Append(AnsiCodes.HideCursor);
}
else
{
_console.Write(AnsiCodes.HideCursor);
}
}
public void StartUpdate()
{
if (_isBatching)
{
throw new InvalidOperationException(CliCommandStrings.ConsoleIsAlreadyInBatchingMode);
}
_stringBuilder.Clear();
_isBatching = true;
}
public void StopUpdate()
{
_console.Write(_stringBuilder.ToString());
_isBatching = false;
}
public void AppendLink(string? path, int? lineNumber)
{
if (string.IsNullOrWhiteSpace(path))
{
return;
}
// For non code files, point to the directory, so we don't end up running the
// exe by clicking at the link.
string? extension = Path.GetExtension(path);
bool linkToFile = !string.IsNullOrWhiteSpace(extension) && KnownFileExtensions.Contains(extension);
bool knownNonExistingFile = path.StartsWith("/_/", ignoreCase: false, CultureInfo.CurrentCulture);
string linkPath = path;
if (!linkToFile)
{
try
{
linkPath = Path.GetDirectoryName(linkPath) ?? linkPath;
}
catch
{
// Ignore all GetDirectoryName errors.
}
}
// If the output path is under the initial working directory, make the console output relative to that to save space.
if (_baseDirectory != null && path.StartsWith(_baseDirectory, FileUtilities.PathComparison))
{
if (path.Length > _baseDirectory.Length
&& (path[_baseDirectory.Length] == Path.DirectorySeparatorChar
|| path[_baseDirectory.Length] == Path.AltDirectorySeparatorChar))
{
path = path[(_baseDirectory.Length + 1)..];
}
}
if (lineNumber != null)
{
path += $":{lineNumber}";
}
if (knownNonExistingFile)
{
Append(path);
return;
}
// Generates file:// schema url string which is better handled by various Terminal clients than raw folder name.
if (Uri.TryCreate(linkPath, UriKind.Absolute, out Uri? uri))
{
// url.ToString() un-escapes the URL which is needed for our case file://
linkPath = uri.ToString();
}
SetColor(TerminalColor.DarkGray);
Append(AnsiCodes.LinkPrefix);
Append(linkPath);
Append(AnsiCodes.LinkInfix);
Append(path);
Append(AnsiCodes.LinkSuffix);
ResetColor();
}
public void MoveCursorUp(int lineCount)
{
string moveCursor = $"{AnsiCodes.CSI}{lineCount}{AnsiCodes.MoveUpToLineStart}";
if (_isBatching)
{
_stringBuilder.AppendLine(moveCursor);
}
else
{
_console.WriteLine(moveCursor);
}
}
public void SetCursorHorizontal(int position)
{
string setCursor = AnsiCodes.SetCursorHorizontal(position);
if (_isBatching)
{
_stringBuilder.Append(setCursor);
}
else
{
_console.Write(setCursor);
}
}
/// <summary>
/// Erases the previously printed live node output.
/// </summary>
public void EraseProgress()
{
if (_currentFrame.RenderedLines == null || _currentFrame.RenderedLines.Count == 0)
{
return;
}
AppendLine($"{AnsiCodes.CSI}{_currentFrame.RenderedLines.Count + 2}{AnsiCodes.MoveUpToLineStart}");
Append($"{AnsiCodes.CSI}{AnsiCodes.EraseInDisplay}");
_currentFrame.Clear();
}
public void RenderProgress(TestProgressState?[] progress)
{
AnsiTerminalTestProgressFrame newFrame = new(Width, Height);
newFrame.Render(_currentFrame, progress, terminal: this);
_currentFrame = newFrame;
}
public void StartBusyIndicator()
{
if (_useBusyIndicator)
{
Append(AnsiCodes.SetBusySpinner);
}
HideCursor();
}
public void StopBusyIndicator()
{
if (_useBusyIndicator)
{
Append(AnsiCodes.RemoveBusySpinner);
}
ShowCursor();
}
}
|