diff --git a/src/LogExpert.Core/Classes/Persister/SessionFileResolver.cs b/src/LogExpert.Core/Classes/Persister/SessionFileResolver.cs
index 6499ec99..dd04b956 100644
--- a/src/LogExpert.Core/Classes/Persister/SessionFileResolver.cs
+++ b/src/LogExpert.Core/Classes/Persister/SessionFileResolver.cs
@@ -1,5 +1,8 @@
using System.Collections.ObjectModel;
+using System.Xml;
+using System.Xml.Linq;
+using LogExpert.Core.Enums;
using LogExpert.Core.Interfaces;
namespace LogExpert.Core.Classes.Persister;
@@ -31,4 +34,39 @@ public static class SessionFileResolver
return resolved.AsReadOnly();
}
+
+ ///
+ /// Recovers the log file paths from a Session's tab layout XML. The DockPanel layout names
+ /// every log window in a PersistString="LogWindow#<path>" attribute, so a Session
+ /// whose FileNames list is missing or empty can still be restored from its layout.
+ ///
+ /// The DockPanel layout XML stored in the Session, may be null or malformed
+ /// The log file paths in layout order; empty if the XML is null, malformed, or names no log windows
+ public static ReadOnlyCollection RecoverFileNamesFromLayout (string tabLayoutXml)
+ {
+ if (string.IsNullOrWhiteSpace(tabLayoutXml))
+ {
+ return ReadOnlyCollection.Empty;
+ }
+
+ var prefix = WindowTypes.LogWindow + "#";
+
+ try
+ {
+ var fileNames = XDocument.Parse(tabLayoutXml)
+ .Descendants("Content")
+ .Select(content => (string)content.Attribute("PersistString"))
+ .Where(persistString => persistString != null &&
+ persistString.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
+ persistString.Length > prefix.Length)
+ .Select(persistString => persistString[prefix.Length..])
+ .ToList();
+
+ return fileNames.AsReadOnly();
+ }
+ catch (XmlException)
+ {
+ return ReadOnlyCollection.Empty;
+ }
+ }
}
diff --git a/src/LogExpert.Core/Classes/Persister/SessionPersister.cs b/src/LogExpert.Core/Classes/Persister/SessionPersister.cs
index 7a26a38e..238a6bd6 100644
--- a/src/LogExpert.Core/Classes/Persister/SessionPersister.cs
+++ b/src/LogExpert.Core/Classes/Persister/SessionPersister.cs
@@ -35,6 +35,21 @@ public static SessionLoadResult LoadSessionData (string sessionFileName, IPlugin
// Set Session file path for alternative file search
sessionData.SessionFilePath = sessionFileName;
+ sessionData.FileNames ??= [];
+
+ // v1.42.0 wrote Sessions with an empty FileNames list (issue #694): the save path
+ // enumerated DockPanelSuite's DisplayingContents with foreach, which yields nothing.
+ // The tab layout XML in those files still names every log window, so recover from there.
+ if (sessionData.FileNames.Count == 0)
+ {
+ var recovered = SessionFileResolver.RecoverFileNamesFromLayout(sessionData.TabLayoutXml);
+
+ if (recovered.Count > 0)
+ {
+ _logger.Warn($"Session {sessionFileName} has an empty file list; recovered {recovered.Count} file(s) from the tab layout");
+ sessionData.FileNames = [.. recovered];
+ }
+ }
// Resolve Session File (.lxp) entries to actual .log files
var resolvedFiles = SessionFileResolver.ResolveSessionFiles(sessionData, pluginRegistry);
diff --git a/src/LogExpert.Persister.Tests/SessionFileResolverTests.cs b/src/LogExpert.Persister.Tests/SessionFileResolverTests.cs
new file mode 100644
index 00000000..c274aa91
--- /dev/null
+++ b/src/LogExpert.Persister.Tests/SessionFileResolverTests.cs
@@ -0,0 +1,197 @@
+using System.Globalization;
+using System.Text;
+
+using LogExpert.Core.Classes.Persister;
+
+using Newtonsoft.Json;
+
+namespace LogExpert.Persister.Tests;
+
+///
+/// Tests for recovering a Session's file list from its tab layout XML (issue #694).
+/// v1.42.0 saved Sessions with an empty FileNames list; the DockPanel layout XML still
+/// names every log window, so loading falls back to it.
+///
+[TestFixture]
+public class SessionFileResolverTests
+{
+ private string _testDirectory;
+
+ [SetUp]
+ public void Setup ()
+ {
+ _testDirectory = Path.Join(Path.GetTempPath(), "LogExpertTests", "SessionResolver", Guid.NewGuid().ToString());
+ _ = Directory.CreateDirectory(_testDirectory);
+
+ _ = PluginRegistry.PluginRegistry.Create(_testDirectory, 1000);
+ }
+
+ [TearDown]
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Unit Test")]
+ public void TearDown ()
+ {
+ try
+ {
+ if (Directory.Exists(_testDirectory))
+ {
+ Directory.Delete(_testDirectory, true);
+ }
+ }
+ catch (Exception)
+ {
+ // Ignore cleanup failures
+ }
+ }
+
+ private static string BuildLayoutXml (params string[] persistStrings)
+ {
+ StringBuilder contents = new();
+ for (var i = 0; i < persistStrings.Length; i++)
+ {
+ _ = contents.Append(CultureInfo.InvariantCulture, $" \r\n");
+ }
+
+ StringBuilder paneRefs = new();
+ for (var i = 0; i < persistStrings.Length; i++)
+ {
+ _ = paneRefs.Append(CultureInfo.InvariantCulture, $" \r\n");
+ }
+
+ // Shape of a real v1.42.0 layout: contents with PersistString, plus pane entries that
+ // reference them through RefID only (those must not be picked up by the recovery).
+ return "\r\n" +
+ "\r\n" +
+ $" \r\n" +
+ contents +
+ " \r\n" +
+ " \r\n" +
+ " \r\n" +
+ $" \r\n" +
+ paneRefs +
+ " \r\n" +
+ " \r\n" +
+ " \r\n" +
+ " \r\n" +
+ "";
+ }
+
+ private string WriteSessionFile (string fileNamesJson, string tabLayoutXml)
+ {
+ var sessionFile = Path.Join(_testDirectory, "session.lxj");
+ var layoutJson = JsonConvert.ToString(tabLayoutXml ?? string.Empty);
+ var json = $"{{\r\n \"FileNames\": {fileNamesJson},\r\n \"TabLayoutXml\": {layoutJson},\r\n \"SessionFilePath\": null\r\n}}";
+ File.WriteAllText(sessionFile, json, Encoding.UTF8);
+ return sessionFile;
+ }
+
+ #region RecoverFileNamesFromLayout
+
+ [Test]
+ public void RecoverFileNamesFromLayout_LayoutWithLogWindows_ReturnsPathsInLayoutOrder ()
+ {
+ var layout = BuildLayoutXml(@"LogWindow#C:\temp\test1.log", @"LogWindow#C:\temp\test2.log");
+
+ var result = SessionFileResolver.RecoverFileNamesFromLayout(layout);
+
+ Assert.That(result, Is.EqualTo(new[] { @"C:\temp\test1.log", @"C:\temp\test2.log" }));
+ }
+
+ [TestCase(null)]
+ [TestCase("")]
+ [TestCase(" ")]
+ public void RecoverFileNamesFromLayout_NullOrWhitespace_ReturnsEmpty (string? layout)
+ {
+ var result = SessionFileResolver.RecoverFileNamesFromLayout(layout);
+
+ Assert.That(result, Is.Empty);
+ }
+
+ [Test]
+ public void RecoverFileNamesFromLayout_MalformedXml_ReturnsEmpty ()
+ {
+ var result = SessionFileResolver.RecoverFileNamesFromLayout("");
+
+ Assert.That(result, Is.Empty);
+ }
+
+ [Test]
+ public void RecoverFileNamesFromLayout_NoLogWindowContents_ReturnsEmpty ()
+ {
+ var layout = BuildLayoutXml("BookmarkWindow");
+
+ var result = SessionFileResolver.RecoverFileNamesFromLayout(layout);
+
+ Assert.That(result, Is.Empty);
+ }
+
+ [Test]
+ public void RecoverFileNamesFromLayout_PersistStringWithoutPath_IsSkipped ()
+ {
+ var layout = BuildLayoutXml("LogWindow#", @"LogWindow#C:\temp\test1.log");
+
+ var result = SessionFileResolver.RecoverFileNamesFromLayout(layout);
+
+ Assert.That(result, Is.EqualTo(new[] { @"C:\temp\test1.log" }));
+ }
+
+ #endregion
+
+ #region SessionPersister.LoadSessionData recovery (issue #694)
+
+ [Test]
+ public void LoadSessionData_EmptyFileNamesWithLayout_RecoversFilesFromLayout ()
+ {
+ // Arrange - a Session as written by v1.42.0: empty FileNames, intact layout XML
+ var log1 = Path.Join(_testDirectory, "test1.log");
+ var log2 = Path.Join(_testDirectory, "test2.log");
+ File.WriteAllText(log1, "line1\n");
+ File.WriteAllText(log2, "line1\n");
+ var sessionFile = WriteSessionFile("[]", BuildLayoutXml($"LogWindow#{log1}", $"LogWindow#{log2}"));
+
+ // Act
+ var result = SessionPersister.LoadSessionData(sessionFile, PluginRegistry.PluginRegistry.Instance);
+
+ // Assert
+ Assert.That(result.SessionData.FileNames, Is.EqualTo(new[] { log1, log2 }));
+ Assert.That(result.ValidationResult.MissingFiles, Is.Empty);
+ Assert.That(result.RequiresUserIntervention, Is.False);
+ }
+
+ [Test]
+ public void LoadSessionData_NullFileNamesWithLayout_RecoversFilesFromLayout ()
+ {
+ var log1 = Path.Join(_testDirectory, "test1.log");
+ File.WriteAllText(log1, "line1\n");
+ var sessionFile = WriteSessionFile("null", BuildLayoutXml($"LogWindow#{log1}"));
+
+ var result = SessionPersister.LoadSessionData(sessionFile, PluginRegistry.PluginRegistry.Instance);
+
+ Assert.That(result.SessionData.FileNames, Is.EqualTo(new[] { log1 }));
+ }
+
+ [Test]
+ public void LoadSessionData_EmptyFileNamesWithoutLayout_StaysEmpty ()
+ {
+ var sessionFile = WriteSessionFile("[]", string.Empty);
+
+ var result = SessionPersister.LoadSessionData(sessionFile, PluginRegistry.PluginRegistry.Instance);
+
+ Assert.That(result.SessionData.FileNames, Is.Empty);
+ }
+
+ [Test]
+ public void LoadSessionData_FileNamesPresent_LayoutDoesNotOverrideThem ()
+ {
+ var log1 = Path.Join(_testDirectory, "listed.log");
+ var log2 = Path.Join(_testDirectory, "layout-only.log");
+ File.WriteAllText(log1, "line1\n");
+ File.WriteAllText(log2, "line1\n");
+ var sessionFile = WriteSessionFile(JsonConvert.SerializeObject(new[] { log1 }), BuildLayoutXml($"LogWindow#{log2}"));
+
+ var result = SessionPersister.LoadSessionData(sessionFile, PluginRegistry.PluginRegistry.Instance);
+
+ Assert.That(result.SessionData.FileNames, Is.EqualTo(new[] { log1 }));
+ }
+
+ #endregion
+}