From 31474bd2032985d8c14778774ed6b339e717eee3 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Wed, 29 Jul 2026 09:48:42 +0200 Subject: [PATCH] fix: store the window's own filter in Session Files, not the global list A Session File used to save a copy of the whole global filter list (Settings.FilterList) as its FilterParamsList, while load only ever restored [0]. Sessions were therefore not self-contained: what an .lxp "remembered" as its filter was whatever the global list happened to hold at save time, and the window's actual filter was restored only if it happened to be that list's first entry. The copy was shallow too, so the pre-save IsFilterTail write aliased into the shared global entry. The snapshot now carries the window's own _filterParams. The on-disk shape is unchanged - FilterParamsList stays a list, holding exactly one entry - and load still reads [0], so existing Session Files (JSON and legacy XML alike) keep loading exactly as before; their trailing entries, which nothing ever restored, are dropped on the next save. Adds Window Filter / Saved Filter List to the glossary, since the change turns on exactly that distinction. Closes #666 --- CONTEXT.md | 12 +++- .../Classes/Persister/PersistenceData.cs | 5 ++ .../Classes/Persister/SessionFileComposer.cs | 13 +++- .../Classes/Persister/SessionSnapshot.cs | 7 ++- .../SessionFileComposerTests.cs | 62 ++++++++++++++++++- .../Controls/LogWindow/LogWindow.cs | 16 ++--- 6 files changed, 103 insertions(+), 12 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index cc6abc0e6..ee1364669 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -130,6 +130,15 @@ split is two *apply phases* of the **Session Snapshot**, not a partial read). results/history → trim history). Used inside the Serial engine, per line by the Log Window's tail filter path, and per hit by Filter Pipes — all three adopt existing lists, so a Filter Run's history is continued in place. +- **Window Filter** (`LogWindow._filterParams`) — The one filter a Log Window + currently holds in its filter panel. Per-window state: it is what a **Session + File** saves and restores, and it is not an entry of the **Saved Filter + List**. A Filter Pipe tab gets its own, cloned when the tab is created. +- **Saved Filter List** (`Settings.FilterList`) — The application-wide list of + filters the user explicitly saved from the filter panel, shown in the filter + save list. Global, never per-window, and never restored from a Session File. + (Session Files written before the Window Filter change stored a copy of it + instead, of which only the first entry was ever restored.) - **Filter Spread** — Context expansion around a filter hit: **Back Spread** (`FilterParams.SpreadBefore`) lines before and **Fore Spread** (`FilterParams.SpreadBehind`) lines after the hit are included in the @@ -146,7 +155,8 @@ split is two *apply phases* of the **Session Snapshot**, not a partial read). Spread**), "additional filter results" (the old internal name — use **Filter Spread**), "multi-threaded filter" as a concept name (it is a preference selecting the Parallel **Filter Engine**), "FilterFx" (legacy -delegate name, deleted). +delegate name, deleted), "the filter list" when the **Window Filter** is meant +(the list is the **Saved Filter List**). ## Log Search diff --git a/src/LogExpert.Core/Classes/Persister/PersistenceData.cs b/src/LogExpert.Core/Classes/Persister/PersistenceData.cs index 11ceda6bc..37e80e377 100644 --- a/src/LogExpert.Core/Classes/Persister/PersistenceData.cs +++ b/src/LogExpert.Core/Classes/Persister/PersistenceData.cs @@ -38,6 +38,11 @@ public class PersistenceData public bool FilterAdvanced { get; set; } + /// + /// The Log Window's own filter, as the single entry of a list. A list for backward + /// compatibility only — owns the mapping and documents what + /// older Session Files carry here. + /// public List FilterParamsList { get; set; } = []; public int FilterPosition { get; set; } = 222; diff --git a/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs b/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs index 3dd9cead3..490aa8c83 100644 --- a/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs +++ b/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs @@ -13,6 +13,10 @@ public static class SessionFileComposer /// (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. + /// + /// FilterParamsList is a list only for backward compatibility: the window's own filter + /// goes in as its single entry, and a snapshot without one composes to an empty list. + /// /// public static PersistenceData Compose (SessionSnapshot snapshot) { @@ -39,7 +43,7 @@ public static PersistenceData Compose (SessionSnapshot snapshot) BookmarkList = snapshot.BookmarkList, RowHeightList = snapshot.RowHeightList, MultiFileNames = snapshot.MultiFileNames, - FilterParamsList = snapshot.FilterParamsList, + FilterParamsList = snapshot.FilterParams is null ? [] : [snapshot.FilterParams], Columnizer = snapshot.Columnizer, FilterTabDataList = [ @@ -55,6 +59,11 @@ public static PersistenceData Compose (SessionSnapshot snapshot) /// /// Maps a loaded Session File back to a snapshot. Fields the snapshot does not carry are /// ignored. + /// + /// The window's filter is FilterParamsList[0], which is what load has always used. + /// Session Files written before the per-window filter change carry a copy of the whole global + /// filter list there; the trailing entries were never restored by anything and are dropped. + /// /// public static SessionSnapshot Decompose (PersistenceData persistenceData) { @@ -81,7 +90,7 @@ public static SessionSnapshot Decompose (PersistenceData persistenceData) BookmarkList = persistenceData.BookmarkList, RowHeightList = persistenceData.RowHeightList, MultiFileNames = persistenceData.MultiFileNames, - FilterParamsList = persistenceData.FilterParamsList, + FilterParams = persistenceData.FilterParamsList.Count > 0 ? persistenceData.FilterParamsList[0] : null, Columnizer = persistenceData.Columnizer, FilterTabs = [ diff --git a/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs b/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs index 6a524e4f0..bec804861 100644 --- a/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs +++ b/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs @@ -67,7 +67,12 @@ public class SessionSnapshot public List MultiFileNames { get; set; } = []; - public List FilterParamsList { get; set; } = []; + /// + /// The window's own filter, or null when there is none to save (Save Filters off, or a + /// Session File that carries no filter). Its serialized form is a one-entry list — see + /// . + /// + public FilterParams FilterParams { get; set; } public ILogLineMemoryColumnizer Columnizer { get; set; } diff --git a/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs b/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs index a0db2e8ad..264b59f09 100644 --- a/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs +++ b/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs @@ -177,7 +177,7 @@ private static SessionSnapshot CreateFullSnapshot (int filterTabDepth = 2) 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")], + FilterParams = CreateFilterParams("ERROR"), Columnizer = new DefaultLogfileColumnizer(), FilterTabs = filterTabDepth > 0 ? @@ -369,6 +369,66 @@ public void DiskTrip_ComposePersistLoadDecompose_ReturnsEqualSnapshot () #endregion + #region Window filter (issue #666) + + // A Session File carries the window's OWN filter, not a copy of the global filter list. The + // on-disk shape stays a list so existing .lxp files keep loading — new saves just write + // exactly one entry into it. + [Test] + public void Compose_WritesTheWindowFilterAsTheSingleFilterParamsListEntry () + { + var snapshot = CreateFullSnapshot(filterTabDepth: 0); + snapshot.FilterParams = CreateFilterParams("the window's own filter"); + + var composed = SessionFileComposer.Compose(snapshot); + + Assert.That(composed.FilterParamsList, Has.Count.EqualTo(1)); + Assert.That(composed.FilterParamsList[0].SearchText, Is.EqualTo("the window's own filter")); + } + + // SaveFilters off (and filter-less windows) leave the snapshot without a filter — the field + // must not serialize a null element into the list. + [Test] + public void Compose_WithoutAWindowFilter_WritesAnEmptyFilterParamsList () + { + var snapshot = CreateFullSnapshot(filterTabDepth: 0); + snapshot.FilterParams = null; + + Assert.That(SessionFileComposer.Compose(snapshot).FilterParamsList, Is.Empty); + } + + // The compatibility story: a pre-#666 Session File carries a copy of the whole global filter + // list, and load has always taken [0] as the window's filter. That stays true — and the + // trailing entries, which nothing ever restored, are dropped when such a file is re-saved. + [Test] + public void Decompose_LegacySessionFileCarryingTheGlobalList_KeepsTheFirstEntryAndDropsTheRest () + { + var legacy = CreateCarriedPersistenceData(filterTabDepth: 0); + legacy.FilterParamsList = + [ + CreateFilterParams("the first global filter"), + CreateFilterParams("another global filter"), + ]; + + var snapshot = SessionFileComposer.Decompose(legacy); + var recomposed = SessionFileComposer.Compose(snapshot); + + Assert.That(snapshot.FilterParams.SearchText, Is.EqualTo("the first global filter")); + Assert.That(recomposed.FilterParamsList, Has.Count.EqualTo(1)); + Assert.That(recomposed.FilterParamsList[0].SearchText, Is.EqualTo("the first global filter")); + } + + [Test] + public void Decompose_SessionFileWithoutFilters_LeavesTheWindowFilterUnset () + { + var data = CreateCarriedPersistenceData(filterTabDepth: 0); + data.FilterParamsList = []; + + Assert.That(SessionFileComposer.Decompose(data).FilterParams, Is.Null); + } + + #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 diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 436f4e10d..c5c23c35d 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -2660,9 +2660,9 @@ private void LoadPersistenceData () [SupportedOSPlatform("windows")] private void RestoreFilters (SessionSnapshot snapshot) { - if (snapshot.FilterParamsList.Count > 0) + if (snapshot.FilterParams != null) { - _filterParams = snapshot.FilterParamsList[0]; + _filterParams = snapshot.FilterParams; ReInitFilterParams(_filterParams); } @@ -5832,8 +5832,8 @@ public string SavePersistenceDataAndReturnFileName (bool force) try { // 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. + // this option doesn't need a press on 'search'. The write must precede the gather, + // which is what copies _filterParams into the snapshot. _filterParams.IsFilterTail = filterTailCheckBox.Checked; var persistenceData = SessionFileComposer.Compose(GatherSessionSnapshot()); @@ -5893,9 +5893,11 @@ public SessionSnapshot GatherSessionSnapshot () if (Preferences.SaveFilters) { - //when a filter is added, its added to the Configmanager.Settings.FilterList and not to the _filterParams, this is probably an oversight and maybe a bug - //but for the consistency the FilterList should be saved as whole for every file - snapshot.FilterParamsList = [.. ConfigManager.Settings.FilterList]; + // The window's own filter, not the global filter list: a Session File is per-window + // state, and the global list is never restored from one anyway. Clone() is a shallow + // copy (ColumnList and the columnizer stay shared) — enough here, because compose and + // serialize run synchronously right after this gather. + snapshot.FilterParams = _filterParams.Clone(); foreach (var filterPipe in _filterPipeList) {