diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs index da9955c26..fe6ad6f13 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs @@ -38,6 +38,7 @@ using ILSpy; using ILSpy.AppEnv; using ILSpy.AssemblyTree; using ILSpy.Commands; +using ILSpy.Docking; using ILSpy.TreeNodes; using ILSpy.ViewModels; using ILSpy.Views; @@ -1090,4 +1091,45 @@ public class AssemblyTreeTests hm.FindNode(assemblyNode)!.IsExpanded.Should().BeTrue( "setting SharpTreeNode.IsExpanded must propagate to the HierarchicalModel wrapper"); } + + [AvaloniaTest] + public async Task Pane_OpenNodeInNewTab_Spawns_A_Fresh_Decompiler_Tab_Without_Touching_Selection() + { + // MMB on a tree row maps to AssemblyListPane.OpenNodeInNewTab — a new decompiler + // tab opens with the supplied node decompiled, the existing tab keeps its content, + // and the assembly-tree selection is unchanged (MMB doesn't alter the selection). + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + typeNode.IsExpanded = true; + var pinned = typeNode.Children.OfType() + .Single(m => m.MethodDefinition.Name == "AsEnumerable"); + var newTabTarget = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Empty"); + vm.AssemblyTreeModel.SelectNode(pinned); + var firstTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType().Any()); + var pane = await window.WaitForComponent(); + + var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; + var initialCount = documents.VisibleDockables?.Count ?? 0; + + pane.OpenNodeInNewTab(newTabTarget); + + await Waiters.WaitForAsync( + () => (documents.VisibleDockables?.Count ?? 0) > initialCount); + var newTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + ReferenceEquals(newTab, firstTab).Should().BeFalse( + "a fresh decompiler tab must be created instead of reusing the existing one"); + newTab.Text.Should().Contain("Empty"); + firstTab.Text.Should().Contain("AsEnumerable"); + // Selection didn't move — pinned remains the model's selection. + ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, pinned).Should().BeTrue( + "middle-click must not move the assembly-tree selection"); + } } diff --git a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs index 3c05268d3..96c0391db 100644 --- a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs +++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs @@ -32,6 +32,9 @@ using Avalonia.VisualTree; using ICSharpCode.ILSpyX.TreeView; using ILSpy.AppEnv; +using ILSpy.Docking; +using ILSpy.Languages; +using ILSpy.TextView; using ILSpy.TreeNodes; namespace ILSpy.AssemblyTree @@ -49,6 +52,10 @@ namespace ILSpy.AssemblyTree InitializeComponent(); TreeGrid.DoubleTapped += OnTreeGridDoubleTapped; TreeGrid.KeyDown += OnTreeGridKeyDown; + // Tunnelling subscription so the MMB is observed before the DataGrid's own + // pointer handling claims it. + TreeGrid.AddHandler(PointerPressedEvent, OnTreeGridPointerPressed, + global::Avalonia.Interactivity.RoutingStrategies.Tunnel); // Context-menu host. Tests bypass this and re-attach via AttachContextMenu so they // can inject stub entries — at app-runtime we resolve the registry through the @@ -319,6 +326,49 @@ namespace ILSpy.AssemblyTree } } + void OnTreeGridPointerPressed(object? sender, PointerPressedEventArgs e) + { + // Middle-click on a tree row → "open in new tab", matching the WPF gesture + // (DecompileInNewViewCommand advertises InputGestureText = "MMB"). MMB does + // not move the tree's selection on its own, so hit-test the row from e.Source + // rather than reading SelectedItem. + if (e.Source is not Visual hit + || !e.GetCurrentPoint(hit).Properties.IsMiddleButtonPressed) + return; + var visual = hit; + while (visual != null && visual.DataContext is not HierarchicalNode) + visual = visual.GetVisualParent(); + if (visual?.DataContext is not HierarchicalNode hn || hn.Item is not ILSpyTreeNode node) + return; + OpenNodeInNewTab(node); + e.Handled = true; + } + + /// + /// Opens in a fresh decompiler tab without disturbing the + /// active one. Shared between the MMB handler above and any test that wants to + /// drive the new-tab path without simulating real pointer input. + /// + internal void OpenNodeInNewTab(ILSpyTreeNode node) + { + DockWorkspace? dock; + LanguageService? langService; + try + { + dock = AppComposition.Current.GetExport(); + langService = AppComposition.Current.GetExport(); + } + catch + { + // Composition isn't available in design-time previews; the gesture is a + // no-op there. + return; + } + var content = new DecompilerTabPageModel { Language = langService.CurrentLanguage }; + dock.OpenNewTab(content); + content.CurrentNodes = new[] { node }; + } + protected override void OnDataContextChanged(System.EventArgs e) { base.OnDataContextChanged(e); diff --git a/ILSpy/Views/MetadataTablePage.axaml.cs b/ILSpy/Views/MetadataTablePage.axaml.cs index b56d12dd6..c0251cb34 100644 --- a/ILSpy/Views/MetadataTablePage.axaml.cs +++ b/ILSpy/Views/MetadataTablePage.axaml.cs @@ -59,22 +59,45 @@ namespace ILSpy.Views AttachContextMenu(TryGetContextMenuEntries()); var grid = this.FindControl("Grid"); if (grid is not null) + { grid.DoubleTapped += OnGridDoubleTapped; + // Subscribe at the tunnelling phase so the middle-click is observed before + // the DataGrid's own selection / scroll handling can swallow it. + grid.AddHandler(PointerPressedEvent, OnGridPointerPressed, + global::Avalonia.Interactivity.RoutingStrategies.Tunnel); + } } void OnGridDoubleTapped(object? sender, global::Avalonia.Input.TappedEventArgs e) { - // Dispatch row-activation through the page model. Shift+double-click opens - // the entity in a new tab; a plain double-click reuses the active tab via - // the regular tree-selection path. + // Plain double-click reuses the active tab — the dock workspace's row-activation + // subscriber resolves the row's metadataFile + Token to an IEntity and selects + // the matching tree node. The "open in new tab" gesture is middle-click, handled + // in OnGridPointerPressed. if (DataContext is not MetadataTablePageModel page) return; var grid = this.FindControl("Grid"); if (grid?.SelectedItem is { } row) - { - bool openInNewTab = (e.KeyModifiers & global::Avalonia.Input.KeyModifiers.Shift) != 0; - page.RaiseRowActivated(row, openInNewTab); - } + page.RaiseRowActivated(row); + } + + void OnGridPointerPressed(object? sender, PointerPressedEventArgs e) + { + // Middle-click on a row → open in a new decompiler tab. MMB doesn't change + // selection, so hit-test the row from e.Source rather than relying on + // SelectedItem (which would point at the previously-clicked row). + if (DataContext is not MetadataTablePageModel page) + return; + if (e.Source is not Visual hit + || !e.GetCurrentPoint(hit).Properties.IsMiddleButtonPressed) + return; + var visual = hit; + while (visual is not null and not DataGridRow) + visual = visual.GetVisualParent(); + if (visual is not DataGridRow row || row.DataContext is null) + return; + page.RaiseRowActivated(row.DataContext, openInNewTab: true); + e.Handled = true; } void OnKeyDown(object? sender, KeyEventArgs e)