File: Components\Controls\TerminalView.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 Aspire.Dashboard.Terminal;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Localization;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
 
namespace Aspire.Dashboard.Components.Controls;
 
/// <summary>
/// Renders a GPU terminal through the dashboard's HWT1 presentation endpoint.
/// </summary>
public sealed partial class TerminalView : ComponentBase, IAsyncDisposable
{
    private ElementReference _terminalElement;
    private ElementReference _selectionTemplateElement;
    private ElementReference _footerElement;
    private IJSObjectReference? _jsModule;
    private DotNetObjectReference<TerminalView>? _selfRef;
    private int _terminalId;
    private int _connectedGeneration = -1;
    private string? _connectedEndpoint;
    private bool _appliedReadOnly;
    private bool _appliedAutoFit;
    private bool _initializationFailed;
    private string? _failedEndpoint;
    private bool _disposed;
    private bool _reconciling;
    private Task? _initializationTask;
    private string? _terminalError;
    private TerminalToolbarState _state = new();
    private IReadOnlyList<TerminalSizePreset> _sizePresets = [];
    private TerminalViewSession? _viewSession;
    private string? _sessionEndpoint;
    private int _paletteResetVersion;
    private static readonly string[] s_paletteChoices = ["light", "dark"];
    private readonly string _sizeSelectId = $"terminal-size-{Guid.NewGuid():N}";
    private readonly string _paletteSelectId = $"terminal-palette-{Guid.NewGuid():N}";
 
    /// <summary>Gets or sets the unique instance name of the resource that owns the terminal.</summary>
    [Parameter]
    public string? ResourceName { get; set; }
 
    /// <summary>Gets or sets the canonical resource name for the detached window, defaulting to the instance name.</summary>
    [Parameter]
    public string? WindowResourceName { get; set; }
 
    /// <summary>Gets or sets the resource icon displayed in the titlebar when progress is inactive.</summary>
    [Parameter]
    public Icon? ResourceIcon { get; set; }
 
    /// <summary>
    /// Gets or sets an explicit endpoint path and query, overriding the resource and replica.
    /// </summary>
    [Parameter]
    public string? EndpointPathAndQuery { get; set; }
 
    /// <summary>Gets or sets whether user input is blocked while terminal output continues.</summary>
    /// <remarks>Changing this value does not reconnect or change the lifetime of the process.</remarks>
    [Parameter]
    public bool ReadOnly { get; set; }
 
    /// <summary>Gets or sets whether the terminal omits its border, titlebar and internal padding.</summary>
    /// <remarks>The dock, interaction dialog and detached windows provide their own surrounding chrome.</remarks>
    [Parameter]
    public bool Chromeless { get; set; }
 
    /// <summary>Gets or sets whether the resource terminal titlebar offers an independent window.</summary>
    /// <remarks>The Terminals page enables this. Chromeless surfaces never render this action.</remarks>
    [Parameter]
    public bool ShowOpenInWindow { get; set; }
 
    /// <summary>Gets or sets the per-surface key for page-lifetime font-size persistence.</summary>
    /// <remarks>Detached windows seed their font from the opener without sharing live font preferences.</remarks>
    [Parameter]
    public string? SizeMemoryKey { get; set; }
 
    /// <summary>Gets or sets the initial font size in CSS pixels when this surface has no remembered preference.</summary>
    /// <remarks>Null uses the terminal's default. Changing this value does not override a mounted view's font.</remarks>
    [Parameter]
    public int? InitialFontSize { get; set; }
 
    /// <summary>Gets the selected font size, or the initial preference before the first state notification.</summary>
    public int? FontSize => _state.FontPx > 0 ? _state.FontPx : InitialFontSize;
 
    /// <summary>Gets the current presentation state for host-owned terminal chrome.</summary>
    public TerminalToolbarState ToolbarState => _state;
 
    /// <summary>Gets or sets whether the footer offers fixed-resolution presets. Defaults to true.</summary>
    /// <remarks>The font stepper remains available on surfaces sized by a splitter or dialog.</remarks>
    [Parameter]
    public bool ShowDimensionsPicker { get; set; } = true;
 
    /// <summary>Gets or sets whether opening this surface fits its grid to the container while preserving font size.</summary>
    /// <remarks>Set this only for the active dock pane. Read-only views do not take resize control.</remarks>
    [Parameter]
    public bool AutoFit { get; set; }
 
    /// <summary>Raised when the terminal's metadata, role, dimensions, font or connection state changes.</summary>
    [Parameter]
    public EventCallback<TerminalToolbarState> OnToolbarStateChanged { get; set; }
 
    [Inject]
    public required IJSRuntime JS { get; init; }
 
    [Inject]
    public required NavigationManager NavigationManager { get; init; }
 
    [Inject]
    public required IStringLocalizer<Resources.TerminalStrings> Loc { get; init; }
 
    [Inject]
    public required IStringLocalizer<Resources.ControlsStrings> ControlsLoc { get; init; }
 
    [Inject]
    public required TerminalViewSessionRegistry ViewSessions { get; init; }
 
    protected override void OnParametersSet()
    {
        // Update the authoritative input gate immediately, including while initialization
        // or an earlier JS policy update is awaiting its Blazor interop round trip.
        _viewSession?.ReadOnly = ReadOnly ||
            !string.Equals(_sessionEndpoint, ResolveEndpoint(), StringComparison.Ordinal);
    }
 
    protected override Task OnAfterRenderAsync(bool firstRender) => ReconcileAsync();
 
    private async Task ReconcileAsync()
    {
        if (_disposed || _reconciling ||
            (_initializationFailed && string.Equals(_failedEndpoint, ResolveEndpoint(), StringComparison.Ordinal)))
        {
            return;
        }
        _initializationFailed = false;
 
        // Blazor can render again while interop awaits. One reconciler owns initialization, endpoint changes
        // (including removal), and input policy changes; it rereads parameters after every interop round trip.
        _reconciling = true;
        try
        {
            while (!_disposed)
            {
                var endpoint = ResolveEndpoint();
                if (!string.Equals(endpoint, _connectedEndpoint, StringComparison.Ordinal))
                {
                    await ReconnectAsync(endpoint);
                    if (_disposed || _initializationFailed)
                    {
                        return;
                    }
                    continue;
                }
 
                if (_terminalId != 0 && _appliedReadOnly != ReadOnly)
                {
                    var readOnly = ReadOnly;
                    _viewSession!.ReadOnly = readOnly;
                    await _jsModule!.InvokeVoidAsync("setReadOnly", _terminalId, readOnly);
                    _appliedReadOnly = readOnly;
                    continue;
                }
                if (_terminalId != 0 && _appliedAutoFit != AutoFit)
                {
                    var autoFit = AutoFit;
                    await _jsModule!.InvokeVoidAsync("setAutoFit", _terminalId, autoFit);
                    _appliedAutoFit = autoFit;
                    continue;
                }
                break;
            }
        }
        catch (JSDisconnectedException)
        {
            // The browser disconnected during the interop round trip.
        }
        catch (Exception)
        {
            // Keep failures local to this view without silently swallowing rendering or input-policy errors.
            ShowInitializationError();
        }
        finally
        {
            _reconciling = false;
        }
    }
 
    private string? ResolveEndpoint()
    {
        if (!string.IsNullOrEmpty(EndpointPathAndQuery))
        {
            return new Uri(new Uri(NavigationManager.BaseUri), EndpointPathAndQuery).PathAndQuery;
        }
 
        if (string.IsNullOrEmpty(ResourceName))
        {
            return null;
        }
        return new Uri(new Uri(NavigationManager.BaseUri),
            $"api/terminal?resource={Uri.EscapeDataString(ResourceName)}").PathAndQuery;
    }
 
    private Task InitializeTerminalAsync(string endpoint)
    {
        return _initializationTask = InitializeTerminalCoreAsync(endpoint);
    }
 
    private async Task InitializeTerminalCoreAsync(string endpoint)
    {
        var moduleUri = new Uri(new Uri(NavigationManager.BaseUri), Assets["Components/Controls/TerminalView.razor.js"]);
        _jsModule ??= await JS.InvokeAsync<IJSObjectReference>("import", moduleUri.PathAndQuery);
        if (_disposed)
        {
            return;
        }
 
        // Internal chrome/error updates need the callback even when the host has no subscriber.
        _selfRef ??= DotNetObjectReference.Create(this);
        _connectedGeneration = -1;
        var readOnly = ReadOnly;
        var autoFit = AutoFit;
        _terminalId = await _jsModule.InvokeAsync<int>(
            "initTerminal", _terminalElement, BuildWebSocketUrl(endpoint), _selfRef,
            new TerminalViewOptions
            {
                ReadOnly = readOnly,
                ShowDimensions = ShowDimensionsPicker,
                AutoFit = autoFit,
                SizeMemoryKey = SizeMemoryKey,
                InitialFontSize = InitialFontSize,
                Label = Loc[nameof(Resources.TerminalStrings.TerminalInputLabel)],
            }, _selectionTemplateElement, _footerElement);
        _appliedReadOnly = readOnly;
        _appliedAutoFit = autoFit;
        if (!_disposed)
        {
            _sizePresets = await GetSizePresetsAsync();
            if (!_disposed)
            {
                StateHasChanged();
            }
        }
    }
 
    /// <summary>Rebinds the view to an endpoint, or explicitly retries the current endpoint.</summary>
    /// <param name="newEndpoint">The endpoint path and query, or null to detach the view.</param>
    public async Task ReconnectAsync(string? newEndpoint)
    {
        if (_disposed)
        {
            return;
        }
 
        if (string.IsNullOrEmpty(newEndpoint))
        {
            ReleaseViewSession();
            if (_jsModule is not null && _terminalId != 0)
            {
                var id = _terminalId;
                _terminalId = 0;
                _connectedGeneration = -1;
                await _jsModule.InvokeVoidAsync("disposeTerminal", id);
            }
            _state = new();
            _terminalError = null;
            if (!_disposed)
            {
                StateHasChanged();
            }
        }
        else
        {
            // Registry identity must match Request.PathBase + Request.Path + Request.QueryString,
            // including when a caller supplied a relative explicit endpoint.
            newEndpoint = new Uri(new Uri(NavigationManager.BaseUri), newEndpoint).PathAndQuery;
            EnsureViewSession(newEndpoint);
            if (_terminalId == 0)
            {
                await InitializeTerminalAsync(newEndpoint);
            }
            else
            {
                var generation = await _jsModule!.InvokeAsync<int>(
                    "reconnectTerminal", _terminalId, BuildWebSocketUrl(newEndpoint));
                _connectedGeneration = Math.Max(_connectedGeneration, generation);
            }
        }
 
        _connectedEndpoint = newEndpoint;
    }
 
    private void EnsureViewSession(string endpoint)
    {
        if (_viewSession is not null && string.Equals(_sessionEndpoint, endpoint, StringComparison.Ordinal))
        {
            return;
        }
        ReleaseViewSession();
        _sessionEndpoint = endpoint;
        _viewSession = ViewSessions.Create(endpoint, ReadOnly);
    }
 
    private void ReleaseViewSession()
    {
        _viewSession?.Dispose();
        _viewSession = null;
        _sessionEndpoint = null;
    }
 
    /// <summary>Updates this view's chrome and forwards the current terminal state to its host.</summary>
    /// <param name="state">The generation-tagged state supplied by the JS adapter.</param>
    [JSInvokable]
    public async Task OnTerminalStateChanged(TerminalToolbarState state)
    {
        if (_disposed || (_terminalId != 0 && state.TerminalId != _terminalId) ||
            (_terminalId == 0 && _initializationTask is not { IsCompleted: false }) ||
            state.Generation < _connectedGeneration)
        {
            return;
        }
 
        _connectedGeneration = state.Generation;
        if (_state != state)
        {
            _state = state;
            if (!_initializationFailed)
            {
                _terminalError = state.Error;
            }
            StateHasChanged();
        }
        await OnToolbarStateChanged.InvokeAsync(state);
    }
 
    /// <summary>Sets the font size in automatic sizing mode, clamped to the package's public bounds.</summary>
    /// <param name="fontPx">The desired font size in CSS pixels.</param>
    public Task SetFontSizeAsync(int fontPx) => InvokeTerminalAsync("setFontSizeFromHost", fontPx);
 
    /// <summary>Selects automatic sizing or one of the terminal's fixed grid presets.</summary>
    /// <param name="sizeKey">The preset key, or <c>auto</c>.</param>
    public Task SetSizeModeAsync(string sizeKey) => InvokeTerminalAsync("setSizeModeFromHost", sizeKey);
 
    /// <summary>Fits the terminal grid to its container without changing the selected font size.</summary>
    public Task FitToContainerAsync() => InvokeTerminalAsync("fitToContainer");
 
    private async Task SetPaletteAsync(string? palette)
    {
        if (palette is null || _disposed || _jsModule is null || _terminalId == 0)
        {
            return;
        }
        try
        {
            if (!await _jsModule.InvokeAsync<bool>("setPaletteFromHost", _terminalId, palette))
            {
                // Fluent updates its browser selection before ValueChanged. Recreate only after failure
                // because assigning the unchanged saved Value doesn't restore the displayed choice.
                _paletteResetVersion++;
            }
        }
        catch (JSDisconnectedException)
        {
            // Expected when the browser leaves this page.
        }
    }
 
    private string GetPaletteLabel(string palette) => Loc[palette switch
    {
        "light" => nameof(Resources.TerminalStrings.TerminalPaletteLight),
        "dark" => nameof(Resources.TerminalStrings.TerminalPaletteDark),
        _ => throw new ArgumentException("Unknown terminal palette.", nameof(palette))
    }];
 
    private IReadOnlyList<TerminalSizePreset> DisplayedSizePresets => _state.Cols > 0 && _state.Rows > 0 &&
        !_sizePresets.Any(p => p.Value == _state.SizeKey)
        ? [new(_state.SizeKey, $"{_state.Cols}\u00d7{_state.Rows}", _state.Cols, _state.Rows), .. _sizePresets]
        : _sizePresets;
 
    /// <summary>Gets the supported grid presets from the JS adapter.</summary>
    /// <returns>The available preset values and dimensions.</returns>
    public async Task<IReadOnlyList<TerminalSizePreset>> GetSizePresetsAsync()
    {
        if (_jsModule is null)
        {
            return [];
        }
        try
        {
            return await _jsModule.InvokeAsync<TerminalSizePreset[]>("getSizePresets");
        }
        catch (JSDisconnectedException)
        {
            return [];
        }
    }
 
    /// <summary>Requests a fresh state notification even if the state has not changed.</summary>
    public Task RefreshToolbarStateAsync() => InvokeTerminalAsync("refreshToolbarState");
 
    /// <summary>Starts or refreshes a view that became visible and focuses its input without reconnecting.</summary>
    public Task RefreshLayoutAsync() => InvokeTerminalAsync("refreshLayout");
 
    private async Task InvokeTerminalAsync(string method, params object?[] arguments)
    {
        if (_disposed || _jsModule is null || _terminalId == 0)
        {
            return;
        }
        try
        {
            await _jsModule.InvokeVoidAsync(method, [_terminalId, .. arguments]);
        }
        catch (JSDisconnectedException)
        {
            // Expected when the browser leaves this page.
        }
    }
 
    private string BuildWebSocketUrl(string pathAndQuery)
    {
        var endpoint = new Uri(new Uri(NavigationManager.BaseUri), pathAndQuery);
        var scheme = endpoint.Scheme == "https" ? "wss" : "ws";
        var boundPathAndQuery = QueryHelpers.AddQueryString(endpoint.PathAndQuery, "viewId", _viewSession!.Id);
        return $"{scheme}://{endpoint.Authority}{boundPathAndQuery}";
    }
 
    private string GetErrorMessage() => Loc[_terminalError switch
    {
        "disconnected" => nameof(Resources.TerminalStrings.TerminalDisconnected),
        "input-failed" => nameof(Resources.TerminalStrings.TerminalInputFailed),
        "sizing-failed" => nameof(Resources.TerminalStrings.TerminalSizingFailed),
        "palette-failed" => nameof(Resources.TerminalStrings.TerminalPaletteSaveFailed),
        _ => nameof(Resources.TerminalStrings.TerminalMountFailed)
    }];
 
    private Task DismissErrorAsync() => InvokeTerminalAsync("dismissError");
 
    private async Task RetryAsync()
    {
        if (_reconciling || _disposed)
        {
            return;
        }
        _initializationFailed = false;
        _terminalError = null;
        _reconciling = true;
        try
        {
            await ReconnectAsync(ResolveEndpoint());
        }
        catch (JSDisconnectedException)
        {
        }
        catch (Exception)
        {
            ShowInitializationError();
        }
        finally
        {
            _reconciling = false;
        }
        await ReconcileAsync();
    }
 
    private void ShowInitializationError()
    {
        _initializationFailed = true;
        _failedEndpoint = ResolveEndpoint();
        _terminalError = "mount-failed";
        if (!_disposed)
        {
            StateHasChanged();
        }
    }
 
    public async ValueTask DisposeAsync()
    {
        if (_disposed)
        {
            return;
        }
        _disposed = true;
        ReleaseViewSession();
        // Wait for the interop result before disposing the module so a worker created during disposal isn't orphaned.
        if (_initializationTask is not null)
        {
            try
            {
                await _initializationTask;
            }
            catch (Exception)
            {
                // Initialization already surfaces its failure while the component is alive.
            }
        }
        if (_jsModule is not null)
        {
            if (_terminalId != 0)
            {
                try
                {
                    await _jsModule.InvokeVoidAsync("disposeTerminal", _terminalId);
                }
                catch (JSDisconnectedException)
                {
                }
            }
            await JSInteropHelpers.SafeDisposeAsync(_jsModule);
            _jsModule = null;
        }
        _terminalId = 0;
        _selfRef?.Dispose();
        _selfRef = null;
    }
}
 
/// <summary>Options serialized to the JS adapter using camelCase property names.</summary>
public sealed record TerminalViewOptions
{
    /// <summary>Whether application input is blocked.</summary>
    public bool ReadOnly { get; init; }
    /// <summary>Whether fixed-resolution presets are offered.</summary>
    public bool ShowDimensions { get; init; } = true;
    /// <summary>Whether opening the active surface requests automatic grid sizing at the current font size.</summary>
    public bool AutoFit { get; init; }
    /// <summary>The per-surface key for remembering the font size.</summary>
    public string? SizeMemoryKey { get; init; }
    /// <summary>The initial font size when no per-surface preference has been remembered.</summary>
    public int? InitialFontSize { get; init; }
    /// <summary>The accessible label for the terminal's keyboard input.</summary>
    public required string Label { get; init; }
}
 
/// <summary>A generation-tagged snapshot of terminal metadata, role, sizing and connection state.</summary>
public sealed record TerminalToolbarState
{
    /// <summary>The unique JS-side view identifier.</summary>
    public int TerminalId { get; init; }
    /// <summary>The connection generation.</summary>
    public int Generation { get; init; }
    /// <summary>One of connecting, primary, viewer or no-primary.</summary>
    public string Status { get; init; } = "connecting";
    /// <summary>Whether a connected frame has been presented.</summary>
    public bool Connected { get; init; }
    /// <summary>The workload-reported title, or empty when unset.</summary>
    public string Title { get; init; } = string.Empty;
    /// <summary>The decoded working directory reported by the shell, or null when unset.</summary>
    public string? WorkingDirectory { get; init; }
    /// <summary>The original working directory URI, displayed as text only.</summary>
    public string? WorkingDirectoryUri { get; init; }
    /// <summary>The reported progress state: none, normal, error, indeterminate or warning.</summary>
    public string ProgressState { get; init; } = "none";
    /// <summary>The reported percentage, or null for hidden or indeterminate progress.</summary>
    public int? ProgressPercentage { get; init; }
    /// <summary>Whether this view owns resize authority.</summary>
    public bool IsPrimary { get; init; }
    /// <summary>Whether requesting resize authority is available.</summary>
    public bool CanTakeControl { get; init; }
    /// <summary>The current sizing mode: font or fixed.</summary>
    public string SizeMode { get; init; } = "font";
    /// <summary>The selected preset key, or auto.</summary>
    public string SizeKey { get; init; } = "auto";
    /// <summary>The saved palette preference: light or dark.</summary>
    public string Palette { get; init; } = "dark";
    /// <summary>The font size in CSS pixels.</summary>
    public int FontPx { get; init; }
    /// <summary>Whether font controls are available.</summary>
    public bool FontControlsEnabled { get; init; }
    /// <summary>Whether decreasing the font respects the public package bounds.</summary>
    public bool CanDecreaseFontSize { get; init; }
    /// <summary>Whether increasing the font respects the public package bounds.</summary>
    public bool CanIncreaseFontSize { get; init; }
    /// <summary>Whether grid presets are available.</summary>
    public bool SizeSelectEnabled { get; init; }
    /// <summary>Whether fitting is available and the view is not already the auto-sized primary.</summary>
    public bool FitEnabled { get; init; }
    /// <summary>The server-authoritative grid width.</summary>
    public int Cols { get; init; }
    /// <summary>The server-authoritative grid height.</summary>
    public int Rows { get; init; }
    /// <summary>The localized error category, or null when healthy.</summary>
    public string? Error { get; init; }
}
 
/// <summary>A named grid preset exposed by the JS terminal.</summary>
public sealed record TerminalSizePreset(string Value, string Label, int Cols, int Rows);