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