Browse Source

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
pull/3769/head
Siegfried Pammer 4 weeks ago committed by Christoph Wille
parent
commit
998d4fd556
  1. 130
      ILSpy.Tests/SessionSettingsTests.cs
  2. 35
      ILSpy/SessionSettings.cs

130
ILSpy.Tests/SessionSettingsTests.cs

@ -16,6 +16,8 @@ @@ -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 @@ -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(@"<SessionSettings>
<WindowState>Normal</WindowState>
<WindowBounds>882.6666666666666,342,750,550</WindowBounds>
</SessionSettings>");
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(@"<SessionSettings>
<ActiveTreeViewPath>
<Node>D\x003A\x005CProjects\x005CILSpy\x005CILSpy\x002Edll</Node>
<Node>TomsToolbox\x002EWpf</Node>
<Node>TomsToolbox\x002EWpf\x002EDelegateCommand</Node>
</ActiveTreeViewPath>
</SessionSettings>");
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(@"<SessionSettings>
<FilterSettings>
<ShowAPILevel>1</ShowAPILevel>
<Language></Language>
<LanguageVersion></LanguageVersion>
</FilterSettings>
<ActiveAssemblyList>.NET 4 (WPF)</ActiveAssemblyList>
<ActiveTreeViewPath>
<Node>D\x003A\x005CProjects\x005CILSpy\x005CILSpy\x002Edll</Node>
<Node>TomsToolbox\x002EWpf</Node>
</ActiveTreeViewPath>
<WindowState>Normal</WindowState>
<WindowBounds>882.6666666666666,342,750,550</WindowBounds>
<SelectedSearchMode>TypeAndMember</SelectedSearchMode>
<Theme>Light</Theme>
</SessionSettings>");
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(@"<SessionSettings>
<ActiveAssemblyList>my-list</ActiveAssemblyList>
<DockLayout>
<LayoutRoot>
<RootPanel Orientation=""Horizontal"">
<LayoutAnchorablePaneGroup Orientation=""Horizontal"" DockWidth=""300"" />
</RootPanel>
</LayoutRoot>
</DockLayout>
<SelectedSearchMode>TypeAndMember</SelectedSearchMode>
<ActiveAutoLoadedAssembly>foo.dll</ActiveAutoLoadedAssembly>
<FutureFeatureFlag value=""xyz"">
<Nested>123</Nested>
</FutureFeatureFlag>
</SessionSettings>");
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(@"<SessionSettings>
<ActiveAssemblyList>my-list</ActiveAssemblyList>
<DockLayout><LayoutRoot /></DockLayout>
<SelectedSearchMode>TypeAndMember</SelectedSearchMode>
<FutureFeatureFlag value=""xyz"" />
</SessionSettings>"));
var saved = loaded.SaveToXml();
saved.Element("DockLayout").Should().BeNull();
saved.Element("SelectedSearchMode").Should().BeNull();
saved.Element("FutureFeatureFlag").Should().BeNull();
}
}

35
ILSpy/SessionSettings.cs

@ -20,6 +20,7 @@ using System; @@ -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 @@ -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));
@ -112,6 +113,11 @@ namespace ILSpy @@ -112,6 +113,11 @@ namespace ILSpy
var bounds = section.Element("WindowBounds");
if (bounds != null)
{
// 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);
@ -120,6 +126,20 @@ namespace ILSpy @@ -120,6 +126,20 @@ namespace ILSpy
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();
foreach (var page in section.Elements("FilterStates").Elements("Page"))
@ -189,5 +209,18 @@ namespace ILSpy @@ -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(?<num>[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());
}
}
}

Loading…
Cancel
Save