From 998d4fd55680d4843bc13b1486dde1f950df1839 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Thu, 11 Jun 2026 07:48:22 +0200 Subject: [PATCH] Load legacy SessionSettings XML from the WPF host The new Avalonia SessionSettings reads WindowBounds as Left/Top/Width/Height attributes and reads ActiveTreeViewPath node values raw, but ILSpy 10.x wrote WindowBounds as a CSV element body ("L,T,W,H", the Rect TypeConverter format) and \xNNNN-hex-escaped every non-letter-or-digit char in each Node value (so "TomsToolbox.Wpf" round-tripped through XML as "TomsToolbox\x002EWpf"). On first launch against an existing ILSpy.xml that means the saved window position resets to the default and the selected tree node never restores -- the escaped path can't match a live node's ToString(). Accept the CSV body when the WindowBounds element has no attributes, and decode \xNNNN escapes in tree-view node values. Both fall back through the existing ParseDouble defaults if a piece is missing, so a corrupted entry won't crash startup. Lock the shape in with tests against the actual section the WPF host emits, plus a forward-compat pair confirming LoadFromXml ignores unknown children (DockLayout, SelectedSearchMode, ActiveAutoLoadedAssembly) and SaveToXml never echoes them back -- the AvalonDock schema doesn't translate to the Avalonia Dock host and would otherwise persist forever as dead state. Assisted-by: Claude:claude-opus-4-7[1m]:Claude Code --- ILSpy.Tests/SessionSettingsTests.cs | 130 ++++++++++++++++++++++++++++ ILSpy/SessionSettings.cs | 47 ++++++++-- 2 files changed, 170 insertions(+), 7 deletions(-) diff --git a/ILSpy.Tests/SessionSettingsTests.cs b/ILSpy.Tests/SessionSettingsTests.cs index 94ee26235..4981d2e66 100644 --- a/ILSpy.Tests/SessionSettingsTests.cs +++ b/ILSpy.Tests/SessionSettingsTests.cs @@ -16,6 +16,8 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +using System.Xml.Linq; + using Avalonia; using Avalonia.Controls; @@ -69,4 +71,132 @@ public class SessionSettingsTests loaded.ActiveTreeViewPath.Should().Equal("My.Assembly.dll", "MyNamespace.MyType", "M:MyMethod"); } + + // Legacy SessionSettings XML emitted by the WPF host (ILSpy 10.x) must still load. + // WindowBounds was a CSV element value (Rect TypeConverter format "L,T,W,H"); the new + // attribute form is forward-only. ActiveTreeViewPath node values were \xNNNN-hex-escaped + // so non-alphanumeric chars survive XML round-trips, and the restored path must compare + // equal to the live tree-node ToString()s — escaped strings will never match. + [Test] + public void Load_accepts_legacy_WindowBounds_csv_body() + { + var section = XElement.Parse(@" + Normal + 882.6666666666666,342,750,550 + "); + + var loaded = new SessionSettings(); + loaded.LoadFromXml(section); + + loaded.WindowPosition.Should().Be(new PixelPoint(882, 342)); + loaded.WindowSize.Should().Be(new Size(750, 550)); + } + + [Test] + public void Load_unescapes_legacy_ActiveTreeViewPath_node_values() + { + // \x002E -> '.', \x003A -> ':', \x005C -> '\' (the escape the WPF SessionSettings.Escape + // helper applied to every non-letter-or-digit char). + var section = XElement.Parse(@" + + D\x003A\x005CProjects\x005CILSpy\x005CILSpy\x002Edll + TomsToolbox\x002EWpf + TomsToolbox\x002EWpf\x002EDelegateCommand + + "); + + var loaded = new SessionSettings(); + loaded.LoadFromXml(section); + + loaded.ActiveTreeViewPath.Should().Equal( + @"D:\Projects\ILSpy\ILSpy.dll", + "TomsToolbox.Wpf", + "TomsToolbox.Wpf.DelegateCommand"); + } + + [Test] + public void Load_accepts_full_legacy_SessionSettings_section() + { + // Top-level structure the WPF host wrote to ILSpy.xml. DockLayout is intentionally + // not asserted — the AvalonDock schema doesn't translate to the Avalonia Dock host + // and is rebuilt from the new layout descriptors. + var section = XElement.Parse(@" + + 1 + + + + .NET 4 (WPF) + + D\x003A\x005CProjects\x005CILSpy\x005CILSpy\x002Edll + TomsToolbox\x002EWpf + + Normal + 882.6666666666666,342,750,550 + TypeAndMember + Light + "); + + var loaded = new SessionSettings(); + loaded.LoadFromXml(section); + + loaded.ActiveAssemblyList.Should().Be(".NET 4 (WPF)"); + loaded.WindowState.Should().Be(WindowState.Normal); + loaded.WindowPosition.Should().Be(new PixelPoint(882, 342)); + loaded.WindowSize.Should().Be(new Size(750, 550)); + loaded.Theme.Should().Be("Light"); + loaded.ActiveTreeViewPath.Should().Equal( + @"D:\Projects\ILSpy\ILSpy.dll", + "TomsToolbox.Wpf"); + loaded.LanguageSettings.Should().NotBeNull(); + loaded.LanguageSettings.ShowApiLevel.Should().Be(ICSharpCode.ILSpyX.ApiVisibility.PublicAndInternal); + } + + // Forward-compat with retired or future SessionSettings children: unknown elements at + // load time must be silently ignored, and SaveToXml must not echo them back. The dock + // layout below is the AvalonDock schema (WPF host); it's incompatible with the Avalonia + // Dock host and would persist forever as dead state if we round-tripped it. + [Test] + public void Load_silently_ignores_unknown_children() + { + var section = XElement.Parse(@" + my-list + + + + + + + + TypeAndMember + foo.dll + + 123 + + "); + + var loaded = new SessionSettings(); + var act = () => loaded.LoadFromXml(section); + + act.Should().NotThrow(); + loaded.ActiveAssemblyList.Should().Be("my-list"); + } + + [Test] + public void Save_does_not_emit_unknown_children_seen_at_load() + { + var loaded = new SessionSettings(); + loaded.LoadFromXml(XElement.Parse(@" + my-list + + TypeAndMember + + ")); + + var saved = loaded.SaveToXml(); + + saved.Element("DockLayout").Should().BeNull(); + saved.Element("SelectedSearchMode").Should().BeNull(); + saved.Element("FutureFeatureFlag").Should().BeNull(); + } } diff --git a/ILSpy/SessionSettings.cs b/ILSpy/SessionSettings.cs index 69fd6c792..f434183bc 100644 --- a/ILSpy/SessionSettings.cs +++ b/ILSpy/SessionSettings.cs @@ -20,6 +20,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Text.RegularExpressions; using System.Xml.Linq; using Avalonia; @@ -102,7 +103,7 @@ namespace ILSpy ActiveAssemblyList = (string?)section.Element("ActiveAssemblyList"); ActiveLanguageName = (string?)section.Element("ActiveLanguageName"); - ActiveTreeViewPath = section.Element("ActiveTreeViewPath")?.Elements().Select(e => (string)e).ToArray(); + ActiveTreeViewPath = section.Element("ActiveTreeViewPath")?.Elements().Select(e => UnescapeNode((string)e)).ToArray(); WindowState = ParseEnum(section.Element("WindowState")?.Value, WindowState.Normal); Theme = (string?)section.Element(nameof(Theme)); var culture = (string?)section.Element(nameof(CurrentCulture)); @@ -113,12 +114,31 @@ namespace ILSpy var bounds = section.Element("WindowBounds"); if (bounds != null) { - int left = ParseInt(bounds.Attribute("Left")?.Value, DefaultWindowPosition.X); - int top = ParseInt(bounds.Attribute("Top")?.Value, DefaultWindowPosition.Y); - double width = ParseDouble(bounds.Attribute("Width")?.Value, DefaultWindowSize.Width); - double height = ParseDouble(bounds.Attribute("Height")?.Value, DefaultWindowSize.Height); - WindowPosition = new PixelPoint(left, top); - WindowSize = new Size(width, height); + // Legacy WPF host wrote bounds as a CSV body "Left,Top,Width,Height" (Rect + // TypeConverter format). Honour that shape for users upgrading from ILSpy 10.x + // so a saved window position survives the move to the Avalonia attribute form. + if (bounds.HasAttributes) + { + int left = ParseInt(bounds.Attribute("Left")?.Value, DefaultWindowPosition.X); + int top = ParseInt(bounds.Attribute("Top")?.Value, DefaultWindowPosition.Y); + double width = ParseDouble(bounds.Attribute("Width")?.Value, DefaultWindowSize.Width); + double height = ParseDouble(bounds.Attribute("Height")?.Value, DefaultWindowSize.Height); + WindowPosition = new PixelPoint(left, top); + WindowSize = new Size(width, height); + } + else + { + var parts = bounds.Value.Split(','); + if (parts.Length == 4) + { + double left = ParseDouble(parts[0], DefaultWindowPosition.X); + double top = ParseDouble(parts[1], DefaultWindowPosition.Y); + double width = ParseDouble(parts[2], DefaultWindowSize.Width); + double height = ParseDouble(parts[3], DefaultWindowSize.Height); + WindowPosition = new PixelPoint((int)left, (int)top); + WindowSize = new Size(width, height); + } + } } FilterStates.Clear(); @@ -189,5 +209,18 @@ namespace ILSpy static double ParseDouble(string? value, double defaultValue) => double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result) ? result : defaultValue; + + // Legacy WPF host hex-escaped every non-letter-or-digit char in tree-view path nodes + // (TomsToolbox.Wpf -> TomsToolbox\x002EWpf). The Avalonia host writes node values raw, + // but old ILSpy.xml files still hold the escaped form — decode so a restored path + // compares equal to the live tree-node ToString()s. + static readonly Regex EscapedCharPattern = new(@"\\x(?[0-9A-Fa-f]{4})", RegexOptions.Compiled); + + static string UnescapeNode(string value) + { + if (string.IsNullOrEmpty(value) || value.IndexOf(@"\x", StringComparison.Ordinal) < 0) + return value; + return EscapedCharPattern.Replace(value, m => ((char)int.Parse(m.Groups["num"].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture)).ToString()); + } } }