Browse Source

First headless test + scroll-into-view fix

DecompileAsEnumerableTest exercises the full path: open mscorlib, navigate
to Enumerable.AsEnumerable, render the decompilation, verify text and
verify the row centred in the assembly tree. Writing it surfaced two
pre-existing bugs the manual smoke-test missed:
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
b953bb97c2
  1. 5
      ILSpy.Tests/AssemblyTree/AssemblyTreeModelTests.cs
  2. 76
      ILSpy.Tests/DecompileAsEnumerableTest.cs
  3. 46
      ILSpy.Tests/MainWindow/MainWindowTests.cs
  4. 99
      ILSpy.Tests/TreeNavigation.cs
  5. 202
      ILSpy.Tests/TreeNodeAssertions.cs
  6. 85
      ILSpy.Tests/Waiters.cs
  7. 50
      ILSpy/AssemblyTree/AssemblyListPane.axaml.cs
  8. 5
      ILSpy/TreeNodes/NamespaceTreeNode.cs

5
ILSpy.Tests/AssemblyTree/AssemblyTreeModelTests.cs

@ -46,7 +46,10 @@ public class AssemblyTreeModelTests @@ -46,7 +46,10 @@ public class AssemblyTreeModelTests
model.Initialize();
model.AssemblyList.Should().NotBeNull("Initialize loads or creates the default AssemblyList.");
model.Root.Should().NotBeNull("Initialize wires Root to an AssemblyListTreeNode of the loaded list.");
// Cast through object so the generic AwesomeAssertions Should() resolves, not the
// TreeNodeAssertionsExtensions.Should(SharpTreeNode) shadow that landed in this commit
// (its TreeNodeAssertions surface doesn't expose NotBeNull).
((object?)model.Root).Should().NotBeNull("Initialize wires Root to an AssemblyListTreeNode of the loaded list.");
}
[AvaloniaTest]

76
ILSpy.Tests/DecompileAsEnumerableTest.cs

@ -0,0 +1,76 @@ @@ -0,0 +1,76 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Linq;
using System.Threading.Tasks;
using AwesomeAssertions;
using Avalonia.Headless.NUnit;
using global::ILSpy.AppEnv;
using global::ILSpy.TreeNodes;
using global::ILSpy.ViewModels;
using global::ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class DecompileAsEnumerableTest
{
[AvaloniaTest]
public async Task Selecting_AsEnumerable_Method_Decompiles_Into_Document_View()
{
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
// MainWindow.Opened triggers AssemblyTreeModel.Initialize, which queues the default
// assembly list (System.Private.CoreLib, System, System.Linq) for async loading.
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
// Walk via the production FindNodeByPath (used by SessionSettings.ActiveTreeViewPath
// save/restore) down to the type. First segment is the assembly short name; the helper
// substitutes the file path AssemblyTreeNode.ToString returns.
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
// Method-level stable identities are built via ILAmbience and impractical to author by
// hand, so we expand the type and pick the overload by name. Enumerable has only one
// AsEnumerable, so this is unambiguous.
typeNode.EnsureLazyChildren();
var methodNode = typeNode.Children.OfType<MethodTreeNode>()
.Single(m => m.MethodDefinition.Name == "AsEnumerable");
methodNode.MethodDefinition.IsExtensionMethod.Should().BeTrue();
// Selecting the node routes through DockWorkspace -> the active decompiler tab.
vm.AssemblyTreeModel.SelectedItem = methodNode;
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
tab.Text.Should().Contain("AsEnumerable");
tab.Text.Should().Contain("IEnumerable<TSource>");
tab.Text.Should().Contain("return source");
// Selecting a deeply-nested node must scroll the assembly tree so it's centered.
methodNode.Should().Be().CenteredInView();
window.CaptureAndShow();
}
}

46
ILSpy.Tests/MainWindow/MainWindowTests.cs

@ -0,0 +1,46 @@ @@ -0,0 +1,46 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using global::ILSpy.AppEnv;
using global::ILSpy.ViewModels;
using global::ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class MainWindowTests
{
[AvaloniaTest]
public void MainWindow_Resolves_From_Composition_And_Shows()
{
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
window.IsVisible.Should().BeTrue();
window.Title.Should().Be("ILSpy");
window.DataContext.Should().BeOfType<MainWindowViewModel>();
window.CaptureAndShow();
}
}

99
ILSpy.Tests/TreeNavigation.cs

@ -0,0 +1,99 @@ @@ -0,0 +1,99 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Linq;
using ICSharpCode.ILSpyX.TreeView;
using global::ILSpy.AssemblyTree;
namespace ICSharpCode.ILSpy.Tests;
/// <summary>
/// Path-based tree navigation built on the production
/// <see cref="AssemblyTreeModel.FindNodeByPath(string[], bool)"/> walk. First segment is an
/// assembly short name (looked up in the assembly list, substituted with its file path);
/// the rest are the stable <c>ToString()</c> identities each tree node emits — namespace
/// name, type reflection name. Member-level identities go through ILAmbience and aren't
/// hand-authorable, so tests should drill to the type with <see cref="FindNode{T}"/> and
/// pick the member from <c>typeNode.Children</c> by <c>Name</c>.
/// </summary>
public static class TreeNavigation
{
public static SharpTreeNode FindNode(this AssemblyTreeModel atm, params string[] segments)
{
ArgumentNullException.ThrowIfNull(atm);
ArgumentNullException.ThrowIfNull(segments);
if (segments.Length == 0)
throw new ArgumentException("At least one segment (the assembly short name) is required.", nameof(segments));
var path = ResolveAssembly(atm, segments);
return atm.FindNodeByPath(path, returnBestMatch: false)
?? Diagnose(atm, path, segments);
}
public static T FindNode<T>(this AssemblyTreeModel atm, params string[] segments)
where T : SharpTreeNode
{
var node = FindNode(atm, segments);
return node as T
?? throw new InvalidOperationException(
$"Path [{string.Join(" / ", segments)}] resolved to {node.GetType().Name}, expected {typeof(T).Name}.");
}
public static SharpTreeNode SelectNode(this AssemblyTreeModel atm, params string[] segments)
{
var node = FindNode(atm, segments);
atm.SelectedItem = node;
return node;
}
public static T SelectNode<T>(this AssemblyTreeModel atm, params string[] segments)
where T : SharpTreeNode
{
var node = FindNode<T>(atm, segments);
atm.SelectedItem = node;
return node;
}
static string[] ResolveAssembly(AssemblyTreeModel atm, string[] segments)
{
var shortName = segments[0];
var asm = atm.AssemblyList?.GetAssemblies()
.FirstOrDefault(a => string.Equals(a.ShortName, shortName, StringComparison.Ordinal))
?? throw new InvalidOperationException(
$"Assembly with ShortName '{shortName}' is not in the active list. Loaded: " +
$"[{string.Join(", ", atm.AssemblyList?.GetAssemblies().Select(a => a.ShortName) ?? Array.Empty<string>())}]");
var translated = (string[])segments.Clone();
translated[0] = asm.FileName;
return translated;
}
static SharpTreeNode Diagnose(AssemblyTreeModel atm, string[] stablePath, string[] originalSegments)
{
var partial = atm.FindNodeByPath(stablePath, returnBestMatch: true);
var children = partial == null
? "(root null)"
: string.Join(", ", partial.Children.Select(c => c.ToString()));
throw new InvalidOperationException(
$"FindNodeByPath could not resolve [{string.Join(" / ", originalSegments)}]. " +
$"Reached '{partial?.ToString() ?? "(null)"}'; available children: [{children}].");
}
}

202
ILSpy.Tests/TreeNodeAssertions.cs

@ -0,0 +1,202 @@ @@ -0,0 +1,202 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AwesomeAssertions;
using ICSharpCode.ILSpyX.TreeView;
using global::ILSpy.AppEnv;
using global::ILSpy.AssemblyTree;
using global::ILSpy.Views;
namespace ICSharpCode.ILSpy.Tests;
public static class TreeNodeAssertionsExtensions
{
public static TreeNodeAssertions Should(this SharpTreeNode subject) => new(subject);
}
public class TreeNodeAssertions
{
public SharpTreeNode Subject { get; }
public TreeNodeAssertions(SharpTreeNode subject)
{
Subject = subject ?? throw new ArgumentNullException(nameof(subject));
}
public TreeNodeBeAssertions Be() => new(Subject);
}
public class TreeNodeBeAssertions
{
static readonly TimeSpan ScrollPollTimeout = TimeSpan.FromSeconds(3);
public SharpTreeNode Subject { get; }
public TreeNodeBeAssertions(SharpTreeNode subject)
{
Subject = subject;
}
public AndConstraint<TreeNodeBeAssertions> ScrolledIntoView(string because = "", params object[] becauseArgs)
=> AssertScrollState(centered: false, because, becauseArgs);
public AndConstraint<TreeNodeBeAssertions> CenteredInView(string because = "", params object[] becauseArgs)
=> AssertScrollState(centered: true, because, becauseArgs);
AndConstraint<TreeNodeBeAssertions> AssertScrollState(bool centered, string because, object[] becauseArgs)
{
var grid = TryFindGrid(out var failure);
if (grid is null)
{
false.Should().BeTrue(
$"cannot verify scroll state for {Describe()}: {failure}{(string.IsNullOrEmpty(because) ? "" : " " + string.Format(because, becauseArgs))}");
return new AndConstraint<TreeNodeBeAssertions>(this);
}
var scrollViewer = grid.GetVisualDescendants().OfType<ScrollViewer>().FirstOrDefault()
?? throw new InvalidOperationException("DataGrid has no ScrollViewer in its visual tree.");
// ScrollIntoView is posted at Background priority — give it time to realise the row.
var deadline = DateTime.UtcNow + ScrollPollTimeout;
DataGridRow? row = null;
bool ok = false;
while (DateTime.UtcNow < deadline)
{
Dispatcher.UIThread.RunJobs();
row = FindRow(grid, Subject);
if (row != null && IsInViewport(row, scrollViewer)
&& (!centered || IsRoughlyCentered(row, scrollViewer)))
{
ok = true;
break;
}
}
ok.Should().BeTrue(
$"{Describe()} should be {(centered ? "centred" : "scrolled into view")}{(string.IsNullOrEmpty(because) ? "" : ", " + string.Format(because, becauseArgs))}, " +
BuildState(scrollViewer, row));
return new AndConstraint<TreeNodeBeAssertions>(this);
}
static DataGrid? TryFindGrid(out string? failure)
{
var window = FindActiveWindow();
if (window is null)
{
failure = "no active Avalonia Window";
return null;
}
var pane = window.GetVisualDescendants().OfType<AssemblyListPane>().FirstOrDefault();
if (pane is null)
{
failure = "AssemblyListPane is not in the visual tree (the assembly tree pane isn't shown)";
return null;
}
var grid = pane.GetVisualDescendants().OfType<DataGrid>().FirstOrDefault(g => g.Name == "TreeGrid");
if (grid is null)
{
failure = "TreeGrid DataGrid not found inside AssemblyListPane";
return null;
}
failure = null;
return grid;
}
static Window? FindActiveWindow()
{
// ApplicationLifetime is null in headless; the [Shared] MainWindow export gives us
// the same instance the test resolved.
try
{
return AppComposition.Current.GetExport<MainWindow>();
}
catch
{
return null;
}
}
static DataGridRow? FindRow(DataGrid grid, SharpTreeNode target)
{
foreach (var row in grid.GetVisualDescendants().OfType<DataGridRow>())
{
var ctx = row.DataContext;
if (ReferenceEquals(ctx, target))
return row;
// HierarchicalNode wraps the underlying item via .Item.
var itemProp = ctx?.GetType().GetProperty("Item");
if (itemProp is not null && ReferenceEquals(itemProp.GetValue(ctx), target))
return row;
}
return null;
}
static bool IsInViewport(DataGridRow row, ScrollViewer scrollViewer)
{
var topLeft = row.TranslatePoint(new Point(0, 0), scrollViewer);
if (topLeft is null)
return false;
var top = topLeft.Value.Y;
var bottom = top + row.Bounds.Height;
return top >= 0 && bottom <= scrollViewer.Viewport.Height + 0.5;
}
static bool IsRoughlyCentered(DataGridRow row, ScrollViewer scrollViewer)
{
var topLeft = row.TranslatePoint(new Point(0, 0), scrollViewer);
if (topLeft is null)
return false;
var rowMid = topLeft.Value.Y + row.Bounds.Height / 2;
var viewportMid = scrollViewer.Viewport.Height / 2;
// Accept rows clamped at the start/end of the list as "as centred as possible".
var nearTopClamp = scrollViewer.Offset.Y <= 0.5;
var nearBottomClamp = scrollViewer.Offset.Y >= scrollViewer.Extent.Height - scrollViewer.Viewport.Height - 0.5;
return Math.Abs(rowMid - viewportMid) <= row.Bounds.Height || nearTopClamp || nearBottomClamp;
}
string BuildState(ScrollViewer scrollViewer, DataGridRow? row)
{
if (row is null)
return $"but no DataGridRow has been realised for it (offset={scrollViewer.Offset}, viewport={scrollViewer.Viewport}, extent={scrollViewer.Extent})";
var topLeft = row.TranslatePoint(new Point(0, 0), scrollViewer);
var top = topLeft?.Y;
var bottom = top + row.Bounds.Height;
return $"but its row sits at {top:0.#}..{bottom:0.#} relative to the viewport " +
$"(viewport height {scrollViewer.Viewport.Height:0.#}, offset {scrollViewer.Offset.Y:0.#}, extent {scrollViewer.Extent.Height:0.#})";
}
string Describe()
{
var text = Subject.Text?.ToString();
return string.IsNullOrEmpty(text) ? Subject.GetType().Name : $"\"{text}\"";
}
}

85
ILSpy.Tests/Waiters.cs

@ -0,0 +1,85 @@ @@ -0,0 +1,85 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Avalonia.Threading;
using global::ILSpy.AssemblyTree;
using global::ILSpy.Docking;
using global::ILSpy.TextView;
namespace ICSharpCode.ILSpy.Tests;
public static class Waiters
{
static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(15);
static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(25);
public static async Task WaitForAsync(
Func<bool> predicate,
TimeSpan? timeout = null,
[CallerArgumentExpression(nameof(predicate))] string? description = null)
{
ArgumentNullException.ThrowIfNull(predicate);
var deadline = DateTime.UtcNow + (timeout ?? DefaultTimeout);
while (DateTime.UtcNow < deadline)
{
if (predicate())
return;
Dispatcher.UIThread.RunJobs();
await Task.Delay(PollInterval);
}
throw new TimeoutException(
$"Timed out after {(timeout ?? DefaultTimeout).TotalSeconds:0.#}s waiting for: {description}");
}
public static async Task WaitForAssembliesAsync(
this AssemblyTreeModel atm,
int minimumCount = 1,
TimeSpan? timeout = null)
{
ArgumentNullException.ThrowIfNull(atm);
await WaitForAsync(
() => (atm.AssemblyList?.GetAssemblies().Length ?? 0) >= minimumCount,
timeout,
$"AssemblyList to contain >= {minimumCount} assemblies");
var assemblies = atm.AssemblyList!.GetAssemblies();
await Task.WhenAll(assemblies.Select(a => a.GetLoadResultAsync()));
}
public static async Task<DecompilerTabPageModel> WaitForDecompiledTextAsync(
this DockWorkspace dock,
TimeSpan? timeout = null)
{
ArgumentNullException.ThrowIfNull(dock);
var documents = ((ILSpyDockFactory)dock.Factory).Documents
?? throw new InvalidOperationException("DockWorkspace has no document dock yet.");
await WaitForAsync(
() => documents.ActiveDockable is DecompilerTabPageModel { IsDecompiling: false } tab
&& !string.IsNullOrEmpty(tab.Text),
timeout,
"active decompiler tab to finish decompiling and produce text");
return (DecompilerTabPageModel)documents.ActiveDockable!;
}
}

50
ILSpy/AssemblyTree/AssemblyListPane.axaml.cs

@ -16,8 +16,10 @@ @@ -16,8 +16,10 @@
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections;
using System.ComponentModel;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
@ -33,8 +35,8 @@ namespace ILSpy.AssemblyTree @@ -33,8 +35,8 @@ 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.
// Suppresses the SelectionChanged → model bounce while we're pushing the model's
// selection into the DataGrid.
bool syncingSelection;
public AssemblyListPane()
@ -72,15 +74,12 @@ namespace ILSpy.AssemblyTree @@ -72,15 +74,12 @@ namespace ILSpy.AssemblyTree
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.
// Each ancestor must be expanded before FindNode can locate the next level's wrapper.
BeginSync();
HierarchicalNode? hNode = null;
for (int i = 0; i < path.Count; i++)
@ -101,15 +100,46 @@ namespace ILSpy.AssemblyTree @@ -101,15 +100,46 @@ namespace ILSpy.AssemblyTree
return;
}
TreeGrid.SelectedItem = target;
TreeGrid.ScrollIntoView(target, TreeGrid.Columns[0]);
// Pass the HierarchicalNode wrapper (DataGrid.IndexOf is against the wrappers, not
// the underlying SharpTreeNodes), and defer past Expand's pending child-realization
// notifications — synchronously the wrapper isn't in the visible list yet.
var scrollTarget = hNode!;
global::Avalonia.Threading.Dispatcher.UIThread.Post(
() => CenterRowInView(scrollTarget),
global::Avalonia.Threading.DispatcherPriority.Background);
EndSync();
}
// DataGrid.ScrollIntoView only brings the row to the nearest viewport edge; we want
// it centred (matching the WPF host).
void CenterRowInView(HierarchicalNode node)
{
TreeGrid.ScrollIntoView(node, TreeGrid.Columns[0]);
var row = TreeGrid.GetVisualDescendants().OfType<DataGridRow>()
.FirstOrDefault(r => ReferenceEquals(r.DataContext, node));
if (row is null)
return;
var scrollViewer = TreeGrid.GetVisualDescendants().OfType<ScrollViewer>().FirstOrDefault();
if (scrollViewer is null)
return;
var rowTopInViewer = row.TranslatePoint(new Point(0, 0), scrollViewer);
if (rowTopInViewer is null)
return;
var desiredTop = (scrollViewer.Viewport.Height - row.Bounds.Height) / 2;
var newOffsetY = scrollViewer.Offset.Y + (rowTopInViewer.Value.Y - desiredTop);
var maxOffset = Math.Max(0, scrollViewer.Extent.Height - scrollViewer.Viewport.Height);
newOffsetY = Math.Clamp(newOffsetY, 0, maxOffset);
scrollViewer.Offset = new Vector(scrollViewer.Offset.X, newOffsetY);
}
void BeginSync() => syncingSelection = true;
// Hold the guard across one dispatch tick so any deferred SelectionChanged emits the
// DataGrid raises in response to our programmatic Expand / SelectedItem / ScrollIntoView
// calls don't bounce back into model.SelectedItem and cancel the navigation.
// Released on a Background dispatch tick so any DataGrid SelectionChanged the Expand /
// SelectedItem / ScrollIntoView calls trigger doesn't bounce back into the model.
void EndSync() => global::Avalonia.Threading.Dispatcher.UIThread.Post(
() => syncingSelection = false,
global::Avalonia.Threading.DispatcherPriority.Background);

5
ILSpy/TreeNodes/NamespaceTreeNode.cs

@ -46,6 +46,11 @@ namespace ILSpy.TreeNodes @@ -46,6 +46,11 @@ namespace ILSpy.TreeNodes
public override object Icon => Images.Images.Namespace;
// Stable identity for SessionSettings.ActiveTreeViewPath (used by AssemblyTreeModel.
// FindNodeByPath / GetPathForNode). Without this override the default Object.ToString
// returns the type name, which makes save/restore of namespace selections silently fail.
public override string ToString() => name;
protected override void LoadChildren()
{
var metadata = module.Metadata;

Loading…
Cancel
Save