Browse Source

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
pull/4039/head
Christoph Wille 4 weeks ago
parent
commit
9132a0960b
  1. 38
      ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs
  2. 33
      ILSpy/AssemblyTree/AssemblyListPane.axaml.cs
  3. 29
      ILSpy/Controls/TreeView/SharpTreeView.cs

38
ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs

@ -103,6 +103,44 @@ public class AnalyzerTreeKeyboardTests @@ -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<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()
{

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

29
ILSpy/Controls/TreeView/SharpTreeView.cs

@ -328,6 +328,11 @@ namespace ICSharpCode.ILSpy.Controls.TreeView @@ -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<SharpTreeViewItem>(includeSelf: true)?.Node
?? SelectedItem as SharpTreeNode;
if (node != null && e.KeyModifiers == KeyModifiers.None)
@ -382,6 +387,28 @@ namespace ICSharpCode.ILSpy.Controls.TreeView @@ -382,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)
@ -447,7 +474,7 @@ namespace ICSharpCode.ILSpy.Controls.TreeView @@ -447,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();

Loading…
Cancel
Save