From 61072ad46a952816c6a7f907b9f43277aeb82df6 Mon Sep 17 00:00:00 2001 From: BRUNER Patrick Date: Tue, 25 Aug 2026 09:48:38 +0200 Subject: [PATCH] fix: recover Session file list from the tab layout XML (#694) v1.42.0 saved Sessions (.lxj) with an empty FileNames list: the save path enumerated DockPanelSuite's DisplayingContents with LINQ/foreach, whose enumerator walks an empty backing list (only Count and the indexer expose the displayed tabs). Loading such a Session failed with 'None of the files in this session could be found'. The enumeration itself is already fixed on Development (5c3d5c90), so newly saved Sessions are correct again. This makes the Sessions written by v1.42.0 loadable: the DockPanel layout XML in them still names every log window (PersistString="LogWindow#"), so when a loaded Session has no file list, recover the paths from the layout before resolving and validating. --- .../Classes/Persister/SessionFileResolver.cs | 38 ++++ .../Classes/Persister/SessionPersister.cs | 15 ++ .../SessionFileResolverTests.cs | 197 ++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 src/LogExpert.Persister.Tests/SessionFileResolverTests.cs 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 +}