Browse Source

Middle-click opens entity in a new decompiler tab

Wires MMB on the assembly tree and the metadata grid to the existing "open in
new tab" path, matching the WPF gesture (DecompileInNewViewCommand carries
InputGestureText = "MMB"). Plain double-click reverts to reusing the active tab;
the unreliable Shift+double-click variant is dropped.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
f7d6197cd0
  1. 42
      ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs
  2. 50
      ILSpy/AssemblyTree/AssemblyListPane.axaml.cs
  3. 35
      ILSpy/Views/MetadataTablePage.axaml.cs

42
ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs

@ -38,6 +38,7 @@ using ILSpy; @@ -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 @@ -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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
typeNode.IsExpanded = true;
var pinned = typeNode.Children.OfType<MethodTreeNode>()
.Single(m => m.MethodDefinition.Name == "AsEnumerable");
var newTabTarget = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Empty");
vm.AssemblyTreeModel.SelectNode(pinned);
var firstTab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<AssemblyListPane>().Any());
var pane = await window.WaitForComponent<AssemblyListPane>();
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");
}
}

50
ILSpy/AssemblyTree/AssemblyListPane.axaml.cs

@ -32,6 +32,9 @@ using Avalonia.VisualTree; @@ -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 @@ -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 @@ -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;
}
/// <summary>
/// Opens <paramref name="node"/> 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.
/// </summary>
internal void OpenNodeInNewTab(ILSpyTreeNode node)
{
DockWorkspace? dock;
LanguageService? langService;
try
{
dock = AppComposition.Current.GetExport<DockWorkspace>();
langService = AppComposition.Current.GetExport<LanguageService>();
}
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);

35
ILSpy/Views/MetadataTablePage.axaml.cs

@ -59,22 +59,45 @@ namespace ILSpy.Views @@ -59,22 +59,45 @@ namespace ILSpy.Views
AttachContextMenu(TryGetContextMenuEntries());
var grid = this.FindControl<DataGrid>("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<DataGrid>("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)

Loading…
Cancel
Save