File: Components\Controls\TerminalTitle.razor.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// 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;
using Aspire.Dashboard.Extensions;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Localization;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
 
namespace Aspire.Dashboard.Components.Controls;
 
/// <summary>Displays the workload title, directory and progress without changing terminal state.</summary>
public partial class TerminalTitle : IAsyncDisposable
{
    private static readonly Icon s_terminalIcon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size20.WindowConsole();
    private readonly string _titleButtonId = $"terminal-title-{Guid.NewGuid():N}";
    private readonly string _directoryButtonId = $"terminal-directory-{Guid.NewGuid():N}";
    private ElementReference _metadataElement;
    private IJSObjectReference? _jsModule;
    private Task? _initializationTask;
    private bool _disposed;
 
    /// <summary>Gets or sets the terminal's latest presentation state.</summary>
    [Parameter]
    public TerminalToolbarState? State { get; set; }
 
    /// <summary>Gets or sets the title used before the workload reports one or after it clears it.</summary>
    [Parameter]
    public string FallbackTitle { get; set; } = string.Empty;
 
    /// <summary>Gets or sets the resource icon shown when progress is inactive; defaults to the terminal icon.</summary>
    [Parameter]
    public Icon? IdleIcon { get; set; }
 
    [Inject]
    public required IStringLocalizer<Resources.TerminalStrings> Loc { get; init; }
 
    [Inject]
    public required IStringLocalizer<Resources.ControlsStrings> ControlsLoc { get; init; }
 
    [Inject]
    public required IJSRuntime JS { get; init; }
 
    [Inject]
    public required ILogger<TerminalTitle> Logger { get; init; }
 
    protected override Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _initializationTask = InitializeAsync();
        }
 
        return _initializationTask ?? Task.CompletedTask;
    }
 
    private async Task InitializeAsync()
    {
        try
        {
            _jsModule = await JS.InvokeAsync<IJSObjectReference>("import", $"./{Assets["Components/Controls/TerminalTitle.razor.js"]}");
            if (!_disposed)
            {
                await _jsModule.InvokeVoidAsync("observePath", _metadataElement);
            }
        }
        catch (JSDisconnectedException)
        {
            // The circuit is gone; there is no metadata observer to keep alive.
        }
        catch (OperationCanceledException) when (_disposed)
        {
            // Disposal can interrupt the optional observer setup.
        }
        catch (JSException ex)
        {
            Logger.LogWarning(ex, "Could not initialize terminal path measurement.");
        }
    }
 
    /// <summary>Releases the path measurement observers.</summary>
    public async ValueTask DisposeAsync()
    {
        _disposed = true;
        try
        {
            if (_initializationTask is not null)
            {
                await _initializationTask;
            }
            if (_jsModule is not null)
            {
                await _jsModule.InvokeVoidAsync("disconnectPath", _metadataElement);
            }
        }
        catch (JSDisconnectedException)
        {
            // The browser may already be gone when the component is disposed.
        }
        catch (OperationCanceledException)
        {
            // The browser may already be gone when the component is disposed.
        }
        finally
        {
            if (_jsModule is not null)
            {
                await JSInteropHelpers.SafeDisposeAsync(_jsModule);
            }
        }
    }
 
    private Dictionary<string, object> TitleCopyAttributes =>
        GetCopyAttributes(DisplayTitle, nameof(Resources.TerminalStrings.TerminalCopyTitle));
 
    private Dictionary<string, object> DirectoryCopyAttributes =>
        GetCopyAttributes(State?.WorkingDirectory ?? string.Empty, nameof(Resources.TerminalStrings.TerminalCopyWorkingDirectory));
 
    private Dictionary<string, object> GetCopyAttributes(string value, string labelResourceName) => FluentUIExtensions.GetClipboardCopyAdditionalAttributes(
        value,
        ControlsLoc[nameof(Resources.ControlsStrings.GridValueCopyToClipboard)],
        ControlsLoc[nameof(Resources.ControlsStrings.GridValueCopied)],
        ("data-copyfailed", Loc[nameof(Resources.TerminalStrings.TerminalCopyFailed)].Value),
        ("aria-label", Loc[labelResourceName, value].Value));
 
    private string DisplayTitle => State is { Title.Length: > 0 } ? State.Title : FallbackTitle;
 
    private double? ProgressValue => State?.ProgressState is "indeterminate" ? null : State?.ProgressPercentage;
 
    private bool HasProgress => State is { Connected: true, ProgressState: "normal" or "indeterminate" or "error" or "warning" };
 
    private string ProgressTooltip => ProgressValue is { } percentage ? (percentage / 100).ToString("P0", CultureInfo.CurrentCulture) : ProgressLabel;
 
    private string ProgressLabel => Loc[State?.ProgressState switch
    {
        "error" => nameof(Resources.TerminalStrings.TerminalProgressError),
        "warning" => nameof(Resources.TerminalStrings.TerminalProgressWarning),
        _ => nameof(Resources.TerminalStrings.TerminalProgress)
    }];
}