Browse Source

Merge pull request #4039 from icsharpcode/fix/ui-regressions-4027-4028-4030

Fix five Avalonia UI regressions: mouse navigation, derived types, Enter, Delete, Analyze in the Analyzer pane
pull/4045/head
Christoph Wille 3 weeks ago committed by GitHub
parent
commit
88dd08a947
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 90
      ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs
  2. 71
      ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs
  3. 29
      ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs
  4. 117
      ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs
  5. 15
      ILSpy/Analyzers/AnalyzeContextMenuEntry.cs
  6. 6
      ILSpy/Analyzers/AnalyzerEntityTreeNode.cs
  7. 10
      ILSpy/Analyzers/AnalyzerTreeViewModel.cs
  8. 33
      ILSpy/AssemblyTree/AssemblyListPane.axaml.cs
  9. 50
      ILSpy/Controls/TreeView/SharpTreeView.cs
  10. 6
      ILSpy/Docking/DockWorkspace.cs
  11. 12
      ILSpy/TreeNodes/DerivedTypesEntryNode.cs
  12. 4
      ILSpy/Views/MainWindow.axaml
  13. 37
      ILSpy/Views/MainWindow.axaml.cs

90
ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs

@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
// DEALINGS IN THE SOFTWARE.
using System.Linq;
using System.Reflection.Metadata;
using System.Threading.Tasks;
using Avalonia.Headless.NUnit;
@ -25,10 +26,12 @@ using AwesomeAssertions; @@ -25,10 +26,12 @@ using AwesomeAssertions;
using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpyX;
using ICSharpCode.ILSpyX.TreeView;
using ICSharpCode.ILSpy;
using ICSharpCode.ILSpy.Analyzers;
using ICSharpCode.ILSpy.Analyzers.TreeNodes;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.TextView;
using ICSharpCode.ILSpy.TreeNodes;
@ -238,6 +241,93 @@ public class AnalyzeContextMenuTests @@ -238,6 +241,93 @@ public class AnalyzeContextMenuTests
}
}
[AvaloniaTest]
public async Task Analyze_Promotes_An_Analyzer_Result_Row_To_A_Top_Level_Entry()
{
// Right-click on a result row inside the analyzer pane (a "Used By" hit, say) must
// offer Analyze and, on Execute, add that row's entity as a new top-level entry.
var (_, vm) = await TestHarness.BootAsync();
var entry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>()
.GetEntry(nameof(Resources.Analyze));
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
typeNode.IsExpanded = true;
var method = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Empty").MethodDefinition;
entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { typeNode } });
var rootRow = analyzerVm.Root.Children.OfType<AnalyzerEntityTreeNode>().Last();
rootRow.EnsureLazyChildren();
// A result row lives underneath an analyzer-search header, never directly under the root.
var resultRow = new AnalyzedMethodTreeNode(method, typeNode.Member);
rootRow.Children.OfType<AnalyzerSearchTreeNode>().First().Children.Add(resultRow);
var context = new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { resultRow } };
entry.IsVisible(context).Should().BeTrue("an analyzer result row wraps an entity, so Analyze must be offered");
entry.IsEnabled(context).Should().BeTrue();
var before = analyzerVm.Root.Children.Count;
entry.Execute(context);
TestCapture.Step("result-row-analyzed");
analyzerVm.Root.Children.Count.Should().Be(before + 1, "the result row's entity must become a top-level entry");
var promoted = analyzerVm.Root.Children.OfType<AnalyzerEntityTreeNode>().Last();
promoted.Member.Should().BeSameAs(method);
((object)analyzerVm.SelectedItems.Single()).Should().BeSameAs(promoted);
}
[AvaloniaTest]
public async Task Analyze_Is_Hidden_For_A_Top_Level_Analyzer_Row()
{
// A top-level analyzer row is already analysed; re-analysing it would be a no-op, so the
// entry stays hidden there (Remove is the entry offered for those rows).
var (_, vm) = await TestHarness.BootAsync();
var entry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>()
.GetEntry(nameof(Resources.Analyze));
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { typeNode } });
var rootRow = analyzerVm.Root.Children.OfType<AnalyzerEntityTreeNode>().Last();
entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { rootRow } })
.Should().BeFalse("a top-level analyzer row is already analysed");
}
[AvaloniaTest]
public async Task Analyze_Reuses_The_Row_When_The_Same_Entity_Comes_From_Another_Type_System()
{
// Analyzer result rows carry entities from the type system each analyzer run builds, so
// the same member reaches the pane as different IEntity/IModule instances depending on
// whether it was analysed from the assembly tree or from a result row. Both must land on
// the same top-level row.
var (_, vm) = await TestHarness.BootAsync();
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
typeNode.IsExpanded = true;
var method = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Empty").MethodDefinition;
var first = analyzerVm.Analyze(method);
var count = analyzerVm.Root.Children.Count;
var file = method.ParentModule!.MetadataFile!;
var otherTypeSystem = new DecompilerTypeSystem(file, file.GetAssemblyResolver());
var other = otherTypeSystem.MainModule.GetDefinition((MethodDefinitionHandle)method.MetadataToken);
other.ParentModule.Should().NotBeSameAs(method.ParentModule, "the test must exercise the cross-type-system case");
var second = analyzerVm.Analyze(other);
TestCapture.Step("same-entity-other-type-system");
((object)second).Should().BeSameAs(first, "the existing row must be reused");
analyzerVm.Root.Children.Count.Should().Be(count);
((object)analyzerVm.SelectedItems.Single()).Should().BeSameAs(first);
}
static AnalyzerTreeViewModel? FindAnalyzerPane(ICSharpCode.ILSpy.Docking.DockWorkspace dockWorkspace)
{
foreach (var dockable in WalkDockables(dockWorkspace.Layout))

71
ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs

@ -70,6 +70,77 @@ public class AnalyzerTreeKeyboardTests @@ -70,6 +70,77 @@ public class AnalyzerTreeKeyboardTests
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<DockWorkspace>();
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"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<ICSharpCode.ILSpy.Analyzers.AnalyzerTreeView>();
var tree = await view.WaitForComponent<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeView>();
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 Delete_Removes_The_Selected_Top_Level_Analyzer_Node()
{
// Delete on a selected top-level analyzer row removes it from the pane (the keyboard
// equivalent of the "Remove" context-menu entry). Rows below the top level are not
// deletable, so Delete on one of them leaves the pane untouched.
var (window, vm) = await TestHarness.BootAsync(3);
var dockWorkspace = AppComposition.Current.GetExport<DockWorkspace>();
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
var analyzed = analyzerVm.Analyze((ITypeDefinition)typeNode.Member!);
analyzed.IsExpanded = true;
var child = analyzed.Children.First();
dockWorkspace.ShowToolPane(AnalyzerTreeViewModel.PaneContentId);
var view = await window.WaitForComponent<ICSharpCode.ILSpy.Analyzers.AnalyzerTreeView>();
var tree = await view.WaitForComponent<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeView>();
tree.SelectedItem = child;
Dispatcher.UIThread.RunJobs();
tree.FocusNode(child);
Dispatcher.UIThread.RunJobs();
window.KeyPress(Key.Delete, RawInputModifiers.None, PhysicalKey.Delete, null);
Dispatcher.UIThread.RunJobs();
analyzed.Children.Should().Contain(child, "Delete must not remove a nested analyzer row");
analyzerVm.Root.Children.Should().Contain(analyzed, "Delete on a nested row must not remove its top-level node");
tree.SelectedItem = analyzed;
Dispatcher.UIThread.RunJobs();
tree.FocusNode(analyzed);
Dispatcher.UIThread.RunJobs();
window.KeyPress(Key.Delete, RawInputModifiers.None, PhysicalKey.Delete, null);
await Waiters.WaitForAsync(() => !analyzerVm.Root.Children.Contains(analyzed),
description: "Delete must remove the selected top-level analyzer node from the pane");
}
[AvaloniaTest]
public async Task Ctrl_R_Analyzes_The_Selected_Member()
{

29
ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs

@ -1539,6 +1539,35 @@ public class AssemblyTreeTests @@ -1539,6 +1539,35 @@ public class AssemblyTreeTests
"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<TypeTreeNode>(
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<DerivedTypesTreeNode>().Single();
derived.IsExpanded = true;
var entries = derived.Children.OfType<DerivedTypesEntryNode>().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()
{

117
ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs

@ -19,9 +19,12 @@ @@ -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; @@ -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 @@ -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<DecompilerTextView>();
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<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
typeNode.IsExpanded = true;
var firstMethod = typeNode.Children.OfType<MethodTreeNode>()
.Single(m => m.MethodDefinition.Name == "AsEnumerable");
var secondMethod = typeNode.Children.OfType<MethodTreeNode>()
.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()
{

15
ILSpy/Analyzers/AnalyzeContextMenuEntry.cs

@ -29,9 +29,11 @@ namespace ICSharpCode.ILSpy.Analyzers @@ -29,9 +29,11 @@ namespace ICSharpCode.ILSpy.Analyzers
{
/// <summary>
/// Right-click → "Analyze" — pushes every selected member (type, method, field, property,
/// event) into the analyzer pane. The pane's <see cref="AnalyzerTreeViewModel.Analyze"/>
/// dedupes entries by <see cref="IEntity.MetadataToken"/> + parent module so re-running
/// the menu on the same entity just refocuses the existing row.
/// event) into the analyzer pane, from the assembly tree, from a code reference, or from a
/// result row inside the analyzer pane itself (promoting it to a top-level entry). The
/// pane's <see cref="AnalyzerTreeViewModel.Analyze"/> dedupes entries by
/// <see cref="IEntity.MetadataToken"/> + parent module so re-running the menu on the same
/// entity just refocuses the existing row.
/// </summary>
[ExportContextMenuEntry(
Header = nameof(Resources.Analyze),
@ -54,7 +56,12 @@ namespace ICSharpCode.ILSpy.Analyzers @@ -54,7 +56,12 @@ namespace ICSharpCode.ILSpy.Analyzers
public bool IsVisible(TextViewContext context)
{
if (context.SelectedTreeNodes is { Length: > 0 } nodes)
return nodes.All(n => n is IMemberTreeNode);
{
// Top-level analyzer rows are already analysed (Remove is the entry for those);
// result rows underneath promote their entity to a new top-level row.
return nodes.All(n => n is IMemberTreeNode
&& n is not AnalyzerEntityTreeNode { Parent.IsRoot: true });
}
// Right-clicking a resolved symbol in the decompiled code: the reference carries the entity.
return context.Reference?.Reference is IEntity;
}

6
ILSpy/Analyzers/AnalyzerEntityTreeNode.cs

@ -30,6 +30,7 @@ using ICSharpCode.ILSpy.AppEnv; @@ -30,6 +30,7 @@ using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.AssemblyTree;
using ICSharpCode.ILSpy.Controls.TreeView;
using ICSharpCode.ILSpy.Themes;
using ICSharpCode.ILSpy.TreeNodes;
using ICSharpCode.ILSpy.Util;
namespace ICSharpCode.ILSpy.Analyzers
@ -39,9 +40,10 @@ namespace ICSharpCode.ILSpy.Analyzers @@ -39,9 +40,10 @@ namespace ICSharpCode.ILSpy.Analyzers
/// per-entity root row plus every analyser result row underneath an
/// <see cref="AnalyzerSearchTreeNode"/>). Concrete subclasses supply the entity, its
/// icon, and its text; this base owns the navigation hook and the assembly-change
/// pruning logic.
/// pruning logic. Implementing <see cref="IMemberTreeNode"/> is what lets the member-based
/// context-menu entries (Analyze, Copy name, ...) act on analyzer rows like on assembly-tree rows.
/// </summary>
public abstract class AnalyzerEntityTreeNode : AnalyzerTreeNode, IRichTextNode
public abstract class AnalyzerEntityTreeNode : AnalyzerTreeNode, IRichTextNode, IMemberTreeNode
{
// Flags reproducing the plain signature the pane used before highlighting: the member's
// declaring type, fully-qualified names, and the usual return-type/parameter detail.

10
ILSpy/Analyzers/AnalyzerTreeViewModel.cs

@ -86,10 +86,12 @@ namespace ICSharpCode.ILSpy.Analyzers @@ -86,10 +86,12 @@ namespace ICSharpCode.ILSpy.Analyzers
static bool IsSameEntity(IEntity? a, IEntity b)
{
if (a == null)
return false;
return a.MetadataToken == b.MetadataToken
&& ReferenceEquals(a.ParentModule, b.ParentModule);
// Entities reaching the pane come from different type systems (the assembly tree's,
// and the fresh one each analyzer run builds), so the IModule instances differ even
// for the same member; the loaded MetadataFile is the stable identity.
return a?.ParentModule?.MetadataFile is { } file
&& a.MetadataToken == b.MetadataToken
&& ReferenceEquals(file, b.ParentModule?.MetadataFile);
}
void SyncSelection(SharpTreeNode node)

33
ILSpy/AssemblyTree/AssemblyListPane.axaml.cs

@ -232,26 +232,12 @@ namespace ICSharpCode.ILSpy.AssemblyTree @@ -232,26 +232,12 @@ namespace ICSharpCode.ILSpy.AssemblyTree
#endregion
#region Keyboard (assembly-specific: Delete, Ctrl+R)
#region Keyboard (assembly-specific: Ctrl+R; Delete is handled by SharpTreeView)
void OnTreeKeyDown(object? sender, KeyEventArgs e)
{
if (DataContext is not AssemblyTreeModel model)
return;
if (e.Key == Key.Delete && e.KeyModifiers == KeyModifiers.None && model.AssemblyList is { } list)
{
var selectedAssemblyNodes = model.SelectedItems.OfType<AssemblyTreeNode>().ToList();
if (selectedAssemblyNodes.Count == 0)
return;
int reselectIndex = FlattenedIndexOf(selectedAssemblyNodes[0]);
foreach (var node in selectedAssemblyNodes)
list.Unload(node.LoadedAssembly);
e.Handled = true;
global::Avalonia.Threading.Dispatcher.UIThread.Post(
() => ReselectAfterDelete(reselectIndex),
global::Avalonia.Threading.DispatcherPriority.Background);
return;
}
if (e.Key == Key.R && e.KeyModifiers == KeyModifiers.Control)
{
var members = model.SelectedItems.OfType<IMemberTreeNode>()
@ -269,23 +255,6 @@ namespace ICSharpCode.ILSpy.AssemblyTree @@ -269,23 +255,6 @@ namespace ICSharpCode.ILSpy.AssemblyTree
}
}
System.Collections.IList? Flattened => Tree.ItemsSource as System.Collections.IList;
int FlattenedIndexOf(SharpTreeNode node) => Flattened?.IndexOf(node) ?? -1;
void ReselectAfterDelete(int index)
{
if (DataContext is not AssemblyTreeModel model)
return;
var flattened = Flattened;
if (flattened == null || flattened.Count == 0 || index < 0)
{
model.SelectNode(null);
return;
}
model.SelectNode(flattened[Math.Clamp(index, 0, flattened.Count - 1)] as SharpTreeNode);
}
#endregion
#region Selection sync

50
ILSpy/Controls/TreeView/SharpTreeView.cs

@ -295,6 +295,27 @@ namespace ICSharpCode.ILSpy.Controls.TreeView @@ -295,6 +295,27 @@ namespace ICSharpCode.ILSpy.Controls.TreeView
scrollViewer.Offset = new Vector(scrollViewer.Offset.X, newOffsetY);
}
/// <summary>
/// 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 <see cref="OnKeyDown"/> 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).
/// </summary>
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
@ -307,6 +328,11 @@ namespace ICSharpCode.ILSpy.Controls.TreeView @@ -307,6 +328,11 @@ namespace ICSharpCode.ILSpy.Controls.TreeView
e.Handled = true;
return;
}
if (e.Key == Key.Delete && e.KeyModifiers == KeyModifiers.None && DeleteSelection())
{
e.Handled = true;
return;
}
var node = (e.Source as Visual)?.FindAncestorOfType<SharpTreeViewItem>(includeSelf: true)?.Node
?? SelectedItem as SharpTreeNode;
if (node != null && e.KeyModifiers == KeyModifiers.None)
@ -361,6 +387,28 @@ namespace ICSharpCode.ILSpy.Controls.TreeView @@ -361,6 +387,28 @@ namespace ICSharpCode.ILSpy.Controls.TreeView
base.OnKeyDown(e);
}
/// <summary>
/// Deletes the top-level selection (see <see cref="GetTopLevelSelection"/>) when every node in it
/// supports deletion, then selects the row that takes the first deleted node's place so a
/// repeated Delete keeps working. Returns false without touching anything otherwise, e.g. for
/// a selection that mixes deletable and non-deletable rows.
/// </summary>
bool DeleteSelection()
{
if (flattener is null)
return false;
var nodes = GetTopLevelSelection().ToArray();
if (nodes.Length == 0 || !nodes.All(n => n.CanDelete()))
return false;
int index = nodes.Min(flattener.IndexOf);
foreach (var node in nodes)
node.Delete();
// The deleted rows leave the selection with the source; pick the nearest survivor.
if (SelectedItems!.Count == 0 && flattener.Count > 0)
SelectAndFocus((SharpTreeNode)flattener[Math.Clamp(index, 0, flattener.Count - 1)]!);
return true;
}
static void ExpandRecursively(SharpTreeNode node)
{
if (!node.CanExpandRecursively)
@ -426,7 +474,7 @@ namespace ICSharpCode.ILSpy.Controls.TreeView @@ -426,7 +474,7 @@ namespace ICSharpCode.ILSpy.Controls.TreeView
searchBuffer = string.Empty;
}
/// <summary>Selected items with no selected ancestor (used by Delete).</summary>
/// <summary>Selected items with no selected ancestor.</summary>
public IEnumerable<SharpTreeNode> GetTopLevelSelection()
{
var selection = SelectedItems!.OfType<SharpTreeNode>().ToHashSet();

6
ILSpy/Docking/DockWorkspace.cs

@ -596,7 +596,11 @@ namespace ICSharpCode.ILSpy.Docking @@ -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)
{

12
ILSpy/TreeNodes/DerivedTypesEntryNode.cs

@ -69,16 +69,18 @@ namespace ICSharpCode.ILSpy.TreeNodes @@ -69,16 +69,18 @@ namespace ICSharpCode.ILSpy.TreeNodes
};
/// <summary>
/// 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:
/// <see cref="LanguageSettings.SearchTermMatches"/> 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: <see cref="LanguageSettings.SearchTermMatches"/> is a
/// no-op so the assembly tree stays independent of the search pane.
/// </summary>
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)

4
ILSpy/Views/MainWindow.axaml

@ -20,8 +20,8 @@ @@ -20,8 +20,8 @@
</Design.DataContext>
<Window.KeyBindings>
<!-- Browser-style navigation. XButton1/2 (mouse Back/Forward) aren't first-class in
Avalonia 12 yet, so we wire keyboard only for now. -->
<!-- Browser-style keyboard navigation. The mouse back/forward buttons drive the same
commands from code-behind (KeyBindings cannot express pointer buttons). -->
<KeyBinding Gesture="Alt+Left" Command="{Binding DockWorkspace.NavigateBackCommand}" />
<KeyBinding Gesture="Alt+Right" Command="{Binding DockWorkspace.NavigateForwardCommand}" />
<!-- Search pane shortcuts. Ctrl+Shift+F is the WPF default; Ctrl+E mirrors the

37
ILSpy/Views/MainWindow.axaml.cs

@ -21,6 +21,8 @@ using System.Composition; @@ -21,6 +21,8 @@ using System.Composition;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.ViewModels;
@ -60,6 +62,16 @@ namespace ICSharpCode.ILSpy.Views @@ -60,6 +62,16 @@ namespace ICSharpCode.ILSpy.Views
// gesture that triggered the DBus call. No-op unless that category is enabled.
InputDiagnostics.Attach(this);
ICSharpCode.ILSpy.MainMenu.Attach(this);
// Mouse back/forward buttons navigate the history, like Alt+Left / Alt+Right. There is
// no KeyBinding equivalent for pointer buttons, so listen window-wide. The press is
// swallowed while tunnelling (the window is the first stop) so the control under the
// pointer never sees it as a click: Dock would activate the pane, AvaloniaEdit would
// focus the editor or toggle a folding marker. The release then navigates;
// handledEventsToo because inner controls handle pointer events for their own gestures
// without ever using the X buttons.
AddHandler(PointerPressedEvent, OnBrowserNavigationPointerPressed, RoutingStrategies.Tunnel);
AddHandler(PointerReleasedEvent, OnBrowserNavigationPointerReleased,
RoutingStrategies.Bubble, handledEventsToo: true);
ApplySessionSettings(settingsService.SessionSettings);
Opened += async (_, _) => {
AppLog.Mark("MainWindow.Opened fired");
@ -77,6 +89,31 @@ namespace ICSharpCode.ILSpy.Views @@ -77,6 +89,31 @@ namespace ICSharpCode.ILSpy.Views
AppLog.Mark("MainWindow ctor exited");
}
void OnBrowserNavigationPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.PointerUpdateKind
is PointerUpdateKind.XButton1Pressed or PointerUpdateKind.XButton2Pressed)
{
e.Handled = true;
}
}
void OnBrowserNavigationPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (DataContext is not MainWindowViewModel viewModel)
return;
var command = e.InitialPressMouseButton switch {
MouseButton.XButton1 => viewModel.DockWorkspace.NavigateBackCommand,
MouseButton.XButton2 => viewModel.DockWorkspace.NavigateForwardCommand,
_ => null
};
if (command?.CanExecute(null) == true)
{
command.Execute(null);
e.Handled = true;
}
}
static void SurfaceCompositionErrors()
{
if (!AppEnv.CompositionErrors.Any)

Loading…
Cancel
Save