From 28a5cbb2194c7b36de1c2abd989835fdc3a2baf1 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Thu, 20 Aug 2026 10:11:53 +0200 Subject: [PATCH 1/4] Fix #4028: derived-type entries were hidden by the filter cascade DerivedTypesEntryNode.Filter reported Recurse, but the cascade's Recurse handling force-loads the entry's lazy children and hides the entry when all of them are hidden. A leaf derived type has no children, so every entry under "Derived Types" ended up hidden, and the hiding propagated up the whole derived chain. The WPF tree showed these entries as matches; Match restores that and also keeps the entries' children lazy instead of eagerly scanning the assembly list for each level of the chain. Assisted-by: Claude:claude-fable-5:Claude Code --- ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs | 29 +++++++++++++++++++ ILSpy/TreeNodes/DerivedTypesEntryNode.cs | 12 ++++---- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs index 7312dcf926..7c8daeb90b 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs @@ -1539,6 +1539,35 @@ public async Task Type_Tree_Node_Exposes_DerivedTypes_Subtree_For_Non_Sealed_Cla "the loaded assembly list contains several Exception subclasses (e.g. SystemException, ArgumentException)"); } + [AvaloniaTest] + public async Task Derived_Type_Entries_Stay_Visible_When_The_DerivedTypes_Node_Is_Expanded() + { + // The filter cascade runs for children added under a visible parent. A derived-type + // entry must report FilterResult.Match there: the Recurse handling force-loads the + // entry's own (lazy) children and hides the entry when all of them are hidden -- a + // leaf derived type has none, so every entry under "Derived Types" ended up hidden. + + var (_, vm) = await TestHarness.BootAsync(3); + + var coreLibName = typeof(object).Assembly.GetName().Name!; + var typeNode = vm.AssemblyTreeModel.FindNode( + coreLibName, "System", "System.Exception"); + // Expand the full ancestor chain so the type node is IsVisible -- the cascade only + // fires for children of visible parents, which is the state the real tree is in. + foreach (var ancestor in typeNode.Ancestors()) + ancestor.IsExpanded = true; + typeNode.IsExpanded = true; + + var derived = typeNode.Children.OfType().Single(); + derived.IsExpanded = true; + + var entries = derived.Children.OfType().ToList(); + entries.Should().NotBeEmpty( + "the loaded assembly list contains several Exception subclasses"); + entries.Should().OnlyContain(e => e.IsVisible, + "public derived-type entries must show under the expanded Derived Types node"); + } + [AvaloniaTest] public async Task Sealed_Class_Has_No_DerivedTypes_Node() { diff --git a/ILSpy/TreeNodes/DerivedTypesEntryNode.cs b/ILSpy/TreeNodes/DerivedTypesEntryNode.cs index 08eda9a4f0..01ce0cdb52 100644 --- a/ILSpy/TreeNodes/DerivedTypesEntryNode.cs +++ b/ILSpy/TreeNodes/DerivedTypesEntryNode.cs @@ -69,16 +69,18 @@ protected override void LoadChildren() }; /// - /// Drops non-public entries under PublicOnly visibility, otherwise recurses so the user - /// can drill into derived chains. The active search term is deliberately not consulted: - /// is a no-op so the assembly tree stays - /// independent of the search pane. + /// Drops non-public entries under PublicOnly visibility, otherwise reports a match. It must + /// not report Recurse: the filter cascade's Recurse handling force-loads this node's lazy + /// children and hides the node when all of them are hidden, so a leaf derived type (no + /// further subclasses, hence no children) would vanish from the tree. The active search term + /// is deliberately not consulted: is a + /// no-op so the assembly tree stays independent of the search pane. /// public override FilterResult Filter(LanguageSettings settings) { if (settings.ShowApiLevel == ApiVisibility.PublicOnly && !IsPublicAPI) return FilterResult.Hidden; - return FilterResult.Recurse; + return FilterResult.Match; } public override void ActivateItem(IPlatformRoutedEventArgs e) From a04d40b89567d7c7e639c9845b95542bb20cc3f6 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Thu, 20 Aug 2026 10:15:58 +0200 Subject: [PATCH 2/4] Fix #4030: Enter did not activate the selected tree row Avalonia 12 treats plain Enter/Space on a ListBoxItem as selection input: the container marks the KeyDown handled before it bubbles, so SharpTreeView.OnKeyDown never saw the keys and its activation handling (navigate to the member from an analyzer row, toggle a checkable row) was dead. Override ShouldTriggerSelection -- the extension point added for this in Avalonia 12 -- to suppress the selection trigger exactly for the case OnKeyDown activates instead: a single selected row that is the row the key landed on. Multi-row selections keep the default collapse-to-focused-row behaviour. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Analyzers/AnalyzerTreeKeyboardTests.cs | 33 +++++++++++++++++++ ILSpy/Controls/TreeView/SharpTreeView.cs | 21 ++++++++++++ 2 files changed, 54 insertions(+) diff --git a/ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs b/ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs index 40d7b56b96..662c54b4da 100644 --- a/ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs @@ -70,6 +70,39 @@ await Waiters.WaitForAsync(() => analyzed.IsExpanded, description: "Right must expand the node via SharpTreeView.OnKeyDown on the analyzer tree"); } + [AvaloniaTest] + public async Task Enter_Activates_The_Selected_Analyzer_Node() + { + // Enter on a single selected analyzer row activates it -- for an entity node that means + // navigating to the member's home in the assembly tree, like 10.x did. The key must reach + // SharpTreeView.OnKeyDown: the container is a ListBoxItem, and Avalonia's default key + // selection triggers treat Enter/Space as selection input and mark the event handled + // before it bubbles, so SharpTreeView suppresses that trigger for the activation case. + var (window, vm) = await TestHarness.BootAsync(3); + var dockWorkspace = AppComposition.Current.GetExport(); + var analyzerVm = AppComposition.Current.GetExport(); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var entity = (ITypeDefinition)typeNode.Member!; + var analyzed = analyzerVm.Analyze(entity); + + dockWorkspace.ShowToolPane(AnalyzerTreeViewModel.PaneContentId); + var view = await window.WaitForComponent(); + var tree = await view.WaitForComponent(); + tree.SelectedItem = analyzed; + Dispatcher.UIThread.RunJobs(); + tree.FocusNode(analyzed); + Dispatcher.UIThread.RunJobs(); + + ((object?)vm.AssemblyTreeModel.SelectedItem).Should().NotBeSameAs(typeNode, + "precondition: the assembly tree must not already sit on the target node"); + + window.KeyPress(Key.Enter, RawInputModifiers.None, PhysicalKey.Enter, null); + await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, typeNode), + description: "Enter must activate the analyzer node and select the type in the assembly tree"); + } + [AvaloniaTest] public async Task Ctrl_R_Analyzes_The_Selected_Member() { diff --git a/ILSpy/Controls/TreeView/SharpTreeView.cs b/ILSpy/Controls/TreeView/SharpTreeView.cs index 83ddbfbeb2..1babaf41ab 100644 --- a/ILSpy/Controls/TreeView/SharpTreeView.cs +++ b/ILSpy/Controls/TreeView/SharpTreeView.cs @@ -295,6 +295,27 @@ void CenterNodeInView(SharpTreeNode node) scrollViewer.Offset = new Vector(scrollViewer.Offset.X, newOffsetY); } + /// + /// Avalonia's default key selection triggers treat plain Enter/Space as selection input: + /// the ListBoxItem container marks the KeyDown handled before it bubbles here, so the + /// activation handling in would never see those keys. Suppress the + /// selection trigger exactly for the case OnKeyDown activates instead -- a single selected + /// row that is the row the key landed on. Multi-row selections keep the default behaviour + /// (Enter/Space collapses the selection to the focused row). + /// + protected override bool ShouldTriggerSelection(Visual selectable, KeyEventArgs eventArgs) + { + if (eventArgs.KeyModifiers == KeyModifiers.None + && eventArgs.Key is Key.Enter or Key.Space + && selectable is SharpTreeViewItem { Node: { } node } + && SelectedItems?.Count == 1 + && ReferenceEquals(SelectedItem, node)) + { + return false; + } + return base.ShouldTriggerSelection(selectable, eventArgs); + } + protected override void OnKeyDown(KeyEventArgs e) { // Ctrl+A select-all must work on the first press even before a current item is From ab4a88d7531f9933dbab039862a73e7c5ec1cb0f Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Thu, 20 Aug 2026 10:26:03 +0200 Subject: [PATCH 3/4] Fix #4027: mouse back/forward buttons navigate the history again WPF translated XButton1/XButton2 into BrowseBack/BrowseForward commands by itself, so the WPF frontend got the behaviour for free. Avalonia has no such translation and KeyBinding cannot express pointer buttons, so only Alt+Left / Alt+Right survived the migration. MainWindow now listens window-wide for PointerReleased and routes the X buttons to the existing DockWorkspace navigation commands (handledEventsToo, because inner controls handle pointer events for their own gestures without ever using the X buttons). Assisted-by: Claude:claude-fable-5:Claude Code --- .../BrowseBackForwardCommandTests.cs | 44 +++++++++++++++++++ ILSpy/Views/MainWindow.axaml | 4 +- ILSpy/Views/MainWindow.axaml.cs | 24 ++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs b/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs index 981cd9d673..ba92f63c3e 100644 --- a/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs +++ b/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs @@ -20,6 +20,7 @@ using System.Threading.Tasks; using Avalonia.Controls; +using Avalonia.Headless; using Avalonia.Headless.NUnit; using Avalonia.Input; using Avalonia.VisualTree; @@ -121,6 +122,49 @@ public async Task BrowseBack_MenuItem_Forwards_CanExecute_And_Execute_To_DockWor "after one back-step the forward stack should be non-empty"); } + [AvaloniaTest] + public async Task Mouse_Back_And_Forward_Buttons_Navigate_The_History() + { + // The extra mouse buttons (XButton1 = back, XButton2 = forward) drive the same history + // as Alt+Left / Alt+Right, matching browsers and the WPF version (where WPF itself + // translated the buttons into BrowseBack/BrowseForward commands). Avalonia has no such + // translation, so MainWindow routes the pointer events to the navigation commands. + + // Arrange — build a two-entry history exactly like the menu-driven test above. + var (window, vm) = await TestHarness.BootAsync(3); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + typeNode.IsExpanded = true; + var firstMethod = typeNode.Children.OfType() + .Single(m => m.MethodDefinition.Name == "AsEnumerable"); + var secondMethod = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Empty"); + + vm.AssemblyTreeModel.SelectNode(firstMethod); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + await Task.Delay(600); + vm.AssemblyTreeModel.SelectNode(secondMethod); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + // Act — click mouse-back anywhere in the window. + var point = new Avalonia.Point(100, 100); + window.MouseDown(point, MouseButton.XButton1); + window.MouseUp(point, MouseButton.XButton1); + + // Assert — selection rewinds, then mouse-forward replays the step. + await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, firstMethod), + description: "XButton1 must navigate back one history entry"); + await Waiters.WaitForAsync(() => vm.DockWorkspace.NavigateForwardCommand.CanExecute(null), + description: "after one back-step the forward stack should be non-empty"); + + window.MouseDown(point, MouseButton.XButton2); + window.MouseUp(point, MouseButton.XButton2); + + await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, secondMethod), + description: "XButton2 must navigate forward one history entry"); + } + [AvaloniaTest] public void BrowseBack_MenuItem_Carries_The_Alt_Left_Gesture() { diff --git a/ILSpy/Views/MainWindow.axaml b/ILSpy/Views/MainWindow.axaml index 1e51d02504..f65eb5d805 100644 --- a/ILSpy/Views/MainWindow.axaml +++ b/ILSpy/Views/MainWindow.axaml @@ -20,8 +20,8 @@ - +