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
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ public interface IScenarioLaunchService
/// works even for logs not present on the local host. The folder enumeration and per-file channel probe run off the
/// caller's thread and honor <paramref name="cancellationToken" />, returning
/// <see cref="ScenarioFolderOutcome.Cancelled" /> if the scan is abandoned before any log is opened.
/// <paramref name="onPhase" />, when supplied, is invoked as the operation crosses each
/// <see cref="ScenarioFolderPhase" /> boundary so a caller can show a cancellable busy indicator only while
/// cancellation is still meaningful.
/// </summary>
Task<ScenarioFolderLaunchResult> LaunchFromFolderAsync(
ScenarioDefinition scenario, DateFilter? dateWindow, CancellationToken cancellationToken = default);
ScenarioDefinition scenario,
DateFilter? dateWindow,
CancellationToken cancellationToken = default,
Func<ScenarioFolderPhase, Task>? onPhase = null);
}
20 changes: 20 additions & 0 deletions src/EventLogExpert.Runtime/Scenarios/ScenarioFolderPhase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// // Copyright (c) Microsoft Corporation.
// // Licensed under the MIT License.

namespace EventLogExpert.Runtime.Scenarios;

/// <summary>
/// A progress phase reported by <see cref="IScenarioLaunchService.LaunchFromFolderAsync" /> so a caller can
/// surface a cancellable busy indicator only while cancellation is meaningful.
/// </summary>
public enum ScenarioFolderPhase
{
/// <summary>The folder picker has returned a folder and the cancellable enumeration/probe scan is starting.</summary>
Scanning,

/// <summary>
/// The scan matched logs and is committing to open them. Past this point the operation is no longer cancellable,
/// so a caller should stop offering Cancel.
/// </summary>
Opening
}
15 changes: 14 additions & 1 deletion src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ public async Task<ScenarioLaunchResult> LaunchAsync(
public async Task<ScenarioFolderLaunchResult> LaunchFromFolderAsync(
ScenarioDefinition scenario,
DateFilter? dateWindow,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
Func<ScenarioFolderPhase, Task>? onPhase = null)
{
ArgumentNullException.ThrowIfNull(scenario);

Expand All @@ -94,6 +95,10 @@ public async Task<ScenarioFolderLaunchResult> LaunchFromFolderAsync(

if (folder is null) { return ScenarioFolderLaunchResult.Cancelled; }

// A real folder was chosen, so the cancellable enumeration/probe scan is about to start. Signal Scanning here
// (not before the picker) so a caller shows its Cancel affordance only once cancellation is meaningful.
if (onPhase is not null) { await onPhase(ScenarioFolderPhase.Scanning); }

FolderMatch match;

try
Expand Down Expand Up @@ -137,6 +142,14 @@ public async Task<ScenarioFolderLaunchResult> LaunchFromFolderAsync(
// logs, while a genuine fault surfacing from the open still propagates (the commit stays outside the catch).
if (cancellationToken.IsCancellationRequested) { return ScenarioFolderLaunchResult.Cancelled; }

// The scan matched logs and is committing to open them; signal Opening so a caller can drop its Cancel
// affordance (the open is not cancellable). Gate on a nonempty match so Opening is never reported for an
// outcome that opens nothing, then recheck cancellation -- independently of whether a callback was supplied --
// so a cancellation that lands while the callback runs still wins before any filter dispatch or open.
if (match.Paths.Count > 0 && onPhase is not null) { await onPhase(ScenarioFolderPhase.Opening); }

if (cancellationToken.IsCancellationRequested) { return ScenarioFolderLaunchResult.Cancelled; }

return await OpenMatchedLogsAsync(scenario, dateWindow, match);
}

Expand Down
22 changes: 21 additions & 1 deletion src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@inherits FluxorComponent

<section aria-labelledby="empty-dashboard-title" class="empty-dashboard" role="region">
<section aria-labelledby="empty-dashboard-title" class="empty-dashboard" @ref="_dashboardRoot" role="region" tabindex="-1">
<header class="empty-dashboard__masthead">
<div class="empty-dashboard__brand">
<span aria-hidden="true" class="empty-dashboard__brand-mark"><i class="bi bi-journal-text"></i></span>
Expand All @@ -10,6 +10,26 @@
</div>
</div>
<div class="empty-dashboard__masthead-actions">
@if (_scanningScenario is { } scanning)
{
<div class="empty-dashboard__chip empty-dashboard__chip--scanning" role="status">
<i aria-hidden="true" class="bi bi-arrow-repeat loader-spin"></i>
@if (_openingLogs)
{
<span class="empty-dashboard__chip-text">Opening logs for @scanning.Name...</span>
}
else if (_cancelRequested)
{
<span class="empty-dashboard__chip-text">Cancelling folder scan for @scanning.Name...</span>
}
else
{
<span class="empty-dashboard__chip-text">Scanning @scanning.Name folder for logs...</span>

<Button OnClick="CancelFolderScan" aria-label="@($"Cancel folder scan for {scanning.Name}")" @ref="_cancelScanButton">Cancel</Button>
}
</div>
}
@if (FilterApplied.Value)
{
<div class="empty-dashboard__chip" role="status">
Expand Down
153 changes: 147 additions & 6 deletions src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
using EventLogExpert.Runtime.Scenarios.Favorites;
using EventLogExpert.Scenarios.Catalog;
using EventLogExpert.UI.Common;
using EventLogExpert.UI.Focus;
using EventLogExpert.UI.Inputs;
using EventLogExpert.UI.Modal;
using Fluxor;
using Fluxor.Blazor.Web.Components;
Expand Down Expand Up @@ -43,12 +45,21 @@ public sealed partial class EmptyStateDashboard : FluxorComponent
private readonly Dictionary<SplashCategory, ScenarioDefinition?> _selectedByCategory = new();

private SplashCategory _activeCategory;
private bool _cancelRequested;
private Button? _cancelScanButton;
private List<(SplashCategory Category, string Label, IReadOnlyList<ScenarioDefinition> Scenarios)> _categories = [];
private ElementReference _dashboardRoot;
private bool _disposed;
private CancellationTokenSource? _folderLaunchCts;
private bool _isBusy;
private LivePresence _livePresence = new(false, FrozenSet<string>.Empty);
private bool _openingLogs;
private bool _pendingCancelFocus;
private bool _pendingScanEndFocus;
private bool _pendingTabFocus;
private IReadOnlyDictionary<string, ChannelReadiness> _readinessByChannel =
new Dictionary<string, ChannelReadiness>(StringComparer.OrdinalIgnoreCase);
private ScenarioDefinition? _scanningScenario;
private SidebarTabs<SplashCategory>? _sidebarTabs;
private IReadOnlyList<ScenarioDefinition>? _splashScenarios;
private IReadOnlyList<(SplashCategory Tab, string Label)> _tabs = [];
Expand Down Expand Up @@ -112,6 +123,7 @@ protected override async ValueTask DisposeAsyncCore(bool disposing)
{
if (disposing)
{
_disposed = true;
Favorites.SelectedValueChanged -= OnFavoritesChanged;
await _lifetimeCts.CancelAsync();
_lifetimeCts.Dispose();
Expand All @@ -122,7 +134,21 @@ protected override async ValueTask DisposeAsyncCore(bool disposing)

protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_pendingTabFocus && _sidebarTabs is not null)
if (_pendingCancelFocus)
{
_pendingCancelFocus = false;

if (_cancelScanButton is not null) { await ElementFocus.SafelyAsync(_cancelScanButton.Element); }
}
else if (_pendingScanEndFocus)
{
_pendingScanEndFocus = false;

var focused = _sidebarTabs is not null && await _sidebarTabs.FocusActiveTabAsync();

if (!focused) { await ElementFocus.SafelyAsync(_dashboardRoot); }
}
else if (_pendingTabFocus && _sidebarTabs is not null)
{
_pendingTabFocus = false;

Expand Down Expand Up @@ -249,6 +275,20 @@ private bool AccessAllowsLaunch(string channel) =>
new ChannelReadiness(channel, ChannelPresence.Unknown, ChannelEnablement.Unknown))
.Access is ChannelAccess.Accessible or ChannelAccess.NotEvaluated;

private void CancelFolderScan()
{
// No-op once the scan has committed to opening or a cancel is already in flight. The null-field guard plus the
// null-before-Dispose ordering in the finally means Cancel() is never called on a disposed source.
if (_openingLogs || _cancelRequested || _folderLaunchCts is null) { return; }

_cancelRequested = true;
_pendingCancelFocus = false;
_pendingTabFocus = false;
_pendingScanEndFocus = true;
_folderLaunchCts.Cancel();
StateHasChanged();
}

private void ClearFilter() => FilterCommands.ClearAllFilters();

private Task EnableChannelAsync(string channel) =>
Expand Down Expand Up @@ -321,7 +361,7 @@ await AlertDialogService.ShowErrorAlert(
"Launch scenario",
message,
"Open from folder",
() => LaunchScenarioFromFolderCoreAsync(scenario));
() => LaunchScenarioFromFolderAsync(scenario));
}
else
{
Expand All @@ -334,9 +374,43 @@ private Task LaunchScenarioFromFolderAsync(ScenarioDefinition scenario) =>

private async Task LaunchScenarioFromFolderCoreAsync(ScenarioDefinition scenario)
{
var result = await ScenarioLaunch.LaunchFromFolderAsync(scenario, null, _lifetimeCts.Token);
var cts = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCts.Token);
_folderLaunchCts = cts;
var scanStarted = false;
ScenarioFolderLaunchResult result;

if (DescribeFolderLaunch(scenario, result) is not { } message) { return; }
try
{
result = await ScenarioLaunch.LaunchFromFolderAsync(scenario, null, cts.Token, OnFolderScanPhaseAsync);
}
finally
{
if (ReferenceEquals(_folderLaunchCts, cts)) { _folderLaunchCts = null; }

cts.Dispose();

if (_scanningScenario is not null)
{
// Hide the scan chip before any result dialog. The banner-retry path does not run in the dashboard's
// event loop, so it will not auto-render; the explicit render is required there. Clear the scan-end
// focus intent too, so an Opening-set request cannot steal focus from a result modal opened below.
_scanningScenario = null;
_openingLogs = false;
_cancelRequested = false;
_pendingCancelFocus = false;
_pendingScanEndFocus = false;

await SafeInvokeAsync(StateHasChanged);
}
}

if (DescribeFolderLaunch(scenario, result) is not { } message)
{
// Cancelled: no dialog takes focus and the Cancel chip that had focus is gone, so restore it.
if (scanStarted) { RestoreFocusAfterScan(); }

return;
}

// A launch that opens logs is self-evident (the workspace changes) and only needs the screen-reader detail;
// the outcomes that leave the dashboard unchanged need a visible dialog so a sighted user sees them.
Expand All @@ -346,12 +420,44 @@ private async Task LaunchScenarioFromFolderCoreAsync(ScenarioDefinition scenario
Announcer.Announce(message);
break;
case ScenarioFolderOutcome.Error:
// ShowErrorAlert surfaces a banner, which does not capture focus; restore it so a scan that showed the
// Cancel chip does not strand keyboard focus on <body>.
if (scanStarted) { RestoreFocusAfterScan(); }

await AlertDialogService.ShowErrorAlert("Open from folder", message);
break;
default:
// ShowAlert opens a focus-capturing modal, so it owns focus; issuing a restore here would fight it.
await AlertDialogService.ShowAlert("Open from folder", message, "OK");
break;
}

// Runs on the UI dispatcher via SafeInvokeAsync and must never throw out of onPhase: it executes outside the
// service's cancellation catch, so an escaping teardown exception would surface a normal launch as a fault.
async Task OnFolderScanPhaseAsync(ScenarioFolderPhase phase) =>
await SafeInvokeAsync(() =>
{
switch (phase)
{
case ScenarioFolderPhase.Scanning:
scanStarted = true;
_scanningScenario = scenario;
_openingLogs = false;
_cancelRequested = false;
_pendingTabFocus = false;
_pendingScanEndFocus = false;
_pendingCancelFocus = true;
break;
case ScenarioFolderPhase.Opening:
_openingLogs = true;
_pendingCancelFocus = false;
_pendingTabFocus = false;
_pendingScanEndFocus = true;
break;
}

StateHasChanged();
});
}

private async void OnFavoritesChanged(object? _, ImmutableHashSet<string> __)
Expand Down Expand Up @@ -465,14 +571,49 @@ private async Task RefreshReadinessAsync()
_livePresence = LivePresence.FromReadiness(readiness);
}

private void RestoreFocusAfterScan()
{
if (_disposed) { return; }

_pendingCancelFocus = false;
_pendingTabFocus = false;
_pendingScanEndFocus = true;
StateHasChanged();
}

private async Task RunGuardedAsync(Func<Task> action)
{
if (_isBusy) { return; }

_isBusy = true;

try { await action(); }
finally { _isBusy = false; }
try
{
// Render immediately (inside the try, so the finally still clears _isBusy if this ever threw) so the
// busy-gated controls disable right away. On a normal button click Blazor auto-renders at the first await
// inside the action, but the banner-retry path runs outside the dashboard's event loop and gets no
// automatic render, so without this the controls would stay visibly enabled during the folder picker.
await SafeInvokeAsync(StateHasChanged);
await action();
}
finally
{
_isBusy = false;

// Re-render after clearing the busy flag for the same non-event-loop reason, so the controls re-enable.
await SafeInvokeAsync(StateHasChanged);
}
}

private async Task SafeInvokeAsync(Action render)
{
if (_disposed) { return; }

// Matches the OnFavoritesChanged teardown pattern: a render can race the dashboard unmounting when a folder
// launch opens logs; treat disposal and cancellation as expected.
try { await InvokeAsync(render); }
catch (ObjectDisposedException) { }
catch (OperationCanceledException) { }
}

private IReadOnlyList<ScenarioDefinition> ScenariosFor(SplashCategory category)
Expand Down
10 changes: 10 additions & 0 deletions src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@
color: var(--clr-yellow);
}

/* Progress accent for the folder-scan chip, distinct from the yellow filter-applied warning chip.
Placed after the base rules so it wins on equal specificity when both classes are present. */
.empty-dashboard__chip--scanning {
border-left-color: var(--clr-statusbar);
}

.empty-dashboard__chip--scanning > .bi {
color: var(--clr-statusbar);
}

.empty-dashboard__chip-text {
color: var(--text-primary);
}
Expand Down
Loading
Loading