diff --git a/CONTEXT.md b/CONTEXT.md index cc6abc0e..ee136466 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 11ceda6b..37e80e37 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 3dd9cead..490aa8c8 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 6a524e4f..bec80486 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 a0db2e8a..264b59f0 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 8005eb54..c5c23c35 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,8 +5893,12 @@ public SessionSnapshot GatherSessionSnapshot () if (Preferences.SaveFilters) { - // Persist this window's active filter; FilterList contains global saved presets. - snapshot.FilterParamsList = [_filterParams.Clone()]; + // 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) { snapshot.FilterTabs.Add(new FilterTabSnapshot