diff --git a/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs b/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs index 124eea5bd4..cadcc62478 100644 --- a/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs @@ -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 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 async Task Every_Analyzer_Tree_Row_Surfaces_A_Non_Null_Icon() } } + [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() + .GetEntry(nameof(Resources.Analyze)); + var analyzerVm = AppComposition.Current.GetExport(); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + typeNode.IsExpanded = true; + var method = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Empty").MethodDefinition; + + entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { typeNode } }); + var rootRow = analyzerVm.Root.Children.OfType().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().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().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() + .GetEntry(nameof(Resources.Analyze)); + var analyzerVm = AppComposition.Current.GetExport(); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { typeNode } }); + var rootRow = analyzerVm.Root.Children.OfType().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(); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + typeNode.IsExpanded = true; + var method = typeNode.Children.OfType() + .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)) diff --git a/ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs b/ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs index 40d7b56b96..7b34d4ed48 100644 --- a/ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs @@ -70,6 +70,77 @@ 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 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(); + var analyzerVm = AppComposition.Current.GetExport(); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "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(); + var tree = await view.WaitForComponent(); + + 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() { 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.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/Analyzers/AnalyzeContextMenuEntry.cs b/ILSpy/Analyzers/AnalyzeContextMenuEntry.cs index f1193965e1..f191135cae 100644 --- a/ILSpy/Analyzers/AnalyzeContextMenuEntry.cs +++ b/ILSpy/Analyzers/AnalyzeContextMenuEntry.cs @@ -29,9 +29,11 @@ namespace ICSharpCode.ILSpy.Analyzers { /// /// Right-click → "Analyze" — pushes every selected member (type, method, field, property, - /// event) into the analyzer pane. The pane's - /// dedupes entries by + 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 dedupes entries by + /// + parent module so re-running the menu on the same + /// entity just refocuses the existing row. /// [ExportContextMenuEntry( Header = nameof(Resources.Analyze), @@ -54,7 +56,12 @@ public AnalyzeContextMenuEntry(AnalyzerTreeViewModel analyzerTreeViewModel, Dock 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; } diff --git a/ILSpy/Analyzers/AnalyzerEntityTreeNode.cs b/ILSpy/Analyzers/AnalyzerEntityTreeNode.cs index 6ab9bf119f..5b1d127463 100644 --- a/ILSpy/Analyzers/AnalyzerEntityTreeNode.cs +++ b/ILSpy/Analyzers/AnalyzerEntityTreeNode.cs @@ -30,6 +30,7 @@ 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 /// per-entity root row plus every analyser result row underneath an /// ). 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 is what lets the member-based + /// context-menu entries (Analyze, Copy name, ...) act on analyzer rows like on assembly-tree rows. /// - 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. diff --git a/ILSpy/Analyzers/AnalyzerTreeViewModel.cs b/ILSpy/Analyzers/AnalyzerTreeViewModel.cs index 3a46f80359..cc796902bc 100644 --- a/ILSpy/Analyzers/AnalyzerTreeViewModel.cs +++ b/ILSpy/Analyzers/AnalyzerTreeViewModel.cs @@ -86,10 +86,12 @@ public AnalyzerEntityTreeNode Analyze(IEntity entity) 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) diff --git a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs index d781850d3c..9f8202fc64 100644 --- a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs +++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs @@ -232,26 +232,12 @@ void OnTreePointerPressed(object? sender, PointerPressedEventArgs e) #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().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() @@ -269,23 +255,6 @@ void OnTreeKeyDown(object? sender, KeyEventArgs e) } } - 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 diff --git a/ILSpy/Controls/TreeView/SharpTreeView.cs b/ILSpy/Controls/TreeView/SharpTreeView.cs index 83ddbfbeb2..f148f6f9ff 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 @@ -307,6 +328,11 @@ protected override void OnKeyDown(KeyEventArgs e) 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(includeSelf: true)?.Node ?? SelectedItem as SharpTreeNode; if (node != null && e.KeyModifiers == KeyModifiers.None) @@ -361,6 +387,28 @@ protected override void OnKeyDown(KeyEventArgs e) base.OnKeyDown(e); } + /// + /// Deletes the top-level selection (see ) 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. + /// + 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 @@ void OnSearchTimeout(object? sender, EventArgs e) searchBuffer = string.Empty; } - /// Selected items with no selected ancestor (used by Delete). + /// Selected items with no selected ancestor. public IEnumerable GetTopLevelSelection() { var selection = SelectedItems!.OfType().ToHashSet(); 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/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) 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 @@ - +