| File: Components\Controls\TerminalWindowButton.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.Model; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Localization; using Microsoft.JSInterop; namespace Aspire.Dashboard.Components.Controls; /// <summary>A terminal launch button whose native listener opens the window before notifying Blazor.</summary> public partial class TerminalWindowButton : ComponentBase, IAsyncDisposable { private readonly string _buttonId = $"terminal-window-button-{Guid.NewGuid():N}"; private TerminalWindowLauncher? _launcher; private bool _ready; private bool _disposed; private bool _adopting; private bool _adoptionFailed; private readonly HashSet<string> _processedKeys = new(StringComparer.Ordinal); /// <summary>Gets or sets the terminal's stable window key.</summary> [Parameter] public string? TerminalKey { get; set; } /// <summary>Gets or sets the dashboard-relative URL of the independent terminal viewer.</summary> [Parameter] public string? Url { get; set; } /// <summary>Gets or sets the originating font preference. Null keeps the button disabled until it is known.</summary> [Parameter] public int? FontSize { get; set; } /// <summary>Gets or sets the localized accessible label and tooltip.</summary> [Parameter, EditorRequired] public required string Label { get; set; } /// <summary>Gets or sets additional button classes.</summary> [Parameter] public string? Class { get; set; } /// <summary>Gets or sets the group identifying native focus controls for this launcher's detached windows.</summary> [Parameter] public string? FocusGroup { get; set; } /// <summary>Gets or sets whether this surface currently permits launching a window.</summary> [Parameter] public bool Disabled { get; set; } /// <summary>Raised with the launched or adopted key and outcome, even if the selected terminal has since changed.</summary> [Parameter] public EventCallback<(string Key, TerminalWindowOpenResult Result)> OnWindowOpened { get; set; } /// <summary>Raised with the key of a tracked window that the user closed.</summary> [Parameter] public EventCallback<string> OnWindowClosed { get; set; } /// <summary>Gets or sets current terminal identities to check for surviving windows before mounting viewers.</summary> [Parameter] public string[] WindowKeysToAdopt { get; set; } = []; /// <summary>Raised with checked keys after their surviving windows have been reconciled through <see cref="OnWindowOpened"/>.</summary> [Parameter] public EventCallback<string[]> OnWindowsAdopted { get; set; } /// <summary>Raised with unchecked keys when browser coordination fails, so callers can offer explicit recovery.</summary> [Parameter] public EventCallback<string[]> OnWindowTrackingFailed { get; set; } [Inject] public required IJSRuntime JS { get; init; } [Inject] public required NavigationManager NavigationManager { get; init; } [Inject] public required ILogger<TerminalWindowButton> Logger { get; init; } [Inject] public required IStringLocalizer<Resources.TerminalStrings> Loc { get; init; } [Inject] public required Microsoft.FluentUI.AspNetCore.Components.INotificationService ToastService { get; init; } // Transfer only the font preference. The independent viewer fits its own viewport instead of inheriting // the opener's grid dimensions. Render this complete URL so the click needs no interop or terminal lookup. private string? LaunchUrl => !string.IsNullOrEmpty(Url) && FontSize is > 0 ? QueryHelpers.AddQueryString(NavigationManager.ToAbsoluteUri(Url).AbsoluteUri, "fontSize", FontSize.Value.ToString(CultureInfo.InvariantCulture)) : null; protected override async Task OnAfterRenderAsync(bool firstRender) { if (_disposed) { return; } // A replacement dock must discover surviving windows before it mounts a viewer, so its listener can // register without a font or enabled launch button. Actual clicks still require complete metadata. if (_launcher is null && (WindowKeysToAdopt.Length > 0 || (!Disabled && !string.IsNullOrEmpty(TerminalKey) && LaunchUrl is not null))) { await RegisterAsync(); } if (!_disposed && _adoptionFailed) { // A storage/registration failure applies to later metadata too. Give newly arriving terminals the // same recovery controls instead of leaving their unchecked panes empty or retrying on every render. var uncheckedKeys = WindowKeysToAdopt.Where(key => !_processedKeys.Contains(key)).ToArray(); if (uncheckedKeys.Length > 0) { _processedKeys.UnionWith(uncheckedKeys); await OnWindowTrackingFailed.InvokeAsync(uncheckedKeys); } return; } if (!_disposed && _ready && !_adopting && _launcher is { } launcher) { var keys = WindowKeysToAdopt.Where(key => !_processedKeys.Contains(key)).ToArray(); if (keys.Length == 0) { return; } _adopting = true; var adopted = false; try { await launcher.AdoptAsync(keys); if (!_disposed) { _processedKeys.UnionWith(keys); adopted = true; } } catch (JSDisconnectedException) { // The replacement circuit will reconcile its own windows before rendering. _adoptionFailed = true; } catch (Exception ex) { // Do not mount an unchecked viewer on failure: it could take sizing control from a live window. _adoptionFailed = true; _processedKeys.UnionWith(keys); Logger.LogWarning(ex, "Failed to adopt existing terminal windows."); if (!_disposed) { await OnWindowTrackingFailed.InvokeAsync(keys); await ToastService.ShowErrorToastAsync(Loc[nameof(Resources.TerminalStrings.TerminalWindowTrackingFailed)]); } } finally { _adopting = false; } if (adopted && !_disposed) { await OnWindowsAdopted.InvokeAsync(keys); } } } private async Task RegisterAsync() { // Assign before awaiting registration so later renders cannot register a second listener. _launcher = new TerminalWindowLauncher(JS, NavigationManager, Assets["js/app-terminalwindow.js"], OnOpenedAsync, key => InvokeAsync(() => _disposed ? Task.CompletedTask : OnWindowClosed.InvokeAsync(key))); try { await _launcher.RegisterAsync(_buttonId); if (!_disposed) { _ready = true; StateHasChanged(); } } catch (JSDisconnectedException) { // The circuit has gone away, so there is no live UI to notify. } catch (Exception ex) { Logger.LogWarning(ex, "Failed to register the terminal window button."); if (!_disposed) { _adoptionFailed = true; _processedKeys.UnionWith(WindowKeysToAdopt); if (WindowKeysToAdopt.Length > 0) { await OnWindowTrackingFailed.InvokeAsync(WindowKeysToAdopt); await ToastService.ShowErrorToastAsync(Loc[nameof(Resources.TerminalStrings.TerminalWindowTrackingFailed)]); } else { await ToastService.ShowErrorToastAsync(Loc[nameof(Resources.TerminalStrings.TerminalToolbarOpenInWindowFailed)]); } } } } private Task OnOpenedAsync(string key, TerminalWindowOpenResult result) => InvokeAsync(async () => { if (_disposed) { return; } if (result == TerminalWindowOpenResult.Failed) { Logger.LogWarning("The browser failed to open or focus a terminal window."); await ToastService.ShowErrorToastAsync(Loc[nameof(Resources.TerminalStrings.TerminalToolbarOpenInWindowFailed)]); } else if (result == TerminalWindowOpenResult.Blocked && !OnWindowOpened.HasDelegate) { await ToastService.ShowErrorToastAsync(Loc[nameof(Resources.TerminalStrings.TerminalToolbarOpenInWindowBlocked)]); } if (!_disposed) { await OnWindowOpened.InvokeAsync((key, result)); } }); /// <summary>Focuses an already detached window, or returns false if it has closed.</summary> /// <param name="key">The terminal window key.</param> /// <returns>Whether the window is still open.</returns> public Task<bool> FocusAsync(string key) => _launcher?.FocusAsync(key) ?? Task.FromResult(false); /// <summary>Closes an independent viewer without stopping its terminal producer.</summary> /// <param name="key">The terminal window key.</param> /// <returns>A task that completes when the browser has closed the window.</returns> public Task CloseAsync(string key) => _launcher?.CloseAsync(key) ?? Task.CompletedTask; /// <inheritdoc /> public async ValueTask DisposeAsync() { _disposed = true; if (_launcher is { } launcher) { await launcher.DisposeAsync(); } } }