From 9132a0960bba5c9f419cbaf5ff6173cfba0d5760 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Fri, 21 Aug 2026 08:17:50 +0200 Subject: [PATCH] Delete key removes the selected tree rows again (#4030) The WPF SharpTreeView bound ApplicationCommands.Delete at class level, so Delete deleted the top-level selection of any tree whose nodes opt in via CanDelete/Delete -- which is how a top-level analyzer entry was removed from the Analyzer pane. The Avalonia tree never received that binding; only the assembly list pane carried a hand-rolled Delete handler for assemblies, so the analyzer pane lost the key entirely even though its nodes still implement the deletion overrides. Moving the gesture back into SharpTreeView restores it for every tree and lets the pane-specific handler (with its own reselect logic) go. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Analyzers/AnalyzerTreeKeyboardTests.cs | 38 +++++++++++++++++++ ILSpy/AssemblyTree/AssemblyListPane.axaml.cs | 33 +--------------- ILSpy/Controls/TreeView/SharpTreeView.cs | 29 +++++++++++++- 3 files changed, 67 insertions(+), 33 deletions(-) diff --git a/ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs b/ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs index 662c54b4d..7b34d4ed4 100644 --- a/ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs @@ -103,6 +103,44 @@ public class AnalyzerTreeKeyboardTests 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/AssemblyTree/AssemblyListPane.axaml.cs b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs index d781850d3..9f8202fc6 100644 --- a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs +++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs @@ -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().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 @@ 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 diff --git a/ILSpy/Controls/TreeView/SharpTreeView.cs b/ILSpy/Controls/TreeView/SharpTreeView.cs index 1babaf41a..f148f6f9f 100644 --- a/ILSpy/Controls/TreeView/SharpTreeView.cs +++ b/ILSpy/Controls/TreeView/SharpTreeView.cs @@ -328,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(includeSelf: true)?.Node ?? SelectedItem as SharpTreeNode; if (node != null && e.KeyModifiers == KeyModifiers.None) @@ -382,6 +387,28 @@ namespace ICSharpCode.ILSpy.Controls.TreeView 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) @@ -447,7 +474,7 @@ namespace ICSharpCode.ILSpy.Controls.TreeView 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();