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