From a190451f624f56cee39d4371c3668dd30fa4c5ed Mon Sep 17 00:00:00 2001 From: Pr0metheus Date: Thu, 23 Jul 2026 12:05:23 +0200 Subject: [PATCH 1/2] Persistence fix on app exit/start Bug Fix : - .lxp persistence on exit when automatic saving and filter-tab restoration are enabled. - Save the live filter UI state before shutdown teardown, so FilterParamsList is no longer written empty or stale. - Prevent close-time tab disposal from overwriting the already saved snapshot. Added: - a regression test covering the snapshot behavior. --- .../Controls/LogWindowPersistenceTests.cs | 166 ++++++++++++++++++ .../Controls/LogWindow/LogWindow.cs | 45 ++++- .../Dialogs/LogTabWindow/LogTabWindow.cs | 18 +- 3 files changed, 222 insertions(+), 7 deletions(-) create mode 100644 src/LogExpert.Tests/Controls/LogWindowPersistenceTests.cs diff --git a/src/LogExpert.Tests/Controls/LogWindowPersistenceTests.cs b/src/LogExpert.Tests/Controls/LogWindowPersistenceTests.cs new file mode 100644 index 00000000..a01a5ebb --- /dev/null +++ b/src/LogExpert.Tests/Controls/LogWindowPersistenceTests.cs @@ -0,0 +1,166 @@ +using System.Reflection; +using System.Runtime.Versioning; + +using LogExpert.Core.Classes.Filter; +using LogExpert.Core.Config; +using LogExpert.Core.Interfaces; +using LogExpert.UI.Controls.LogWindow; +using LogExpert.UI.Interface; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.Tests.Controls; + +[TestFixture] +[Apartment(ApartmentState.STA)] +[SupportedOSPlatform("windows")] +public sealed class LogWindowPersistenceTests : IDisposable +{ + private Mock _coordinatorMock = null!; + private Mock _configManagerMock = null!; + private Settings _settings = null!; + private WindowsFormsSynchronizationContext? _syncContext; + private LogWindow _logWindow = null!; + private bool _disposed; + + [OneTimeSetUp] + public void OneTimeSetUp () + { + var dir = Path.GetDirectoryName(typeof(LogWindowPersistenceTests).Assembly.Location)!; + _ = PluginRegistry.PluginRegistry.Create(dir, 500); + } + + [SetUp] + public void SetUp () + { + if (SynchronizationContext.Current == null) + { + _syncContext = new WindowsFormsSynchronizationContext(); + SynchronizationContext.SetSynchronizationContext(_syncContext); + } + + _coordinatorMock = new Mock(); + _ = _coordinatorMock.Setup(c => c.ResolveHighlightGroup(It.IsAny(), It.IsAny())) + .Returns(new LogExpert.Core.Entities.HighlightGroup()); + _ = _coordinatorMock.Setup(c => c.SearchParams).Returns(new LogExpert.Core.Entities.SearchParams()); + + _configManagerMock = new Mock(); + _settings = new Settings(); + _ = _configManagerMock.Setup(cm => cm.Settings).Returns(_settings); + + _settings.FilterList.Add(new FilterParams + { + SearchText = "SHARED_FILTER_LIST_ENTRY", + IsCaseSensitive = false, + IsRegex = false, + IsFilterTail = true, + FuzzyValue = 0, + SpreadBefore = 0, + SpreadBehind = 0 + }); + + _logWindow = new LogWindow( + _coordinatorMock.Object, + "test.log", + isTempFile: false, + forcePersistenceLoading: false, + configManager: _configManagerMock.Object); + } + + [TearDown] + public void TearDown () + { + _logWindow?.Dispose(); + _syncContext?.Dispose(); + _syncContext = null; + } + + public void Dispose () + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose (bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _logWindow?.Dispose(); + _syncContext?.Dispose(); + } + + _disposed = true; + } + + [Test] + public void GatherSessionSnapshot_SavesLiveFilterState_InsteadOfSharedFilterList () + { + SetText("filterComboBox", "LIVE_FILTER_TEXT"); + SetText("filterRangeComboBox", "LIVE_RANGE_TEXT"); + SetChecked("filterCaseSensitiveCheckBox", true); + SetChecked("filterRegexCheckBox", true); + SetChecked("filterTailCheckBox", false); + SetChecked("invertFilterCheckBox", true); + SetChecked("rangeCheckBox", true); + SetChecked("columnRestrictCheckBox", true); + SetValue("knobControlFuzzy", 3); + SetValue("knobControlFilterBackSpread", 4); + SetValue("knobControlFilterForeSpread", 5); + + var snapshot = _logWindow.GatherSessionSnapshot(); + + Assert.That(snapshot.FilterParamsList, Has.Count.EqualTo(1)); + + var saved = snapshot.FilterParamsList[0]; + var shared = _settings.FilterList[0]; + + Assert.Multiple(() => + { + Assert.That(saved, Is.Not.SameAs(shared)); + Assert.That(saved.SearchText, Is.EqualTo("LIVE_FILTER_TEXT")); + Assert.That(saved.RangeSearchText, Is.EqualTo("LIVE_RANGE_TEXT")); + Assert.That(saved.IsCaseSensitive, Is.True); + Assert.That(saved.IsRegex, Is.True); + Assert.That(saved.IsFilterTail, Is.False); + Assert.That(saved.IsInvert, Is.True); + Assert.That(saved.IsRangeSearch, Is.True); + Assert.That(saved.ColumnRestrict, Is.True); + Assert.That(saved.FuzzyValue, Is.EqualTo(3)); + Assert.That(saved.SpreadBefore, Is.EqualTo(4)); + Assert.That(saved.SpreadBehind, Is.EqualTo(5)); + Assert.That(saved.SearchText, Is.Not.EqualTo(shared.SearchText)); + }); + } + + private void SetText (string fieldName, string value) + { + var control = GetField(fieldName); + control.Text = value; + } + + private void SetChecked (string fieldName, bool value) + { + var control = GetField(fieldName)!; + control.GetType().GetProperty("Checked")!.SetValue(control, value); + } + + private void SetValue (string fieldName, int value) + { + var control = GetField(fieldName)!; + control.GetType().GetProperty("Value")!.SetValue(control, value); + } + + private T GetField (string fieldName) + { + var field = _logWindow.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null, $"Could not find private field '{fieldName}' on LogWindow"); + return (T)field!.GetValue(_logWindow)!; + } +} diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index ae3a2131..734fdb1b 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -168,6 +168,15 @@ internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, IL private bool _waitingForClose; + /// + /// Set by the application shutdown path after a pre-save pass has already captured the + /// current persistence state. Prevents the per-window close handler from writing an older + /// snapshot after child filter tabs have already been torn down. + /// + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + internal bool SkipPersistenceSaveOnClose { get; set; } + #endregion #region cTor @@ -905,7 +914,10 @@ private void OnLogWindowClosing (object sender, CancelEventArgs e) return; } - SavePersistenceData(false); + if (!SkipPersistenceSaveOnClose) + { + SavePersistenceData(false); + } CloseLogWindow(); } @@ -5953,9 +5965,14 @@ public SessionSnapshot GatherSessionSnapshot () if (Preferences.SaveFilters) { - //when a filter is added, its added to the Configmanager.Settings.FilterList and not to the _filterParams, this is probably an oversight and maybe a bug - //but for the consistency the FilterList should be saved as whole for every file - snapshot.FilterParamsList = [.. ConfigManager.Settings.FilterList]; + // Persist the live filter state for this window. The active filter lives in the window + // controls / _filterParams, while ConfigManager.Settings.FilterList is only the shared + // saved-filter list UI. The session loader restores the first entry as the active + // filter, so that first slot must be the current filter state. + snapshot.FilterParamsList = + [ + CaptureCurrentFilterParams(), + ]; foreach (var filterPipe in _filterPipeList) { @@ -5983,6 +6000,24 @@ public SessionSnapshot GatherSessionSnapshot () return snapshot; } + private FilterParams CaptureCurrentFilterParams () + { + var filterParams = _filterParams.Clone(); + filterParams.SearchText = filterComboBox.Text; + filterParams.IsRangeSearch = rangeCheckBox.Checked; + filterParams.RangeSearchText = filterRangeComboBox.Text; + filterParams.IsCaseSensitive = filterCaseSensitiveCheckBox.Checked; + filterParams.IsRegex = filterRegexCheckBox.Checked; + filterParams.IsFilterTail = filterTailCheckBox.Checked; + filterParams.IsInvert = invertFilterCheckBox.Checked; + filterParams.FuzzyValue = knobControlFuzzy.Value; + filterParams.SpreadBefore = knobControlFilterBackSpread.Value; + filterParams.SpreadBehind = knobControlFilterForeSpread.Value; + filterParams.ColumnRestrict = columnRestrictCheckBox.Checked; + filterParams.CurrentColumnizer = CurrentColumnizer; + return filterParams; + } + public void Close (bool dontAsk) { Preferences.AskForClose = !dontAsk; @@ -7957,4 +7992,4 @@ public void RefreshLogView () } #endregion -} \ No newline at end of file +} diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs index d4e07c61..78e6ecb8 100644 --- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs +++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs @@ -1705,11 +1705,25 @@ private void OnLogTabWindowFormClosing (object sender, CancelEventArgs e) { try { - IList deleteLogWindowList = []; + var windowsToClose = _tabController.GetAllWindows().ToList(); + ConfigManager.Settings.AlwaysOnTop = TopMost && ConfigManager.Settings.Preferences.AllowOnlyOneInstance; _fileOperationService.SaveLastOpenFilesList(); - foreach (var logWindow in _tabController.GetAllWindows()) + // Capture the current persistence state before any tab teardown starts. Closing a child + // filter tab removes it from its parent window's filter-tab list, so saving during the + // close cascade can overwrite the parent's .lxp with an incomplete snapshot. + foreach (var logWindow in windowsToClose) + { + logWindow.SkipPersistenceSaveOnClose = true; + } + + foreach (var logWindow in windowsToClose) + { + logWindow.SavePersistenceData(false); + } + + foreach (var logWindow in windowsToClose) { RemoveAndDisposeLogWindow(logWindow, true); } From c3bfadfcbc0acd2b1d9bcd76f32dd26c4d72f080 Mon Sep 17 00:00:00 2001 From: Pr0metheus Date: Thu, 23 Jul 2026 17:55:23 +0200 Subject: [PATCH 2/2] Fix: Highlight dialog Import/Export error - Fix the Highlight dialog export button by correcting the SaveFileDialog.Filter resource string. - The filter now uses valid description|pattern pairs, so exporting highlight groups no longer throws an ArgumentException. - Update the localized resources consistently so the export dialog works in all supported languages. --- src/LogExpert.Resources/Resources.de.resx | 4 ++-- src/LogExpert.Resources/Resources.resx | 4 ++-- src/LogExpert.Resources/Resources.zh-CN.resx | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx index 1d076172..c428f4e4 100644 --- a/src/LogExpert.Resources/Resources.de.resx +++ b/src/LogExpert.Resources/Resources.de.resx @@ -233,7 +233,7 @@ Exportieren der Einstellungen in eine Datei - Einstellungen (*.json)|*.json|Alle Dateien (*.*) + Einstellungen (*.json)|*.json|Alle Dateien (*.*)|*.* Kopie von @@ -2241,4 +2241,4 @@ LogExpert neu starten, um die Änderungen zu übernehmen? Sysout für das Tool wurde konfiguriert, aber es gibt keine aktive Logdatei. Das Tool wird ohne die Sysout-Pipe gestartet - \ No newline at end of file + diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx index b5e64674..e0b09abd 100644 --- a/src/LogExpert.Resources/Resources.resx +++ b/src/LogExpert.Resources/Resources.resx @@ -239,7 +239,7 @@ Export Settings to file - Settings (*.json)|*.json|All files (*.*) + Settings (*.json)|*.json|All files (*.*)|*.* Copy of @@ -2275,4 +2275,4 @@ Restart LogExpert to apply changes? Sysout for the Tool was configured, but there is no active logfile the Tool will be started without the Sysout Pipe - \ No newline at end of file + diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx index 28502ceb..464a0d99 100644 --- a/src/LogExpert.Resources/Resources.zh-CN.resx +++ b/src/LogExpert.Resources/Resources.zh-CN.resx @@ -1,4 +1,4 @@ - +