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 +}