| File: Commands\Test\MTP\Terminal\AnsiTerminal.cs | Web Access |
| Project: src\sdk\src\Cli\dotnet\dotnet.csproj (dotnet) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. 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); _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(); } }