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/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.) 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 new file mode 100644 index 00000000..3dd9cead --- /dev/null +++ b/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs @@ -0,0 +1,109 @@ +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. +/// +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, + 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), + }), + ], + }; + } + + /// + /// 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, + 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), + }), + ], + }; + } + + /// + /// 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 Log Window; 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..6a524e4f --- /dev/null +++ b/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs @@ -0,0 +1,79 @@ +using System.Text; + +using ColumnizerLib; + +using LogExpert.Core.Classes.Filter; +using LogExpert.Core.Entities; + +namespace LogExpert.Core.Classes.Persister; + +/// +/// 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 +/// . +/// +public class SessionSnapshot +{ + public bool FollowTail { 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 + /// 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 new file mode 100644 index 00000000..a0db2e8a --- /dev/null +++ b/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs @@ -0,0 +1,417 @@ +using System.Collections; +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; + +namespace LogExpert.Persister.Tests; + +[TestFixture] +public class SessionFileComposerTests +{ + #region Fields + + private string _testDirectory; + private string _sessionDirectory; + private string _logFileName; + + /// + /// 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, + }; + + /// + /// 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), + ]; + + + #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 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, IReadOnlyCollection excludedProperties) + { + var baseline = Activator.CreateInstance(instance.GetType()); + List unpopulated = []; + + foreach (var property in instance.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (excludedProperties.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(), + _ => value.Equals(property.GetValue(baseline)), + }; + + if (isUnpopulated) + { + unpopulated.Add(property.Name); + } + } + + 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. 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 (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. 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 + { + // 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, + 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(), + FilterTabDataList = filterTabDepth > 0 + ? + [ + new FilterTabData + { + FilterParams = CreateFilterParams($"WARN depth {filterTabDepth}"), + PersistenceData = CreateCarriedPersistenceData(filterTabDepth - 1), + } + ] + : [], + }; + } + + #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); + } + + [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), 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), + Is.EqualTo(SerializeExcluding(original, _namedExclusions))); + } + + // 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 () + { + 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 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 + // 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 +} 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.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 7a1cc03c..ae3a2131 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -149,6 +149,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 @@ -288,9 +294,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); @@ -371,9 +377,6 @@ public bool ShowBookmarkBubbles public string FileName { get; private set; } - [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] - public string SessionFileName { get; set; } - public bool IsMultiFile { get; @@ -2431,8 +2434,48 @@ 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) 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; + } + } + + /// + /// 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) @@ -2440,28 +2483,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)) @@ -2469,40 +2514,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) @@ -2521,8 +2564,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) @@ -2547,32 +2596,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 { @@ -2583,27 +2628,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) @@ -2613,47 +2655,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) @@ -3154,7 +3212,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; @@ -4900,7 +4958,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; @@ -4966,11 +5024,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) @@ -4985,9 +5043,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)); } } @@ -5025,22 +5083,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")] @@ -5723,7 +5777,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; } @@ -5778,6 +5832,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(); @@ -5827,7 +5891,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) @@ -5850,7 +5919,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 = []; @@ -5862,7 +5931,7 @@ public PersistenceData GetPersistenceData () } } - PersistenceData persistenceData = new() + SessionSnapshot snapshot = new() { BookmarkList = manualBookmarks, RowHeightList = _rowHeightList, @@ -5878,45 +5947,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) @@ -7380,7 +7444,7 @@ public void PreferencesChanged (Font font, bool setLastColumnWidth, int lastColu if (CurrentColumnizer.IsTimeshiftImplemented()) { - _ = timeSpreadingControl.Invoke(new MethodInvoker(timeSpreadingControl.Refresh)); + 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) diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 4f5e3603..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-11 16:16:07 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"] = "BE97EF8EA8722C979D374A02E53FA9FDBE19EA15D0FB28835B72DBEFD64D7CF8", + ["AutoColumnizer.dll"] = "B61B8761E0191EE6907EA24CD32E310253E84DE1E30635A68F5330A9D8020DBA", ["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"] = "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"] = "7C41428A3CA5D68D4CA4BDE3D6E043276467B2B46F0F9118867653B97630F506", - ["SftpFileSystem.dll"] = "9A8BCA76877E0CD86C8FBFC2D4B40478FAB5097E3E9554A110AB0BACBB485896", - ["SftpFileSystem.dll (x86)"] = "57CACE749999F7412B5ACC608A4844EB3C3077668E8827CDF651D939BAF26810", - ["SftpFileSystem.Resources.dll"] = "47C83AB62174AD5F747A2DB43040B099AE8CE64E790C2806AECBA05CED61B9A8", - ["SftpFileSystem.Resources.dll (x86)"] = "47C83AB62174AD5F747A2DB43040B099AE8CE64E790C2806AECBA05CED61B9A8", + ["RegexColumnizer.dll"] = "EDD00D81B825F4E7EC4707F580EC6F17A6D94550C7E4296D54D24F6696D87D7F", + ["SftpFileSystem.dll"] = "D844F8A8C43ED3FC5B88F661761D7FD59C2F72A5B59D22D78D925F0C91CB28CF", + ["SftpFileSystem.dll (x86)"] = "3C1A36AFE6466E895B01C1A2EBA944128397C5C91D1832DFBA086A0F43861AC6", + ["SftpFileSystem.Resources.dll"] = "D5DDC872EC727E3CB4917707891912F443BA02E477E0F50151C7D9BC7669832D", + ["SftpFileSystem.Resources.dll (x86)"] = "D5DDC872EC727E3CB4917707891912F443BA02E477E0F50151C7D9BC7669832D", }; }