From 2f36cfb0f4627a0890494365c2cc6301714dc2cd Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 19:28:32 +0200 Subject: [PATCH 1/9] docs: add Session File Composer terms to the glossary Session Snapshot, Session File Composer, and Rollover staleness rule enter the Sessions section; "options-only load" joins the Avoid list. The stale "Sessions & Persistence" block that redefined Session File as .lxj against the flagged-ambiguity resolution is replaced by a pointer to the canonical definitions. Co-Authored-By: Claude Fable 5 --- CONTEXT.md | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 88332402..ae086aa2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -58,10 +58,30 @@ meaning; do not redefine them locally. field of a Session File is one of several sources that can be selected by **Columnizer Selection Priority**; the other fields always load when the Session File exists. +- **Session Snapshot** (`SessionSnapshot`) — A neutral, UI-free capture of + one Log Window's persistable state. Gathered by the Log Window when a + Session File is saved and applied back in two phases when one is loaded + (pre-load: options like encoding, multi-file, columnizer, panel layout; + post-load: content-dependent state like bookmarks, scroll position, + filters). Recursive: carries a child snapshot per filter-pipe tab. +- **Session File Composer** (`SessionFileComposer`) — The pure Core module + beside `Persister` that owns the field mapping between a Session + Snapshot and a Session File's serialized form, in both directions + (compose on save, decompose on load), plus the Rollover staleness rule. + It never does I/O, shows UI, or has side effects; the Log Window owns + gathering, applying, timing, and error display. +- **Rollover staleness rule** — The predicate declaring a Session Snapshot + stale because it was saved against a longer file than the one on disk + (the saved line count exceeds the current one) — i.e. the log file has + rolled over since the save. A stale snapshot's post-load state + (bookmarks, scroll, filters) is discarded; the pre-load options still + apply. Owned by the Session File Composer. *Avoid*: "project" / "project file" / "workspace" (use **Session**), "persistence file" / "per-file persistence" (use **Session File**), -bare "session file" when you mean the workspace (that's a **Session**). +bare "session file" when you mean the workspace (that's a **Session**), +"options-only load" (a Session File is always read whole; the options/full +split is two *apply phases* of the **Session Snapshot**, not a partial read). ## Control character display @@ -239,9 +259,8 @@ _Avoid_: Stdout pipe, output redirect _Avoid_: Toolbar, tool strip ### Sessions & Persistence - -**Session File**: A `.lxj` file that stores the list of log files, per-file settings, and the dock layout for a Session. -_Avoid_: Project file, config file -**Session File Reference** (`.lxp`): An indirection file that maps to one or more actual log files, allowing a Session to track a logical source rather than a fixed path. -_Avoid_: Log pointer, alias +Defined in the **Sessions** section above: **Session** = `.lxj` (workspace), +**Session File** = `.lxp` (per-file state) — see also the flagged-ambiguity +resolution. (This section previously redefined both terms the pre-resolution +way; removed 2026-07-11.) From dc4c8594c05581727b044efd498b714fe98cdc18 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 19:28:32 +0200 Subject: [PATCH 2/9] feat: add SessionSnapshot and SessionFileComposer with round-trip test rig Ticket 1 of the Session File Composer effort (candidate 5, docs/specs/session-file-composer.md): the pure Core seam lands tested and unused. SessionSnapshot carries the first field slice (FollowTail, Encoding, LineCount); static SessionFileComposer maps it to/from PersistenceData (Compose/Decompose) and owns the Rollover staleness rule (IsStale). The test rig in LogExpert.Persister.Tests is the ticket's real payload: serialize-&-compare equality reusing the Columnizer/Encoding converters, fixture-completeness reflection guards (a new field left out of the fixtures fails immediately), forward and reverse trips with the spec's named exclusion list plus a temporary not-yet-mapped list that Ticket 2 shrinks to empty, purity pins, and the flagship disk-leg trip through real Persister I/O guarding the missing-converter class of bug. Co-Authored-By: Claude Fable 5 --- .../Classes/Persister/SessionFileComposer.cs | 58 ++++ .../Classes/Persister/SessionSnapshot.cs | 21 ++ .../SessionFileComposerTests.cs | 319 ++++++++++++++++++ 3 files changed, 398 insertions(+) create mode 100644 src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs create mode 100644 src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs create mode 100644 src/LogExpert.Persister.Tests/SessionFileComposerTests.cs diff --git a/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs b/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs new file mode 100644 index 00000000..711a0285 --- /dev/null +++ b/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs @@ -0,0 +1,58 @@ +namespace LogExpert.Core.Classes.Persister; + +/// +/// Pure mapping between a and the Session File's serialized form +/// (), in both directions, plus the Rollover staleness rule. +/// No I/O, no UI, no side effects — the control owns gathering, applying, timing, and error +/// display; owns serialization. Field-by-field contract: +/// docs/specs/session-file-composer.md. +/// +public static class SessionFileComposer +{ + /// + /// Maps a snapshot to the Session File's serialized form. Fields the snapshot does not carry + /// (the dead fields and SessionFileName named in the spec) are left at their type + /// defaults. Note: rewrites FileName on SameDir saves *after* + /// compose — transformed by design, outside this contract. + /// + public static PersistenceData Compose (SessionSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + + return new PersistenceData + { + FollowTail = snapshot.FollowTail, + Encoding = snapshot.Encoding, + LineCount = snapshot.LineCount, + }; + } + + /// + /// Maps a loaded Session File back to a snapshot. Fields the snapshot does not carry are + /// ignored. + /// + public static SessionSnapshot Decompose (PersistenceData persistenceData) + { + ArgumentNullException.ThrowIfNull(persistenceData); + + return new SessionSnapshot + { + FollowTail = persistenceData.FollowTail, + Encoding = persistenceData.Encoding, + LineCount = persistenceData.LineCount, + }; + } + + /// + /// Declares a snapshot stale because it was saved against a longer file than the one on disk + /// — i.e. the log file has rolled over since the save. A stale snapshot's post-load state + /// (bookmarks, scroll, filters) must be discarded by the control; the pre-load options still + /// apply. + /// + public static bool IsStale (SessionSnapshot snapshot, int currentLineCount) + { + ArgumentNullException.ThrowIfNull(snapshot); + + return snapshot.LineCount > currentLineCount; + } +} diff --git a/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs b/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs new file mode 100644 index 00000000..509e33f6 --- /dev/null +++ b/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs @@ -0,0 +1,21 @@ +namespace LogExpert.Core.Classes.Persister; + +/// +/// A neutral, UI-free capture of one Log Window's persistable state (the Session Snapshot of +/// CONTEXT.md). Gathered by the control when a Session File is saved and applied back in two +/// control-side phases when one is loaded. Mapped to and from by +/// ; see docs/specs/session-file-composer.md for the full field +/// mapping table. +/// +public class SessionSnapshot +{ + public bool FollowTail { get; set; } + + public System.Text.Encoding Encoding { get; set; } + + /// + /// The log file's line count at save time. Never applied to the window — it is the input to + /// the Rollover staleness rule (). + /// + public int LineCount { get; set; } +} diff --git a/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs b/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs new file mode 100644 index 00000000..1257e3b7 --- /dev/null +++ b/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs @@ -0,0 +1,319 @@ +using System.Collections; +using System.Reflection; +using System.Text; + +using LogExpert.Core.Classes.JsonConverters; +using LogExpert.Core.Classes.Persister; +using LogExpert.Core.Config; + +using Newtonsoft.Json; + +namespace LogExpert.Persister.Tests; + +[TestFixture] +public class SessionFileComposerTests +{ + private string _testDirectory; + private string _sessionDirectory; + private string _logFileName; + + [SetUp] + public void Setup () + { + _testDirectory = Path.Join(Path.GetTempPath(), "LogExpertTests", Guid.NewGuid().ToString()); + _ = Directory.CreateDirectory(_testDirectory); + _sessionDirectory = Path.Join(_testDirectory, "sessionFiles"); + + _logFileName = Path.Join(_testDirectory, "test.log"); + File.WriteAllText(_logFileName, "Test log content"); + } + + [TearDown] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Unit Test")] + public void TearDown () + { + if (Directory.Exists(_testDirectory)) + { + try + { + Directory.Delete(_testDirectory, true); + } + catch + { + // Ignore cleanup errors + } + } + } + + /// + /// Equality is serialize-&-compare: both sides are serialized with deterministic settings + /// (reusing the Columnizer/Encoding converters) and compared as strings. Deep by + /// construction — a new field automatically joins the comparison. + /// + private static readonly JsonSerializerSettings _comparisonSettings = new() + { + Converters = + { + new ColumnizerJsonConverter(), + new EncodingJsonConverter() + }, + Formatting = Formatting.Indented, + ReferenceLoopHandling = ReferenceLoopHandling.Serialize, + PreserveReferencesHandling = PreserveReferencesHandling.Objects, + }; + + private static string SerializeForComparison (object value) + { + return JsonConvert.SerializeObject(value, _comparisonSettings); + } + + /// + /// Serialize-&-compare with the excluded top-level fields removed from the JSON — used by + /// the reverse trip, whose exclusions are named and justified, never implicit. + /// + private static string SerializeExcluding (object value, params string[][] exclusionLists) + { + var json = Newtonsoft.Json.Linq.JObject.Parse(SerializeForComparison(value)); + + foreach (var exclusions in exclusionLists) + { + foreach (var propertyName in exclusions) + { + _ = json.Remove(propertyName); + } + } + + return json.ToString(); + } + + /// + /// The fully-populated snapshot fixture: every property must hold a non-default value (the + /// fixture-completeness test enforces this), so every mapped field takes part in every trip. + /// + private static SessionSnapshot CreateFullSnapshot () + { + return new SessionSnapshot + { + FollowTail = true, + Encoding = Encoding.UTF8, + LineCount = 1234, + }; + } + + /// + /// The populated fixture. Mapped fields hold non-default + /// values; the named-exclusion fields are populated too, so the reverse trip proves the + /// exclusion list is load-bearing. Fields not yet carried by the snapshot stay at their + /// defaults until Ticket 2 maps them. + /// + private static PersistenceData CreateFullPersistenceData () + { + return new PersistenceData + { + FollowTail = true, + Encoding = Encoding.UTF8, + LineCount = 1234, + FileName = @"C:\logs\app.log", + SessionFileName = "legacy-never-set.lxp", + }; + } + + /// + /// The named exclusion list (spec, Testing Decisions): fields that legitimately do not + /// round-trip. Each entry is justified in the spec's mapping table. + /// + private static readonly string[] _namedExclusions = + [ + // Dead fields — written only by the legacy XML reader, applied nowhere: + nameof(PersistenceData.BookmarkListPosition), + nameof(PersistenceData.BookmarkListVisible), + nameof(PersistenceData.ShowBookmarkCommentColumn), + nameof(PersistenceData.ColumnizerName), + nameof(PersistenceData.SettingsSaveLoadLocation), + // Always null in practice — never assigned anywhere in the UI: + nameof(PersistenceData.SessionFileName), + // Transformed by Persister on SameDir saves after compose — documented, not asserted: + nameof(PersistenceData.FileName), + ]; + + /// + /// Fields the snapshot does not carry YET — this list shrinks to empty in Ticket 2, which + /// maps them. It is not part of the spec's named exclusion list. + /// + private static readonly string[] _notYetMappedTicket2 = + [ + nameof(PersistenceData.BookmarkList), + nameof(PersistenceData.Columnizer), + nameof(PersistenceData.CurrentLine), + nameof(PersistenceData.FilterAdvanced), + nameof(PersistenceData.FilterParamsList), + nameof(PersistenceData.FilterPosition), + nameof(PersistenceData.FilterSaveListVisible), + nameof(PersistenceData.FilterTabDataList), + nameof(PersistenceData.CellSelectMode), + nameof(PersistenceData.FirstDisplayedLine), + nameof(PersistenceData.HighlightGroupName), + nameof(PersistenceData.FilterVisible), + nameof(PersistenceData.MultiFile), + nameof(PersistenceData.MultiFileMaxDays), + nameof(PersistenceData.MultiFileNames), + nameof(PersistenceData.MultiFilePattern), + nameof(PersistenceData.RowHeightList), + nameof(PersistenceData.TabName), + ]; + + /// + /// Reflects over an instance's public properties and returns the names of those still at + /// their "unpopulated" value: default(T) for value types, null or empty for strings + /// and collections, null for other references. The fixture-completeness tests use this so + /// that adding a field without extending the fixtures fails immediately. + /// + private static List GetUnpopulatedProperties (object instance, params string[][] exclusionLists) + { + var excluded = exclusionLists.SelectMany(x => x).ToHashSet(); + List unpopulated = []; + + foreach (var property in instance.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (excluded.Contains(property.Name)) + { + continue; + } + + var value = property.GetValue(instance); + var isUnpopulated = value switch + { + null => true, + string text => text.Length == 0, + IEnumerable items => !items.Cast().Any(), + _ => property.PropertyType.IsValueType && value.Equals(Activator.CreateInstance(property.PropertyType)), + }; + + if (isUnpopulated) + { + unpopulated.Add(property.Name); + } + } + + return unpopulated; + } + + #region Fixture completeness (the regression guard) + + // Pins that the guard itself can fire — an all-defaults instance must be reported. + [Test] + public void FixtureCompleteness_UnpopulatedSnapshot_IsDetected () + { + Assert.That(GetUnpopulatedProperties(new SessionSnapshot()), Is.Not.Empty); + } + + [Test] + public void FixtureCompleteness_SnapshotFixture_LeavesNoPropertyUnpopulated () + { + Assert.That(GetUnpopulatedProperties(CreateFullSnapshot()), Is.Empty, + "Extend CreateFullSnapshot — every SessionSnapshot property must hold a populated value so the round trips exercise it"); + } + + [Test] + public void FixtureCompleteness_PersistenceDataFixture_LeavesNoMappedPropertyUnpopulated () + { + Assert.That(GetUnpopulatedProperties(CreateFullPersistenceData(), _namedExclusions, _notYetMappedTicket2), Is.Empty, + "Extend CreateFullPersistenceData (or, if the field legitimately never round-trips, justify it on the spec's named exclusion list)"); + } + + #endregion + + #region Round trips + + [Test] + public void ForwardTrip_ComposeThenDecompose_ReturnsEqualSnapshot () + { + var snapshot = CreateFullSnapshot(); + + var persistenceData = SessionFileComposer.Compose(snapshot); + var roundTripped = SessionFileComposer.Decompose(persistenceData); + + Assert.That(SerializeForComparison(roundTripped), Is.EqualTo(SerializeForComparison(snapshot))); + } + + // The reverse trip catches new PersistenceData fields the snapshot doesn't carry — the + // classic "added a setting, forgot to persist it" case starts on this side. + [Test] + public void ReverseTrip_DecomposeThenCompose_PreservesEverythingButTheNamedExclusions () + { + var original = CreateFullPersistenceData(); + + var snapshot = SessionFileComposer.Decompose(original); + var recomposed = SessionFileComposer.Compose(snapshot); + + Assert.That( + SerializeExcluding(recomposed, _namedExclusions, _notYetMappedTicket2), + Is.EqualTo(SerializeExcluding(original, _namedExclusions, _notYetMappedTicket2))); + } + + // The composer is pure: composing a session can never change live window state (the + // IsFilterTail side effect this seam removes lives at the control's call site now). + [Test] + public void Compose_DoesNotMutateTheSnapshot () + { + var snapshot = CreateFullSnapshot(); + var before = SerializeForComparison(snapshot); + + _ = SessionFileComposer.Compose(snapshot); + + Assert.That(SerializeForComparison(snapshot), Is.EqualTo(before)); + } + + [Test] + public void Decompose_DoesNotMutateThePersistenceData () + { + var persistenceData = CreateFullPersistenceData(); + var before = SerializeForComparison(persistenceData); + + _ = SessionFileComposer.Decompose(persistenceData); + + Assert.That(SerializeForComparison(persistenceData), Is.EqualTo(before)); + } + + // The flagship trip: one test guarding the mapping AND the serialization (the + // missing-JSON-converter class of bug). Uses a non-SameDir save location because Persister + // rewrites FileName on SameDir saves (transformed by design, see the spec). + [Test] + public void DiskTrip_ComposePersistLoadDecompose_ReturnsEqualSnapshot () + { + var snapshot = CreateFullSnapshot(); + var preferences = new Preferences + { + SaveLocation = SessionSaveLocation.ApplicationStartupDir, + }; + + var persistenceData = SessionFileComposer.Compose(snapshot); + var savedFileName = Core.Classes.Persister.Persister.SavePersistenceData(_logFileName, persistenceData, preferences, _sessionDirectory); + var loadedData = Core.Classes.Persister.Persister.LoadPersistenceData(_logFileName, preferences, _sessionDirectory); + var roundTripped = SessionFileComposer.Decompose(loadedData); + + Assert.That(File.Exists(savedFileName), Is.True, "The .lxp must actually have been written"); + Assert.That(SerializeForComparison(roundTripped), Is.EqualTo(SerializeForComparison(snapshot))); + } + + #endregion + + #region IsStale (Rollover staleness rule) + + // Saved LineCount greater than the file's current line count means the snapshot + // belongs to a longer, since-rolled file. + [TestCase(100, 50, ExpectedResult = true, TestName = "IsStale_SavedCountGreaterThanCurrent_IsStale")] + [TestCase(100, 100, ExpectedResult = false, TestName = "IsStale_SavedCountEqualToCurrent_IsNotStale")] + [TestCase(50, 100, ExpectedResult = false, TestName = "IsStale_SavedCountLessThanCurrent_IsNotStale")] + [TestCase(0, 0, ExpectedResult = false, TestName = "IsStale_BothCountsZero_IsNotStale")] + [TestCase(1, 0, ExpectedResult = true, TestName = "IsStale_SavedCountAgainstEmptyFile_IsStale")] + [TestCase(0, 100, ExpectedResult = false, TestName = "IsStale_NoSavedCount_IsNotStale")] + public bool IsStale_ComparesSavedLineCountToCurrent (int savedLineCount, int currentLineCount) + { + var snapshot = new SessionSnapshot { LineCount = savedLineCount }; + + return SessionFileComposer.IsStale(snapshot, currentLineCount); + } + + #endregion +} From e8993c1169e6a2a84344f997101cdda1352f32d7 Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 19:36:46 +0200 Subject: [PATCH 3/9] test: harden the completeness guard against construction defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings applied. The fixture-completeness guard now compares fixture values against a freshly-constructed baseline instead of CLR defaults: a fixture value equal to a property initializer (PersistenceData.FollowTail is true by construction) cannot prove a mapping exists — a dropped Compose line would leave the initializer standing and every trip would still pass. The PersistenceData fixture flips FollowTail to false accordingly, closing that blind spot for the reverse trip. Also: glossary wording in comments (Session File, Log Window — not "session" / "the control"), using-directive idiom in SessionSnapshot, test fields gathered in a Fields region, the ticket number dropped from the not-yet-mapped list's identifier, and the exclusion params simplified to one combined list. Co-Authored-By: Claude Fable 5 --- .../Classes/Persister/SessionFileComposer.cs | 6 +- .../Classes/Persister/SessionSnapshot.cs | 8 +- .../SessionFileComposerTests.cs | 215 +++++++++--------- 3 files changed, 121 insertions(+), 108 deletions(-) diff --git a/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs b/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs index 711a0285..350b2582 100644 --- a/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs +++ b/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs @@ -3,7 +3,7 @@ namespace LogExpert.Core.Classes.Persister; /// /// Pure mapping between a and the Session File's serialized form /// (), in both directions, plus the Rollover staleness rule. -/// No I/O, no UI, no side effects — the control owns gathering, applying, timing, and error +/// No I/O, no UI, no side effects — the Log Window owns gathering, applying, timing, and error /// display; owns serialization. Field-by-field contract: /// docs/specs/session-file-composer.md. /// @@ -46,8 +46,8 @@ public static SessionSnapshot Decompose (PersistenceData persistenceData) /// /// Declares a snapshot stale because it was saved against a longer file than the one on disk /// — i.e. the log file has rolled over since the save. A stale snapshot's post-load state - /// (bookmarks, scroll, filters) must be discarded by the control; the pre-load options still - /// apply. + /// (bookmarks, scroll, filters) must be discarded by the Log Window; the pre-load options + /// still apply. /// public static bool IsStale (SessionSnapshot snapshot, int currentLineCount) { diff --git a/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs b/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs index 509e33f6..5f551ff0 100644 --- a/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs +++ b/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs @@ -1,9 +1,11 @@ +using System.Text; + namespace LogExpert.Core.Classes.Persister; /// /// A neutral, UI-free capture of one Log Window's persistable state (the Session Snapshot of -/// CONTEXT.md). Gathered by the control when a Session File is saved and applied back in two -/// control-side phases when one is loaded. Mapped to and from by +/// CONTEXT.md). Gathered by the Log Window when a Session File is saved and applied back in two +/// Log-Window-side phases when one is loaded. Mapped to and from by /// ; see docs/specs/session-file-composer.md for the full field /// mapping table. /// @@ -11,7 +13,7 @@ public class SessionSnapshot { public bool FollowTail { get; set; } - public System.Text.Encoding Encoding { get; set; } + public Encoding Encoding { get; set; } /// /// The log file's line count at save time. Never applied to the window — it is the input to diff --git a/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs b/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs index 1257e3b7..d7108713 100644 --- a/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs +++ b/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs @@ -13,38 +13,12 @@ namespace LogExpert.Persister.Tests; [TestFixture] public class SessionFileComposerTests { + #region Fields + private string _testDirectory; private string _sessionDirectory; private string _logFileName; - [SetUp] - public void Setup () - { - _testDirectory = Path.Join(Path.GetTempPath(), "LogExpertTests", Guid.NewGuid().ToString()); - _ = Directory.CreateDirectory(_testDirectory); - _sessionDirectory = Path.Join(_testDirectory, "sessionFiles"); - - _logFileName = Path.Join(_testDirectory, "test.log"); - File.WriteAllText(_logFileName, "Test log content"); - } - - [TearDown] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Unit Test")] - public void TearDown () - { - if (Directory.Exists(_testDirectory)) - { - try - { - Directory.Delete(_testDirectory, true); - } - catch - { - // Ignore cleanup errors - } - } - } - /// /// Equality is serialize-&-compare: both sides are serialized with deterministic settings /// (reusing the Columnizer/Encoding converters) and compared as strings. Deep by @@ -62,62 +36,6 @@ public void TearDown () PreserveReferencesHandling = PreserveReferencesHandling.Objects, }; - private static string SerializeForComparison (object value) - { - return JsonConvert.SerializeObject(value, _comparisonSettings); - } - - /// - /// Serialize-&-compare with the excluded top-level fields removed from the JSON — used by - /// the reverse trip, whose exclusions are named and justified, never implicit. - /// - private static string SerializeExcluding (object value, params string[][] exclusionLists) - { - var json = Newtonsoft.Json.Linq.JObject.Parse(SerializeForComparison(value)); - - foreach (var exclusions in exclusionLists) - { - foreach (var propertyName in exclusions) - { - _ = json.Remove(propertyName); - } - } - - return json.ToString(); - } - - /// - /// The fully-populated snapshot fixture: every property must hold a non-default value (the - /// fixture-completeness test enforces this), so every mapped field takes part in every trip. - /// - private static SessionSnapshot CreateFullSnapshot () - { - return new SessionSnapshot - { - FollowTail = true, - Encoding = Encoding.UTF8, - LineCount = 1234, - }; - } - - /// - /// The populated fixture. Mapped fields hold non-default - /// values; the named-exclusion fields are populated too, so the reverse trip proves the - /// exclusion list is load-bearing. Fields not yet carried by the snapshot stay at their - /// defaults until Ticket 2 maps them. - /// - private static PersistenceData CreateFullPersistenceData () - { - return new PersistenceData - { - FollowTail = true, - Encoding = Encoding.UTF8, - LineCount = 1234, - FileName = @"C:\logs\app.log", - SessionFileName = "legacy-never-set.lxp", - }; - } - /// /// The named exclusion list (spec, Testing Decisions): fields that legitimately do not /// round-trip. Each entry is justified in the spec's mapping table. @@ -137,10 +55,10 @@ private static PersistenceData CreateFullPersistenceData () ]; /// - /// Fields the snapshot does not carry YET — this list shrinks to empty in Ticket 2, which - /// maps them. It is not part of the spec's named exclusion list. + /// Fields the snapshot does not carry YET — this list shrinks to empty in Ticket 2 of the + /// composer effort, which maps them. It is not part of the spec's named exclusion list. /// - private static readonly string[] _notYetMappedTicket2 = + private static readonly string[] _notYetMappedFields = [ nameof(PersistenceData.BookmarkList), nameof(PersistenceData.Columnizer), @@ -162,20 +80,76 @@ private static PersistenceData CreateFullPersistenceData () nameof(PersistenceData.TabName), ]; + private static readonly string[] _reverseTripExclusions = [.. _namedExclusions, .. _notYetMappedFields]; + + #endregion + + [SetUp] + public void Setup () + { + _testDirectory = Path.Join(Path.GetTempPath(), "LogExpertTests", Guid.NewGuid().ToString()); + _ = Directory.CreateDirectory(_testDirectory); + _sessionDirectory = Path.Join(_testDirectory, "sessionFiles"); + + _logFileName = Path.Join(_testDirectory, "test.log"); + File.WriteAllText(_logFileName, "Test log content"); + } + + [TearDown] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Unit Test")] + public void TearDown () + { + if (Directory.Exists(_testDirectory)) + { + try + { + Directory.Delete(_testDirectory, true); + } + catch + { + // Ignore cleanup errors + } + } + } + + #region Helpers and fixtures + + private static string SerializeForComparison (object value) + { + return JsonConvert.SerializeObject(value, _comparisonSettings); + } + + /// + /// Serialize-&-compare with the excluded top-level fields removed from the JSON — used by + /// the reverse trip, whose exclusions are named and justified, never implicit. + /// + private static string SerializeExcluding (object value, IReadOnlyCollection excludedProperties) + { + var json = Newtonsoft.Json.Linq.JObject.Parse(SerializeForComparison(value)); + + foreach (var propertyName in excludedProperties) + { + _ = json.Remove(propertyName); + } + + return json.ToString(); + } + /// - /// Reflects over an instance's public properties and returns the names of those still at - /// their "unpopulated" value: default(T) for value types, null or empty for strings - /// and collections, null for other references. The fixture-completeness tests use this so - /// that adding a field without extending the fixtures fails immediately. + /// Reflects over an instance's public properties and returns the names of those that could + /// not prove a mapping exists: a value equal to a freshly-constructed baseline (a dropped + /// mapping would leave the construction default standing and every trip would still pass), + /// or a null/empty string or collection. The fixture-completeness tests use this so that + /// adding a field without extending the fixtures fails immediately. /// - private static List GetUnpopulatedProperties (object instance, params string[][] exclusionLists) + private static List GetUnpopulatedProperties (object instance, IReadOnlyCollection excludedProperties) { - var excluded = exclusionLists.SelectMany(x => x).ToHashSet(); + var baseline = Activator.CreateInstance(instance.GetType()); List unpopulated = []; foreach (var property in instance.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) { - if (excluded.Contains(property.Name)) + if (excludedProperties.Contains(property.Name)) { continue; } @@ -186,7 +160,7 @@ private static List GetUnpopulatedProperties (object instance, params st null => true, string text => text.Length == 0, IEnumerable items => !items.Cast().Any(), - _ => property.PropertyType.IsValueType && value.Equals(Activator.CreateInstance(property.PropertyType)), + _ => value.Equals(property.GetValue(baseline)), }; if (isUnpopulated) @@ -198,26 +172,63 @@ private static List GetUnpopulatedProperties (object instance, params st return unpopulated; } + /// + /// The fully-populated snapshot fixture: every property must hold a value differing from the + /// construction default (the fixture-completeness test enforces this), so every mapped field + /// takes part in every trip. + /// + private static SessionSnapshot CreateFullSnapshot () + { + return new SessionSnapshot + { + FollowTail = true, + Encoding = Encoding.UTF8, + LineCount = 1234, + }; + } + + /// + /// The populated fixture. Mapped fields hold values differing + /// from the construction defaults; the named-exclusion fields are populated too, so the + /// reverse trip proves the exclusion list is load-bearing. Fields not yet carried by the + /// snapshot stay at their defaults until Ticket 2 maps them. + /// + private static PersistenceData CreateFullPersistenceData () + { + return new PersistenceData + { + // false because PersistenceData's initializer is true: a fixture value equal to the + // construction default cannot prove the mapping exists. + FollowTail = false, + Encoding = Encoding.UTF8, + LineCount = 1234, + FileName = @"C:\logs\app.log", + SessionFileName = "legacy-never-set.lxp", + }; + } + + #endregion + #region Fixture completeness (the regression guard) // Pins that the guard itself can fire — an all-defaults instance must be reported. [Test] public void FixtureCompleteness_UnpopulatedSnapshot_IsDetected () { - Assert.That(GetUnpopulatedProperties(new SessionSnapshot()), Is.Not.Empty); + Assert.That(GetUnpopulatedProperties(new SessionSnapshot(), []), Is.Not.Empty); } [Test] public void FixtureCompleteness_SnapshotFixture_LeavesNoPropertyUnpopulated () { - Assert.That(GetUnpopulatedProperties(CreateFullSnapshot()), Is.Empty, + Assert.That(GetUnpopulatedProperties(CreateFullSnapshot(), []), Is.Empty, "Extend CreateFullSnapshot — every SessionSnapshot property must hold a populated value so the round trips exercise it"); } [Test] public void FixtureCompleteness_PersistenceDataFixture_LeavesNoMappedPropertyUnpopulated () { - Assert.That(GetUnpopulatedProperties(CreateFullPersistenceData(), _namedExclusions, _notYetMappedTicket2), Is.Empty, + Assert.That(GetUnpopulatedProperties(CreateFullPersistenceData(), _reverseTripExclusions), Is.Empty, "Extend CreateFullPersistenceData (or, if the field legitimately never round-trips, justify it on the spec's named exclusion list)"); } @@ -247,12 +258,12 @@ public void ReverseTrip_DecomposeThenCompose_PreservesEverythingButTheNamedExclu var recomposed = SessionFileComposer.Compose(snapshot); Assert.That( - SerializeExcluding(recomposed, _namedExclusions, _notYetMappedTicket2), - Is.EqualTo(SerializeExcluding(original, _namedExclusions, _notYetMappedTicket2))); + SerializeExcluding(recomposed, _reverseTripExclusions), + Is.EqualTo(SerializeExcluding(original, _reverseTripExclusions))); } - // The composer is pure: composing a session can never change live window state (the - // IsFilterTail side effect this seam removes lives at the control's call site now). + // The composer is pure: composing a Session File can never change live Log Window state (the + // IsFilterTail side effect this seam removes lives at the Log Window's call site now). [Test] public void Compose_DoesNotMutateTheSnapshot () { From 5db68561c17454c6d5151a6dd31493ebb0f1148a Mon Sep 17 00:00:00 2001 From: Hirogen Date: Sat, 11 Jul 2026 21:12:26 +0200 Subject: [PATCH 4/9] session file composer --- .vscode/settings.json | 8 +- .../Classes/Persister/FilterTabSnapshot.cs | 15 + .../Classes/Persister/SessionFileComposer.cs | 52 ++++ .../Classes/Persister/SessionSnapshot.cs | 58 ++++ src/LogExpert.Core/Interfaces/ILogWindow.cs | 14 +- .../SessionFileComposerTests.cs | 161 +++++++--- .../Bookmark/HighlightBookmarkTriggerTests.cs | 4 +- .../Controls/LogWindow/LogWindow.cs | 274 +++++++++++------- 8 files changed, 433 insertions(+), 153 deletions(-) create mode 100644 src/LogExpert.Core/Classes/Persister/FilterTabSnapshot.cs diff --git a/.vscode/settings.json b/.vscode/settings.json index bbde8a53..4a7732b7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,5 +17,11 @@ "git rev-parse": true }, "dotnet.preferCSharpExtension": true, - "dotnet.defaultSolution": "src/LogExpert.sln" + "dotnet.defaultSolution": "src/LogExpert.sln", + "cSpell.words": [ + "Columnizer", + "multifile", + "Respawned", + "timeshift" + ] } \ No newline at end of file diff --git a/src/LogExpert.Core/Classes/Persister/FilterTabSnapshot.cs b/src/LogExpert.Core/Classes/Persister/FilterTabSnapshot.cs new file mode 100644 index 00000000..fabb040f --- /dev/null +++ b/src/LogExpert.Core/Classes/Persister/FilterTabSnapshot.cs @@ -0,0 +1,15 @@ +using LogExpert.Core.Classes.Filter; + +namespace LogExpert.Core.Classes.Persister; + +/// +/// One Filter Pipe tab's persistable state: the filter that feeds the tab plus the tab's own +/// window state as a nested snapshot — the recursion point of . +/// Mapped to and from by . +/// +public class FilterTabSnapshot +{ + public FilterParams FilterParams { get; set; } + + public SessionSnapshot Snapshot { get; set; } +} diff --git a/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs b/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs index 350b2582..86cab56d 100644 --- a/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs +++ b/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs @@ -24,6 +24,32 @@ public static PersistenceData Compose (SessionSnapshot snapshot) FollowTail = snapshot.FollowTail, Encoding = snapshot.Encoding, LineCount = snapshot.LineCount, + CurrentLine = snapshot.CurrentLine, + FirstDisplayedLine = snapshot.FirstDisplayedLine, + FilterPosition = snapshot.FilterPosition, + FilterVisible = snapshot.FilterVisible, + FilterAdvanced = snapshot.FilterAdvanced, + CellSelectMode = snapshot.CellSelectMode, + FilterSaveListVisible = snapshot.FilterSaveListVisible, + MultiFile = snapshot.MultiFile, + MultiFileMaxDays = snapshot.MultiFileMaxDays, + MultiFilePattern = snapshot.MultiFilePattern, + TabName = snapshot.TabName, + HighlightGroupName = snapshot.HighlightGroupName, + FileName = snapshot.FileName, + BookmarkList = snapshot.BookmarkList, + RowHeightList = snapshot.RowHeightList, + MultiFileNames = snapshot.MultiFileNames, + FilterParamsList = snapshot.FilterParamsList, + Columnizer = snapshot.Columnizer, + FilterTabDataList = + [ + .. snapshot.FilterTabs.Select(tab => new FilterTabData + { + FilterParams = tab.FilterParams, + PersistenceData = Compose(tab.Snapshot), + }), + ], }; } @@ -40,6 +66,32 @@ public static SessionSnapshot Decompose (PersistenceData persistenceData) FollowTail = persistenceData.FollowTail, Encoding = persistenceData.Encoding, LineCount = persistenceData.LineCount, + CurrentLine = persistenceData.CurrentLine, + FirstDisplayedLine = persistenceData.FirstDisplayedLine, + FilterPosition = persistenceData.FilterPosition, + FilterVisible = persistenceData.FilterVisible, + FilterAdvanced = persistenceData.FilterAdvanced, + CellSelectMode = persistenceData.CellSelectMode, + FilterSaveListVisible = persistenceData.FilterSaveListVisible, + MultiFile = persistenceData.MultiFile, + MultiFileMaxDays = persistenceData.MultiFileMaxDays, + MultiFilePattern = persistenceData.MultiFilePattern, + TabName = persistenceData.TabName, + HighlightGroupName = persistenceData.HighlightGroupName, + FileName = persistenceData.FileName, + BookmarkList = persistenceData.BookmarkList, + RowHeightList = persistenceData.RowHeightList, + MultiFileNames = persistenceData.MultiFileNames, + FilterParamsList = persistenceData.FilterParamsList, + Columnizer = persistenceData.Columnizer, + FilterTabs = + [ + .. persistenceData.FilterTabDataList.Select(tab => new FilterTabSnapshot + { + FilterParams = tab.FilterParams, + Snapshot = Decompose(tab.PersistenceData), + }), + ], }; } diff --git a/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs b/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs index 5f551ff0..502ca7c1 100644 --- a/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs +++ b/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs @@ -1,5 +1,10 @@ using System.Text; +using ColumnizerLib; + +using LogExpert.Core.Classes.Filter; +using LogExpert.Core.Entities; + namespace LogExpert.Core.Classes.Persister; /// @@ -20,4 +25,57 @@ public class SessionSnapshot /// the Rollover staleness rule (). /// public int LineCount { get; set; } + + public int CurrentLine { get; set; } + + public int FirstDisplayedLine { get; set; } + + /// Splitter distance of the filter panel, as a plain value. + public int FilterPosition { get; set; } + + public bool FilterVisible { get; set; } + + public bool FilterAdvanced { get; set; } + + public bool CellSelectMode { get; set; } + + public bool FilterSaveListVisible { get; set; } + + public bool MultiFile { get; set; } + + public int MultiFileMaxDays { get; set; } + + public string MultiFilePattern { get; set; } + + public string TabName { get; set; } + + public string HighlightGroupName { get; set; } + + /// + /// The log file the session belongs to. Never applied to the window — consumed by + /// PersisterHelpers.FindFilenameForSettings on direct .lxp open. Note: + /// rewrites it on SameDir saves *after* compose (transformed by + /// design, named in the round-trip exclusion list). + /// + public string FileName { get; set; } + + /// + /// Manual bookmarks only — filtering out auto-generated bookmarks is a gather-side rule of + /// the Log Window; the snapshot carries what it is given. + /// + public SortedList BookmarkList { get; set; } = []; + + public SortedList RowHeightList { get; set; } = []; + + public List MultiFileNames { get; set; } = []; + + public List FilterParamsList { get; set; } = []; + + public ILogLineMemoryColumnizer Columnizer { get; set; } + + /// + /// The window's Filter Pipe tabs. Each child carries its own full nested snapshot, so the + /// whole tab tree rides along on save and load. + /// + public List FilterTabs { get; set; } = []; } diff --git a/src/LogExpert.Core/Interfaces/ILogWindow.cs b/src/LogExpert.Core/Interfaces/ILogWindow.cs index 6c297b9e..7cc3afb7 100644 --- a/src/LogExpert.Core/Interfaces/ILogWindow.cs +++ b/src/LogExpert.Core/Interfaces/ILogWindow.cs @@ -123,17 +123,19 @@ public interface ILogWindow void SelectLine (int lineNum, bool triggerSyncCall, bool shouldScroll); /// - /// Gets the persistence data for this log window, which can be saved and later restored. + /// Gathers a Session Snapshot of this log window's persistable state, including recursive + /// child snapshots for its filter pipe tabs. /// /// - /// A object containing the current state of the window, - /// including the current line, filters, columnizer configuration, and other settings. + /// A capturing the current state of the window: current line, + /// filters, columnizer configuration, and other settings. /// /// - /// This data is used to restore the log window state between sessions, including - /// the current scroll position, active filters, and columnizer settings. + /// The snapshot is mapped to the Session File's serialized form by + /// and applied back in two phases when a session is + /// loaded. /// - PersistenceData GetPersistenceData (); + SessionSnapshot GatherSessionSnapshot (); /// /// Creates a new temporary file tab with the specified content. diff --git a/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs b/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs index d7108713..a0db2e8a 100644 --- a/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs +++ b/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs @@ -2,9 +2,11 @@ using System.Reflection; using System.Text; +using LogExpert.Core.Classes.Filter; using LogExpert.Core.Classes.JsonConverters; using LogExpert.Core.Classes.Persister; using LogExpert.Core.Config; +using LogExpert.Core.Entities; using Newtonsoft.Json; @@ -54,33 +56,6 @@ public class SessionFileComposerTests nameof(PersistenceData.FileName), ]; - /// - /// Fields the snapshot does not carry YET — this list shrinks to empty in Ticket 2 of the - /// composer effort, which maps them. It is not part of the spec's named exclusion list. - /// - private static readonly string[] _notYetMappedFields = - [ - nameof(PersistenceData.BookmarkList), - nameof(PersistenceData.Columnizer), - nameof(PersistenceData.CurrentLine), - nameof(PersistenceData.FilterAdvanced), - nameof(PersistenceData.FilterParamsList), - nameof(PersistenceData.FilterPosition), - nameof(PersistenceData.FilterSaveListVisible), - nameof(PersistenceData.FilterTabDataList), - nameof(PersistenceData.CellSelectMode), - nameof(PersistenceData.FirstDisplayedLine), - nameof(PersistenceData.HighlightGroupName), - nameof(PersistenceData.FilterVisible), - nameof(PersistenceData.MultiFile), - nameof(PersistenceData.MultiFileMaxDays), - nameof(PersistenceData.MultiFileNames), - nameof(PersistenceData.MultiFilePattern), - nameof(PersistenceData.RowHeightList), - nameof(PersistenceData.TabName), - ]; - - private static readonly string[] _reverseTripExclusions = [.. _namedExclusions, .. _notYetMappedFields]; #endregion @@ -175,25 +150,84 @@ private static List GetUnpopulatedProperties (object instance, IReadOnly /// /// The fully-populated snapshot fixture: every property must hold a value differing from the /// construction default (the fixture-completeness test enforces this), so every mapped field - /// takes part in every trip. + /// takes part in every trip. Carries levels of fully + /// populated Filter Pipe children, so the round trips cover the recursion (the spec requires + /// at least two). /// - private static SessionSnapshot CreateFullSnapshot () + private static SessionSnapshot CreateFullSnapshot (int filterTabDepth = 2) { return new SessionSnapshot { FollowTail = true, Encoding = Encoding.UTF8, LineCount = 1234, + CurrentLine = 42, + FirstDisplayedLine = 17, + FilterPosition = 333, + FilterVisible = true, + FilterAdvanced = true, + CellSelectMode = true, + FilterSaveListVisible = true, + MultiFile = true, + MultiFileMaxDays = 7, + MultiFilePattern = "app*.log", + TabName = "My Tab", + HighlightGroupName = "Errors", + FileName = @"C:\logs\app.log", + BookmarkList = new SortedList { [5] = new Bookmark(5, "a manual bookmark") }, + RowHeightList = new SortedList { [3] = new RowHeightEntry(3, 60) }, + MultiFileNames = ["app.1.log", "app.2.log"], + FilterParamsList = [CreateFilterParams("ERROR")], + Columnizer = new DefaultLogfileColumnizer(), + FilterTabs = filterTabDepth > 0 + ? + [ + new FilterTabSnapshot + { + FilterParams = CreateFilterParams($"WARN depth {filterTabDepth}"), + Snapshot = CreateFullSnapshot(filterTabDepth - 1), + } + ] + : [], + }; + } + + private static FilterParams CreateFilterParams (string searchText) + { + return new FilterParams + { + SearchText = searchText, + IsCaseSensitive = true, + IsRegex = true, + SpreadBefore = 1, + SpreadBehind = 2, }; } /// - /// The populated fixture. Mapped fields hold values differing - /// from the construction defaults; the named-exclusion fields are populated too, so the - /// reverse trip proves the exclusion list is load-bearing. Fields not yet carried by the - /// snapshot stay at their defaults until Ticket 2 maps them. + /// The populated fixture. Every carried field holds a value + /// differing from the construction defaults (the fixture-completeness test enforces this), + /// and the named-exclusion fields are populated on top, so the reverse trip proves the + /// exclusion list is load-bearing. Children carry only mapped fields — the exclusion + /// stripping is top-level by design, so nothing excluded may hide in the tree. /// private static PersistenceData CreateFullPersistenceData () + { + var data = CreateCarriedPersistenceData(filterTabDepth: 2); + + data.SessionFileName = "legacy-never-set.lxp"; + data.BookmarkListPosition = 999; + data.BookmarkListVisible = true; + data.ShowBookmarkCommentColumn = true; +#pragma warning disable CS0618 // populated deliberately: the exclusion list must be load-bearing + data.ColumnizerName = "legacy XML columnizer name"; +#pragma warning restore CS0618 + data.SettingsSaveLoadLocation = "legacy save location"; + + return data; + } + + private static PersistenceData CreateCarriedPersistenceData (int filterTabDepth) { return new PersistenceData { @@ -202,8 +236,34 @@ private static PersistenceData CreateFullPersistenceData () FollowTail = false, Encoding = Encoding.UTF8, LineCount = 1234, + CurrentLine = 42, + FirstDisplayedLine = 17, + FilterPosition = 333, + FilterVisible = true, + FilterAdvanced = true, + CellSelectMode = true, + FilterSaveListVisible = true, + MultiFile = true, + MultiFileMaxDays = 7, + MultiFilePattern = "app*.log", + TabName = "My Tab", + HighlightGroupName = "Errors", FileName = @"C:\logs\app.log", - SessionFileName = "legacy-never-set.lxp", + BookmarkList = new SortedList { [5] = new Bookmark(5, "a manual bookmark") }, + RowHeightList = new SortedList { [3] = new RowHeightEntry(3, 60) }, + MultiFileNames = ["app.1.log", "app.2.log"], + FilterParamsList = [CreateFilterParams("ERROR")], + Columnizer = new DefaultLogfileColumnizer(), + FilterTabDataList = filterTabDepth > 0 + ? + [ + new FilterTabData + { + FilterParams = CreateFilterParams($"WARN depth {filterTabDepth}"), + PersistenceData = CreateCarriedPersistenceData(filterTabDepth - 1), + } + ] + : [], }; } @@ -228,7 +288,7 @@ public void FixtureCompleteness_SnapshotFixture_LeavesNoPropertyUnpopulated () [Test] public void FixtureCompleteness_PersistenceDataFixture_LeavesNoMappedPropertyUnpopulated () { - Assert.That(GetUnpopulatedProperties(CreateFullPersistenceData(), _reverseTripExclusions), Is.Empty, + Assert.That(GetUnpopulatedProperties(CreateFullPersistenceData(), _namedExclusions), Is.Empty, "Extend CreateFullPersistenceData (or, if the field legitimately never round-trips, justify it on the spec's named exclusion list)"); } @@ -258,8 +318,8 @@ public void ReverseTrip_DecomposeThenCompose_PreservesEverythingButTheNamedExclu var recomposed = SessionFileComposer.Compose(snapshot); Assert.That( - SerializeExcluding(recomposed, _reverseTripExclusions), - Is.EqualTo(SerializeExcluding(original, _reverseTripExclusions))); + SerializeExcluding(recomposed, _namedExclusions), + Is.EqualTo(SerializeExcluding(original, _namedExclusions))); } // The composer is pure: composing a Session File can never change live Log Window state (the @@ -309,6 +369,33 @@ public void DiskTrip_ComposePersistLoadDecompose_ReturnsEqualSnapshot () #endregion + #region Omitted fields (the ✖ rows of the mapping table) + + // The snapshot does not carry the dead fields or SessionFileName, so Compose must leave them + // at their construction defaults — the .lxp on-disk shape stays unchanged. (Decompose + // ignoring them is structural: the snapshot has no such properties.) The reverse trip strips + // these fields, so only this test would catch Compose writing into them. + [Test] + public void Compose_LeavesOmittedFieldsAtConstructionDefaults () + { + var composed = SessionFileComposer.Compose(CreateFullSnapshot()); + var defaults = new PersistenceData(); + + Assert.Multiple(() => + { + Assert.That(composed.SessionFileName, Is.EqualTo(defaults.SessionFileName)); + Assert.That(composed.BookmarkListPosition, Is.EqualTo(defaults.BookmarkListPosition)); + Assert.That(composed.BookmarkListVisible, Is.EqualTo(defaults.BookmarkListVisible)); + Assert.That(composed.ShowBookmarkCommentColumn, Is.EqualTo(defaults.ShowBookmarkCommentColumn)); +#pragma warning disable CS0618 // asserting the obsolete field stays at its default is the point + Assert.That(composed.ColumnizerName, Is.EqualTo(defaults.ColumnizerName)); +#pragma warning restore CS0618 + Assert.That(composed.SettingsSaveLoadLocation, Is.EqualTo(defaults.SettingsSaveLoadLocation)); + }); + } + + #endregion + #region IsStale (Rollover staleness rule) // Saved LineCount greater than the file's current line count means the snapshot diff --git a/src/LogExpert.Tests/Bookmark/HighlightBookmarkTriggerTests.cs b/src/LogExpert.Tests/Bookmark/HighlightBookmarkTriggerTests.cs index 831d94c9..713c7293 100644 --- a/src/LogExpert.Tests/Bookmark/HighlightBookmarkTriggerTests.cs +++ b/src/LogExpert.Tests/Bookmark/HighlightBookmarkTriggerTests.cs @@ -681,7 +681,7 @@ public void PersistenceExclusion_AutoBookmarks_FilteredFromSerialization () provider.AddBookmark(Core.Entities.Bookmark.CreateAutoGenerated(20, "auto", "ERROR")); provider.AddBookmark(new Core.Entities.Bookmark(30, "manual2")); - // Act — simulate GetPersistenceData filtering + // Act — simulate the manual-bookmarks-only rule at the GatherSessionSnapshot gather site SortedList manualBookmarks = []; foreach (var kvp in provider.BookmarkList) { @@ -706,7 +706,7 @@ public void PersistenceExclusion_ConvertedBookmarks_IncludedInSerialization () provider.AddBookmark(Core.Entities.Bookmark.CreateAutoGenerated(10, "auto", "ERROR")); _ = provider.ConvertToManualBookmark(10); - // Act — simulate GetPersistenceData filtering + // Act — simulate the manual-bookmarks-only rule at the GatherSessionSnapshot gather site SortedList manualBookmarks = []; foreach (var kvp in provider.BookmarkList) { diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 7ef5a109..8419b287 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -153,6 +153,12 @@ internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, IL private int _reloadOverloadCounter; private SortedList _rowHeightList = []; private int _selectedCol; // set by context menu event for column headers only + + /// The deserialize-once result of the Session File read, kept for the window-open + /// in progress. Written by the read at the start of each open (or direct multi-file load), + /// applied by the two apply phases ( pre-load, + /// post-load). + private SessionSnapshot _sessionSnapshot; private bool _shouldCallTimeSync; /// Window-lifetime cancellation: per-job token sources link to it, so closing the /// window cancels every running job. Live jobs are also registered in the cancel-handler @@ -300,9 +306,9 @@ public LogWindow (ILogWindowCoordinator logWindowCoordinator, string fileName, b #region Delegates // used for filterTab restore - public delegate void FilterRestoreFx (LogWindow newWin, PersistenceData persistenceData); + public delegate void FilterRestoreFx (LogWindow newWin, SessionSnapshot snapshot); - public delegate void RestoreFiltersFx (PersistenceData persistenceData); + public delegate void RestoreFiltersFx (SessionSnapshot snapshot); public delegate bool ScrollToTimestampFx (DateTime timestamp, bool roundToSeconds, bool triggerSyncCall); @@ -383,9 +389,6 @@ public bool ShowBookmarkBubbles public string FileName { get; private set; } - [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] - public string SessionFileName { get; set; } - public bool IsMultiFile { get; @@ -2445,8 +2448,44 @@ private void UnRegisterLogFileReaderEvents () } } + /// + /// The single Session File read of a window open: deserializes the .lxp once (via + /// ) and decomposes it into the Session Snapshot both apply phases + /// work from. Returns null when there is nothing to load or the read fails (log + defaults, + /// silent — the load error contract). + /// + private SessionSnapshot ReadSessionSnapshot (string fileName) + { + // Union of both apply phases' gates: the pre-load phase needs SaveSessions or a forced + // file name, the post-load phase additionally honors the session-load one-shot flag. + if (!Preferences.SaveSessions && !ForcePersistenceLoading && ForcedPersistenceFileName == null) + { + return null; + } + + try + { + var persistenceData = ForcedPersistenceFileName == null + ? Persister.LoadPersistenceData(fileName, Preferences, ConfigManager.ActiveSessionDir) + : Persister.LoadPersistenceDataFromFixedFile(ForcedPersistenceFileName); + + return persistenceData == null ? null : SessionFileComposer.Decompose(persistenceData); + } + catch (Exception e) + { + _logger.Error(string.Format(CultureInfo.InvariantCulture, Resources.Logger_Error_In_Function, nameof(ReadSessionSnapshot), e)); + return null; + } + } + + /// + /// The pre-load apply phase of the Session Snapshot: options that must be in place before + /// the file content loads (encoding, multi-file, columnizer-by-name, tab name, highlight + /// group, panel layout). The return value gates columnizer auto-detection in + /// , as before. Also performs the window open's single Session File + /// read, keeping the snapshot for the post-load phase. + /// [SupportedOSPlatform("windows")] - //TODO This should be part of the Persister private bool LoadPersistenceOptions () { if (InvokeRequired) @@ -2454,28 +2493,30 @@ private bool LoadPersistenceOptions () return (bool)Invoke(new BoolReturnDelegate(LoadPersistenceOptions)); } + _sessionSnapshot = ReadSessionSnapshot(FileName); + if (!Preferences.SaveSessions && ForcedPersistenceFileName == null) { + // The snapshot may still exist for the post-load phase (session load with + // SaveSessions off) — but this phase does not apply it, as before. return false; } - try + if (_sessionSnapshot == null) { - var persistenceData = ForcedPersistenceFileName == null - ? Persister.LoadPersistenceDataOptionsOnly(FileName, Preferences, ConfigManager.ActiveSessionDir) - : Persister.LoadPersistenceDataOptionsOnlyFromFixedFile(ForcedPersistenceFileName); + //_logger.Info($"No persistence data for {FileName} found."); + return false; + } - if (persistenceData == null) - { - //_logger.Info($"No persistence data for {FileName} found."); - return false; - } + try + { + var snapshot = _sessionSnapshot; - IsMultiFile = persistenceData.MultiFile; + IsMultiFile = snapshot.MultiFile; _multiFileOptions = new MultiFileOptions { - FormatPattern = persistenceData.MultiFilePattern, - MaxDayTry = persistenceData.MultiFileMaxDays + FormatPattern = snapshot.MultiFilePattern, + MaxDayTry = snapshot.MultiFileMaxDays }; if (string.IsNullOrEmpty(_multiFileOptions.FormatPattern)) @@ -2483,40 +2524,38 @@ private bool LoadPersistenceOptions () _multiFileOptions = ObjectClone.Clone(Preferences.MultiFileOptions); } - splitContainerLogWindow.SplitterDistance = persistenceData.FilterPosition; - splitContainerLogWindow.Panel2Collapsed = !persistenceData.FilterVisible; - ToggleHighlightPanel(persistenceData.FilterSaveListVisible); - ShowAdvancedFilterPanel(persistenceData.FilterAdvanced); + splitContainerLogWindow.SplitterDistance = snapshot.FilterPosition; + splitContainerLogWindow.Panel2Collapsed = !snapshot.FilterVisible; + ToggleHighlightPanel(snapshot.FilterSaveListVisible); + ShowAdvancedFilterPanel(snapshot.FilterAdvanced); if (_reloadMemento == null) { - PreSelectColumnizerByName(persistenceData.Columnizer?.GetName()); + PreSelectColumnizerByName(snapshot.Columnizer?.GetName()); } - FollowTailChanged(persistenceData.FollowTail, false); - if (persistenceData.TabName != null) + if (snapshot.TabName != null) { - Text = persistenceData.TabName; + Text = snapshot.TabName; } AdjustHighlightSplitterWidth(); - SetCurrentHighlightGroup(persistenceData.HighlightGroupName); + SetCurrentHighlightGroup(snapshot.HighlightGroupName); - SetCellSelectionMode(persistenceData.CellSelectMode, true); + SetCellSelectionMode(snapshot.CellSelectMode, true); - if (persistenceData.MultiFileNames.Count > 0) + if (snapshot.MultiFileNames.Count > 0) { //_logger.Info($"Detected MultiFile name list in persistence options"); - _fileNames = new string[persistenceData.MultiFileNames.Count]; - persistenceData.MultiFileNames.CopyTo(_fileNames); + _fileNames = new string[snapshot.MultiFileNames.Count]; + snapshot.MultiFileNames.CopyTo(_fileNames); } else { _fileNames = null; } - //this.bookmarkWindow.ShowBookmarkCommentColumn = persistenceData.showBookmarkCommentColumn; - SetExplicitEncoding(persistenceData.Encoding); + SetExplicitEncoding(snapshot.Encoding); return true; } catch (Exception e) @@ -2535,8 +2574,14 @@ private void SetDefaultsFromPrefs () _multiFileOptions = ObjectClone.Clone(Preferences.MultiFileOptions); } + /// + /// The post-load apply phase of the Session Snapshot: content-dependent state that needs the + /// loaded file (bookmarks, row heights, scroll position, follow-tail, filters, filter tabs). + /// Works from the snapshot kept by the window open's single read — no re-deserialization. + /// Skipped entirely when the Rollover staleness rule fires; the pre-load options stay + /// applied. + /// [SupportedOSPlatform("windows")] - //TODO This should be part of the Persister private void LoadPersistenceData () { if (InvokeRequired) @@ -2561,32 +2606,28 @@ private void LoadPersistenceData () try { - var persistenceData = ForcedPersistenceFileName == null - ? Persister.LoadPersistenceData(FileName, Preferences, ConfigManager.ActiveSessionDir) - : Persister.LoadPersistenceDataFromFixedFile(ForcedPersistenceFileName); + var snapshot = _sessionSnapshot; - if (persistenceData == null) + if (snapshot == null) { _logger.Info($"No persistence data for {FileName} found."); return; } - if (persistenceData.LineCount > _logFileReader.LineCount) + if (SessionFileComposer.IsStale(snapshot, _logFileReader.LineCount)) { - // outdated persistence data (logfile rollover) - // MessageBox.Show(this, "Persistence data for " + this.FileName + " is outdated. It was discarded.", "Log Expert"); - //_logger.Info($"Persistence data for {FileName} is outdated. It was discarded.")); - _ = LoadPersistenceOptions(); + // Outdated persistence data (logfile rollover): the content-dependent state is + // discarded; the pre-load options are already applied and stay. return; } - _bookmarkProvider.SetBookmarks(persistenceData.BookmarkList); - _rowHeightList = persistenceData.RowHeightList; + _bookmarkProvider.SetBookmarks(snapshot.BookmarkList); + _rowHeightList = snapshot.RowHeightList; try { - if (persistenceData.CurrentLine >= 0 && persistenceData.CurrentLine < dataGridView.RowCount) + if (snapshot.CurrentLine >= 0 && snapshot.CurrentLine < dataGridView.RowCount) { - SelectLine(persistenceData.CurrentLine, false, true); + SelectLine(snapshot.CurrentLine, false, true); } else { @@ -2597,27 +2638,24 @@ private void LoadPersistenceData () } } - if (persistenceData.FirstDisplayedLine >= 0 && - persistenceData.FirstDisplayedLine < dataGridView.RowCount) + if (snapshot.FirstDisplayedLine >= 0 && + snapshot.FirstDisplayedLine < dataGridView.RowCount) { - dataGridView.FirstDisplayedScrollingRowIndex = persistenceData.FirstDisplayedLine; + dataGridView.FirstDisplayedScrollingRowIndex = snapshot.FirstDisplayedLine; } - if (persistenceData.FollowTail) - { - FollowTailChanged(persistenceData.FollowTail, false); - } + // Applied once, here (was double-applied: always pre-load, here only when true). + // Applying true after positioning keeps the jump-to-tail semantics. + FollowTailChanged(snapshot.FollowTail, false); } catch (ArgumentOutOfRangeException) { // FirstDisplayedScrollingRowIndex calculates sometimes the wrong scrolling ranges??? } - SetCellSelectionMode(persistenceData.CellSelectMode, true); - if (Preferences.SaveFilters) { - RestoreFilters(persistenceData); + RestoreFilters(snapshot); } } catch (IOException e) @@ -2627,47 +2665,63 @@ private void LoadPersistenceData () } } + // Note: the panel layout fields (FilterPosition, FilterVisible, FilterAdvanced) are no + // longer re-applied here — they are pre-load fields, applied once in LoadPersistenceOptions + // (one field, one phase). Restored filter-tab child windows get them via + // ApplyRestoredFilterTabSnapshot instead. [SupportedOSPlatform("windows")] - private void RestoreFilters (PersistenceData persistenceData) + private void RestoreFilters (SessionSnapshot snapshot) { - if (persistenceData.FilterParamsList.Count > 0) + if (snapshot.FilterParamsList.Count > 0) { - _filterParams = persistenceData.FilterParamsList[0]; + _filterParams = snapshot.FilterParamsList[0]; ReInitFilterParams(_filterParams); } ApplyFilterParams(); // re-loaded filter settings _ = BeginInvoke(new MethodInvoker(FilterSearch)); - try - { - splitContainerLogWindow.SplitterDistance = persistenceData.FilterPosition; - splitContainerLogWindow.Panel2Collapsed = !persistenceData.FilterVisible; - } - catch (InvalidOperationException e) - { - _logger.Error($"Error setting splitter distance: {e}"); - } - - ShowAdvancedFilterPanel(persistenceData.FilterAdvanced); if (_filterPipeList.Count == 0) // don't restore if it's only a reload { - RestoreFilterTabs(persistenceData); + RestoreFilterTabs(snapshot); } } - private void RestoreFilterTabs (PersistenceData persistenceData) + private void RestoreFilterTabs (SessionSnapshot snapshot) { - foreach (var data in persistenceData.FilterTabDataList) + foreach (var tab in snapshot.FilterTabs) { - var persistFilterParams = data.FilterParams; + var persistFilterParams = tab.FilterParams; ReInitFilterParams(persistFilterParams); // Each restored tab runs its own filter with fresh spread history — its content no // longer depends on the window's shared history state at restore time. var filterRun = new SerialFilterEngine().Run(persistFilterParams, new ColumnizerCallback(this), CancellationToken.None); FilterPipe pipe = new(persistFilterParams.Clone(), this); - WritePipeToTab(pipe, [.. filterRun.ResultLines], data.PersistenceData.TabName, data.PersistenceData); + WritePipeToTab(pipe, [.. filterRun.ResultLines], tab.Snapshot.TabName, tab.Snapshot); + } + } + + /// + /// Applies a restored filter-tab child window's snapshot. A child window never runs the two + /// window-open apply phases against its own snapshot, so the panel layout it would get + /// pre-load is applied here, once, followed by its filters (and its own filter tabs, + /// recursively). + /// + [SupportedOSPlatform("windows")] + private void ApplyRestoredFilterTabSnapshot (SessionSnapshot snapshot) + { + try + { + splitContainerLogWindow.SplitterDistance = snapshot.FilterPosition; + splitContainerLogWindow.Panel2Collapsed = !snapshot.FilterVisible; } + catch (InvalidOperationException e) + { + _logger.Error($"Error setting splitter distance: {e}"); + } + + ShowAdvancedFilterPanel(snapshot.FilterAdvanced); + RestoreFilters(snapshot); } private void ReInitFilterParams (FilterParams filterParams) @@ -4915,7 +4969,7 @@ private void WriteFilterToTab () } [SupportedOSPlatform("windows")] - private void WritePipeToTab (FilterPipe pipe, List lineNumberList, string name, PersistenceData persistenceData) + private void WritePipeToTab (FilterPipe pipe, List lineNumberList, string name, SessionSnapshot snapshot) { StatusLineText(Resources.LogWindow_UI_StatusLineText_WritePipeToTab_WritingToTempFile); _guiStateArgs.MenuEnabled = false; @@ -4981,11 +5035,11 @@ private void WritePipeToTab (FilterPipe pipe, List lineNumberList, string n return; } - Invoke(() => WriteFilterToTabFinished(pipe, name, persistenceData, wasCancelled)); + Invoke(() => WriteFilterToTabFinished(pipe, name, snapshot, wasCancelled)); } [SupportedOSPlatform("windows")] - private void WriteFilterToTabFinished (FilterPipe pipe, string name, PersistenceData persistenceData, bool wasCancelled) + private void WriteFilterToTabFinished (FilterPipe pipe, string name, SessionSnapshot snapshot, bool wasCancelled) { _isSearching = false; if (!wasCancelled) @@ -5000,9 +5054,9 @@ private void WriteFilterToTabFinished (FilterPipe pipe, string name, Persistence var newWin = _logWindowCoordinator.AddFilterTab(pipe, title, preProcessColumnizer); newWin.FilterPipe = pipe; pipe.OwnLogWindow = newWin; - if (persistenceData != null) + if (snapshot != null) { - _ = Task.Run(() => FilterRestore(newWin, persistenceData)); + _ = Task.Run(() => FilterRestore(newWin, snapshot)); } } @@ -5040,22 +5094,18 @@ internal void WritePipeTab (IList lineEntryList, string title) } [SupportedOSPlatform("windows")] - private static void FilterRestore (LogWindow newWin, PersistenceData persistenceData) + private static void FilterRestore (LogWindow newWin, SessionSnapshot snapshot) { newWin.WaitForLoadingFinished(); - var columnizer = ColumnizerPicker.FindMemoryColumnizerByName(persistenceData.Columnizer.GetName(), PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers); + var columnizer = ColumnizerPicker.FindMemoryColumnizerByName(snapshot.Columnizer.GetName(), PluginRegistry.PluginRegistry.Instance.RegisteredColumnizers); if (columnizer != null) { SetColumnizerFx fx = newWin.ForceColumnizer; _ = newWin.Invoke(fx, [columnizer]); } - //else - //{ - // _logger.Warn($"FilterRestore(): Columnizer {persistenceData.ColumnizerName} not found")); - //} - _ = newWin.BeginInvoke(new RestoreFiltersFx(newWin.RestoreFilters), [persistenceData]); + _ = newWin.BeginInvoke(new RestoreFiltersFx(newWin.ApplyRestoredFilterTabSnapshot), [snapshot]); } [SupportedOSPlatform("windows")] @@ -6150,7 +6200,7 @@ public void LoadFile (string fileName, EncodingOptions encodingOptions) // this may be set after loading persistence data if (_fileNames != null && IsMultiFile) { - LoadFilesAsMulti(_fileNames, EncodingOptions); + StartMultiFileReader(_fileNames, EncodingOptions); return; } @@ -6205,6 +6255,16 @@ public void LoadFile (string fileName, EncodingOptions encodingOptions) } public void LoadFilesAsMulti (string[] fileNames, EncodingOptions encodingOptions) + { + // A direct multi-file load (new multi tab, multi-file reload) never runs the LoadFile + // flow, so the window open's single Session File read happens here. LoadFile's own + // multi-file branch calls StartMultiFileReader directly — its read already happened. + _sessionSnapshot = ReadSessionSnapshot(fileNames[^1]); + + StartMultiFileReader(fileNames, encodingOptions); + } + + private void StartMultiFileReader (string[] fileNames, EncodingOptions encodingOptions) { EnterLoadFileStatus(); @@ -6254,7 +6314,12 @@ public string SavePersistenceDataAndReturnFileName (bool force) try { - var persistenceData = GetPersistenceData(); + // Relocated from the old composing getter (the composer must stay side-effect-free): + // this option doesn't need a press on 'search'. The write must precede the gather — + // the gather copies the global FilterList, and this write may alias into it. + _filterParams.IsFilterTail = filterTailCheckBox.Checked; + + var persistenceData = SessionFileComposer.Compose(GatherSessionSnapshot()); return ForcedPersistenceFileName == null ? Persister.SavePersistenceData(FileName, persistenceData, Preferences, ConfigManager.ActiveSessionDir) @@ -6277,7 +6342,7 @@ public void SavePersistenceData (bool force) _ = SavePersistenceDataAndReturnFileName(force); } - public PersistenceData GetPersistenceData () + public SessionSnapshot GatherSessionSnapshot () { // Filter out auto-generated bookmarks — they are transient and will be re-generated on load SortedList manualBookmarks = []; @@ -6289,7 +6354,7 @@ public PersistenceData GetPersistenceData () } } - PersistenceData persistenceData = new() + SessionSnapshot snapshot = new() { BookmarkList = manualBookmarks, RowHeightList = _rowHeightList, @@ -6305,45 +6370,40 @@ public PersistenceData GetPersistenceData () CellSelectMode = _guiStateArgs.CellSelectMode, FileName = FileName, TabName = Text, - SessionFileName = SessionFileName, Columnizer = CurrentColumnizer, LineCount = _logFileReader != null ? _logFileReader.LineCount : 0 }; - _filterParams.IsFilterTail = filterTailCheckBox.Checked; // this option doesnt need a press on 'search' - 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 - persistenceData.FilterParamsList = [.. ConfigManager.Settings.FilterList]; + snapshot.FilterParamsList = [.. ConfigManager.Settings.FilterList]; foreach (var filterPipe in _filterPipeList) { - FilterTabData data = new() + snapshot.FilterTabs.Add(new FilterTabSnapshot { - PersistenceData = filterPipe.OwnLogWindow.GetPersistenceData(), - FilterParams = filterPipe.FilterParams - }; - persistenceData.FilterTabDataList.Add(data); + FilterParams = filterPipe.FilterParams, + Snapshot = filterPipe.OwnLogWindow.GatherSessionSnapshot(), + }); } } if (_currentHighlightGroup != null) { - persistenceData.HighlightGroupName = _currentHighlightGroup.GroupName; + snapshot.HighlightGroupName = _currentHighlightGroup.GroupName; } if (_fileNames != null && IsMultiFile) { - persistenceData.MultiFileNames.AddRange(_fileNames); + snapshot.MultiFileNames.AddRange(_fileNames); } - //persistenceData.showBookmarkCommentColumn = this.bookmarkWindow.ShowBookmarkCommentColumn; - persistenceData.FilterSaveListVisible = !highlightSplitContainer.Panel2Collapsed; - persistenceData.Encoding = _logFileReader.CurrentEncoding; + snapshot.FilterSaveListVisible = !highlightSplitContainer.Panel2Collapsed; + snapshot.Encoding = _logFileReader.CurrentEncoding; - return persistenceData; + return snapshot; } public void Close (bool dontAsk) From 38facf1f3463ff3aa70c3f10abd0e9022cee7114 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Jul 2026 07:10:57 +0000 Subject: [PATCH 5/9] chore: update plugin hashes [skip ci] --- .../PluginHashGenerator.Generated.cs | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index f39f98ad..a448ef75 100644 --- a/src/PluginRegistry/PluginHashGenerator.Generated.cs +++ b/src/PluginRegistry/PluginHashGenerator.Generated.cs @@ -10,7 +10,7 @@ public static partial class PluginValidator { /// /// Gets pre-calculated SHA256 hashes for built-in plugins. - /// Generated: 2026-07-11 15:26:05 UTC + /// Generated: 2026-07-13 07:10:55 UTC /// Configuration: Release /// Plugin count: 21 /// @@ -18,27 +18,27 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "1A1D4B6181F93955C71CB0591779628EDCCBCD4D46891DD2FF36828086C883C9", + ["AutoColumnizer.dll"] = "1A2699AD090B3555C9104A65797B3B41ED7FEAAB74BD5A6A32173B09861AB7B3", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "6A2FD7BA67EE1DF94CF11D326E6E66EF341B3E4025B1EDA0ED599F4A256817E4", - ["CsvColumnizer.dll (x86)"] = "6A2FD7BA67EE1DF94CF11D326E6E66EF341B3E4025B1EDA0ED599F4A256817E4", - ["DefaultPlugins.dll"] = "8CFAC3764C109FFB1292B3DF62BABDFFDCF9025DAD09A93D9EDA60B322624237", - ["FlashIconHighlighter.dll"] = "A1F84BF9CB7A061719633CA879B65A7EB3D6A7E48C1C0B2BCEFEF2A461347941", - ["GlassfishColumnizer.dll"] = "1A70A1C2498170F21674B5891A93631AC9DDCE3D1D8CC547E3755DA91F8E897D", - ["JsonColumnizer.dll"] = "7FA4DABA1E29841B0DC254BA9047E96C70E10A086F063F6D1089E94D567CE110", - ["JsonCompactColumnizer.dll"] = "52F762DFBDA5771FEFE65E8A4A326F23D5FB33F69933E33A4FB9A236953B38B7", - ["Log4jXmlColumnizer.dll"] = "1A50FF0ED6C60D934B05058987F8A2C19300449E95AD2324B42432FF20339FB6", - ["LogExpert.Resources.dll"] = "EF96F863858116E94F7FF4C635CEE3AF2A43FCACC2EEE89E081E2CC5C739874A", + ["CsvColumnizer.dll"] = "2323B2ACBA3AA703D20B0E19799194BF9F14DC72284A5B14D5B04820EB0CE34F", + ["CsvColumnizer.dll (x86)"] = "2323B2ACBA3AA703D20B0E19799194BF9F14DC72284A5B14D5B04820EB0CE34F", + ["DefaultPlugins.dll"] = "A0D780B865FCA33A65C4B75B1C50B252540AFFD1C93FC9B2D4B34069F3201D1D", + ["FlashIconHighlighter.dll"] = "D53194614337BC6A6E1D11847E3E506D7D23FCCDA755519287740086A1A94437", + ["GlassfishColumnizer.dll"] = "A9DFC8ADEBA0FE4DB3C799BF17AF98ABC59AE1AA3D69C58A7FB9C886F53FB652", + ["JsonColumnizer.dll"] = "34A1AAF4F321EC7A7962FE59738C9F3FFABDD064C57CFF88B986B9873C32A737", + ["JsonCompactColumnizer.dll"] = "0197197A1303E8D2356CC78E4DA817471E07C6278AFCF79D37C4313D7C898E41", + ["Log4jXmlColumnizer.dll"] = "E5F4E911D58DEC48678ACD8E6FD0CD2CE82276D9B846A573D989BBE7C0CA0E94", + ["LogExpert.Resources.dll"] = "58310AD9AA55F4E89E1D1CB77D3CE6B5F8B8167510F41DA3F954AA0188FE8648", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll (x86)"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.Logging.Abstractions.dll"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", ["Microsoft.Extensions.Logging.Abstractions.dll (x86)"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", - ["RegexColumnizer.dll"] = "C6D70682B1708E8EF4479D105CBC6F3A92E9C5A6FB7E1C5D68C4B6ECB5CDD75F", - ["SftpFileSystem.dll"] = "E2551A64D602C59F069B6600FABDA8ACB49E9D6B9697C82DE8166BAF0E3836B9", - ["SftpFileSystem.dll (x86)"] = "37F2E09661274513A2B2418BD0CFE6012C8704F74B041A118B125CCB90CC4E14", - ["SftpFileSystem.Resources.dll"] = "A8934B9231E463B7A9ED62589523FF045489721AF1A4E6F941241CA1D285FC7F", - ["SftpFileSystem.Resources.dll (x86)"] = "A8934B9231E463B7A9ED62589523FF045489721AF1A4E6F941241CA1D285FC7F", + ["RegexColumnizer.dll"] = "1DAC78865ADDF0D1D512A34E371743393C50291A72EEF8A15C080130699F23A3", + ["SftpFileSystem.dll"] = "4AAA37FC36932F115320F3D876D964B7C8A080F3BCA9287E877F76BD59B54F6A", + ["SftpFileSystem.dll (x86)"] = "56440FFE4EEB7DC434545AE0BD4C56BF3F7A723D257265BD8AD60BA8C0066F75", + ["SftpFileSystem.Resources.dll"] = "3D9276709E8A3E84302FFE1925022CCB10E56B7835062F134CF5CF3E5F42EA94", + ["SftpFileSystem.Resources.dll (x86)"] = "3D9276709E8A3E84302FFE1925022CCB10E56B7835062F134CF5CF3E5F42EA94", }; } From e9a478cf0e0f294ac7ac059433fca8fca832f277 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Jul 2026 07:13:37 +0000 Subject: [PATCH 6/9] chore: update plugin hashes [skip ci] --- .../PluginHashGenerator.Generated.cs | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 4f5e3603..01bc281a 100644 --- a/src/PluginRegistry/PluginHashGenerator.Generated.cs +++ b/src/PluginRegistry/PluginHashGenerator.Generated.cs @@ -10,7 +10,7 @@ public static partial class PluginValidator { /// /// Gets pre-calculated SHA256 hashes for built-in plugins. - /// Generated: 2026-07-11 16:16:07 UTC + /// Generated: 2026-07-13 07:13:36 UTC /// Configuration: Release /// Plugin count: 21 /// @@ -18,27 +18,27 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "BE97EF8EA8722C979D374A02E53FA9FDBE19EA15D0FB28835B72DBEFD64D7CF8", + ["AutoColumnizer.dll"] = "DCE3FBC38394436CDB868D5989C2E264ACCED602D05E3DA640DA7F36D9FCB4D9", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "4D5C0510D1EBF57B78D29D2EEDAFCC46F4389A1483CCEA100583D38179513AB0", - ["CsvColumnizer.dll (x86)"] = "4D5C0510D1EBF57B78D29D2EEDAFCC46F4389A1483CCEA100583D38179513AB0", - ["DefaultPlugins.dll"] = "7B1253DA6F4E5692C905D224E020107B50A3A2272840BD5919CA25BC1EBC8D52", - ["FlashIconHighlighter.dll"] = "5FF2344266318C436ED2519E8F4584CD1BC677B41BB5CC83846D14A4FC097B95", - ["GlassfishColumnizer.dll"] = "63F7EA7BC7A60B9896D6F45EC4F7676DAF4BB169F2EC6B3548075AAD61C56A13", - ["JsonColumnizer.dll"] = "C0C2E835028A2A8EF55BC81F5A8B09918918F349C23D52C09FED19FB2C93C786", - ["JsonCompactColumnizer.dll"] = "07CACA23CE4D4DA75A182F8C809CE3EC6635EDE575E054036FE3EB9751CE35D0", - ["Log4jXmlColumnizer.dll"] = "A919564C63E425155146D7DB777D2E1F59D0EFCFBBA975B58CCA6F26B05712AB", - ["LogExpert.Resources.dll"] = "95E86A298153DABA3D66889E1E30F1DF8DC5EA702C797F1DED66170C727348E3", + ["CsvColumnizer.dll"] = "DB72A979D7005C0F40BA57E15171B5DEBF2A500E6E31947213F45457B758BB32", + ["CsvColumnizer.dll (x86)"] = "DB72A979D7005C0F40BA57E15171B5DEBF2A500E6E31947213F45457B758BB32", + ["DefaultPlugins.dll"] = "89569E51E48B65D06521444025122B720004ACDF91D10FBA8CA908EE61F7EE2B", + ["FlashIconHighlighter.dll"] = "3A84E24FA9A72F93579E3342A85D40CAC8373315DAA804F2D1D80B001115C089", + ["GlassfishColumnizer.dll"] = "48727BE656250E987963CCDADADD9940A7094AB0A4544247864504E36E306356", + ["JsonColumnizer.dll"] = "95906BA441ECC3B970B5D40A0D01CB14550DBDB9C5DB4E172490EE05D547700D", + ["JsonCompactColumnizer.dll"] = "E149C5F913F139972C4A0674FBEFCDFF39DF5213B6FFED00DCC120D50C30FBFE", + ["Log4jXmlColumnizer.dll"] = "064C7031360F409AE1A5ADC58D56AE425B6F812EF49E3590797AEED87D31750A", + ["LogExpert.Resources.dll"] = "EF0CC3FD81D060A794CD9ECBE70AAED8A7BFC390567E79A007C6F42BB8CC7DCC", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll (x86)"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.Logging.Abstractions.dll"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", ["Microsoft.Extensions.Logging.Abstractions.dll (x86)"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", - ["RegexColumnizer.dll"] = "7C41428A3CA5D68D4CA4BDE3D6E043276467B2B46F0F9118867653B97630F506", - ["SftpFileSystem.dll"] = "9A8BCA76877E0CD86C8FBFC2D4B40478FAB5097E3E9554A110AB0BACBB485896", - ["SftpFileSystem.dll (x86)"] = "57CACE749999F7412B5ACC608A4844EB3C3077668E8827CDF651D939BAF26810", - ["SftpFileSystem.Resources.dll"] = "47C83AB62174AD5F747A2DB43040B099AE8CE64E790C2806AECBA05CED61B9A8", - ["SftpFileSystem.Resources.dll (x86)"] = "47C83AB62174AD5F747A2DB43040B099AE8CE64E790C2806AECBA05CED61B9A8", + ["RegexColumnizer.dll"] = "BD47C82DD3AD384790B47D87D67F7057C8B986C4908A893E284832CDAC9AD4B8", + ["SftpFileSystem.dll"] = "0B3FC44BCE252D49B4E53B6038C78B5BBDF1C0B2C601733A78FA4EC4980D83F1", + ["SftpFileSystem.dll (x86)"] = "5289185265B73464C9A44666CBEB9248349934A9FA24307BAACAFB643344C91B", + ["SftpFileSystem.Resources.dll"] = "9211F21481C2984339B4EBF75F986B993082FCDD758A4933E357A96FE04CD5AA", + ["SftpFileSystem.Resources.dll (x86)"] = "9211F21481C2984339B4EBF75F986B993082FCDD758A4933E357A96FE04CD5AA", }; } From 0b245b717ff1a4f3a0bb6b7c2eab2c3da3b5a1ff Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Mon, 13 Jul 2026 12:36:23 +0200 Subject: [PATCH 7/9] fix: guard time-spread refresh against missing window handle The TimeSpreadCalculator worker raises StartCalc/CalcDone from a thread-pool task; TimeSpreadingControl called BeginInvoke(Refresh) unguarded, which throws InvalidOperationException when the window was loaded but never displayed (background tab of a restored session, restored filter-pipe child) and crashed the process as an unhandled thread-pool exception. Pre-existing race, surfaced by session-restore smoke testing. Also replaces the pointless Invoke in PreferencesChanged with a direct Refresh: the method already runs on the UI thread, and Invoke shares the same no-handle crash vector. --- .../Controls/LogWindow/LogWindow.cs | 5 +++- .../Controls/LogWindow/TimeSpreadigControl.cs | 27 +++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 8419b287..124fafe8 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -7867,7 +7867,10 @@ public void PreferencesChanged (Font font, bool setLastColumnWidth, int lastColu if (CurrentColumnizer.IsTimeshiftImplemented()) { - _ = timeSpreadingControl.Invoke(new MethodInvoker(timeSpreadingControl.Refresh)); + // Already on the UI thread (this method touches fonts and grids directly); a + // direct Refresh is also safe on a control whose handle doesn't exist yet, + // where Invoke would throw. + timeSpreadingControl.Refresh(); ShowTimeSpread(Preferences.ShowTimeSpread); } diff --git a/src/LogExpert.UI/Controls/LogWindow/TimeSpreadigControl.cs b/src/LogExpert.UI/Controls/LogWindow/TimeSpreadigControl.cs index 84591cf3..424b1c7e 100644 --- a/src/LogExpert.UI/Controls/LogWindow/TimeSpreadigControl.cs +++ b/src/LogExpert.UI/Controls/LogWindow/TimeSpreadigControl.cs @@ -204,7 +204,7 @@ private void OnTimeSpreadCalcCalcDone (object sender, EventArgs e) } } - _ = BeginInvoke(new MethodInvoker(Refresh)); + RefreshOnUiThread(); } private void OnTimeSpreadCalcStartCalc (object sender, EventArgs e) @@ -238,7 +238,30 @@ private void OnTimeSpreadCalcStartCalc (object sender, EventArgs e) gfx.DrawString(Resources.TimeSpreadingControl_UI_GFX_OnTimeSpreadCalcStartCalc_CalculatingTimeSpreadView, Font, fgBrush, rectf, format); } - _ = BeginInvoke(new MethodInvoker(Refresh)); + RefreshOnUiThread(); + } + + /// + /// The calculator raises its events from a thread-pool worker; the control may not have a + /// window handle yet (a loaded but never displayed window, e.g. a background tab of a + /// restored session) or may be tearing down. BeginInvoke throws on both — skip the refresh, + /// the control paints when it first becomes visible anyway. + /// + private void RefreshOnUiThread () + { + if (!IsHandleCreated || IsDisposed) + { + return; + } + + try + { + _ = BeginInvoke(new MethodInvoker(Refresh)); + } + catch (InvalidOperationException) + { + // Handle destroyed between the check and the call — the window is closing. + } } private void OnTimeSpreadingControlSizeChanged (object sender, EventArgs e) From cb2a059d734b2e2e9120e031660ae532a50a4fdb Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Mon, 13 Jul 2026 12:55:48 +0200 Subject: [PATCH 8/9] updated after review --- .../Classes/Persister/SessionFileComposer.cs | 3 +- .../Classes/Persister/SessionSnapshot.cs | 6 +-- .../Filter/FilterSpreadTests.cs | 46 ++++++------------- .../Controls/LogWindow/LogWindow.cs | 13 +++--- 4 files changed, 25 insertions(+), 43 deletions(-) diff --git a/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs b/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs index 86cab56d..3dd9cead 100644 --- a/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs +++ b/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs @@ -4,8 +4,7 @@ namespace LogExpert.Core.Classes.Persister; /// Pure mapping between a and the Session File's serialized form /// (), in both directions, plus the Rollover staleness rule. /// No I/O, no UI, no side effects — the Log Window owns gathering, applying, timing, and error -/// display; owns serialization. Field-by-field contract: -/// docs/specs/session-file-composer.md. +/// display; owns serialization. /// public static class SessionFileComposer { diff --git a/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs b/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs index 502ca7c1..6a524e4f 100644 --- a/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs +++ b/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs @@ -8,11 +8,9 @@ namespace LogExpert.Core.Classes.Persister; /// -/// A neutral, UI-free capture of one Log Window's persistable state (the Session Snapshot of -/// CONTEXT.md). Gathered by the Log Window when a Session File is saved and applied back in two +/// A neutral, UI-free capture of one Log Window's persistable state. Gathered by the Log Window when a Session File is saved and applied back in two /// Log-Window-side phases when one is loaded. Mapped to and from by -/// ; see docs/specs/session-file-composer.md for the full field -/// mapping table. +/// . /// public class SessionSnapshot { diff --git a/src/LogExpert.Tests/Filter/FilterSpreadTests.cs b/src/LogExpert.Tests/Filter/FilterSpreadTests.cs index b2bee626..026744f1 100644 --- a/src/LogExpert.Tests/Filter/FilterSpreadTests.cs +++ b/src/LogExpert.Tests/Filter/FilterSpreadTests.cs @@ -1,5 +1,3 @@ -using System.Collections.ObjectModel; - using LogExpert.Core.Classes.Filter; using NUnit.Framework; @@ -10,25 +8,15 @@ namespace LogExpert.Tests.Filter; public class FilterSpreadTests { // Row format: hit line, spread before, spread behind, line count, already-taken history, expected lines. - // Expected values are worked examples from the spec (docs/specs/filter-spread-extraction.md). - [TestCase(5, 0, 0, 100, new int[0], new[] { 5 }, - TestName = "Expand_NoSpreadConfigured_ReturnsOnlyTheHitLine")] - [TestCase(5, 0, 0, 100, new[] { 5 }, new[] { 5 }, - TestName = "Expand_NoSpreadConfigured_IgnoresHistory_HistoricalQuirkPreserved")] - [TestCase(0, 3, 2, 100, new int[0], new[] { 0, 1, 2 }, - TestName = "Expand_HitAtLineZero_EmitsNoNegativeLines")] - [TestCase(3, 5, 0, 100, new int[0], new[] { 0, 1, 2, 3 }, - TestName = "Expand_BackSpreadReachingPastTopOfFile_ClampsAtLineZeroAndIncludesIt")] - [TestCase(97, 0, 5, 100, new int[0], new[] { 97, 98, 99 }, - TestName = "Expand_ForeSpreadReachingPastEndOfFile_ClampsAtLastLine")] - [TestCase(99, 0, 3, 100, new int[0], new[] { 99 }, - TestName = "Expand_HitAtLastLineWithForeSpread_ReturnsOnlyTheHit")] - [TestCase(10, 1, 3, 100, new int[0], new[] { 9, 10, 11, 12, 13 }, - TestName = "Expand_AsymmetricSpread_ReturnsBackLinesHitThenForeLines")] - [TestCase(12, 2, 2, 100, new[] { 8, 9, 10, 11, 12 }, new[] { 13, 14 }, - TestName = "Expand_OverlappingWithEarlierHit_SuppressesLinesAlreadyTaken")] - [TestCase(5, 1, 1, 100, new[] { 5 }, new[] { 4, 6 }, - TestName = "Expand_HitItselfAlreadyTaken_IsNotEmittedAgain")] + [TestCase(5, 0, 0, 100, new int[0], new[] { 5 }, TestName = "Expand_NoSpreadConfigured_ReturnsOnlyTheHitLine")] + [TestCase(5, 0, 0, 100, new[] { 5 }, new[] { 5 }, TestName = "Expand_NoSpreadConfigured_IgnoresHistory_HistoricalQuirkPreserved")] + [TestCase(0, 3, 2, 100, new int[0], new[] { 0, 1, 2 }, TestName = "Expand_HitAtLineZero_EmitsNoNegativeLines")] + [TestCase(3, 5, 0, 100, new int[0], new[] { 0, 1, 2, 3 }, TestName = "Expand_BackSpreadReachingPastTopOfFile_ClampsAtLineZeroAndIncludesIt")] + [TestCase(97, 0, 5, 100, new int[0], new[] { 97, 98, 99 }, TestName = "Expand_ForeSpreadReachingPastEndOfFile_ClampsAtLastLine")] + [TestCase(99, 0, 3, 100, new int[0], new[] { 99 }, TestName = "Expand_HitAtLastLineWithForeSpread_ReturnsOnlyTheHit")] + [TestCase(10, 1, 3, 100, new int[0], new[] { 9, 10, 11, 12, 13 }, TestName = "Expand_AsymmetricSpread_ReturnsBackLinesHitThenForeLines")] + [TestCase(12, 2, 2, 100, new[] { 8, 9, 10, 11, 12 }, new[] { 13, 14 }, TestName = "Expand_OverlappingWithEarlierHit_SuppressesLinesAlreadyTaken")] + [TestCase(5, 1, 1, 100, new[] { 5 }, new[] { 4, 6 }, TestName = "Expand_HitItselfAlreadyTaken_IsNotEmittedAgain")] public void Expand_Table (int lineNum, int spreadBefore, int spreadBehind, int lineCount, int[] alreadyTaken, int[] expected) { var result = FilterSpread.Expand(lineNum, spreadBefore, spreadBehind, lineCount, alreadyTaken); @@ -66,11 +54,7 @@ public void TrimHistory_WithinWindow_IsUnchanged () public void TrimHistory_WorksOnAnyIListImplementation () { // The Filter Pipe history is an IList, not a List — the trim rule must not care. - IList history = new Collection(); - foreach (var i in Enumerable.Range(0, 200)) - { - history.Add(i); - } + IList history = [.. Enumerable.Range(0, 200)]; FilterSpread.TrimHistory(history); @@ -86,7 +70,7 @@ public void ShiftLines_OnRollover_SubtractsOffsetFromEveryLine () { var shifted = FilterSpread.ShiftLines([10, 20, 30], offset: 5); - Assert.That(shifted, Is.EqualTo(new[] { 5, 15, 25 })); + Assert.That(shifted, Is.EqualTo([5, 15, 25])); } [Test] @@ -95,7 +79,7 @@ public void ShiftLines_LinesRolledOffTheStart_AreDropped () // Offset 15 rolls lines 10 and 14 off the virtual file; 15 shifts to 0 and survives. var shifted = FilterSpread.ShiftLines([10, 14, 15, 40], offset: 15); - Assert.That(shifted, Is.EqualTo(new[] { 0, 25 })); + Assert.That(shifted, Is.EqualTo([0, 25])); } [Test] @@ -120,7 +104,7 @@ public void RebuildHistory_ResultsShorterThanSpreadMax_TakesAllOfThem () { var history = FilterSpread.RebuildHistory([3, 4, 5]); - Assert.That(history, Is.EqualTo(new[] { 3, 4, 5 })); + Assert.That(history, Is.EqualTo([3, 4, 5])); } [Test] @@ -159,8 +143,8 @@ public void AccumulationRecipe_OverlappingHitSequence_ProducesPinnedResultHitAnd Assert.Multiple(() => { // Hit 5 contributes 3..7; hit 7 contributes only 8,9 (5..7 already taken); hit 50 contributes 48..52. - Assert.That(resultLines, Is.EqualTo(new[] { 3, 4, 5, 6, 7, 8, 9, 48, 49, 50, 51, 52 })); - Assert.That(hitList, Is.EqualTo(new[] { 5, 7, 50 })); + Assert.That(resultLines, Is.EqualTo([3, 4, 5, 6, 7, 8, 9, 48, 49, 50, 51, 52])); + Assert.That(hitList, Is.EqualTo([5, 7, 50])); Assert.That(history, Is.EqualTo(resultLines), "history below the window size mirrors the result lines"); }); } diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 124fafe8..2ec6938b 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -2471,7 +2471,11 @@ private SessionSnapshot ReadSessionSnapshot (string fileName) return persistenceData == null ? null : SessionFileComposer.Decompose(persistenceData); } - catch (Exception e) + catch (Exception e) when (e is IOException or + UnauthorizedAccessException or + NotSupportedException or + InvalidOperationException or + ArgumentException) { _logger.Error(string.Format(CultureInfo.InvariantCulture, Resources.Logger_Error_In_Function, nameof(ReadSessionSnapshot), e)); return null; @@ -3222,7 +3226,7 @@ private void UpdateGrid (LogEventArgs logEventArgs) private void CheckFilterAndHighlight (LogEventArgs e) { - // The Tail trigger path (CONTEXT.md): the one loop that evaluates highlight entries against + // The Tail trigger path: the one loop that evaluates highlight entries against // newly appended lines. Side-effecting triggers (Audio Alert, Set Bookmark, Stop Tail) fire // here and only here — the bulk scanner (HighlightBookmarkScanner) has no access to them. var doFilter = filterTailCheckBox.Checked || _filterPipeList.Count > 0; @@ -6401,7 +6405,7 @@ public SessionSnapshot GatherSessionSnapshot () } snapshot.FilterSaveListVisible = !highlightSplitContainer.Panel2Collapsed; - snapshot.Encoding = _logFileReader.CurrentEncoding; + snapshot.Encoding = _logFileReader?.CurrentEncoding; return snapshot; } @@ -7867,9 +7871,6 @@ public void PreferencesChanged (Font font, bool setLastColumnWidth, int lastColu if (CurrentColumnizer.IsTimeshiftImplemented()) { - // Already on the UI thread (this method touches fonts and grids directly); a - // direct Refresh is also safe on a control whose handle doesn't exist yet, - // where Invoke would throw. timeSpreadingControl.Refresh(); ShowTimeSpread(Preferences.ShowTimeSpread); } From 52c2d0b5aadbcceb6bc0f4bd6ac1dca7f6b0ef9f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Jul 2026 10:58:14 +0000 Subject: [PATCH 9/9] chore: update plugin hashes [skip ci] --- .../PluginHashGenerator.Generated.cs | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 01bc281a..d6ef3dc9 100644 --- a/src/PluginRegistry/PluginHashGenerator.Generated.cs +++ b/src/PluginRegistry/PluginHashGenerator.Generated.cs @@ -10,7 +10,7 @@ public static partial class PluginValidator { /// /// Gets pre-calculated SHA256 hashes for built-in plugins. - /// Generated: 2026-07-13 07:13:36 UTC + /// Generated: 2026-07-13 10:58:13 UTC /// Configuration: Release /// Plugin count: 21 /// @@ -18,27 +18,27 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "DCE3FBC38394436CDB868D5989C2E264ACCED602D05E3DA640DA7F36D9FCB4D9", + ["AutoColumnizer.dll"] = "B61B8761E0191EE6907EA24CD32E310253E84DE1E30635A68F5330A9D8020DBA", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "DB72A979D7005C0F40BA57E15171B5DEBF2A500E6E31947213F45457B758BB32", - ["CsvColumnizer.dll (x86)"] = "DB72A979D7005C0F40BA57E15171B5DEBF2A500E6E31947213F45457B758BB32", - ["DefaultPlugins.dll"] = "89569E51E48B65D06521444025122B720004ACDF91D10FBA8CA908EE61F7EE2B", - ["FlashIconHighlighter.dll"] = "3A84E24FA9A72F93579E3342A85D40CAC8373315DAA804F2D1D80B001115C089", - ["GlassfishColumnizer.dll"] = "48727BE656250E987963CCDADADD9940A7094AB0A4544247864504E36E306356", - ["JsonColumnizer.dll"] = "95906BA441ECC3B970B5D40A0D01CB14550DBDB9C5DB4E172490EE05D547700D", - ["JsonCompactColumnizer.dll"] = "E149C5F913F139972C4A0674FBEFCDFF39DF5213B6FFED00DCC120D50C30FBFE", - ["Log4jXmlColumnizer.dll"] = "064C7031360F409AE1A5ADC58D56AE425B6F812EF49E3590797AEED87D31750A", - ["LogExpert.Resources.dll"] = "EF0CC3FD81D060A794CD9ECBE70AAED8A7BFC390567E79A007C6F42BB8CC7DCC", + ["CsvColumnizer.dll"] = "48AC082B0DB2C771A36620769C7036FA8105F556A461916B217DB182E4366A7E", + ["CsvColumnizer.dll (x86)"] = "48AC082B0DB2C771A36620769C7036FA8105F556A461916B217DB182E4366A7E", + ["DefaultPlugins.dll"] = "861CC05AF069D0B01E6495F7907E6D74641C2B1694CEB85D769A17D7D814457A", + ["FlashIconHighlighter.dll"] = "357A85897384F7A725B262478D50A932AC78AEB37876C3D4139322CB1B93D1CC", + ["GlassfishColumnizer.dll"] = "94205EA6CCA805A79FDDF63A2F29315722686C5FA7DAE6799E108061E0B42B9C", + ["JsonColumnizer.dll"] = "6065E697DFE8E4ABF54C799222C6414C40DC0EB2C2FB3EC812C83D31A700B219", + ["JsonCompactColumnizer.dll"] = "390F5E197E195809A33EBC4B9A72F80E4E6D479305E052441AEBB5C44556C564", + ["Log4jXmlColumnizer.dll"] = "312053A5E119CA070F06F184FDD560C4AD10BE466A97F7C5C5871595795DDE03", + ["LogExpert.Resources.dll"] = "F31094E9877204B27252FE5ED6F50694111C954F1B6E0AE71C8EB036B593B601", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll (x86)"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.Logging.Abstractions.dll"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", ["Microsoft.Extensions.Logging.Abstractions.dll (x86)"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", - ["RegexColumnizer.dll"] = "BD47C82DD3AD384790B47D87D67F7057C8B986C4908A893E284832CDAC9AD4B8", - ["SftpFileSystem.dll"] = "0B3FC44BCE252D49B4E53B6038C78B5BBDF1C0B2C601733A78FA4EC4980D83F1", - ["SftpFileSystem.dll (x86)"] = "5289185265B73464C9A44666CBEB9248349934A9FA24307BAACAFB643344C91B", - ["SftpFileSystem.Resources.dll"] = "9211F21481C2984339B4EBF75F986B993082FCDD758A4933E357A96FE04CD5AA", - ["SftpFileSystem.Resources.dll (x86)"] = "9211F21481C2984339B4EBF75F986B993082FCDD758A4933E357A96FE04CD5AA", + ["RegexColumnizer.dll"] = "EDD00D81B825F4E7EC4707F580EC6F17A6D94550C7E4296D54D24F6696D87D7F", + ["SftpFileSystem.dll"] = "D844F8A8C43ED3FC5B88F661761D7FD59C2F72A5B59D22D78D925F0C91CB28CF", + ["SftpFileSystem.dll (x86)"] = "3C1A36AFE6466E895B01C1A2EBA944128397C5C91D1832DFBA086A0F43861AC6", + ["SftpFileSystem.Resources.dll"] = "D5DDC872EC727E3CB4917707891912F443BA02E477E0F50151C7D9BC7669832D", + ["SftpFileSystem.Resources.dll (x86)"] = "D5DDC872EC727E3CB4917707891912F443BA02E477E0F50151C7D9BC7669832D", }; }