diff --git a/src/EventLogExpert.UI/Store/EventLog/EventLogAction.cs b/src/EventLogExpert.UI/Store/EventLog/EventLogAction.cs index fd132fd7..1dcd315d 100644 --- a/src/EventLogExpert.UI/Store/EventLog/EventLogAction.cs +++ b/src/EventLogExpert.UI/Store/EventLog/EventLogAction.cs @@ -33,7 +33,12 @@ public sealed record LoadNewEvents; public sealed record OpenLog(string LogName, LogType LogType, CancellationToken Token = default); - public sealed record SelectEvent(DisplayEventModel? SelectedEvent); + public sealed record SelectEvent( + DisplayEventModel SelectedEvent, + bool IsMultiSelect = false, + bool ShouldStaySelected = false); + + public sealed record SelectEvents(IEnumerable SelectedEvents); public sealed record SetContinouslyUpdate(bool ContinuouslyUpdate); diff --git a/src/EventLogExpert.UI/Store/EventLog/EventLogReducers.cs b/src/EventLogExpert.UI/Store/EventLog/EventLogReducers.cs index 04c6f1ef..918b1d4c 100644 --- a/src/EventLogExpert.UI/Store/EventLog/EventLogReducers.cs +++ b/src/EventLogExpert.UI/Store/EventLog/EventLogReducers.cs @@ -23,7 +23,7 @@ public static EventLogState ReduceCloseAll(EventLogState state) => state with { ActiveLogs = ImmutableDictionary.Empty, - SelectedEvent = null, + SelectedEvents = [], NewEventBuffer = new List().AsReadOnly(), NewEventBufferIsFull = false }; @@ -75,9 +75,37 @@ state with [ReducerMethod] public static EventLogState ReduceSelectEvent(EventLogState state, EventLogAction.SelectEvent action) { - if (state.SelectedEvent == action.SelectedEvent) { return state; } + if (!state.SelectedEvents.Contains(action.SelectedEvent)) + { + return state with + { + SelectedEvents = action.IsMultiSelect ? + state.SelectedEvents.Add(action.SelectedEvent) : [action.SelectedEvent] + }; + } + + if (action is { IsMultiSelect: true, ShouldStaySelected: false }) + { + return state with { SelectedEvents = state.SelectedEvents.Remove(action.SelectedEvent) }; + } + + return action.ShouldStaySelected ? state : state with { SelectedEvents = [action.SelectedEvent] }; + } + + [ReducerMethod] + public static EventLogState ReduceSelectEvents(EventLogState state, EventLogAction.SelectEvents action) + { + List eventsToAdd = []; + + foreach (var selectedEvent in action.SelectedEvents) + { + if (!state.SelectedEvents.Contains(selectedEvent)) + { + eventsToAdd.Add(selectedEvent); + } + } - return state with { SelectedEvent = action.SelectedEvent }; + return state with { SelectedEvents = state.SelectedEvents.AddRange(eventsToAdd) }; } [ReducerMethod] diff --git a/src/EventLogExpert.UI/Store/EventLog/EventLogState.cs b/src/EventLogExpert.UI/Store/EventLog/EventLogState.cs index 1a8238fc..fe4ab26d 100644 --- a/src/EventLogExpert.UI/Store/EventLog/EventLogState.cs +++ b/src/EventLogExpert.UI/Store/EventLog/EventLogState.cs @@ -27,5 +27,5 @@ public sealed record EventLogState public bool NewEventBufferIsFull { get; init; } - public DisplayEventModel? SelectedEvent { get; init; } + public ImmutableList SelectedEvents { get; init; } = []; } diff --git a/src/EventLogExpert.UI/Store/LoggingMiddleware.cs b/src/EventLogExpert.UI/Store/LoggingMiddleware.cs index 1aaa4280..0b7eb6ca 100644 --- a/src/EventLogExpert.UI/Store/LoggingMiddleware.cs +++ b/src/EventLogExpert.UI/Store/LoggingMiddleware.cs @@ -68,6 +68,11 @@ public override void BeforeDispatch(object action) _debugLogger.Trace($"Action: {nameof(EventLogAction.SelectEvent)} selected " + $"{selectEventAction.SelectedEvent?.Source} event ID {selectEventAction.SelectedEvent?.Id}."); + break; + case EventLogAction.SelectEvents selectEventsAction: + _debugLogger.Trace($"Action: {nameof(EventLogAction.SelectEvents)} selected " + + $"{selectEventsAction.SelectedEvents.Count()} events"); + break; case StatusBarAction.SetEventsLoading: _debugLogger.Trace($"Action: {action.GetType()} {JsonSerializer.Serialize(action, _serializerOptions)}", LogLevel.Debug); diff --git a/src/EventLogExpert/Components/DetailsPane.razor.cs b/src/EventLogExpert/Components/DetailsPane.razor.cs index 49a55a6b..f6932de5 100644 --- a/src/EventLogExpert/Components/DetailsPane.razor.cs +++ b/src/EventLogExpert/Components/DetailsPane.razor.cs @@ -9,6 +9,7 @@ using Fluxor; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; +using System.Collections.Immutable; namespace EventLogExpert.Components; @@ -26,7 +27,7 @@ public sealed partial class DetailsPane private DisplayEventModel? SelectedEvent { get; set; } - [Inject] private IStateSelection SelectedEventSelection { get; init; } = null!; + [Inject] private IStateSelection> SelectedEventSelection { get; init; } = null!; [Inject] private IState SettingsState { get; init; } = null!; @@ -42,11 +43,11 @@ protected override async Task OnAfterRenderAsync(bool firstRender) protected override void OnInitialized() { - SelectedEventSelection.Select(s => s.SelectedEvent); + SelectedEventSelection.Select(s => s.SelectedEvents); - SelectedEventSelection.SelectedValueChanged += (s, selectedEvent) => + SelectedEventSelection.SelectedValueChanged += (s, selectedEvents) => { - SelectedEvent = selectedEvent; + SelectedEvent = selectedEvents.LastOrDefault(); if (SelectedEvent is not null && (!_hasOpened || SettingsState.Value.Config.ShowDisplayPaneOnSelectionChange)) diff --git a/src/EventLogExpert/Components/EventTable.razor b/src/EventLogExpert/Components/EventTable.razor index 6bf56ff9..dc749e78 100644 --- a/src/EventLogExpert/Components/EventTable.razor +++ b/src/EventLogExpert/Components/EventTable.razor @@ -4,10 +4,10 @@
- +
- @foreach ((ColumnName column, bool _) in EventTableState.Value.Columns.Where(column => column.Value)) + @foreach ((ColumnName column, bool _) in _eventTableState.Columns.Where(column => column.Value)) { - @if (EventTableState.Value.ActiveEventLogId is not null) + @if (_currentTable is not null) { - - - @foreach ((ColumnName column, bool _) in EventTableState.Value.Columns.Where(column => column.Value)) + + + @foreach ((ColumnName column, bool _) in _eventTableState.Columns.Where(column => column.Value)) {
@if (column == ColumnName.DateAndTime) @@ -18,9 +18,9 @@ { @column.ToFullString() } - @if (EventTableState.Value.OrderBy == column) + @if (_eventTableState.OrderBy == column) { - + } @@ -31,11 +31,12 @@
@switch (column) @@ -44,7 +45,7 @@ @evt.Level break; case ColumnName.DateAndTime: - @evt.TimeCreated.ConvertTimeZone(TimeZoneSettings.Value) + @evt.TimeCreated.ConvertTimeZone(_timeZoneSettings) break; case ColumnName.ActivityId: @evt.ActivityId diff --git a/src/EventLogExpert/Components/EventTable.razor.cs b/src/EventLogExpert/Components/EventTable.razor.cs index eda7e134..71f95cd7 100644 --- a/src/EventLogExpert/Components/EventTable.razor.cs +++ b/src/EventLogExpert/Components/EventTable.razor.cs @@ -5,6 +5,7 @@ using EventLogExpert.Eventing.Models; using EventLogExpert.Services; using EventLogExpert.UI; +using EventLogExpert.UI.Models; using EventLogExpert.UI.Store.EventLog; using EventLogExpert.UI.Store.EventTable; using EventLogExpert.UI.Store.FilterPane; @@ -13,14 +14,16 @@ using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.JSInterop; +using System.Collections.Immutable; using IDispatcher = Fluxor.IDispatcher; namespace EventLogExpert.Components; public sealed partial class EventTable { + private EventTableModel? _currentTable; private EventTableState _eventTableState = null!; - private DisplayEventModel? _selectedEventState; + private ImmutableList _selectedEventState = []; private TimeZoneInfo _timeZoneSettings = null!; [Inject] private IClipboardService ClipboardService { get; init; } = null!; @@ -33,20 +36,25 @@ public sealed partial class EventTable [Inject] private IJSRuntime JSRuntime { get; init; } = null!; - [Inject] private IStateSelection SelectedEventState { get; init; } = null!; + [Inject] private IStateSelection> SelectedEventState { get; init; } = null!; [Inject] private IStateSelection TimeZoneSettings { get; init; } = null!; protected override async Task OnInitializedAsync() { - SelectedEventState.Select(s => s.SelectedEvent); + SelectedEventState.Select(s => s.SelectedEvents); TimeZoneSettings.Select(settings => settings.Config.TimeZoneInfo); - SubscribeToAction(action => RegisterTableEventHandlers().AndForget()); SubscribeToAction(action => ScrollToSelectedEvent().AndForget()); + SubscribeToAction(action => RegisterTableEventHandlers().AndForget()); SubscribeToAction(action => ScrollToSelectedEvent().AndForget()); SubscribeToAction(action => ScrollToSelectedEvent().AndForget()); + _eventTableState = EventTableState.Value; + _currentTable = _eventTableState.EventTables.FirstOrDefault(x => x.Id == _eventTableState.ActiveEventLogId); + _selectedEventState = SelectedEventState.Value; + _timeZoneSettings = TimeZoneSettings.Value; + await base.OnInitializedAsync(); } @@ -57,6 +65,7 @@ protected override bool ShouldRender() TimeZoneSettings.Value.Equals(_timeZoneSettings)) { return false; } _eventTableState = EventTableState.Value; + _currentTable = _eventTableState.EventTables.FirstOrDefault(x => x.Id == _eventTableState.ActiveEventLogId); _selectedEventState = SelectedEventState.Value; _timeZoneSettings = TimeZoneSettings.Value; @@ -72,9 +81,16 @@ private static string GetLevelClass(string level) => _ => string.Empty, }; + private void DragSelectEvent(MouseEventArgs args, DisplayEventModel @event) + { + if (args.Buttons == 1) + { + Dispatcher.Dispatch(new EventLogAction.SelectEvent(@event, IsMultiSelect: true, ShouldStaySelected: !args.CtrlKey)); + } + } + private string GetCss(DisplayEventModel @event) => - SelectedEventState.Value?.RecordId == @event.RecordId ? - "table-row selected" : $"table-row {GetHighlightedColor(@event)}"; + _selectedEventState.Contains(@event) ? "table-row selected" : $"table-row {GetHighlightedColor(@event)}"; private string GetDateColumnHeader() => TimeZoneSettings.Value.Equals(TimeZoneInfo.Local) ? @@ -92,14 +108,14 @@ private string GetHighlightedColor(DisplayEventModel @event) return string.Empty; } - private void HandleKeyUp(KeyboardEventArgs args) + private void HandleKeyDown(KeyboardEventArgs args) { // https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values switch (args) { case { CtrlKey: true, Code: "KeyC" }: ClipboardService.CopySelectedEvent(); - break; + return; } } @@ -113,15 +129,13 @@ private async Task InvokeTableColumnMenu(MouseEventArgs args) => private async Task ScrollToSelectedEvent() { - var table = EventTableState.Value.EventTables.FirstOrDefault(x => x.Id == EventTableState.Value.ActiveEventLogId); + var entry = _currentTable?.DisplayedEvents.FirstOrDefault(x => + string.Equals(x.LogName, _selectedEventState.LastOrDefault()?.LogName) && + x.RecordId == _selectedEventState.LastOrDefault()?.RecordId); - var entry = table?.DisplayedEvents.FirstOrDefault(x => - string.Equals(x.LogName, SelectedEventState.Value?.LogName) && - x.RecordId == SelectedEventState.Value?.RecordId); + if (entry is null) { return; } - if (table is null || entry is null) { return; } - - var index = table.DisplayedEvents.IndexOf(entry); + var index = _currentTable?.DisplayedEvents.IndexOf(entry); if (index >= 0) { @@ -129,7 +143,42 @@ private async Task ScrollToSelectedEvent() } } - private void SelectEvent(DisplayEventModel @event) => Dispatcher.Dispatch(new EventLogAction.SelectEvent(@event)); + private void SelectEvent(MouseEventArgs args, DisplayEventModel @event) + { + switch (args) + { + case { CtrlKey: true }: + Dispatcher.Dispatch(new EventLogAction.SelectEvent(@event, true)); + return; + case { ShiftKey: true }: + var startEvent = _selectedEventState.LastOrDefault(); + + if (startEvent is null || _currentTable is null) { return; } + + var startIndex = _currentTable.DisplayedEvents.IndexOf(startEvent); + var endIndex = _currentTable.DisplayedEvents.IndexOf(@event); + + if (startIndex < endIndex) + { + Dispatcher.Dispatch(new EventLogAction.SelectEvents( + _currentTable.DisplayedEvents + .Skip(startIndex) + .Take(endIndex - startIndex + 1))); + } + else + { + Dispatcher.Dispatch(new EventLogAction.SelectEvents( + _currentTable.DisplayedEvents + .Skip(endIndex) + .Take(startIndex - endIndex + 1))); + } + + return; + default: + Dispatcher.Dispatch(new EventLogAction.SelectEvent(@event)); + return; + } + } private void ToggleSorting() => Dispatcher.Dispatch(new EventTableAction.ToggleSorting()); } diff --git a/src/EventLogExpert/Components/EventTable.razor.css b/src/EventLogExpert/Components/EventTable.razor.css index 1598ba80..907565f1 100644 --- a/src/EventLogExpert/Components/EventTable.razor.css +++ b/src/EventLogExpert/Components/EventTable.razor.css @@ -41,7 +41,10 @@ th, td { &:last-child { border-right: none; } } -tr { height: 22px; } +tr { + height: 22px; + user-select: none; +} .error { color: var(--clr-red); } diff --git a/src/EventLogExpert/Services/ClipboardService.cs b/src/EventLogExpert/Services/ClipboardService.cs index ca14131a..c75f4c4d 100644 --- a/src/EventLogExpert/Services/ClipboardService.cs +++ b/src/EventLogExpert/Services/ClipboardService.cs @@ -6,6 +6,7 @@ using EventLogExpert.UI.Store.EventLog; using EventLogExpert.UI.Store.Settings; using Fluxor; +using System.Collections.Immutable; using System.Text; using System.Xml.Linq; @@ -18,60 +19,75 @@ public interface IClipboardService public sealed class ClipboardService : IClipboardService { - private readonly IStateSelection _selectedEvent; + private readonly IStateSelection> _selectedEvents; private readonly IState _settingsState; public ClipboardService( - IStateSelection selectedEvent, + IStateSelection> selectedEvents, IState settingsState) { - _selectedEvent = selectedEvent; + _selectedEvents = selectedEvents; _settingsState = settingsState; - _selectedEvent.Select(s => s.SelectedEvent); + _selectedEvents.Select(s => s.SelectedEvents); } public void CopySelectedEvent(CopyType? copyType = null) { - if (_selectedEvent.Value is null) { return; } + if (_selectedEvents.Value.IsEmpty) { return; } + + if (_selectedEvents.Value.Count == 1) + { + Clipboard.SetTextAsync(GetFormattedEvent(copyType, _selectedEvents.Value[0])); + + return; + } StringBuilder stringToCopy = new(); + foreach (var selectedEvent in _selectedEvents.Value) + { + stringToCopy.AppendLine(GetFormattedEvent(copyType, selectedEvent)); + } + + Clipboard.SetTextAsync(stringToCopy.ToString()); + } + + private string GetFormattedEvent(CopyType? copyType, DisplayEventModel @event) + { switch (copyType ?? _settingsState.Value.Config.CopyType) { case CopyType.Simple: - stringToCopy.Append($"\"{_selectedEvent.Value.Level}\" "); - stringToCopy.Append($"\"{_selectedEvent.Value.TimeCreated.ConvertTimeZone(_settingsState.Value.Config.TimeZoneInfo)}\" "); - stringToCopy.Append($"\"{_selectedEvent.Value.Source}\" "); - stringToCopy.Append($"\"{_selectedEvent.Value.Id}\" "); - stringToCopy.Append($"\"{_selectedEvent.Value.Description}\""); + StringBuilder simpleEvent = new(); - Clipboard.SetTextAsync(stringToCopy.ToString()); + simpleEvent.Append($"\"{@event.Level}\" "); + simpleEvent.Append($"\"{@event.TimeCreated.ConvertTimeZone(_settingsState.Value.Config.TimeZoneInfo)}\" "); + simpleEvent.Append($"\"{@event.Source}\" "); + simpleEvent.Append($"\"{@event.Id}\" "); + simpleEvent.Append($"\"{@event.Description}\""); - break; + return simpleEvent.ToString(); case CopyType.Xml: - Clipboard.SetTextAsync(XElement.Parse(_selectedEvent.Value.Xml).ToString()); - - break; + return XElement.Parse(@event.Xml).ToString(); case CopyType.Full: default: - stringToCopy.AppendLine($"Log Name: {_selectedEvent.Value.LogName}"); - stringToCopy.AppendLine($"Source: {_selectedEvent.Value.Source}"); - stringToCopy.AppendLine($"Date: {_selectedEvent.Value.TimeCreated.ConvertTimeZone(_settingsState.Value.Config.TimeZoneInfo)}"); - stringToCopy.AppendLine($"Event ID: {_selectedEvent.Value.Id}"); - stringToCopy.AppendLine($"Task Category: {_selectedEvent.Value.TaskCategory}"); - stringToCopy.AppendLine($"Level: {_selectedEvent.Value.Level}"); - stringToCopy.AppendLine(_selectedEvent.Value.KeywordsDisplayNames.GetEventKeywords()); - stringToCopy.AppendLine("User:"); // TODO: Update after DisplayEventModel is updated - stringToCopy.AppendLine($"Computer: {_selectedEvent.Value.ComputerName}"); - stringToCopy.AppendLine("Description:"); - stringToCopy.AppendLine(_selectedEvent.Value.Description); - stringToCopy.AppendLine("Event Xml:"); - stringToCopy.AppendLine(XElement.Parse(_selectedEvent.Value.Xml).ToString()); - - Clipboard.SetTextAsync(stringToCopy.ToString()); - - break; + StringBuilder fullEvent = new(); + + fullEvent.AppendLine($"Log Name: {@event.LogName}"); + fullEvent.AppendLine($"Source: {@event.Source}"); + fullEvent.AppendLine($"Date: {@event.TimeCreated.ConvertTimeZone(_settingsState.Value.Config.TimeZoneInfo)}"); + fullEvent.AppendLine($"Event ID: {@event.Id}"); + fullEvent.AppendLine($"Task Category: {@event.TaskCategory}"); + fullEvent.AppendLine($"Level: {@event.Level}"); + fullEvent.AppendLine(@event.KeywordsDisplayNames.GetEventKeywords()); + fullEvent.AppendLine($"User: {@event.UserId}"); + fullEvent.AppendLine($"Computer: {@event.ComputerName}"); + fullEvent.AppendLine("Description:"); + fullEvent.AppendLine(@event.Description); + fullEvent.AppendLine("Event Xml:"); + fullEvent.AppendLine(XElement.Parse(@event.Xml).ToString()); + + return fullEvent.ToString(); } } } diff --git a/src/EventLogExpert/Shared/Components/ContextMenu.razor.cs b/src/EventLogExpert/Shared/Components/ContextMenu.razor.cs index 27ea991c..266b4620 100644 --- a/src/EventLogExpert/Shared/Components/ContextMenu.razor.cs +++ b/src/EventLogExpert/Shared/Components/ContextMenu.razor.cs @@ -9,6 +9,7 @@ using EventLogExpert.UI.Store.FilterPane; using Fluxor; using Microsoft.AspNetCore.Components; +using System.Collections.Immutable; using IDispatcher = Fluxor.IDispatcher; namespace EventLogExpert.Shared.Components; @@ -20,11 +21,11 @@ public sealed partial class ContextMenu [Inject] private IDispatcher Dispatcher { get; init; } = null!; [Inject] - private IStateSelection SelectedEventState { get; init; } = null!; + private IStateSelection> SelectedEventState { get; init; } = null!; protected override void OnInitialized() { - SelectedEventState.Select(s => s.SelectedEvent); + SelectedEventState.Select(s => s.SelectedEvents); base.OnInitialized(); } @@ -33,17 +34,19 @@ protected override void OnInitialized() private void FilterEvent(FilterCategory filterType, bool shouldExclude = false) { - if (SelectedEventState.Value is null) { return; } + if (SelectedEventState.Value.IsEmpty) { return; } + + var selectedEvent = SelectedEventState.Value.Last(); string filterValue = filterType switch { - FilterCategory.Id => SelectedEventState.Value.Id.ToString(), - FilterCategory.ActivityId => SelectedEventState.Value.ActivityId.ToString()!, - FilterCategory.Level => SelectedEventState.Value.Level, - FilterCategory.KeywordsDisplayNames => SelectedEventState.Value.KeywordsDisplayNames.GetEventKeywords(), - FilterCategory.Source => SelectedEventState.Value.Source, - FilterCategory.TaskCategory => SelectedEventState.Value.TaskCategory, - FilterCategory.Description => SelectedEventState.Value.Description, + FilterCategory.Id => selectedEvent.Id.ToString(), + FilterCategory.ActivityId => selectedEvent.ActivityId.ToString()!, + FilterCategory.Level => selectedEvent.Level, + FilterCategory.KeywordsDisplayNames => selectedEvent.KeywordsDisplayNames.GetEventKeywords(), + FilterCategory.Source => selectedEvent.Source, + FilterCategory.TaskCategory => selectedEvent.TaskCategory, + FilterCategory.Description => selectedEvent.Description, _ => string.Empty, }; diff --git a/src/EventLogExpert/wwwroot/js/event_table.js b/src/EventLogExpert/wwwroot/js/event_table.js index 92cd56f6..2e80fcb0 100644 --- a/src/EventLogExpert/wwwroot/js/event_table.js +++ b/src/EventLogExpert/wwwroot/js/event_table.js @@ -62,10 +62,12 @@ window.enableColumnResize = (table) => { window.registerKeyHandlers = (table) => { const selectAdjacentRow = function(direction) { const tableRows = table.getElementsByTagName("tr"); - const selectedRow = table.getElementsByClassName("selected")[0]; + const focusedRow = document.activeElement; + + if (focusedRow.tagName.toLowerCase() !== "tr") { return; } for (let i = 0; i < tableRows.length; i++) { - if (tableRows[i] === selectedRow) { + if (tableRows[i] === focusedRow) { tableRows[i + direction].focus(); break;