Browse Source

Persist + restore the selected tree node across launches

SessionSettings gains an ActiveTreeViewPath (string array) saved every
time AssemblyTreeModel.SelectedItem changes and consulted on the next
launch when the assembly list root is built. To make the path stable
across language switches, each tree node overrides ToString to return a
language-independent identifier — file path for assemblies, ITypeDefinition.
ReflectionName for types, and IL-formatted entity strings for members,
matching the WPF host's scheme.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
cb12ab9372
  1. 15
      ILSpy.Tests/SessionSettingsTests.cs
  2. 3
      ILSpy/AssemblyTree/AssemblyListPane.axaml
  3. 77
      ILSpy/AssemblyTree/AssemblyListPane.axaml.cs
  4. 55
      ILSpy/AssemblyTree/AssemblyTreeModel.cs
  5. 10
      ILSpy/SessionSettings.cs
  6. 4
      ILSpy/TreeNodes/AssemblyTreeNode.cs
  7. 2
      ILSpy/TreeNodes/EventTreeNode.cs
  8. 2
      ILSpy/TreeNodes/FieldTreeNode.cs
  9. 10
      ILSpy/TreeNodes/MethodTreeNode.cs
  10. 9
      ILSpy/TreeNodes/PropertyTreeNode.cs
  11. 8
      ILSpy/TreeNodes/TypeTreeNode.cs

15
ILSpy.Tests/SessionSettingsTests.cs

@ -54,4 +54,19 @@ public class SessionSettingsTests @@ -54,4 +54,19 @@ public class SessionSettingsTests
loaded.WindowPosition.Should().Be(new PixelPoint(200, 300));
loaded.WindowSize.Should().Be(new Size(1024, 768));
}
[Test]
public void Save_then_Load_round_trips_ActiveTreeViewPath()
{
var original = new SessionSettings {
ActiveTreeViewPath = ["My.Assembly.dll", "MyNamespace.MyType", "M:MyMethod"],
};
var xml = original.SaveToXml();
var loaded = new SessionSettings();
loaded.LoadFromXml(xml);
loaded.ActiveTreeViewPath.Should().Equal("My.Assembly.dll", "MyNamespace.MyType", "M:MyMethod");
}
}

3
ILSpy/AssemblyTree/AssemblyListPane.axaml

@ -15,7 +15,8 @@ @@ -15,7 +15,8 @@
GridLinesVisibility="None"
IsReadOnly="True"
CanUserResizeColumns="False"
SelectionMode="Single">
SelectionMode="Single"
SelectionChanged="OnTreeGridSelectionChanged">
<DataGrid.Columns>
<DataGridHierarchicalColumn Header="Name" Width="*"
x:CompileBindings="False"

77
ILSpy/AssemblyTree/AssemblyListPane.axaml.cs

@ -31,12 +31,72 @@ namespace ILSpy.AssemblyTree @@ -31,12 +31,72 @@ namespace ILSpy.AssemblyTree
{
public partial class AssemblyListPane : UserControl
{
// Set while propagating model.SelectedItem → DataGrid.SelectedItem so the resulting
// SelectionChanged event doesn't bounce right back into the model.
bool syncingSelection;
public AssemblyListPane()
{
InitializeComponent();
TreeGrid.DoubleTapped += OnTreeGridDoubleTapped;
}
void OnTreeGridSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (syncingSelection || DataContext is not AssemblyTreeModel model)
return;
SharpTreeNode? selected = null;
if (TreeGrid.SelectedItem is HierarchicalNode node && node.Item is SharpTreeNode tree)
selected = tree;
else if (TreeGrid.SelectedItem is SharpTreeNode direct)
selected = direct;
if (model.SelectedItem == selected)
return;
model.SelectedItem = selected;
}
void SyncSelectionFromModel(SharpTreeNode? target)
{
if (target == null)
{
if (TreeGrid.SelectedItem != null)
{
syncingSelection = true;
TreeGrid.SelectedItem = null!;
syncingSelection = false;
}
return;
}
if (TreeGrid.HierarchicalModel is not IHierarchicalModel hm)
return;
// Build the SharpTreeNode path from a root (a child of the hidden AssemblyListTreeNode)
// down to the target.
var path = new System.Collections.Generic.List<SharpTreeNode>();
for (var n = target; n.Parent != null; n = n.Parent)
path.Add(n);
path.Reverse();
// Walk top-down: ProDataGrid only materialises children of expanded nodes, so each
// ancestor must be expanded before FindNode can locate the next level's wrapper.
HierarchicalNode? hNode = null;
for (int i = 0; i < path.Count; i++)
{
hNode = hm.FindNode(path[i]);
if (hNode == null)
return;
if (i < path.Count - 1 && !hNode.IsExpanded)
hm.Expand(hNode);
}
if (ReferenceEquals(TreeGrid.SelectedItem, target))
return;
syncingSelection = true;
TreeGrid.SelectedItem = target;
syncingSelection = false;
TreeGrid.ScrollIntoView(target, TreeGrid.Columns[0]);
}
void OnTreeGridDoubleTapped(object? sender, TappedEventArgs e)
{
if (TreeGrid.HierarchicalModel is not IHierarchicalModel model)
@ -59,14 +119,29 @@ namespace ILSpy.AssemblyTree @@ -59,14 +119,29 @@ namespace ILSpy.AssemblyTree
{
model.PropertyChanged += Model_PropertyChanged;
if (model.Root != null)
{
BindTree(model.Root);
// Push any already-set selection (e.g. one restored from SessionSettings
// before the pane subscribed to PropertyChanged) into the DataGrid.
if (model.SelectedItem != null)
SyncSelectionFromModel(model.SelectedItem);
}
}
}
void Model_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(AssemblyTreeModel.Root) && sender is AssemblyTreeModel model && model.Root != null)
if (sender is not AssemblyTreeModel model)
return;
if (e.PropertyName == nameof(AssemblyTreeModel.Root) && model.Root != null)
{
BindTree(model.Root);
SyncSelectionFromModel(model.SelectedItem);
}
else if (e.PropertyName == nameof(AssemblyTreeModel.SelectedItem))
{
SyncSelectionFromModel(model.SelectedItem);
}
}
void BindTree(SharpTreeNode root)

55
ILSpy/AssemblyTree/AssemblyTreeModel.cs

@ -17,8 +17,10 @@ @@ -17,8 +17,10 @@
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Composition;
using System.Linq;
using System.Runtime.Serialization;
using CommunityToolkit.Mvvm.ComponentModel;
@ -125,6 +127,59 @@ namespace ILSpy.AssemblyTree @@ -125,6 +127,59 @@ namespace ILSpy.AssemblyTree
Root = new AssemblyListTreeNode(AssemblyList);
settingsService.SessionSettings.ActiveAssemblyList = value;
// Restore the previously-selected tree node, if any. Walks the tree by ToString()
// matching, materialising lazy children as it goes.
var savedPath = settingsService.SessionSettings.ActiveTreeViewPath;
if (savedPath is { Length: > 0 })
{
var restored = FindNodeByPath(savedPath, returnBestMatch: true);
if (restored != null && restored != Root)
SelectedItem = restored;
}
}
partial void OnSelectedItemChanged(SharpTreeNode? value)
{
settingsService.SessionSettings.ActiveTreeViewPath = GetPathForNode(value);
}
/// <summary>
/// Walks down from <see cref="Root"/> matching each path segment against
/// <see cref="object.ToString"/>, expanding lazy children along the way.
/// </summary>
public SharpTreeNode? FindNodeByPath(string[]? path, bool returnBestMatch)
{
if (path == null || Root == null)
return null;
SharpTreeNode? node = Root;
SharpTreeNode? bestMatch = node;
foreach (var element in path)
{
if (node == null)
break;
bestMatch = node;
node.EnsureLazyChildren();
node = node.Children.FirstOrDefault(c => c.ToString() == element);
}
return returnBestMatch ? node ?? bestMatch : node;
}
/// <summary>
/// The path of <paramref name="node"/>'s ancestors (root excluded), in root-first order.
/// </summary>
public static string[]? GetPathForNode(SharpTreeNode? node)
{
if (node == null)
return null;
var path = new List<string>();
while (node.Parent != null)
{
path.Add(node.ToString()!);
node = node.Parent;
}
path.Reverse();
return path.ToArray();
}
static void LoadInitialAssemblies(AssemblyList assemblyList)

10
ILSpy/SessionSettings.cs

@ -18,6 +18,7 @@ @@ -18,6 +18,7 @@
using System;
using System.Globalization;
using System.Linq;
using System.Xml.Linq;
using Avalonia;
@ -46,6 +47,12 @@ namespace ILSpy @@ -46,6 +47,12 @@ namespace ILSpy
[ObservableProperty]
private string? activeLanguageName;
/// <summary>
/// Path to the previously-selected tree node (one ToString() per ancestor, root-first).
/// Used to restore the selection on the next launch.
/// </summary>
public string[]? ActiveTreeViewPath { get; set; }
public WindowState WindowState { get; set; } = WindowState.Normal;
public PixelPoint WindowPosition { get; set; } = DefaultWindowPosition;
@ -56,6 +63,7 @@ namespace ILSpy @@ -56,6 +63,7 @@ namespace ILSpy
{
ActiveAssemblyList = (string?)section.Element("ActiveAssemblyList");
ActiveLanguageName = (string?)section.Element("ActiveLanguageName");
ActiveTreeViewPath = section.Element("ActiveTreeViewPath")?.Elements().Select(e => (string)e).ToArray();
WindowState = ParseEnum(section.Element("WindowState")?.Value, WindowState.Normal);
var bounds = section.Element("WindowBounds");
@ -77,6 +85,8 @@ namespace ILSpy @@ -77,6 +85,8 @@ namespace ILSpy
section.Add(new XElement("ActiveAssemblyList", ActiveAssemblyList));
if (!string.IsNullOrEmpty(ActiveLanguageName))
section.Add(new XElement("ActiveLanguageName", ActiveLanguageName));
if (ActiveTreeViewPath is { Length: > 0 } path)
section.Add(new XElement("ActiveTreeViewPath", path.Select(p => new XElement("Node", p))));
section.Add(new XElement("WindowState", WindowState.ToString()));
section.Add(new XElement("WindowBounds",
new XAttribute("Left", WindowPosition.X.ToString(CultureInfo.InvariantCulture)),

4
ILSpy/TreeNodes/AssemblyTreeNode.cs

@ -71,6 +71,10 @@ namespace ILSpy.TreeNodes @@ -71,6 +71,10 @@ namespace ILSpy.TreeNodes
public override object Text => assembly.ShortName;
// ToString is the stable identity used by SessionSettings.ActiveTreeViewPath — must not
// depend on the active language. The full file path uniquely identifies the assembly.
public override string ToString() => assembly.FileName;
public override object Icon {
get {
if (assembly.HasLoadError || loadError != null)

2
ILSpy/TreeNodes/EventTreeNode.cs

@ -36,5 +36,7 @@ namespace ILSpy.TreeNodes @@ -36,5 +36,7 @@ namespace ILSpy.TreeNodes
public override object Icon => Images.Images.GetIcon(Images.Images.Event,
Images.Images.GetOverlay(EventDefinition.Accessibility), EventDefinition.IsStatic);
public override bool ShowExpander => false;
public override string ToString() => "Event " + EventDefinition.Name;
}
}

2
ILSpy/TreeNodes/FieldTreeNode.cs

@ -36,5 +36,7 @@ namespace ILSpy.TreeNodes @@ -36,5 +36,7 @@ namespace ILSpy.TreeNodes
public override object Icon => Images.Images.GetIcon(Images.Images.Field,
Images.Images.GetOverlay(FieldDefinition.Accessibility), FieldDefinition.IsStatic);
public override bool ShowExpander => false;
public override string ToString() => "Field " + FieldDefinition.Name;
}
}

10
ILSpy/TreeNodes/MethodTreeNode.cs

@ -47,5 +47,15 @@ namespace ILSpy.TreeNodes @@ -47,5 +47,15 @@ namespace ILSpy.TreeNodes
}
public override bool ShowExpander => false;
// Stable identity for SessionSettings.ActiveTreeViewPath; matches the WPF host's format.
public override string ToString()
=> "Method " + new ICSharpCode.Decompiler.IL.ILAmbience {
ConversionFlags = ConversionFlags.ShowTypeParameterList
| ConversionFlags.PlaceReturnTypeAfterParameterList
| ConversionFlags.ShowReturnType
| ConversionFlags.ShowParameterList
| ConversionFlags.ShowParameterModifiers,
}.ConvertSymbol(MethodDefinition);
}
}

9
ILSpy/TreeNodes/PropertyTreeNode.cs

@ -36,5 +36,14 @@ namespace ILSpy.TreeNodes @@ -36,5 +36,14 @@ namespace ILSpy.TreeNodes
public override object Icon => Images.Images.GetIcon(Images.Images.Property,
Images.Images.GetOverlay(PropertyDefinition.Accessibility), PropertyDefinition.IsStatic);
public override bool ShowExpander => false;
public override string ToString()
=> "Property " + new ICSharpCode.Decompiler.IL.ILAmbience {
ConversionFlags = ConversionFlags.ShowTypeParameterList
| ConversionFlags.PlaceReturnTypeAfterParameterList
| ConversionFlags.ShowReturnType
| ConversionFlags.ShowParameterList
| ConversionFlags.ShowParameterModifiers,
}.ConvertSymbol(PropertyDefinition);
}
}

8
ILSpy/TreeNodes/TypeTreeNode.cs

@ -71,6 +71,14 @@ namespace ILSpy.TreeNodes @@ -71,6 +71,14 @@ namespace ILSpy.TreeNodes
public override bool CanExpandRecursively => true;
// Stable identity for SessionSettings.ActiveTreeViewPath. ReflectionName is
// language-independent.
public override string ToString()
{
var typeDef = ResolveTypeDefinition();
return typeDef?.ReflectionName ?? module.Metadata.GetString(module.Metadata.GetTypeDefinition(handle).Name);
}
ITypeDefinition? ResolveTypeDefinition()
{
var typeSystem = module.GetTypeSystemOrNull();

Loading…
Cancel
Save