From 6e3f223a04c42099de2b3db85a4d49105ed4144a Mon Sep 17 00:00:00 2001 From: jschick04 Date: Wed, 15 Jul 2026 03:47:33 +0000 Subject: [PATCH] Add in-view incremental Find (Ctrl+F) with match highlighting --- .../LogTable/EventFindMatcher.cs | 64 ++ .../LogTable/SetAllGroupsCollapsedAction.cs | 3 +- .../UiServiceCollectionExtensions.cs | 2 + .../Keyboard/KeyboardShortcutService.cs | 7 + .../LogTable/Find/FindBar.razor | 64 ++ .../LogTable/Find/FindBar.razor.cs | 145 +++++ .../LogTable/Find/FindBar.razor.css | 153 +++++ .../LogTable/Find/FindBar.razor.js | 38 ++ .../LogTable/Find/FindCoordinator.cs | 27 + .../LogTable/Find/IFindCoordinator.cs | 11 + .../LogTable/LogTablePane.Find.cs | 602 ++++++++++++++++++ .../LogTable/LogTablePane.razor | 236 ++++--- .../LogTable/LogTablePane.razor.cs | 66 +- .../LogTable/LogTablePane.razor.css | 75 +++ .../LogTable/LogTablePane.razor.js | 9 + .../wwwroot/Keyboard/Keyboard.js | 2 +- src/EventLogExpert.UI/wwwroot/app.css | 4 + .../LogTable/EventFindMatcherTests.cs | 131 ++++ .../LogTable/FindBarTests.cs | 211 ++++++ .../LogTable/LogTablePaneFindTests.cs | 233 +++++++ .../LogTablePaneDependenciesExtensions.cs | 2 + 21 files changed, 1981 insertions(+), 104 deletions(-) create mode 100644 src/EventLogExpert.Runtime/LogTable/EventFindMatcher.cs create mode 100644 src/EventLogExpert.UI/LogTable/Find/FindBar.razor create mode 100644 src/EventLogExpert.UI/LogTable/Find/FindBar.razor.cs create mode 100644 src/EventLogExpert.UI/LogTable/Find/FindBar.razor.css create mode 100644 src/EventLogExpert.UI/LogTable/Find/FindBar.razor.js create mode 100644 src/EventLogExpert.UI/LogTable/Find/FindCoordinator.cs create mode 100644 src/EventLogExpert.UI/LogTable/Find/IFindCoordinator.cs create mode 100644 src/EventLogExpert.UI/LogTable/LogTablePane.Find.cs create mode 100644 tests/Unit/EventLogExpert.Runtime.Tests/LogTable/EventFindMatcherTests.cs create mode 100644 tests/Unit/EventLogExpert.UI.Tests/LogTable/FindBarTests.cs create mode 100644 tests/Unit/EventLogExpert.UI.Tests/LogTable/LogTablePaneFindTests.cs diff --git a/src/EventLogExpert.Runtime/LogTable/EventFindMatcher.cs b/src/EventLogExpert.Runtime/LogTable/EventFindMatcher.cs new file mode 100644 index 000000000..18f21f00e --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/EventFindMatcher.cs @@ -0,0 +1,64 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; + +namespace EventLogExpert.Runtime.LogTable; + +public static class EventFindMatcher +{ + public static StringComparison ComparisonFor(bool caseSensitive) => + caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + + public static int IndexOfMatch(string text, string query, int startIndex, StringComparison comparison, bool wholeWord) + { + if (string.IsNullOrEmpty(query)) { return -1; } + + if (!wholeWord) { return text.IndexOf(query, startIndex, comparison); } + + int from = startIndex; + + while (from <= text.Length) + { + int hit = text.IndexOf(query, from, comparison); + + if (hit < 0) { return -1; } + + if (IsWordBounded(text, hit, hit + query.Length)) { return hit; } + + from = hit + 1; + } + + return -1; + } + + public static bool RowMatches( + ResolvedEvent @event, + IReadOnlyList columns, + TimeZoneInfo timeZone, + string query, + bool caseSensitive, + bool wholeWord) + { + if (string.IsNullOrEmpty(query)) { return false; } + + StringComparison comparison = ComparisonFor(caseSensitive); + + for (int i = 0; i < columns.Count; i++) + { + if (IndexOfMatch(EventTableColumnFormatter.GetCellText(@event, columns[i], timeZone), query, 0, comparison, wholeWord) >= 0) + { + return true; + } + } + + return IndexOfMatch(@event.Description ?? string.Empty, query, 0, comparison, wholeWord) >= 0; + } + + // Word boundary = string edge, a non-word neighbor, or the match's own edge char is a separator (VS Code wordSeparators rule; word char = letter/digit/underscore). + private static bool IsWordBounded(string text, int start, int end) => + (start == 0 || !IsWordChar(text[start - 1]) || !IsWordChar(text[start])) && + (end == text.Length || !IsWordChar(text[end]) || !IsWordChar(text[end - 1])); + + private static bool IsWordChar(char value) => char.IsLetterOrDigit(value) || value == '_'; +} diff --git a/src/EventLogExpert.Runtime/LogTable/SetAllGroupsCollapsedAction.cs b/src/EventLogExpert.Runtime/LogTable/SetAllGroupsCollapsedAction.cs index 365c4e973..e274ec4ad 100644 --- a/src/EventLogExpert.Runtime/LogTable/SetAllGroupsCollapsedAction.cs +++ b/src/EventLogExpert.Runtime/LogTable/SetAllGroupsCollapsedAction.cs @@ -3,4 +3,5 @@ namespace EventLogExpert.Runtime.LogTable; -internal sealed record SetAllGroupsCollapsedAction(bool Collapsed); +// Public so LogTablePane can subscribe via SubscribeToAction, like the other LogTable actions. +public sealed record SetAllGroupsCollapsedAction(bool Collapsed); diff --git a/src/EventLogExpert.UI/DependencyInjection/UiServiceCollectionExtensions.cs b/src/EventLogExpert.UI/DependencyInjection/UiServiceCollectionExtensions.cs index 0250a62f2..e135f5f32 100644 --- a/src/EventLogExpert.UI/DependencyInjection/UiServiceCollectionExtensions.cs +++ b/src/EventLogExpert.UI/DependencyInjection/UiServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.UI.Keyboard; +using EventLogExpert.UI.LogTable.Find; using EventLogExpert.UI.Menu; namespace Microsoft.Extensions.DependencyInjection; @@ -22,6 +23,7 @@ public IServiceCollection AddEventLogUiServices() ArgumentNullException.ThrowIfNull(services); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); return services; diff --git a/src/EventLogExpert.UI/Keyboard/KeyboardShortcutService.cs b/src/EventLogExpert.UI/Keyboard/KeyboardShortcutService.cs index 11a6cb466..a5a3e7274 100644 --- a/src/EventLogExpert.UI/Keyboard/KeyboardShortcutService.cs +++ b/src/EventLogExpert.UI/Keyboard/KeyboardShortcutService.cs @@ -5,6 +5,7 @@ using EventLogExpert.Runtime.Modal; using EventLogExpert.Runtime.Settings; using EventLogExpert.UI.Common.Interop; +using EventLogExpert.UI.LogTable.Find; using Microsoft.JSInterop; namespace EventLogExpert.UI.Keyboard; @@ -17,9 +18,11 @@ namespace EventLogExpert.UI.Keyboard; public sealed class KeyboardShortcutService( IMenuActionService actions, IModalCoordinator modalCoordinator, + IFindCoordinator findCoordinator, ISettingsService settings) : IAsyncDisposable { private readonly IMenuActionService _actions = actions; + private readonly IFindCoordinator _findCoordinator = findCoordinator; private readonly IModalCoordinator _modalCoordinator = modalCoordinator; private readonly ISettingsService _settings = settings; @@ -96,6 +99,10 @@ public async Task HandleShortcutAsync(string code, bool ctrl, bool alt, bool shi switch (code) { + case "KeyF": + _findCoordinator.RequestOpen(); + return; + case "KeyO": await _actions.OpenFileAsync(false); return; diff --git a/src/EventLogExpert.UI/LogTable/Find/FindBar.razor b/src/EventLogExpert.UI/LogTable/Find/FindBar.razor new file mode 100644 index 000000000..157f1d38d --- /dev/null +++ b/src/EventLogExpert.UI/LogTable/Find/FindBar.razor @@ -0,0 +1,64 @@ +@namespace EventLogExpert.UI.LogTable.Find + + diff --git a/src/EventLogExpert.UI/LogTable/Find/FindBar.razor.cs b/src/EventLogExpert.UI/LogTable/Find/FindBar.razor.cs new file mode 100644 index 000000000..15780f302 --- /dev/null +++ b/src/EventLogExpert.UI/LogTable/Find/FindBar.razor.cs @@ -0,0 +1,145 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.UI.Common.Interop; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.JSInterop; + +namespace EventLogExpert.UI.LogTable.Find; + +public sealed partial class FindBar : IAsyncDisposable +{ + private ElementReference _input; + private int _lastFocusSignal; + private IJSObjectReference? _module; + private bool _optionsOpen; + + [Parameter] + public bool CaseSensitive { get; set; } + + [Parameter] + public EventCallback CaseSensitiveChanged { get; set; } + + /// The 1-based position of the current match, or 0 when there is none. + [Parameter] + public int CurrentOrdinal { get; set; } + + [Parameter] + public int FocusSignal { get; set; } + + [Parameter] + public bool IsScanning { get; set; } + + [Parameter] + public int MatchCount { get; set; } + + [Parameter] + public EventCallback OnClose { get; set; } + + [Parameter] + public EventCallback OnNext { get; set; } + + [Parameter] + public EventCallback OnPrevious { get; set; } + + [Parameter] + public string Query { get; set; } = string.Empty; + + [Parameter] + public EventCallback QueryChanged { get; set; } + + [Parameter] + public bool WholeWord { get; set; } + + [Parameter] + public EventCallback WholeWordChanged { get; set; } + + [Parameter] + public string WrapAnnouncement { get; set; } = string.Empty; + + private string CountText => + Query.Length == 0 ? string.Empty : + IsScanning ? "Searching\u2026" : + MatchCount == 0 ? "No results" : + $"{CurrentOrdinal}/{MatchCount}"; + + [Inject] + private IJSRuntime JSRuntime { get; init; } = null!; + + private bool NavDisabled => IsScanning || MatchCount == 0; + + private bool OptionsActive => CaseSensitive || WholeWord; + + public async ValueTask DisposeAsync() + { + await JsModuleInterop.DisposeModuleSafelyAsync( + _module, + static module => module.InvokeVoidAsync("detachNativeFindSuppression")); + + _module = null; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + try + { + if (firstRender) + { + _module = await JSRuntime.InvokeAsync( + "import", "./_content/EventLogExpert.UI/LogTable/Find/FindBar.razor.js"); + await _module.InvokeVoidAsync("attachNativeFindSuppression"); + } + + if (FocusSignal != _lastFocusSignal && _module is not null) + { + _lastFocusSignal = FocusSignal; + await _module.InvokeVoidAsync("focusAndSelect", _input); + } + } + catch (JSDisconnectedException) { } + catch (JSException) { } + } + + // Enter is bound to the input only (a focused nav button's Enter stays a single native click); F3/Esc bind to the bar root so they fire whichever child holds focus. + private async Task HandleInputKeyDown(KeyboardEventArgs args) + { + switch (args.Key) + { + case "Enter" when args.ShiftKey: + await OnPrevious.InvokeAsync(); + return; + + case "Enter": + await OnNext.InvokeAsync(); + return; + } + } + + private async Task HandleRootKeyDown(KeyboardEventArgs args) + { + switch (args.Key) + { + case "F3" when args.ShiftKey: + await OnPrevious.InvokeAsync(); + return; + + case "F3": + await OnNext.InvokeAsync(); + return; + + case "Escape": + await OnClose.InvokeAsync(); + return; + } + } + + private async Task OnCaseChanged(bool value) => await CaseSensitiveChanged.InvokeAsync(value); + + private async Task OnInput(ChangeEventArgs args) => + await QueryChanged.InvokeAsync(args.Value as string ?? string.Empty); + + private async Task OnWholeWordChanged(bool value) => await WholeWordChanged.InvokeAsync(value); + + private void ToggleOptions() => _optionsOpen = !_optionsOpen; +} diff --git a/src/EventLogExpert.UI/LogTable/Find/FindBar.razor.css b/src/EventLogExpert.UI/LogTable/Find/FindBar.razor.css new file mode 100644 index 000000000..0113c48f5 --- /dev/null +++ b/src/EventLogExpert.UI/LogTable/Find/FindBar.razor.css @@ -0,0 +1,153 @@ +.find-bar { + position: absolute; + top: 6px; + right: 18px; + z-index: 20; + /* Fixed width so the right-anchored floating bar doesn't jitter as the count text changes. */ + --find-bar-width: 392px; + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 6px; + background-color: var(--background-darkgray); + border: 1px solid var(--border-divider); + border-radius: 4px; + box-shadow: var(--shadow-menu); + width: var(--find-bar-width); +} + +.find-main { + display: flex; + align-items: center; + gap: 4px; +} + +.find-inputbox { + position: relative; + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + background-color: var(--background-dark); + border: 1px solid var(--border-divider); + border-radius: 3px; +} + +.find-inputbox:focus-within { + outline: 2px solid var(--text-primary); + outline-offset: 1px; +} + +.find-input { + flex: 1 1 auto; + min-width: 0; + padding: 3px 128px 3px 6px; + background: transparent; + color: var(--text-primary); + border: none; + outline: none; +} + +.find-input::placeholder { + color: var(--clr-placeholder); +} + +/* The inline count + nav zone; pointer-events:none lets clicks fall through to focus the input, the nav buttons re-enable it. */ +.find-inline { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 4px; + z-index: 1; + pointer-events: none; +} + +.find-inline .find-nav { + pointer-events: auto; +} + +.find-count { + position: relative; + top: 1px; + max-width: 72px; + font-size: 0.85em; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.find-wrap { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.find-nav, +.find-options-toggle, +.find-close { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + background: transparent; + color: var(--text-primary); + border: none; + border-radius: 3px; + cursor: pointer; +} + +.find-nav { + width: 20px; + height: 20px; +} + +.find-options-toggle, +.find-close { + width: 24px; + height: 24px; +} + +.find-options-toggle:hover, +.find-close:hover { + background-color: var(--background-dark); +} + +.find-nav:hover { + background-color: var(--background-darkgray); +} + +.find-nav:disabled { + opacity: 0.4; + cursor: default; + background: transparent; + pointer-events: none; +} + +.find-options-toggle.is-active { + background-color: var(--toggle-accent); + color: var(--accent-on-fill); +} + +.find-options { + display: flex; + flex-direction: column; + gap: 4px; + padding-top: 4px; + border-top: 1px solid var(--border-divider); +} + +.find-options label { + color: var(--text-secondary); + cursor: pointer; +} diff --git a/src/EventLogExpert.UI/LogTable/Find/FindBar.razor.js b/src/EventLogExpert.UI/LogTable/Find/FindBar.razor.js new file mode 100644 index 000000000..52945d9ed --- /dev/null +++ b/src/EventLogExpert.UI/LogTable/Find/FindBar.razor.js @@ -0,0 +1,38 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +// Cancel WebView2's native F3 find accelerator in the capture phase; the Blazor @onkeydown handler drives navigation on the bubble phase. +let suppressor = null; + +export function attachNativeFindSuppression() { + if (suppressor) { + return; + } + + suppressor = (e) => { + if (e.key === "F3") { + e.preventDefault(); + } + }; + + document.addEventListener("keydown", suppressor, true); +} + +export function detachNativeFindSuppression() { + if (suppressor) { + document.removeEventListener("keydown", suppressor, true); + suppressor = null; + } +} + +export function focusAndSelect(element) { + if (!element) { + return; + } + + element.focus(); + + if (typeof element.select === "function") { + element.select(); + } +} diff --git a/src/EventLogExpert.UI/LogTable/Find/FindCoordinator.cs b/src/EventLogExpert.UI/LogTable/Find/FindCoordinator.cs new file mode 100644 index 000000000..404ddd6cd --- /dev/null +++ b/src/EventLogExpert.UI/LogTable/Find/FindCoordinator.cs @@ -0,0 +1,27 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI.LogTable.Find; + +public sealed class FindCoordinator : IFindCoordinator +{ + private Action? _openHandler; + + public void RequestOpen() => _openHandler?.Invoke(); + + public IDisposable SetActivePane(Action openHandler) + { + _openHandler = openHandler; + + return new Registration(this, openHandler); + } + + // Clear only if this token still owns the handler, so a newer pane's registration survives an older pane's disposal. + private sealed class Registration(FindCoordinator coordinator, Action handler) : IDisposable + { + public void Dispose() + { + if (ReferenceEquals(coordinator._openHandler, handler)) { coordinator._openHandler = null; } + } + } +} diff --git a/src/EventLogExpert.UI/LogTable/Find/IFindCoordinator.cs b/src/EventLogExpert.UI/LogTable/Find/IFindCoordinator.cs new file mode 100644 index 000000000..c1b4f6c2b --- /dev/null +++ b/src/EventLogExpert.UI/LogTable/Find/IFindCoordinator.cs @@ -0,0 +1,11 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI.LogTable.Find; + +public interface IFindCoordinator +{ + void RequestOpen(); + + IDisposable SetActivePane(Action openHandler); +} diff --git a/src/EventLogExpert.UI/LogTable/LogTablePane.Find.cs b/src/EventLogExpert.UI/LogTable/LogTablePane.Find.cs new file mode 100644 index 000000000..d8d0ebbad --- /dev/null +++ b/src/EventLogExpert.UI/LogTable/LogTablePane.Find.cs @@ -0,0 +1,602 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Runtime.LogTable; +using EventLogExpert.UI.LogTable.Find; +using EventLogExpert.UI.LogTable.Grouping; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace EventLogExpert.UI.LogTable; + +// In-view incremental Find (Ctrl+F): read-only over the current view; it never dispatches filter/lens/sort/selection actions. +public sealed partial class LogTablePane +{ + private const int FindChunkSize = 4096; + private const int FindDebounceMs = 200; + private const int MaxMarksPerCell = 32; + + private readonly HashSet _findExpandedGroupKeys = []; + + private bool _findCaseSensitive; + private int _findCurrentIndex = -1; + private ValueKey? _findCurrentKey; + private EventLocator? _findCurrentLocator; + private CancellationTokenSource? _findDebounceCts; + private int _findFocusSignal; + private (EventLogId? TableId, ColumnName? GroupBy) _findGroupContext; + private List _findMatches = []; + private HashSet _findMatchSet = []; + private bool _findOpen; + private string _findQuery = string.Empty; + private IDisposable? _findRegistration; + private bool _findRenderRequested; + private CancellationTokenSource? _findScanCts; + private int _findScanEpoch; + private bool _findScanning; + private bool _findScrollToCurrentOnRender; + private bool _findWholeWord; + private string _findWrapAnnouncement = string.Empty; + + [Inject] + private IFindCoordinator FindCoordinator { get; init; } = null!; + + private int FindCurrentOrdinal => _findCurrentIndex >= 0 ? _findCurrentIndex + 1 : 0; + + private int FindMatchCount => _findMatches.Count; + + // Renders Blazor-escaped segments (never MarkupString) so event text can't inject markup; capped at MaxMarksPerCell so a pathological cell can't explode the DOM. + private IReadOnlyList BuildFindSegments(string? text) + { + string value = text ?? string.Empty; + + if (_findQuery.Length == 0 || value.Length == 0) { return [new FindSegment(value, IsMark: false)]; } + + var segments = new List(); + StringComparison comparison = EventFindMatcher.ComparisonFor(_findCaseSensitive); + int position = 0; + int marks = 0; + + while (position < value.Length && marks < MaxMarksPerCell) + { + int hit = EventFindMatcher.IndexOfMatch(value, _findQuery, position, comparison, _findWholeWord); + + if (hit < 0) { break; } + + if (hit > position) { segments.Add(new FindSegment(value[position..hit], IsMark: false)); } + + segments.Add(new FindSegment(value.Substring(hit, _findQuery.Length), IsMark: true)); + position = hit + _findQuery.Length; + marks++; + } + + if (position < value.Length) { segments.Add(new FindSegment(value[position..], IsMark: false)); } + + return segments; + } + + private void CancelFindScans() + { + // Bump the epoch so a scan already past its cancellation check is rejected at publish time. + _findScanEpoch++; + _findScanCts?.Cancel(); + _findScanCts?.Dispose(); + _findScanCts = null; + _findScanning = false; + } + + private void ClearFindMatches() + { + _findMatches = []; + _findMatchSet = []; + _findCurrentIndex = -1; + _findCurrentKey = null; + _findCurrentLocator = null; + _findScanning = false; + _findWrapAnnouncement = string.Empty; + } + + private Task CloseFind() + { + _findOpen = false; + CancelFindScans(); + _findDebounceCts?.Cancel(); + _findDebounceCts?.Dispose(); + _findDebounceCts = null; + + // Re-collapse only the groups Find expanded, keeping the current match's group open so the cursor lands on the event. + RecollapseFindGroups(); + + if (TryGetCurrentMatchLocator(out EventLocator locator)) + { + SetCursorEvent(locator); + } + + _focusActiveOnNextRender = true; + + ClearFindMatches(); + _findQuery = string.Empty; + RequestFindRender(); + + return Task.CompletedTask; + } + + private (EventLogId? TableId, ColumnName? GroupBy) CurrentFindGroupContext() => + (_currentTable?.Id, _logTableState.GroupBy); + + private async Task DebounceThenScanAsync(CancellationTokenSource cts) + { + try { await Task.Delay(FindDebounceMs, cts.Token); } + catch (OperationCanceledException) { return; } + + if (!ReferenceEquals(_findDebounceCts, cts)) { return; } + + _findDebounceCts = null; + cts.Dispose(); + + StartFindScan(); + } + + private void DisposeFind() + { + _findRegistration?.Dispose(); + _findRegistration = null; + + CancelFindScans(); + + _findDebounceCts?.Cancel(); + _findDebounceCts?.Dispose(); + _findDebounceCts = null; + } + + private int FindAnchorIndex() + { + // Anchor in event-display-index space (Rank / group StartIndex), not visible-row space, so the first jump targets the right match under headers / collapsed groups. + if (_cursor is { Kind: TableRowKind.Event, Handle: { } handle }) + { + int rank = _activeDisplayedEvents.Rank(handle); + + return rank >= 0 ? rank : 0; + } + + if (_cursor is { Kind: TableRowKind.Header, GroupKey: { } key } && + _rowView is { } view && + view.TryGetGroupByKey(key, out EventGroup group)) + { + return group.StartIndex; + } + + return 0; + } + + private void FindNext() => StepFind(1); + + private void FindPrevious() => StepFind(-1); + + private int FirstMatchAtOrAfterAnchor() + { + if (_findMatches.Count == 0) { return -1; } + + int anchor = FindAnchorIndex(); + + for (int i = 0; i < _findMatches.Count; i++) + { + if (_activeDisplayedEvents.Rank(_findMatches[i]) >= anchor) { return i; } + } + + return 0; + } + + // Enter/F3 must act on the just-typed query, not a debounce-stale result set: flush the pending debounce and scan now. + private void FlushPendingFindScan() + { + if (_findDebounceCts is null) { return; } + + _findDebounceCts.Cancel(); + _findDebounceCts.Dispose(); + _findDebounceCts = null; + + StartFindScan(); + } + + private string? GetFindState(DisplayRow row) + { + if (!_findOpen || _findMatchSet.Count == 0) { return null; } + + if (IsCurrentFindMatch(row)) { return "current"; } + + return _findMatchSet.Contains(row.Loc) ? "match" : null; + } + + private bool IsCurrentFindMatch(DisplayRow row) => + _findOpen && _findCurrentLocator is { } locator && locator.Equals(row.Loc); + + // Called when the searchable text or event set changed; collapse/regroup deliberately do NOT call this (they keep the same view reference and Find keeps its group-expansion ownership). + private void NotifyFindViewChanged() + { + if (!_findOpen) { return; } + + if (_findQuery.Length == 0) + { + CancelFindScans(); + ClearFindMatches(); + + return; + } + + StartFindScan(); + } + + private void OnFindCaseChanged(bool caseSensitive) + { + _findCaseSensitive = caseSensitive; + ScheduleFindScan(); + RequestFindRender(); + } + + private void OnFindQueryChanged(string query) + { + _findQuery = query; + ScheduleFindScan(); + RequestFindRender(); + } + + private void OnFindWholeWordChanged(bool wholeWord) + { + _findWholeWord = wholeWord; + ScheduleFindScan(); + RequestFindRender(); + } + + private void OpenFind() + { + bool wasOpen = _findOpen; + _findOpen = true; + _findFocusSignal++; + + if (!wasOpen && _findQuery.Length > 0) { StartFindScan(); } + + RequestFindRender(); + } + + private void PruneFindGroupOwnershipOnContextChange() + { + if (_findExpandedGroupKeys.Count > 0 && !CurrentFindGroupContext().Equals(_findGroupContext)) + { + _findExpandedGroupKeys.Clear(); + } + } + + private void PublishFindMatches(List matches) + { + EventLocator? priorLocator = _findCurrentLocator; + + _findMatches = matches; + _findMatchSet = new HashSet(matches); + _findScanning = false; + _findWrapAnnouncement = string.Empty; + + ResolveCurrentMatchAfterScan(priorLocator); + _findScrollToCurrentOnRender = _findCurrentIndex >= 0; + + RequestFindRender(); + } + + private void RecollapseFindGroups() + { + if (_findExpandedGroupKeys.Count == 0) { return; } + + string? currentGroupKey = null; + + if (TryGetCurrentMatchLocator(out EventLocator locator) && _rowView is { } view) + { + int index = RowIndexOf(locator); + + if (index >= 0) { currentGroupKey = view.GroupForEvent(index).Key; } + } + + foreach (string key in _findExpandedGroupKeys) + { + if (key == currentGroupKey) { continue; } + + SetGroupCollapsed(key, collapse: true); + } + + _findExpandedGroupKeys.Clear(); + } + + private bool RecollapseSteppedAwayGroups(GroupedRowView view, string targetGroupKey) + { + bool collapsedAny = false; + + foreach (string key in _findExpandedGroupKeys.ToArray()) + { + if (key == targetGroupKey) { continue; } + + if (view.TryGetGroupByKey(key, out EventGroup owned) && !owned.IsCollapsed) + { + _findExpandedGroupKeys.Remove(key); + SetGroupCollapsed(key, collapse: true); + collapsedAny = true; + } + else + { + _findExpandedGroupKeys.Remove(key); + } + } + + return collapsedAny; + } + + private void RegisterFind() => _findRegistration = FindCoordinator.SetActivePane(OpenFind); + + private void RelinquishFindGroupOwnership() => _ = InvokeAsync(() => _findExpandedGroupKeys.Clear()); + + private void RequestFindRender() + { + _findRenderRequested = true; + StateHasChanged(); + } + + private void ResolveCurrentMatchAfterScan(EventLocator? priorLocator) + { + if (_findMatches.Count == 0) + { + _findCurrentIndex = -1; + _findCurrentKey = null; + _findCurrentLocator = null; + + return; + } + + // Preserve the current match across a rescan: the locator survives a same-generation rescan and the ValueKey survives a reload; else fall back to the first match at/after the cursor. + if (priorLocator is { } locator && _findMatchSet.Contains(locator)) + { + SetCurrentMatchIndex(_findMatches.IndexOf(locator)); + + return; + } + + if (_findCurrentKey is { } key && _activeDisplayedEvents.ResolveByKey(key) is { } resolved) + { + int existing = _findMatches.IndexOf(resolved); + + if (existing >= 0) + { + SetCurrentMatchIndex(existing); + + return; + } + } + + SetCurrentMatchIndex(FirstMatchAtOrAfterAnchor()); + } + + private async Task RunFindScanAsync( + IEventColumnView view, + ColumnName[] columns, + TimeZoneInfo timeZone, + string query, + bool caseSensitive, + bool wholeWord, + int epoch, + CancellationToken token) + { + List? matches = null; + + try + { + matches = await Task.Run( + () => + { + var found = new List(); + int total = view.Count; + + for (int start = 0; start < total; start += FindChunkSize) + { + token.ThrowIfCancellationRequested(); + + int count = Math.Min(FindChunkSize, total - start); + IReadOnlyList slice = view.Slice(start, count); + + foreach (DisplayRow row in slice) + { + if (EventFindMatcher.RowMatches(row.Lean, columns, timeZone, query, caseSensitive, wholeWord)) + { + found.Add(row.Loc); + } + } + } + + return found; + }, + token); + } + catch (OperationCanceledException) { return; } + catch (Exception e) + { + TraceLogger.Error($"{nameof(LogTablePane)}: find scan failed: {e}"); + } + + try + { + await InvokeAsync(() => + { + // Publish only if this scan is still the current one: same epoch, same view instance, same query terms. + if (epoch != _findScanEpoch || + !ReferenceEquals(view, _activeDisplayedEvents) || + !string.Equals(query, _findQuery, StringComparison.Ordinal) || + caseSensitive != _findCaseSensitive || + wholeWord != _findWholeWord) + { + return; + } + + if (matches is null) + { + _findScanning = false; + RequestFindRender(); + + return; + } + + PublishFindMatches(matches); + }); + } + catch (ObjectDisposedException) { /* Component torn down mid-publish; nothing to update. */ } + } + + private void ScheduleFindScan() + { + _findDebounceCts?.Cancel(); + _findDebounceCts?.Dispose(); + _findDebounceCts = null; + + if (_findQuery.Length == 0) + { + CancelFindScans(); + ClearFindMatches(); + + return; + } + + // Mark scanning immediately so stepping is blocked during the debounce window (otherwise Enter would navigate the pre-edit result set). + _findScanning = true; + _findWrapAnnouncement = string.Empty; + + var cts = new CancellationTokenSource(); + _findDebounceCts = cts; + + _ = DebounceThenScanAsync(cts); + } + + private async Task ScrollToCurrentFindMatchAsync() + { + if (_findCurrentIndex < 0 || _findCurrentIndex >= _findMatches.Count) + { + _findScrollToCurrentOnRender = false; + + return; + } + + EventLocator locator = _findMatches[_findCurrentIndex]; + int index = RowIndexOf(locator); + + if (index < 0) + { + _findScrollToCurrentOnRender = false; + + return; + } + + if (_rowView is { } view) + { + EventGroup targetGroup = view.GroupForEvent(index); + + if (RecollapseSteppedAwayGroups(view, targetGroup.Key)) { return; } + + if (targetGroup.IsCollapsed) + { + _findExpandedGroupKeys.Add(targetGroup.Key); + _findGroupContext = CurrentFindGroupContext(); + SetGroupCollapsed(targetGroup.Key, collapse: false); + + return; + } + } + + _findScrollToCurrentOnRender = false; + int targetRow = _rowView?.VisibleRowForEvent(index) ?? index; + + if (_tableModule is not null) + { + await _tableModule.InvokeVoidAsync("scrollToRow", targetRow); + } + } + + private void SetCurrentMatchIndex(int index) + { + _findCurrentIndex = index; + _findCurrentLocator = index >= 0 && index < _findMatches.Count ? _findMatches[index] : null; + UpdateCurrentMatchKey(); + } + + private void StartFindScan() + { + CancelFindScans(); + + if (_findQuery.Length == 0) + { + ClearFindMatches(); + RequestFindRender(); + + return; + } + + int epoch = ++_findScanEpoch; + IEventColumnView view = _activeDisplayedEvents; + var columns = (ColumnName[])_enabledColumns.Clone(); + TimeZoneInfo timeZone = _timeZoneSettings; + string query = _findQuery; + bool caseSensitive = _findCaseSensitive; + bool wholeWord = _findWholeWord; + + _findScanning = true; + var cts = new CancellationTokenSource(); + _findScanCts = cts; + + _ = RunFindScanAsync(view, columns, timeZone, query, caseSensitive, wholeWord, epoch, cts.Token); + + RequestFindRender(); + } + + private void StepFind(int direction) + { + FlushPendingFindScan(); + + if (_findScanning || _findMatches.Count == 0) { return; } + + int previousIndex = _findCurrentIndex < 0 ? (direction > 0 ? -1 : 0) : _findCurrentIndex; + int next = (previousIndex + direction + _findMatches.Count) % _findMatches.Count; + + _findWrapAnnouncement = direction > 0 && next <= previousIndex ? "Wrapped to first match" + : direction < 0 && next >= previousIndex ? "Wrapped to last match" + : string.Empty; + + SetCurrentMatchIndex(next); + _findScrollToCurrentOnRender = true; + + RequestFindRender(); + } + + private bool TryGetCurrentMatchLocator(out EventLocator locator) + { + if (_findCurrentLocator is { } current) + { + locator = current; + + return true; + } + + locator = default; + + return false; + } + + private void UpdateCurrentMatchKey() => + _findCurrentKey = + _findCurrentLocator is { } locator && + ValueKey.TryCreate(_activeDisplayedEvents.GetDetailLean(locator), out ValueKey key) + ? key + : null; + + private void UserSetGroupCollapsed(string key, bool collapse) + { + _findExpandedGroupKeys.Remove(key); + SetGroupCollapsed(key, collapse); + } + + private readonly record struct FindSegment(string Text, bool IsMark); +} diff --git a/src/EventLogExpert.UI/LogTable/LogTablePane.razor b/src/EventLogExpert.UI/LogTable/LogTablePane.razor index 04a46f0d5..793979909 100644 --- a/src/EventLogExpert.UI/LogTable/LogTablePane.razor +++ b/src/EventLogExpert.UI/LogTable/LogTablePane.razor @@ -1,33 +1,58 @@ +@using EventLogExpert.UI.LogTable.Find @using EventLogExpert.UI.LogTable.Grouping @inherits FluxorComponent @{ + RenderFragment FindableCell((string? Text, bool Mark) context) => + @ + @if (context.Mark) + { + foreach (var segment in BuildFindSegments(context.Text)) + { + if (segment.IsMark) + { + @segment.Text + } + else { @segment.Text } + } + } + else { @context.Text } + ; + RenderFragment RenderEventRow(DisplayRow row) => - @ + @{ + bool isCurrentFindMatch = IsCurrentFindMatch(row); + } @for (int i = 0; i < _enabledColumns.Length; i++) { var capturedColumn = _enabledColumns[i]; + + @if (capturedColumn == ColumnName.Level) { } - @EventTableColumnFormatter.GetCellText(row.Lean, capturedColumn, _timeZoneSettings) + + @FindableCell((EventTableColumnFormatter.GetCellText(row.Lean, capturedColumn, _timeZoneSettings), isCurrentFindMatch)) } @@ -36,109 +61,128 @@ @oncontextmenu:preventDefault="true" @oncontextmenu:stopPropagation="true" role="gridcell"> - @row.Lean.Description + @FindableCell((row.Lean.Description, isCurrentFindMatch)) ; } -
- - - - @for (int columnIndex = 0; columnIndex < _enabledColumns.Length; columnIndex++) - { - var columnHeader = _enabledColumns[columnIndex]; - _headerName = columnHeader.ToFullString(); - var width = GetColumnWidth(columnHeader); +
+ @if (_findOpen) + { + + } +
+
+ + + @for (int columnIndex = 0; columnIndex < _enabledColumns.Length; columnIndex++) + { + var columnHeader = _enabledColumns[columnIndex]; + _headerName = columnHeader.ToFullString(); + var width = GetColumnWidth(columnHeader); - - } + + } - - - - - @if (_currentTable is not null) - { - @if (_rowView is null) + + + + + @if (_currentTable is not null) { - @* Ungrouped: ItemsProvider fetches one Slice per viewport window, avoiding the Items= binding's + @if (_rowView is null) + { + @* Ungrouped: ItemsProvider fetches one Slice per viewport window, avoiding the Items= binding's per-row this[i] re-seek over the K-way merge. *@ - - @RenderEventRow(row) - - } - else - { - var view = _rowView; + + @RenderEventRow(row) + + } + else + { + var view = _rowView; - - @if (row.Kind == TableRowKind.Header) - { - var group = view.GroupAt(row); + + @if (row.Kind == TableRowKind.Header) + { + var group = view.GroupAt(row); - - - - } - else - { - @RenderEventRow(view.EventAt(row)) - } - + + + + } + else + { + @RenderEventRow(view.EventAt(row)) + } + + } } - } - -
- @if (columnHeader == ColumnName.DateAndTime) - { - @GetDateColumnHeader() - } - else - { - @_headerName - } - @if (_logTableState.OrderBy == columnHeader) - { - - } - + @if (columnHeader == ColumnName.DateAndTime) + { + @GetDateColumnHeader() + } + else + { + @_headerName + } + @if (_logTableState.OrderBy == columnHeader) + { + + } + - @EventTableColumnFormatter.DescriptionColumnHeader -
+ @EventTableColumnFormatter.DescriptionColumnHeader +
- - - - @GetGroupName(): - @GetGroupValueText(group) - (@group.EventCount) -
+ + + + @GetGroupName(): + @GetGroupValueText(group) + (@group.EventCount) +
+ + +
diff --git a/src/EventLogExpert.UI/LogTable/LogTablePane.razor.cs b/src/EventLogExpert.UI/LogTable/LogTablePane.razor.cs index 375c0430c..91a30474d 100644 --- a/src/EventLogExpert.UI/LogTable/LogTablePane.razor.cs +++ b/src/EventLogExpert.UI/LogTable/LogTablePane.razor.cs @@ -156,6 +156,8 @@ protected override async ValueTask DisposeAsyncCore(bool disposing) { if (disposing) { + DisposeFind(); + await JsModuleInterop.DisposeModuleSafelyAsync( _tableModule, static module => module.InvokeVoidAsync("disposeTableEvents")); @@ -250,6 +252,20 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } } + if (_findScrollToCurrentOnRender) + { + try + { + await ScrollToCurrentFindMatchAsync(); + } + catch (JSDisconnectedException) { /* Circuit gone; nothing to scroll. */ _findScrollToCurrentOnRender = false; } + catch (Exception e) + { + _findScrollToCurrentOnRender = false; + TraceLogger.Error($"{nameof(LogTablePane)}: failed to scroll to find match: {e}"); + } + } + await base.OnAfterRenderAsync(firstRender); } @@ -264,6 +280,9 @@ protected override async Task OnInitializedAsync() SubscribeToAction(_ => RescrollToSelected()); SubscribeToAction(_ => RescrollToSelected()); + // A bulk expand/collapse (any trigger) is a user choice: Find relinquishes group-expansion ownership so it won't later undo the user's collapse. + SubscribeToAction(_ => RelinquishFindGroupOwnership()); + _logTableState = LogTableState.Value; _currentTable = _logTableState.EventTables.FirstOrDefault(x => x.Id == _logTableState.ActiveEventLogId); @@ -282,6 +301,8 @@ protected override async Task OnInitializedAsync() RebuildRowMaps(); + RegisterFind(); + await base.OnInitializedAsync(); } @@ -298,6 +319,7 @@ protected override bool ShouldRender() if (!_focusActiveOnNextRender && !_repaintViewportOnNextRender && !_rescrollToSelectedOnRender && + !_findRenderRequested && ReferenceEquals(LogTableState.Value, _logTableState) && ReferenceEquals(Selection.Value, _selection) && !focusChanged && @@ -305,13 +327,16 @@ protected override bool ShouldRender() Settings.TimeZoneInfo.Equals(_timeZoneSettings)) { return false; } _repaintViewportOnNextRender = false; + _findRenderRequested = false; bool selectionChanged = !ReferenceEquals(Selection.Value, _selection); _logTableState = LogTableState.Value; _currentTable = _logTableState.EventTables.FirstOrDefault(x => x.Id == _logTableState.ActiveEventLogId); + var previousColumnsForFind = _enabledColumns; _enabledColumns = GetOrderedEnabledColumns(); + bool findSearchTextChanged = _findOpen && !previousColumnsForFind.SequenceEqual(_enabledColumns); if (selectionChanged) { @@ -339,10 +364,13 @@ protected override bool ShouldRender() } } + findSearchTextChanged |= _findOpen && !Settings.TimeZoneInfo.Equals(_timeZoneSettings); _timeZoneSettings = Settings.TimeZoneInfo; RebuildRowMaps(); + if (findSearchTextChanged) { NotifyFindViewChanged(); } + return true; } @@ -512,12 +540,19 @@ private async Task FocusActiveRow() { int visibleRow = ResolveCursorVisibleRow(); - // A negative index would target aria-rowindex=1 (the header row). - if (visibleRow < 0) { return; } - try { - if (_tableModule is not null) { await _tableModule.InvokeVoidAsync("focusEventTableRow", visibleRow); } + if (_tableModule is null) { return; } + + // No cursor row to land on (e.g. closing Find over an empty table): focus the scroll container so keyboard nav isn't stranded on a removed element. + if (visibleRow < 0) + { + await _tableModule.InvokeVoidAsync("focusTableContainer"); + + return; + } + + await _tableModule.InvokeVoidAsync("focusEventTableRow", visibleRow); } catch (JSDisconnectedException) { /* Circuit gone; focus best-effort during teardown. */ } catch (Exception e) @@ -646,6 +681,14 @@ private int GetRowStripe(EventLocator loc) private async Task HandleKeyDown(KeyboardEventArgs args) { + // Esc closes an open Find here BEFORE the selection-clearing Escape branch below, so closing Find never wipes the user's selection. + if (_findOpen && args.Code == "Escape") + { + await CloseFind(); + + return; + } + var displayedEvents = _activeDisplayedEvents; if (displayedEvents.Count == 0) { return; } @@ -1001,6 +1044,9 @@ private void RebuildRowMaps() var displayedEvents = ResolveActiveDisplayedEvents(); _activeDisplayedEvents = displayedEvents; + // Drop Find's group-expansion ownership when the group-key namespace (active table / GroupBy) has changed. + PruneFindGroupOwnershipOnContextChange(); + var currentTableId = _currentTable?.Id; // Highlight results are keyed by locator (stable within a generation across re-sorts). A reload mints a new @@ -1017,6 +1063,9 @@ private void RebuildRowMaps() _lastIndexedDisplayedEvents = displayedEvents; _refreshEventViewportOnRender = true; + // The event set changed (filter/sort/append/reload) so prior Find matches are stale; collapse/regroup keep the same reference and deliberately don't reach here. + NotifyFindViewChanged(); + if (displayedEvents.Count == 0) { _selectionAnchor = null; @@ -1401,7 +1450,7 @@ private IReadOnlyList ShowGroupContextMenuItems(EventGroup group) [ MenuItem.Item( collapsedNow ? "Expand Group" : "Collapse Group", - () => SetGroupCollapsed(group.Key, !collapsedNow)), + () => UserSetGroupCollapsed(group.Key, !collapsedNow)), MenuItem.Item("Expand All Groups", () => LogTableCommands.SetAllGroupsCollapsed(false)), MenuItem.Item("Collapse All Groups", () => LogTableCommands.SetAllGroupsCollapsed(true)), MenuItem.Separator(), @@ -1414,7 +1463,12 @@ private IReadOnlyList ShowGroupContextMenuItems(EventGroup group) ]; } - private void ToggleGroupCollapsed(string groupKey) => LogTableCommands.ToggleGroupCollapsed(groupKey); + private void ToggleGroupCollapsed(string groupKey) + { + // A manual toggle of a Find-expanded group hands ownership back to the user, so Find will not later re-collapse it. + _findExpandedGroupKeys.Remove(groupKey); + LogTableCommands.ToggleGroupCollapsed(groupKey); + } private void ToggleSorting() => LogTableCommands.ToggleSortDirection(); diff --git a/src/EventLogExpert.UI/LogTable/LogTablePane.razor.css b/src/EventLogExpert.UI/LogTable/LogTablePane.razor.css index 5bc411ad8..fb8cd20bc 100644 --- a/src/EventLogExpert.UI/LogTable/LogTablePane.razor.css +++ b/src/EventLogExpert.UI/LogTable/LogTablePane.razor.css @@ -116,6 +116,81 @@ tr { } } +/* Flex (not a bare position:relative wrapper) so it stays a flex item with a constrained scroll viewport, and gives the absolute FindBar a containing block. */ +.table-find-host { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-height: 0; + position: relative; +} + +/* box-shadow is one property, so the find ring is comma-composed with the selection/highlight stripe shadows on rows that carry both; current match = 2px vs 1px weight. */ +.table-row[data-find] { + box-shadow: inset 0 0 0 1px var(--text-secondary); +} + +.table-row[data-find="current"] { + box-shadow: inset 0 0 0 2px var(--text-secondary); +} + +.table-row.selected[data-find] { + box-shadow: inset 3px 0 0 var(--clr-statusbar), inset 0 0 0 1px currentColor; +} + +.table-row.selected[data-find="current"] { + box-shadow: inset 3px 0 0 var(--clr-statusbar), inset 0 0 0 2px currentColor; +} + +.table-row[data-highlight][data-find] { + box-shadow: inset 3px 0 0 var(--hl-fg), inset 0 0 0 1px currentColor; +} + +.table-row[data-highlight][data-find="current"] { + box-shadow: inset 3px 0 0 var(--hl-fg), inset 0 0 0 2px currentColor; +} + +.table-row.selected[data-highlight][data-find] { + box-shadow: inset 3px 0 0 var(--clr-statusbar), inset 6px 0 0 var(--hl-fg), inset 0 0 0 1px currentColor; +} + +.table-row.selected[data-highlight][data-find="current"] { + box-shadow: inset 3px 0 0 var(--clr-statusbar), inset 6px 0 0 var(--hl-fg), inset 0 0 0 2px currentColor; +} + +.find-mark { + background-color: var(--clr-find); + color: var(--clr-on-find); + border-radius: 2px; +} + +/* box-shadow computes to none in forced-colors, so the ring becomes a weight-encoded outline; the highlight stripe is re-asserted with forced-color-adjust:none. */ +@media (forced-colors: active) { + .table-row[data-highlight][data-find] { + box-shadow: inset 3px 0 0 MarkText; + forced-color-adjust: none; + } + + .table-row.selected[data-highlight][data-find] { + box-shadow: inset 3px 0 0 Highlight, inset 6px 0 0 MarkText; + forced-color-adjust: none; + } + + .table-row[data-find] { + outline: 1px solid Highlight; + outline-offset: -2px; + } + + .table-row[data-find="current"] { + outline-width: 2px; + } + + .find-mark { + background-color: Mark; + color: MarkText; + forced-color-adjust: none; + } +} .description { width: 100%; } .group-header-row { cursor: pointer; } diff --git a/src/EventLogExpert.UI/LogTable/LogTablePane.razor.js b/src/EventLogExpert.UI/LogTable/LogTablePane.razor.js index a88b2cf86..430d0108d 100644 --- a/src/EventLogExpert.UI/LogTable/LogTablePane.razor.js +++ b/src/EventLogExpert.UI/LogTable/LogTablePane.razor.js @@ -406,6 +406,15 @@ function registerKeyHandlers(table, signal) { { capture: true, signal }); } +export function focusTableContainer() { + const table = document.getElementById("eventTable"); + const container = table ? table.parentNode : null; + + if (container) { + container.focus({ preventScroll: true }); + } +} + export function scrollToRow(offset) { const generation = ++scrollToRowGeneration; diff --git a/src/EventLogExpert.UI/wwwroot/Keyboard/Keyboard.js b/src/EventLogExpert.UI/wwwroot/Keyboard/Keyboard.js index 5d59d0f94..a662cb6f3 100644 --- a/src/EventLogExpert.UI/wwwroot/Keyboard/Keyboard.js +++ b/src/EventLogExpert.UI/wwwroot/Keyboard/Keyboard.js @@ -34,7 +34,7 @@ export function registerKeyboardShortcuts(ref) { if (e.altKey || e.shiftKey) { return; } const code = e.code; - if (code !== "KeyO" && code !== "KeyH" && code !== "KeyC") { return; } + if (code !== "KeyO" && code !== "KeyH" && code !== "KeyC" && code !== "KeyF") { return; } // Ctrl+C must yield to the browser's native copy whenever the user could // reasonably be copying text. The .NET handler can't tell from afar, so the diff --git a/src/EventLogExpert.UI/wwwroot/app.css b/src/EventLogExpert.UI/wwwroot/app.css index 828f9e4ec..3e0f2c631 100644 --- a/src/EventLogExpert.UI/wwwroot/app.css +++ b/src/EventLogExpert.UI/wwwroot/app.css @@ -19,6 +19,10 @@ --clr-placeholder: light-dark(#6B6B6B, #A3A1A1); --clr-statusbar: light-dark(#2E50C0, #3C64E7); + /* Find highlight: highlighter-yellow background fill for the current match's inline marks; #1A1A1A text clears WCAG AA on both themes. */ + --clr-find: light-dark(#FFE066, #F8C84B); + --clr-on-find: #1A1A1A; + --background-dark: light-dark(#E0E0E0, #222222); --background-darkgray: light-dark(#CECECE, #353535); diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/EventFindMatcherTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/EventFindMatcherTests.cs new file mode 100644 index 000000000..6b13882ce --- /dev/null +++ b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/EventFindMatcherTests.cs @@ -0,0 +1,131 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Runtime.LogTable; + +namespace EventLogExpert.Runtime.Tests.LogTable; + +public sealed class EventFindMatcherTests +{ + private static readonly ColumnName[] s_allColumns = + [ + ColumnName.Level, ColumnName.DateAndTime, ColumnName.Source, ColumnName.EventId, ColumnName.ComputerName + ]; + private static readonly ResolvedEvent s_event = new("Server\\Security.evtx", LogPathType.File) + { + RecordId = 42, + Level = "Information", + TimeCreated = new DateTime(2026, 6, 18, 6, 57, 20, DateTimeKind.Utc), + Id = 4624, + Source = "Microsoft-Windows-Security-Auditing", + ComputerName = "DC01", + Description = "An account was successfully logged on." + }; + + [Fact] + public void ComparisonFor_MapsCaseFlagToOrdinalVariants() + { + Assert.Equal(StringComparison.Ordinal, EventFindMatcher.ComparisonFor(caseSensitive: true)); + Assert.Equal(StringComparison.OrdinalIgnoreCase, EventFindMatcher.ComparisonFor(caseSensitive: false)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void IndexOfMatch_EmptyQuery_ReturnsMinusOne(bool wholeWord) + { + // Empty query returns -1 in both modes: guards the public helper (whole-word boundary indexing would otherwise read text[-1]). + Assert.Equal(-1, EventFindMatcher.IndexOfMatch("account", string.Empty, 0, StringComparison.Ordinal, wholeWord)); + } + + [Fact] + public void IndexOfMatch_Substring_ReturnsEveryOccurrence() + { + // Without whole-word, IndexOfMatch returns the sub-token "cat" inside "catalog" (index 4). + Assert.Equal(4, EventFindMatcher.IndexOfMatch("cat catalog cat", "cat", 1, StringComparison.Ordinal, wholeWord: false)); + } + + [Theory] + [InlineData("cat catalog cat", "cat", 0, 0)] // first token is bounded + [InlineData("cat catalog cat", "cat", 1, 12)] // skips the "cat" inside "catalog", finds the trailing token + [InlineData("catalog", "cat", 0, -1)] // only sub-token occurrence -> no whole-word match + public void IndexOfMatch_WholeWord_ReturnsOnlyWordBoundedOccurrences(string text, string query, int start, int expected) + { + Assert.Equal(expected, EventFindMatcher.IndexOfMatch(text, query, start, StringComparison.Ordinal, wholeWord: true)); + } + + [Fact] + public void RowMatches_CaseInsensitiveByDefault() + { + Assert.True(EventFindMatcher.RowMatches(s_event, s_allColumns, TimeZoneInfo.Utc, "security-auditing", caseSensitive: false, wholeWord: false)); + } + + [Fact] + public void RowMatches_CaseSensitive_RejectsWrongCase_AcceptsExactCase() + { + Assert.False(EventFindMatcher.RowMatches(s_event, s_allColumns, TimeZoneInfo.Utc, "security-auditing", caseSensitive: true, wholeWord: false)); + Assert.True(EventFindMatcher.RowMatches(s_event, s_allColumns, TimeZoneInfo.Utc, "Security-Auditing", caseSensitive: true, wholeWord: false)); + } + + [Fact] + public void RowMatches_EmptyQuery_NeverMatches() + { + Assert.False(EventFindMatcher.RowMatches(s_event, s_allColumns, TimeZoneInfo.Utc, string.Empty, caseSensitive: false, wholeWord: false)); + } + + [Fact] + public void RowMatches_MatchesDescriptionText() + { + Assert.True(EventFindMatcher.RowMatches(s_event, s_allColumns, TimeZoneInfo.Utc, "successfully logged on", caseSensitive: false, wholeWord: false)); + } + + [Fact] + public void RowMatches_MatchesFormattedColumnText() + { + Assert.True(EventFindMatcher.RowMatches(s_event, s_allColumns, TimeZoneInfo.Utc, "4624", caseSensitive: false, wholeWord: false)); + Assert.True(EventFindMatcher.RowMatches(s_event, s_allColumns, TimeZoneInfo.Utc, "DC01", caseSensitive: false, wholeWord: false)); + } + + [Fact] + public void RowMatches_NoMatch_ReturnsFalse() + { + Assert.False(EventFindMatcher.RowMatches(s_event, s_allColumns, TimeZoneInfo.Utc, "nonexistent-token", caseSensitive: false, wholeWord: false)); + } + + [Fact] + public void RowMatches_OnlySearchesEnabledColumns() + { + Assert.False(EventFindMatcher.RowMatches( + s_event, [ColumnName.EventId, ColumnName.ComputerName], TimeZoneInfo.Utc, "Security-Auditing", caseSensitive: false, wholeWord: false)); + } + + [Fact] + public void RowMatches_WholeWord_MatchesBoundedEventIdAndHyphenSegment() + { + Assert.True(EventFindMatcher.RowMatches(s_event, s_allColumns, TimeZoneInfo.Utc, "4624", caseSensitive: false, wholeWord: true)); + Assert.True(EventFindMatcher.RowMatches(s_event, s_allColumns, TimeZoneInfo.Utc, "Security", caseSensitive: false, wholeWord: true)); + } + + [Fact] + public void RowMatches_WholeWord_QueryWithSeparatorEdge_IsBounded() + { + Assert.True(EventFindMatcher.RowMatches(s_event, s_allColumns, TimeZoneInfo.Utc, "-Auditing", caseSensitive: false, wholeWord: true)); + } + + [Fact] + public void RowMatches_WholeWord_RejectsMidWordSubstring() + { + Assert.True(EventFindMatcher.RowMatches(s_event, s_allColumns, TimeZoneInfo.Utc, "count", caseSensitive: false, wholeWord: false)); + Assert.False(EventFindMatcher.RowMatches(s_event, s_allColumns, TimeZoneInfo.Utc, "count", caseSensitive: false, wholeWord: true)); + Assert.True(EventFindMatcher.RowMatches(s_event, s_allColumns, TimeZoneInfo.Utc, "account", caseSensitive: false, wholeWord: true)); + } + + [Fact] + public void RowMatches_WholeWord_RejectsNumericSubtoken() + { + Assert.True(EventFindMatcher.RowMatches(s_event, s_allColumns, TimeZoneInfo.Utc, "462", caseSensitive: false, wholeWord: false)); + Assert.False(EventFindMatcher.RowMatches(s_event, s_allColumns, TimeZoneInfo.Utc, "462", caseSensitive: false, wholeWord: true)); + } +} diff --git a/tests/Unit/EventLogExpert.UI.Tests/LogTable/FindBarTests.cs b/tests/Unit/EventLogExpert.UI.Tests/LogTable/FindBarTests.cs new file mode 100644 index 000000000..9acbcce67 --- /dev/null +++ b/tests/Unit/EventLogExpert.UI.Tests/LogTable/FindBarTests.cs @@ -0,0 +1,211 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Bunit; +using EventLogExpert.UI.LogTable.Find; +using Microsoft.AspNetCore.Components.Web; + +namespace EventLogExpert.UI.Tests.LogTable; + +public sealed class FindBarTests : BunitContext +{ + public FindBarTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + JSInterop.SetupModule("./_content/EventLogExpert.UI/LogTable/Find/FindBar.razor.js"); + } + + [Fact] + public void CaseToggle_InOptionsTray_TogglesCaseSensitive() + { + bool? toggled = null; + var cut = Render(parameters => parameters + .Add(p => p.Query, "x") + .Add(p => p.CaseSensitive, false) + .Add(p => p.CaseSensitiveChanged, value => toggled = value)); + + cut.Find(".find-options-toggle").Click(); + cut.FindAll(".find-options .toggle-input")[0].Change(true); + + Assert.True(toggled); + } + + [Fact] + public void CountText_EmptyQuery_IsBlank() + { + var cut = Render(parameters => parameters.Add(p => p.Query, string.Empty)); + + Assert.Equal(string.Empty, cut.Find(".find-count").TextContent.Trim()); + } + + [Fact] + public void CountText_NoMatches_ShowsNoResults() + { + var cut = Render(parameters => parameters + .Add(p => p.Query, "x") + .Add(p => p.IsScanning, false) + .Add(p => p.MatchCount, 0)); + + Assert.Equal("No results", cut.Find(".find-count").TextContent.Trim()); + } + + [Fact] + public void CountText_Scanning_ShowsSearching() + { + var cut = Render(parameters => parameters + .Add(p => p.Query, "x") + .Add(p => p.IsScanning, true)); + + Assert.Equal("Searching\u2026", cut.Find(".find-count").TextContent.Trim()); + } + + [Fact] + public void CountText_WithMatches_ShowsOrdinalOfTotal() + { + var cut = Render(parameters => parameters + .Add(p => p.Query, "x") + .Add(p => p.IsScanning, false) + .Add(p => p.MatchCount, 57) + .Add(p => p.CurrentOrdinal, 3)); + + Assert.Equal("3/57", cut.Find(".find-count").TextContent.Trim()); + } + + [Fact] + public void Enter_InvokesNext_ShiftEnter_InvokesPrevious() + { + int next = 0; + int previous = 0; + var cut = Render(parameters => parameters + .Add(p => p.Query, "x") + .Add(p => p.OnNext, () => next++) + .Add(p => p.OnPrevious, () => previous++)); + + cut.Find(".find-input").KeyDown(new KeyboardEventArgs { Key = "Enter" }); + cut.Find(".find-input").KeyDown(new KeyboardEventArgs { Key = "Enter", ShiftKey = true }); + + Assert.Equal(1, next); + Assert.Equal(1, previous); + } + + [Fact] + public void Escape_InvokesOnClose() + { + int closed = 0; + var cut = Render(parameters => parameters + .Add(p => p.Query, "x") + .Add(p => p.OnClose, () => closed++)); + + cut.Find(".find-bar").KeyDown(new KeyboardEventArgs { Key = "Escape" }); + + Assert.Equal(1, closed); + } + + [Fact] + public void F3_InvokesNext_ShiftF3_InvokesPrevious() + { + int next = 0; + int previous = 0; + var cut = Render(parameters => parameters + .Add(p => p.Query, "x") + .Add(p => p.OnNext, () => next++) + .Add(p => p.OnPrevious, () => previous++)); + + cut.Find(".find-bar").KeyDown(new KeyboardEventArgs { Key = "F3" }); + cut.Find(".find-bar").KeyDown(new KeyboardEventArgs { Key = "F3", ShiftKey = true }); + + Assert.Equal(1, next); + Assert.Equal(1, previous); + } + + [Fact] + public void Input_InvokesQueryChanged() + { + string? typed = null; + var cut = Render(parameters => parameters + .Add(p => p.Query, string.Empty) + .Add(p => p.QueryChanged, value => typed = value)); + + cut.Find(".find-input").Input("error"); + + Assert.Equal("error", typed); + } + + [Fact] + public void NavButtons_DisabledWhileScanning_AndWhenNoMatches() + { + var scanning = Render(parameters => parameters + .Add(p => p.Query, "x") + .Add(p => p.IsScanning, true) + .Add(p => p.MatchCount, 5)); + + Assert.True(scanning.FindAll(".find-nav").All(button => button.HasAttribute("disabled"))); + + var noMatches = Render(parameters => parameters + .Add(p => p.Query, "x") + .Add(p => p.IsScanning, false) + .Add(p => p.MatchCount, 0)); + + Assert.True(noMatches.FindAll(".find-nav").All(button => button.HasAttribute("disabled"))); + } + + [Fact] + public void NavButtons_EnabledWithMatches() + { + var cut = Render(parameters => parameters + .Add(p => p.Query, "x") + .Add(p => p.IsScanning, false) + .Add(p => p.MatchCount, 5) + .Add(p => p.CurrentOrdinal, 1)); + + Assert.True(cut.FindAll(".find-nav").All(button => !button.HasAttribute("disabled"))); + } + + [Fact] + public void OptionsButton_ShowsActiveAccent_OnlyWhenAConstraintIsOn() + { + var inactive = Render(parameters => parameters + .Add(p => p.Query, "x") + .Add(p => p.CaseSensitive, false) + .Add(p => p.WholeWord, false)); + + Assert.DoesNotContain("is-active", inactive.Find(".find-options-toggle").ClassName); + + var active = Render(parameters => parameters + .Add(p => p.Query, "x") + .Add(p => p.WholeWord, true)); + + Assert.Contains("is-active", active.Find(".find-options-toggle").ClassName); + } + + [Fact] + public void OptionsTray_CollapsedByDefault_ExpandsOnClick() + { + var cut = Render(parameters => parameters.Add(p => p.Query, "x")); + + Assert.Empty(cut.FindAll(".find-options")); + Assert.Equal("false", cut.Find(".find-options-toggle").GetAttribute("aria-expanded")); + Assert.False(cut.Find(".find-options-toggle").HasAttribute("aria-controls")); + + cut.Find(".find-options-toggle").Click(); + + Assert.Single(cut.FindAll(".find-options")); + Assert.Equal("true", cut.Find(".find-options-toggle").GetAttribute("aria-expanded")); + Assert.Equal("find-options", cut.Find(".find-options-toggle").GetAttribute("aria-controls")); + } + + [Fact] + public void WholeWordToggle_InOptionsTray_TogglesWholeWord() + { + bool? toggled = null; + var cut = Render(parameters => parameters + .Add(p => p.Query, "x") + .Add(p => p.WholeWord, false) + .Add(p => p.WholeWordChanged, value => toggled = value)); + + cut.Find(".find-options-toggle").Click(); + cut.FindAll(".find-options .toggle-input")[1].Change(true); + + Assert.True(toggled); + } +} diff --git a/tests/Unit/EventLogExpert.UI.Tests/LogTable/LogTablePaneFindTests.cs b/tests/Unit/EventLogExpert.UI.Tests/LogTable/LogTablePaneFindTests.cs new file mode 100644 index 000000000..848e9f280 --- /dev/null +++ b/tests/Unit/EventLogExpert.UI.Tests/LogTable/LogTablePaneFindTests.cs @@ -0,0 +1,233 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Bunit; +using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Filtering.Persistence; +using EventLogExpert.Runtime.EventLog; +using EventLogExpert.Runtime.FilterPane; +using EventLogExpert.Runtime.LogTable; +using EventLogExpert.Runtime.Settings; +using EventLogExpert.UI.LogTable; +using EventLogExpert.UI.LogTable.Find; +using EventLogExpert.UI.Tests.TestUtils; +using Fluxor; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using System.Collections.Immutable; +using System.Globalization; + +namespace EventLogExpert.UI.Tests.LogTable; + +public sealed class LogTablePaneFindTests : BunitContext +{ + private const string LogName = "Application"; + + private readonly ILogTableColumnDefaultsProvider _columnDefaults = Substitute.For(); + private readonly IEventLogCommands _eventLogCommands = Substitute.For(); + private readonly IState _filterPaneState = Substitute.For>(); + private readonly IHighlightSelector _highlightSelector = Substitute.For(); + private readonly EventLogId _logId = EventLogId.Create(); + private readonly IState _logTableState = Substitute.For>(); + private readonly IStateSelection _selectedEvent = Substitute.For>(); + private readonly IStateSelection> _selectedEvents = Substitute.For>>(); + private readonly ISettingsService _settings = Substitute.For(); + + private bool _selectionDispatched; + + public LogTablePaneFindTests() + { + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + JSInterop.Mode = JSRuntimeMode.Loose; + JSInterop.SetupModule("./_content/EventLogExpert.UI/LogTable/LogTablePane.razor.js"); + JSInterop.SetupModule("./_content/EventLogExpert.UI/LogTable/Find/FindBar.razor.js"); + + _columnDefaults.ColumnOrder.Returns(ImmutableList.Create(ColumnName.Level)); + _filterPaneState.Value.Returns(new FilterPaneState()); + _highlightSelector.Select(Arg.Any>()).Returns([]); + _highlightSelector.ComputeHighlightKey(Arg.Any>()).Returns(0); + _settings.TimeZoneInfo.Returns(TimeZoneInfo.Utc); + _selectedEvent.Value.Returns((SelectionEntry?)null); + _selectedEvents.Value.Returns(ImmutableList.Empty); + + _eventLogCommands + .When(c => c.SetSelectedEvents(Arg.Any>(), Arg.Any())) + .Do(_ => _selectionDispatched = true); + + Services.AddLogTablePaneDependencies(); + Services.AddSingleton(_columnDefaults); + Services.AddSingleton(_eventLogCommands); + Services.AddSingleton(_filterPaneState); + Services.AddSingleton(_highlightSelector); + Services.AddSingleton(_logTableState); + Services.AddSingleton(_selectedEvent); + Services.AddSingleton(_selectedEvents); + Services.AddSingleton(_settings); + Services.AddFluxor(options => options.ScanAssemblies(typeof(LogTablePane).Assembly)); + } + + [Fact] + public void CurrentMatch_RendersInlineMark() + { + var cut = RenderWithEvents(NewEvent(1, "alpha"), NewEvent(2, "beta match"), NewEvent(3, "gamma match")); + + OpenFindAndSearch(cut, "match"); + + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("mark.find-mark"))); + Assert.Equal("match", cut.Find("mark.find-mark").TextContent); + } + + [Fact] + public void CurrentMatchRow_HasAriaCurrent() + { + var cut = RenderWithEvents(NewEvent(1, "alpha match"), NewEvent(2, "beta match"), NewEvent(3, "gamma")); + + OpenFindAndSearch(cut, "match"); + + cut.WaitForAssertion(() => Assert.Single(cut.FindAll("tr[data-find='current']"))); + Assert.Equal("true", cut.Find("tr[data-find='current']").GetAttribute("aria-current")); + Assert.DoesNotContain(cut.FindAll("tr[data-find='match']"), row => row.HasAttribute("aria-current")); + } + + [Fact] + public void Escape_FromGrid_ClosesFindWithoutClearingSelection() + { + var cut = RenderWithEvents(NewEvent(1, "alpha match"), NewEvent(2, "beta")); + + OpenFindAndSearch(cut, "match"); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("tr[data-find]"))); + + _selectionDispatched = false; + cut.Find(".table-container").KeyDown(new KeyboardEventArgs { Code = "Escape", Key = "Escape" }); + + cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".find-bar"))); + Assert.False(_selectionDispatched); + } + + [Fact] + public void OpenFind_ShowsFindBar() + { + var cut = RenderWithEvents(NewEvent(1, "alpha")); + + OpenFind(cut); + + Assert.NotEmpty(cut.FindAll(".find-bar")); + } + + [Fact] + public void Query_MarksMatchingRowsAndReportsCount() + { + var cut = RenderWithEvents( + NewEvent(1, "alpha"), NewEvent(2, "beta match"), NewEvent(3, "gamma"), NewEvent(4, "delta match")); + + OpenFindAndSearch(cut, "match"); + + cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll("tr[data-find]").Count)); + Assert.Single(cut.FindAll("tr[data-find='current']")); + Assert.Contains("/2", cut.Find(".find-count").TextContent); + } + + [Fact] + public void Stepping_ToNextMatch_DoesNotChangeSelection() + { + var cut = RenderWithEvents(NewEvent(1, "match one"), NewEvent(2, "match two"), NewEvent(3, "other")); + + OpenFindAndSearch(cut, "match"); + cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll("tr[data-find]").Count)); + + _selectionDispatched = false; + cut.FindAll(".find-nav")[1].Click(); + + Assert.False(_selectionDispatched); + } + + [Fact] + public void SteppingPastLastMatch_AnnouncesWrap() + { + var cut = RenderWithEvents(NewEvent(1, "match one"), NewEvent(2, "match two"), NewEvent(3, "other")); + + OpenFindAndSearch(cut, "match"); + cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll("tr[data-find]").Count)); + + cut.FindAll(".find-nav")[1].Click(); + cut.FindAll(".find-nav")[1].Click(); + + cut.WaitForAssertion(() => Assert.Contains("Wrapped to first", cut.Find(".find-wrap").TextContent)); + } + + [Fact] + public void Typing_ImmediatelyEntersScanningState_BeforeDebounceFires() + { + var cut = RenderWithEvents(NewEvent(1, "alpha match"), NewEvent(2, "beta")); + + OpenFind(cut); + cut.Find(".find-input").Input("match"); + + Assert.Contains("Searching", cut.Find(".find-count").TextContent); + Assert.True(cut.FindAll(".find-nav").All(button => button.HasAttribute("disabled"))); + } + + [Fact] + public void UnmatchedRows_HaveNoFindMarker() + { + var cut = RenderWithEvents(NewEvent(1, "alpha match"), NewEvent(2, "beta"), NewEvent(3, "gamma")); + + OpenFindAndSearch(cut, "match"); + + cut.WaitForAssertion(() => Assert.Single(cut.FindAll("tr[data-find]"))); + } + + [Fact] + public void WholeWord_RestrictsMatchesToWordBoundedOccurrences() + { + var cut = RenderWithEvents(NewEvent(1, "match"), NewEvent(2, "matches found"), NewEvent(3, "rematch")); + + // Enable whole-word via the tray BEFORE typing so there is a single scan cycle (the tray opens against an empty query, avoiding a scan race). + OpenFind(cut); + cut.Find(".find-options-toggle").Click(); + cut.FindAll(".find-options .toggle-input")[1].Change(true); + cut.Find(".find-input").Input("match"); + + cut.WaitForAssertion(() => Assert.Single(cut.FindAll("tr[data-find]")), TimeSpan.FromSeconds(5)); + } + + private static ResolvedEvent NewEvent(int id, string description) => + new(LogName, LogPathType.Channel) + { + Id = id, + RecordId = id, + TimeCreated = new DateTime(2024, 1, 1, 0, 0, id, DateTimeKind.Utc), + Description = description, + Level = "Information" + }; + + private void OpenFind(IRenderedComponent cut) + { + var coordinator = Services.GetRequiredService(); + cut.InvokeAsync(() => coordinator.RequestOpen()); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".find-input"))); + } + + private void OpenFindAndSearch(IRenderedComponent cut, string query) + { + OpenFind(cut); + cut.Find(".find-input").Input(query); + } + + private IRenderedComponent RenderWithEvents(params ResolvedEvent[] events) + { + _logTableState.Value.Returns(new LogTableState + { + ActiveEventLogId = _logId, + EventTables = ImmutableList.Create(new LogView(_logId) { LogName = LogName }), + EventCountByLog = ImmutableDictionary.Empty.Add(_logId, events.Length), + Columns = ImmutableDictionary.Empty.Add(ColumnName.Level, true), + ColumnOrder = ImmutableList.Create(ColumnName.Level) + }.WithLogEvents(_logId, events)); + + return Render(); + } +} diff --git a/tests/Unit/EventLogExpert.UI.Tests/TestUtils/LogTablePaneDependenciesExtensions.cs b/tests/Unit/EventLogExpert.UI.Tests/TestUtils/LogTablePaneDependenciesExtensions.cs index d3d9a3917..5471eb85f 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/TestUtils/LogTablePaneDependenciesExtensions.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/TestUtils/LogTablePaneDependenciesExtensions.cs @@ -9,6 +9,7 @@ using EventLogExpert.Runtime.FilterPane; using EventLogExpert.Runtime.LogTable; using EventLogExpert.Runtime.Menu; +using EventLogExpert.UI.LogTable.Find; using Microsoft.Extensions.DependencyInjection; using NSubstitute; @@ -25,6 +26,7 @@ public IServiceCollection AddLogTablePaneDependencies() services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); + services.AddSingleton(); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For());