Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions src/EventLogExpert.Runtime/Modal/IModalCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,29 @@ public interface IModalCoordinator

void ForceCloseActive();

Task<TResult?> PushAsync<TModal, TResult>(IDictionary<string, object?>? parameters = null)
/// <summary>Returns the active modal's scope, or <see langword="null" /> if no modal is active.</summary>
ModalScope? GetActiveModalScope();

/// <summary>
/// Opens <typeparamref name="TModal" />. If an active modal exists, asks it to close via the veto pipeline first;
/// if vetoed, returns a result with <see cref="ModalOpenResult{TResult}.WasOpened" /> set to <see langword="false" />.
/// </summary>
Task<ModalOpenResult<TResult>> PushAsync<TModal, TResult>(IDictionary<string, object?>? parameters = null)
where TModal : IComponent;

/// <summary>Register a modal's close handler, scope, and optional inline-alert host. Stale ids are ignored.</summary>
void RegisterModal(ModalRegistration registration);

/// <summary>
/// Register <paramref name="host" /> as the inline-alert host for the modal identified by
/// <paramref name="modalId" />. Stale ids are ignored.
/// Asks the active modal to close. Coalesces concurrent calls; the first verdict wins. Critical-scoped modals
/// reject <see cref="ModalCloseReason.OtherModalActivation" /> immediately, regardless of in-flight state.
/// If a close handler throws <see cref="OperationCanceledException" /> (e.g., from a force-close race), the
/// close is treated as accepted so coalesced awaiters resolve successfully.
/// </summary>
void RegisterInlineAlertHost(ModalId modalId, IInlineAlertHost host);
Task<bool> RequestCloseActiveAsync(ModalCloseReason reason);

/// <summary>Returns the registered inline-alert host for the active modal, if any.</summary>
bool TryGetInlineAlertHost([NotNullWhen(true)] out IInlineAlertHost? host);

void UnregisterInlineAlertHost(ModalId modalId);
void UnregisterModal(ModalId modalId);
}

13 changes: 13 additions & 0 deletions src/EventLogExpert.Runtime/Modal/ModalCloseReason.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// // Copyright (c) Microsoft Corporation.
// // Licensed under the MIT License.

namespace EventLogExpert.Runtime.Modal;

public enum ModalCloseReason
{
UserDismiss,
EscKey,
OutsideClick,
ProgrammaticCancel,
OtherModalActivation
}
6 changes: 6 additions & 0 deletions src/EventLogExpert.Runtime/Modal/ModalCloseRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// // Copyright (c) Microsoft Corporation.
// // Licensed under the MIT License.

namespace EventLogExpert.Runtime.Modal;

public readonly record struct ModalCloseRequest(ModalCloseReason Reason);
131 changes: 110 additions & 21 deletions src/EventLogExpert.Runtime/Modal/ModalCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ internal sealed class ModalCoordinator : IModalCoordinator, IDisposable
private readonly IModalService _modalService;
private readonly Lock _stateLock = new();

private ModalRegistration? _activeRegistration;
private ModalSession? _activeSession;
private bool _disposed;
private IInlineAlertHost? _host;
private ModalId _hostModalId;
private TaskCompletionSource<bool>? _inFlightCloseTcs;

public ModalCoordinator(IModalService modalService)
{
Expand Down Expand Up @@ -46,47 +46,136 @@ public void Dispose()

public void ForceCloseActive() => _modalService.CancelActive();

public Task<TResult?> PushAsync<TModal, TResult>(IDictionary<string, object?>? parameters = null)
public ModalScope? GetActiveModalScope()
{
lock (_stateLock) { return GetActiveRegistration()?.Scope; }
}

public async Task<ModalOpenResult<TResult>> PushAsync<TModal, TResult>(IDictionary<string, object?>? parameters = null)
where TModal : IComponent
=> _modalService.Show<TModal, TResult>(parameters);
{
// Use the service-derived active id (immediately available) rather than _activeRegistration
// (component-lifecycle gap between Show and OnInitialized's RegisterModal).
if (_modalService.ActiveModalId != ModalId.None)
{
bool accepted = await RequestCloseActiveAsync(ModalCloseReason.OtherModalActivation);
if (!accepted) { return new ModalOpenResult<TResult>(default, WasOpened: false); }
}

public void RegisterInlineAlertHost(ModalId modalId, IInlineAlertHost host)
TResult? result = await _modalService.Show<TModal, TResult>(parameters);
return new ModalOpenResult<TResult>(result, WasOpened: true);
}

public void RegisterModal(ModalRegistration registration)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(registration);

lock (_stateLock)
{
if (modalId != _modalService.ActiveModalId) { return; }
if (registration.ModalId.IsNone || registration.ModalId != _modalService.ActiveModalId) { return; }

_hostModalId = modalId;
_host = host;
_activeRegistration = registration;
}
}

public bool TryGetInlineAlertHost([NotNullWhen(true)] out IInlineAlertHost? host)
public async Task<bool> RequestCloseActiveAsync(ModalCloseReason reason)
{
ModalRegistration? snapshot = null;
TaskCompletionSource<bool>? newTcs = null;
Task<bool>? inFlight = null;

lock (_stateLock)
{
if (_hostModalId != _modalService.ActiveModalId)
ModalRegistration? activeRegistration = GetActiveRegistration();

// Scope policy FIRST (before coalescing) so Critical+OtherModalActivation is always rejected.
if (activeRegistration is not null
&& activeRegistration.Scope == ModalScope.Critical
&& reason == ModalCloseReason.OtherModalActivation)
{
Comment thread
jschick04 marked this conversation as resolved.
_host = null;
_hostModalId = ModalId.None;
return false;
}

host = _host;
if (_inFlightCloseTcs is not null)
{
inFlight = _inFlightCloseTcs.Task;
}
else if (activeRegistration is null)
{
// Init-window guard: the service may publish ActiveModalId before OnInitialized's RegisterModal lands.
// Reject OtherModalActivation during the gap so a not-yet-registered modal can't be preempted past its scope policy.
return reason != ModalCloseReason.OtherModalActivation || _modalService.ActiveModalId == ModalId.None;
}
else
{
snapshot = activeRegistration;
newTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_inFlightCloseTcs = newTcs;
}
}

if (inFlight is not null) { return await inFlight; }

try
{
bool accepted = await snapshot!.RequestClose(new ModalCloseRequest(reason));
newTcs!.TrySetResult(accepted);
return accepted;
}
catch (OperationCanceledException)
{
// Modal is gone via ForceCloseActive race; treat as accepted so coalesced callers can proceed.
newTcs!.TrySetResult(true);
return true;
}
catch (Exception ex)
{
newTcs!.TrySetException(ex);
throw;
}
finally
{
lock (_stateLock) { _inFlightCloseTcs = null; }
}
}

public bool TryGetInlineAlertHost([NotNullWhen(true)] out IInlineAlertHost? host)
{
lock (_stateLock)
{
host = GetActiveRegistration()?.InlineAlertHost;

return host is not null;
}
}

public void UnregisterInlineAlertHost(ModalId modalId)
public void UnregisterModal(ModalId modalId)
{
lock (_stateLock)
{
if (_hostModalId != modalId) { return; }
if (_activeRegistration?.ModalId == modalId)
{
_activeRegistration = null;
}
}
}

_host = null;
_hostModalId = ModalId.None;
// Stale-clear inside the read path: ModalService publishes ActiveModalId changes BEFORE firing StateChanged,
// so reads via _activeRegistration can race the registration's modal being cancelled/completed. If the
// stored registration's id no longer matches the service, drop it and return null — the next OnInitialized
// (or OnModalServiceStateChanged backstop) will install the correct successor.
private ModalRegistration? GetActiveRegistration()
{
if (_activeRegistration is null) { return null; }

if (_activeRegistration.ModalId == _modalService.ActiveModalId)
{
return _activeRegistration;
}

_activeRegistration = null;

return null;
}

private void OnModalServiceStateChanged()
Expand All @@ -106,10 +195,10 @@ private void OnModalServiceStateChanged()
changed = !Equals(_activeSession, newSession);
_activeSession = newSession;

if (_hostModalId != id)
// Stale-clear: drop _activeRegistration if the active id moved past it.
if (_activeRegistration is not null && _activeRegistration.ModalId != id)
{
_host = null;
_hostModalId = ModalId.None;
_activeRegistration = null;
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/EventLogExpert.Runtime/Modal/ModalOpenResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// // Copyright (c) Microsoft Corporation.
// // Licensed under the MIT License.

namespace EventLogExpert.Runtime.Modal;

/// <summary>
/// Result of <see cref="IModalCoordinator.PushAsync{TModal,TResult}" />. <c>WasOpened</c> distinguishes
/// user-completion-with-default from preempt-veto.
/// </summary>
public sealed record ModalOpenResult<TResult>(TResult? Result, bool WasOpened);
31 changes: 31 additions & 0 deletions src/EventLogExpert.Runtime/Modal/ModalRegistration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// // Copyright (c) Microsoft Corporation.
// // Licensed under the MIT License.

using EventLogExpert.Runtime.Alerts;

namespace EventLogExpert.Runtime.Modal;

public sealed class ModalRegistration
{
Comment thread
jschick04 marked this conversation as resolved.
public ModalRegistration(
ModalId modalId,
Func<ModalCloseRequest, Task<bool>> requestClose,
ModalScope scope,
IInlineAlertHost? inlineAlertHost)
{
ArgumentNullException.ThrowIfNull(requestClose);

ModalId = modalId;
RequestClose = requestClose;
Scope = scope;
InlineAlertHost = inlineAlertHost;
}

public IInlineAlertHost? InlineAlertHost { get; }

public ModalId ModalId { get; }

public Func<ModalCloseRequest, Task<bool>> RequestClose { get; }

public ModalScope Scope { get; }
}
10 changes: 10 additions & 0 deletions src/EventLogExpert.Runtime/Modal/ModalScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// // Copyright (c) Microsoft Corporation.
// // Licensed under the MIT License.

namespace EventLogExpert.Runtime.Modal;

public enum ModalScope
{
Standard,
Critical
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
InlineAlert="@CurrentInlineAlert"
MaxWidth="min(80rem, calc(100vw - 2rem))"
MinWidth="min(80rem, calc(100vw - 2rem))"
OnCancel="OnCancelAsync"
OnClose="OnCancelAsync"
OnCancel="HandleCancelButtonClickAsync"
OnClose="HandleCancelButtonClickAsync"
OnDialogClosedByUser="HandleDialogClosedByUserAsync"
OnInlineAlertResolved="HandleInlineAlertResolvedAsync"
@ref="ChromeRef">
Expand Down
49 changes: 26 additions & 23 deletions src/EventLogExpert.UI/DatabaseTools/DatabaseToolsModal.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// // Licensed under the MIT License.

using EventLogExpert.Runtime.Alerts;
using EventLogExpert.Runtime.Modal;
using EventLogExpert.UI.DatabaseTools.Tabs;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
Expand Down Expand Up @@ -88,31 +89,33 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
await base.OnAfterRenderAsync(firstRender);
}

protected override async Task OnCancelAsync()
protected override async Task OnClosingAsync()
{
if (AnyTabIsRunning)
{
var confirm = await ShowInlineAlertAsync(
new InlineAlertRequest(
Title: "Operation in progress",
Message: "An operation is running. Cancel and close anyway?",
AcceptLabel: "Cancel and close",
CancelLabel: "Continue running",
IsPrompt: false,
PromptInitialValue: null),
CancellationToken.None);

if (!confirm.Accepted) { return; }

// Best-effort: cancel running tabs before closing.
_showTab?.CancelIfRunning();
_createTab?.CancelIfRunning();
_mergeTab?.CancelIfRunning();
_diffTab?.CancelIfRunning();
_upgradeTab?.CancelIfRunning();
}
// CancelIfRunning is a no-op when not running; safe to call from all close paths.
_showTab?.CancelIfRunning();
_createTab?.CancelIfRunning();
_mergeTab?.CancelIfRunning();
_diffTab?.CancelIfRunning();
_upgradeTab?.CancelIfRunning();

await base.OnClosingAsync();
}

await base.OnCancelAsync();
protected override async Task<bool> OnRequestCloseAsync(ModalCloseRequest request)
{
if (!AnyTabIsRunning) { return true; }

var confirm = await ShowInlineAlertAsync(
new InlineAlertRequest(
Title: "Operation in progress",
Message: "An operation is running. Cancel and close anyway?",
AcceptLabel: "Cancel and close",
CancelLabel: "Continue running",
IsPrompt: false,
PromptInitialValue: null),
CancellationToken.None);

return confirm.Accepted;
}

private static DatabaseToolsTab NextTab(DatabaseToolsTab current)
Expand Down
Loading