diff --git a/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs b/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs index 981cd9d67..ce2a1a6c7 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 AwesomeAssertions; 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 class BrowseBackForwardCommandTests "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 63d9031c0..ca88b3367 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -596,7 +596,11 @@ namespace ICSharpCode.ILSpy.Docking 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 1e51d0250..f65eb5d80 100644 --- a/ILSpy/Views/MainWindow.axaml +++ b/ILSpy/Views/MainWindow.axaml @@ -20,8 +20,8 @@ - +