diff --git a/ILSpy.Tests/AssemblyTree/AssemblyTreeModelTests.cs b/ILSpy.Tests/AssemblyTree/AssemblyTreeModelTests.cs index 0051a4bdd..106159a19 100644 --- a/ILSpy.Tests/AssemblyTree/AssemblyTreeModelTests.cs +++ b/ILSpy.Tests/AssemblyTree/AssemblyTreeModelTests.cs @@ -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] diff --git a/ILSpy.Tests/DecompileAsEnumerableTest.cs b/ILSpy.Tests/DecompileAsEnumerableTest.cs new file mode 100644 index 000000000..074cd07d8 --- /dev/null +++ b/ILSpy.Tests/DecompileAsEnumerableTest.cs @@ -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(); + 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( + "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() + .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"); + 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(); + } +} diff --git a/ILSpy.Tests/MainWindow/MainWindowTests.cs b/ILSpy.Tests/MainWindow/MainWindowTests.cs new file mode 100644 index 000000000..b67f10bd6 --- /dev/null +++ b/ILSpy.Tests/MainWindow/MainWindowTests.cs @@ -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(); + window.Show(); + + window.IsVisible.Should().BeTrue(); + window.Title.Should().Be("ILSpy"); + window.DataContext.Should().BeOfType(); + + window.CaptureAndShow(); + } +} diff --git a/ILSpy.Tests/TreeNavigation.cs b/ILSpy.Tests/TreeNavigation.cs new file mode 100644 index 000000000..25b2c2e73 --- /dev/null +++ b/ILSpy.Tests/TreeNavigation.cs @@ -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; + +/// +/// Path-based tree navigation built on the production +/// walk. First segment is an +/// assembly short name (looked up in the assembly list, substituted with its file path); +/// the rest are the stable ToString() 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 and +/// pick the member from typeNode.Children by Name. +/// +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(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(this AssemblyTreeModel atm, params string[] segments) + where T : SharpTreeNode + { + var node = FindNode(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())}]"); + + 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}]."); + } +} diff --git a/ILSpy.Tests/TreeNodeAssertions.cs b/ILSpy.Tests/TreeNodeAssertions.cs new file mode 100644 index 000000000..c9fa1f7eb --- /dev/null +++ b/ILSpy.Tests/TreeNodeAssertions.cs @@ -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 ScrolledIntoView(string because = "", params object[] becauseArgs) + => AssertScrollState(centered: false, because, becauseArgs); + + public AndConstraint CenteredInView(string because = "", params object[] becauseArgs) + => AssertScrollState(centered: true, because, becauseArgs); + + AndConstraint 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(this); + } + + var scrollViewer = grid.GetVisualDescendants().OfType().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(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().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().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(); + } + catch + { + return null; + } + } + + static DataGridRow? FindRow(DataGrid grid, SharpTreeNode target) + { + foreach (var row in grid.GetVisualDescendants().OfType()) + { + 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}\""; + } +} diff --git a/ILSpy.Tests/Waiters.cs b/ILSpy.Tests/Waiters.cs new file mode 100644 index 000000000..3a79b9461 --- /dev/null +++ b/ILSpy.Tests/Waiters.cs @@ -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 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 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!; + } +} diff --git a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs index 911cfbe2e..8e33f0460 100644 --- a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs +++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs @@ -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 { 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 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(); 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 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() + .FirstOrDefault(r => ReferenceEquals(r.DataContext, node)); + if (row is null) + return; + + var scrollViewer = TreeGrid.GetVisualDescendants().OfType().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); diff --git a/ILSpy/TreeNodes/NamespaceTreeNode.cs b/ILSpy/TreeNodes/NamespaceTreeNode.cs index 55b2b8bde..16fdd82eb 100644 --- a/ILSpy/TreeNodes/NamespaceTreeNode.cs +++ b/ILSpy/TreeNodes/NamespaceTreeNode.cs @@ -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;