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..ba92f63c3e 100644 --- a/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs +++ b/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs @@ -20,6 +20,7 @@ using System.Threading.Tasks; using Avalonia.Controls; +using Avalonia.Headless; using Avalonia.Headless.NUnit; using Avalonia.Input; using Avalonia.VisualTree; @@ -121,6 +122,49 @@ 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 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(); + + // Act — click mouse-back anywhere in the window. + var point = new Avalonia.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 void BrowseBack_MenuItem_Carries_The_Alt_Left_Gesture() { 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/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 @@ - +