From 28a5cbb2194c7b36de1c2abd989835fdc3a2baf1 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Thu, 20 Aug 2026 10:11:53 +0200 Subject: [PATCH 1/6] 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/6] 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 dfca502681a1d9186e02ac8a0a390f79734c2499 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Thu, 20 Aug 2026 10:26:03 +0200 Subject: [PATCH 3/6] 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 and the buttons never reached the control under the pointer as a click. Avalonia has no such translation and KeyBinding cannot express pointer buttons, so only Alt+Left / Alt+Right survived the migration. MainWindow now swallows the X-button press while it tunnels (Dock would otherwise activate the pane under the pointer, AvaloniaEdit would focus the editor or toggle a folding marker) and routes the release to the existing DockWorkspace navigation commands. Navigating also no longer moves the active pane to the editor: the history target is usually the already-active tab, and Dock's ActiveDockable setter re-runs InitActiveDockable -> SetFocusedDockable even for an unchanged value, so re-activating it only moved the focus. WPF's ActiveTabPage setter was a no-op for the same value. Assisted-by: Claude:claude-fable-5:Claude Code --- .../BrowseBackForwardCommandTests.cs | 117 ++++++++++++++++++ ILSpy/Docking/DockWorkspace.cs | 6 +- ILSpy/Views/MainWindow.axaml | 4 +- ILSpy/Views/MainWindow.axaml.cs | 37 ++++++ 4 files changed, 161 insertions(+), 3 deletions(-) diff --git a/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs b/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs index 981cd9d673..ce2a1a6c78 100644 --- a/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs +++ b/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs @@ -19,9 +19,12 @@ using System.Linq; using System.Threading.Tasks; +using Avalonia; using Avalonia.Controls; +using Avalonia.Headless; using Avalonia.Headless.NUnit; using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.VisualTree; using AwesomeAssertions; @@ -29,8 +32,10 @@ using ICSharpCode.ILSpy.Properties; using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.AssemblyTree; using ICSharpCode.ILSpy.Commands; using ICSharpCode.ILSpy.Docking; +using ICSharpCode.ILSpy.TextView; using ICSharpCode.ILSpy.TreeNodes; using ICSharpCode.ILSpy.ViewModels; using ICSharpCode.ILSpy.Views; @@ -121,6 +126,118 @@ 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 (firstMethod, secondMethod) = await BuildTwoEntryHistoryAsync(vm); + + // Act — click mouse-back anywhere in the window. + var point = new 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 async Task Mouse_Back_Button_Press_Does_Not_Reach_The_Control_Under_The_Pointer() + { + // The X buttons are navigation gestures, not clicks (WPF never delivered them to the + // control under the pointer). The press must not activate the pane under the pointer, + // move keyboard focus, or toggle a folding marker; only the release navigates, and the + // active pane stays where it was across the navigation. + + // Arrange — two-entry history, assembly pane active, pointer over the editor. + var (window, vm) = await TestHarness.BootAsync(3); + var (firstMethod, _) = await BuildTwoEntryHistoryAsync(vm); + var view = await window.WaitForComponent(); + + vm.DockWorkspace.ShowToolPane(AssemblyTreeModel.PaneContentId); + var activePane = vm.DockWorkspace.Layout.FocusedDockable; + activePane.Should().NotBeNull("showing the assembly pane must make it the focused dockable"); + var focusedElement = window.FocusManager?.GetFocusedElement(); + + int pressedInEditor = 0; + view.AddHandler(InputElement.PointerPressedEvent, (_, _) => pressedInEditor++, + RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + var point = view.TranslatePoint(new Point(view.Bounds.Width / 2, view.Bounds.Height / 2), window); + point.Should().NotBeNull("the editor centre must map into the test window"); + + // Act / Assert — the press is swallowed at the window ... + window.MouseDown(point!.Value, MouseButton.XButton1); + pressedInEditor.Should().Be(0, "an X-button press must not reach the control under the pointer"); + vm.DockWorkspace.Layout.FocusedDockable.Should().BeSameAs(activePane, + "pressing a mouse navigation button must not activate the pane under the pointer"); + ReferenceEquals(window.FocusManager?.GetFocusedElement(), focusedElement).Should().BeTrue( + "pressing a mouse navigation button must not move keyboard focus"); + + // ... and the release navigates without moving the active pane to the editor. + window.MouseUp(point.Value, MouseButton.XButton1); + await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, firstMethod), + description: "XButton1 must navigate back one history entry"); + vm.DockWorkspace.Layout.FocusedDockable.Should().BeSameAs(activePane, + "navigating back must not move the active pane to the editor"); + } + + [AvaloniaTest] + public async Task Browse_Back_Keeps_The_Active_Pane() + { + // Back/Forward re-select a tree node and restore the tab's view state; the tab being + // navigated is already the active document, so the navigation must not move the active + // pane to it (WPF kept the current view focused). Exercises the command directly, which + // is what the Alt+Left key binding and the View menu invoke. + var (_, vm) = await TestHarness.BootAsync(3); + var (firstMethod, _) = await BuildTwoEntryHistoryAsync(vm); + vm.DockWorkspace.ShowToolPane(AssemblyTreeModel.PaneContentId); + var activePane = vm.DockWorkspace.Layout.FocusedDockable; + activePane.Should().NotBeNull("showing the assembly pane must make it the focused dockable"); + + vm.DockWorkspace.NavigateBackCommand.Execute(null); + + await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, firstMethod), + description: "BrowseBack must navigate back one history entry"); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + vm.DockWorkspace.Layout.FocusedDockable.Should().BeSameAs(activePane, + "navigating back must not move the active pane to the editor"); + } + + // Selects two methods of System.Linq.Enumerable with a pause in between so the history records + // them as two separate entries; returns them in selection order. + static async Task<(MethodTreeNode First, MethodTreeNode Second)> BuildTwoEntryHistoryAsync(MainWindowViewModel vm) + { + 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(); + return (firstMethod, secondMethod); + } + [AvaloniaTest] public void BrowseBack_MenuItem_Carries_The_Alt_Left_Gesture() { diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 63d9031c02..ca88b33674 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -596,7 +596,11 @@ void ApplyNavigationTarget(NavigationEntry target) suppressHistoryRecording = true; try { - if (factory.Documents?.VisibleDockables is { } docs && docs.Contains(target.Tab)) + // Only activate a tab that is not already active: Dock's ActiveDockable setter re-runs + // InitActiveDockable -> SetFocusedDockable even for an unchanged value, which would + // move the active pane to the document on every navigation. + if (factory.Documents is { VisibleDockables: { } docs } documents + && docs.Contains(target.Tab) && !ReferenceEquals(documents.ActiveDockable, target.Tab)) factory.SetActiveDockable(target.Tab); if (target is TreeNodeEntry treeNode) { 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 @@ - +