Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/LogExpert.Core/Classes/Persister/SessionFileResolver.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -31,4 +34,39 @@ public static class SessionFileResolver

return resolved.AsReadOnly();
}

/// <summary>
/// Recovers the log file paths from a Session's tab layout XML. The DockPanel layout names
/// every log window in a <c>PersistString="LogWindow#&lt;path&gt;"</c> attribute, so a Session
/// whose FileNames list is missing or empty can still be restored from its layout.
/// </summary>
/// <param name="tabLayoutXml">The DockPanel layout XML stored in the Session, may be null or malformed</param>
/// <returns>The log file paths in layout order; empty if the XML is null, malformed, or names no log windows</returns>
public static ReadOnlyCollection<string> RecoverFileNamesFromLayout (string tabLayoutXml)
{
if (string.IsNullOrWhiteSpace(tabLayoutXml))
{
return ReadOnlyCollection<string>.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<string>.Empty;
}
}
}
15 changes: 15 additions & 0 deletions src/LogExpert.Core/Classes/Persister/SessionPersister.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
197 changes: 197 additions & 0 deletions src/LogExpert.Persister.Tests/SessionFileResolverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
using System.Globalization;
using System.Text;

using LogExpert.Core.Classes.Persister;

using Newtonsoft.Json;

namespace LogExpert.Persister.Tests;

/// <summary>
/// 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.
/// </summary>
[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
}
Comment thread
Hirogen marked this conversation as resolved.
Dismissed
}

private static string BuildLayoutXml (params string[] persistStrings)
{
StringBuilder contents = new();
for (var i = 0; i < persistStrings.Length; i++)
{
_ = contents.Append(CultureInfo.InvariantCulture, $" <Content ID=\"{i}\" PersistString=\"{persistStrings[i]}\" AutoHidePortion=\"0.25\" IsHidden=\"False\" IsFloat=\"False\" />\r\n");
}

StringBuilder paneRefs = new();
for (var i = 0; i < persistStrings.Length; i++)
{
_ = paneRefs.Append(CultureInfo.InvariantCulture, $" <Content ID=\"{i}\" RefID=\"{i}\" />\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 "<!--DockPanel configuration file. Author: Weifen Luo, all rights reserved.-->\r\n" +
"<DockPanel FormatVersion=\"1.0\" DockLeftPortion=\"0.25\" DockRightPortion=\"0.25\" DockTopPortion=\"0.25\" DockBottomPortion=\"0.25\" ActiveDocumentPane=\"0\" ActivePane=\"0\">\r\n" +
$" <Contents Count=\"{persistStrings.Length}\">\r\n" +
contents +
" </Contents>\r\n" +
" <Panes Count=\"1\">\r\n" +
" <Pane ID=\"0\" DockState=\"Document\" ActiveContent=\"0\">\r\n" +
$" <Contents Count=\"{persistStrings.Length}\">\r\n" +
paneRefs +
" </Contents>\r\n" +
" </Pane>\r\n" +
" </Panes>\r\n" +
" <FloatWindows Count=\"0\" />\r\n" +
"</DockPanel>";
}

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("<DockPanel><Contents></DockPanel>");

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