Browse Source

Implement assembly drag-reorder on SharpTreeView

Ports assembly reorder onto the ListBox-based tree using Avalonia's DragDrop pipeline: a left-drag off a top-level assembly row starts DragDrop.DoDragDropAsync carrying a marker DataFormat (the dragged set is held in a field -- it's an internal move), DragOver/Drop dispatch reorder vs. file-drop on that marker, and the reorder itself goes through AssemblyList.Move via the new CanReorder/ReorderAssemblies methods (same validation as the old AssemblyRowDropHandler: top-level non-package assemblies only, never Inside/onto-self). The reorder tests now drive CanReorder/ReorderAssemblies directly instead of ProDataGrid's RowDropHandler, and are no longer [Ignore]d (4 passing).

Not yet ported: the drag insert-marker line (drop feedback is the Move cursor for now).
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
281c84e160
  1. 129
      ILSpy.Tests/AssemblyList/AssemblyTreeDragReorderTests.cs
  2. 138
      ILSpy/AssemblyTree/AssemblyListPane.axaml.cs

129
ILSpy.Tests/AssemblyList/AssemblyTreeDragReorderTests.cs

@ -19,72 +19,43 @@ @@ -19,72 +19,43 @@
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.DataGridDragDrop;
using Avalonia.Headless.NUnit;
using Avalonia.Input;
using Avalonia.VisualTree;
using AwesomeAssertions;
using ICSharpCode.ILSpyX;
using ILSpy.AssemblyTree;
using ILSpy.TreeNodes;
using NUnit.Framework;
using DropPosition = ILSpy.AssemblyTree.AssemblyListPane.DropPosition;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
[Ignore("Assembly drag-reorder on the ListBox-based SharpTreeView is not yet implemented (these "
+ "test the old ProDataGrid RowDropHandler mechanism). Tracked as a follow-up; the reorder "
+ "logic in AssemblyRowDropHandler is retained to port onto the new drag pipeline.")]
public class AssemblyTreeDragReorderTests
{
[AvaloniaTest]
public async Task AssemblyListPane_Enables_Row_Reorder_On_The_DataGrid()
{
// Mirrors WPF's SharpTreeView AllowDropOrder=True — the assembly tree must opt in to
// ProDataGrid's row-drag-drop machinery (otherwise the handler we wire below is never
// asked to validate anything).
var (window, vm) = await TestHarness.BootAsync();
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
grid.CanUserReorderRows.Should().BeTrue();
grid.RowDragHandle.Should().Be(DataGridRowDragHandle.Row,
"the assembly tree has no row-headers so the drag gesture must originate from the row body");
// Regression — ProDataGrid's row-drag controller short-circuits when IsReadOnly is true
// (DataGridRowDragDropController.ShouldHandlePointer + DataGridHierarchicalRowReorderHandler
// both bail on grid.IsReadOnly). Read-only intent moved onto the column instead so cells
// stay uneditable without disabling drag.
grid.IsReadOnly.Should().BeFalse();
grid.Columns[0].IsReadOnly.Should().BeTrue();
}
static AssemblyTreeNode[] TopLevel(AssemblyTreeModel model)
=> model.Root!.Children.OfType<AssemblyTreeNode>().ToArray();
[AvaloniaTest]
public async Task AssemblyListPane_Wires_AssemblyRowDropHandler_With_The_Live_AssemblyList()
public async Task CanReorder_Accepts_A_TopLevel_Assembly_Dropped_After_Another()
{
// The pane owns the handler instance — it builds one from the model's AssemblyList so
// dropping into the grid mutates the same list that file-open and Unload mutate.
var (window, vm) = await TestHarness.BootAsync();
var (window, vm) = await TestHarness.BootAsync(2);
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var top = TopLevel(vm.AssemblyTreeModel);
grid.RowDropHandler.Should().BeOfType<AssemblyRowDropHandler>();
pane.CanReorder(new[] { top[0] }, top[1], DropPosition.After).Should().BeTrue(
"a top-level assembly may be reordered before/after another");
}
[AvaloniaTest]
public async Task Dropping_An_Assembly_After_Another_Reorders_The_AssemblyList()
{
// End-to-end on the live drop handler: simulate "drag row[1] After row[0]" and verify
// the underlying AssemblyList reordered. The handler is responsible for turning
// HierarchicalNode wrappers (or bare AssemblyTreeNodes) into LoadedAssembly refs and
// calling AssemblyList.Move with the correct insert index.
// End-to-end on the reorder path: "drag assembly[0] After assembly[1]" must move it through
// AssemblyList.Move (the same persistence path file-open / Unload use).
var (window, vm) = await TestHarness.BootAsync(2);
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var list = vm.AssemblyTreeModel.AssemblyList!;
var before = list.GetAssemblies();
@ -93,93 +64,37 @@ public class AssemblyTreeDragReorderTests @@ -93,93 +64,37 @@ public class AssemblyTreeDragReorderTests
var second = vm.AssemblyTreeModel.Root!.Children.OfType<AssemblyTreeNode>()
.First(n => n.LoadedAssembly == before[1]);
var handler = (AssemblyRowDropHandler)grid.RowDropHandler;
// "Drop first AFTER second" → ordering should become [second, first, ...rest].
var args = MakeArgs(items: new object[] { first }, target: second,
position: DataGridRowDropPosition.After);
handler.Validate(args).Should().BeTrue();
handler.Execute(args).Should().BeTrue();
pane.ReorderAssemblies(new[] { first }, second, DropPosition.After).Should().BeTrue();
TestCapture.Step("after-reorder-first-after-second");
var after = list.GetAssemblies();
after[0].Should().BeSameAs(before[1]);
after[1].Should().BeSameAs(before[0]);
// Restore so subsequent tests run against the original order.
// Restore the original order for following tests.
list.Move(new[] { after[1] }, 0);
}
[AvaloniaTest]
public async Task Validate_Rejects_Inside_Position()
public async Task CanReorder_Rejects_Dropping_A_Node_Onto_Itself()
{
// "Inside" would mean dropping one assembly as a child of another — there's no such
// relationship in the model, so the handler must refuse it (the grid then renders the
// "not allowed" cursor).
var (window, vm) = await TestHarness.BootAsync(2);
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var top = TopLevel(vm.AssemblyTreeModel);
var topLevel = vm.AssemblyTreeModel.Root!.Children.OfType<AssemblyTreeNode>().ToArray();
var handler = (AssemblyRowDropHandler)grid.RowDropHandler;
var args = MakeArgs(items: new object[] { topLevel[1] }, target: topLevel[0],
position: DataGridRowDropPosition.Inside);
handler.Validate(args).Should().BeFalse();
pane.CanReorder(new[] { top[0] }, top[0], DropPosition.After).Should().BeFalse();
}
[AvaloniaTest]
public async Task Validate_Rejects_Non_TopLevel_Target()
public async Task CanReorder_Rejects_Append_Without_A_Before_After_Target()
{
// Dropping onto a child of an assembly (a namespace or type) must not reorder anything
// — that target doesn't live in AssemblyList at all.
var (window, vm) = await TestHarness.BootAsync();
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var topLevel = vm.AssemblyTreeModel.Root!.Children.OfType<AssemblyTreeNode>().First();
topLevel.IsExpanded = true;
TestCapture.Step("top-level-expanded");
var childNode = topLevel.Children.First();
var handler = (AssemblyRowDropHandler)grid.RowDropHandler;
var args = MakeArgs(items: new object[] { topLevel }, target: childNode,
position: DataGridRowDropPosition.Before);
handler.Validate(args).Should().BeFalse();
}
[AvaloniaTest]
public async Task Validate_Rejects_Dragging_Non_Assembly_Nodes()
{
// Sub-nodes (namespaces, types, etc.) must not be picked up by the reorder gesture —
// only top-level AssemblyTreeNodes are eligible source items.
// Append (a drop on empty space / a non-assembly row, where the hit-test yields no target)
// is an open, not a reorder.
var (window, vm) = await TestHarness.BootAsync(2);
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var top = TopLevel(vm.AssemblyTreeModel);
var topLevel = vm.AssemblyTreeModel.Root!.Children.OfType<AssemblyTreeNode>().ToArray();
topLevel[0].IsExpanded = true;
TestCapture.Step("first-assembly-expanded");
var childOfFirst = topLevel[0].Children.First();
var handler = (AssemblyRowDropHandler)grid.RowDropHandler;
var args = MakeArgs(items: new object[] { childOfFirst }, target: topLevel[1],
position: DataGridRowDropPosition.Before);
handler.Validate(args).Should().BeFalse();
pane.CanReorder(new[] { top[0] }, top[1], DropPosition.Append).Should().BeFalse();
pane.CanReorder(new[] { top[0] }, null, DropPosition.Before).Should().BeFalse();
}
static DataGridRowDropEventArgs MakeArgs(
object[] items, object target, DataGridRowDropPosition position)
=> new(
grid: null!,
targetList: null,
items: items,
sourceIndices: System.Array.Empty<int>(),
targetItem: target,
targetIndex: 0,
insertIndex: 0,
targetRow: null,
position: position,
isSameGrid: true,
requestedEffect: DragDropEffects.Move,
dragEventArgs: null!);
}

138
ILSpy/AssemblyTree/AssemblyListPane.axaml.cs

@ -52,6 +52,16 @@ namespace ILSpy.AssemblyTree @@ -52,6 +52,16 @@ namespace ILSpy.AssemblyTree
SharpTreeViewItem? contextMenuOpenItem;
SharpTreeNode? contextMenuTargetNode;
// Assembly drag-reorder. It's an internal move, so the dragged set is kept in a field rather
// than serialised; the DataTransfer just carries a marker so DragOver/Drop can tell a reorder
// drag from an Explorer file drop.
static readonly DataFormat<string> AssemblyReorderFormat =
DataFormat.CreateStringApplicationFormat("ilspy-assembly-reorder");
IReadOnlyList<AssemblyTreeNode>? draggingAssemblies;
AssemblyTreeNode? pressedAssembly;
PointerPressedEventArgs? dragPress;
Point dragStartPos;
public AssemblyListPane()
{
InitializeComponent();
@ -63,10 +73,12 @@ namespace ILSpy.AssemblyTree @@ -63,10 +73,12 @@ namespace ILSpy.AssemblyTree
// Right-press marks the context target without moving selection; MMB opens a new tab.
Tree.AddHandler(PointerPressedEvent, OnTreePointerPressed, RoutingStrategies.Tunnel);
Tree.AddHandler(ContextRequestedEvent, OnTreeContextRequested, RoutingStrategies.Bubble, handledEventsToo: true);
Tree.AddHandler(PointerMovedEvent, OnTreePointerMoved, RoutingStrategies.Bubble, handledEventsToo: true);
Tree.AddHandler(PointerReleasedEvent, OnTreePointerReleased, RoutingStrategies.Bubble, handledEventsToo: true);
Tree.KeyDown += OnTreeKeyDown;
// Explorer -> tree file drop (the tree only receives drops; assembly reorder is a
// separate follow-up on the new control).
// Explorer file drop AND internal assembly drag-reorder both arrive through Avalonia's
// DragDrop pipeline; OnTreeDragOver/OnTreeDrop dispatch on the data format.
DragDrop.SetAllowDrop(Tree, true);
Tree.AddHandler(DragDrop.DragOverEvent, OnTreeDragOver);
Tree.AddHandler(DragDrop.DropEvent, OnTreeDrop);
@ -225,14 +237,76 @@ namespace ILSpy.AssemblyTree @@ -225,14 +237,76 @@ namespace ILSpy.AssemblyTree
// Any non-right press starts a fresh gesture -- drop a stale right-click target.
contextMenuTargetNode = null;
SetContextTargetItem(null);
if (point.IsMiddleButtonPressed
&& hit.FindAncestorOfType<SharpTreeViewItem>(includeSelf: true)?.Node is ILSpyTreeNode node)
pressedAssembly = null;
dragPress = null;
var pressedNode = hit.FindAncestorOfType<SharpTreeViewItem>(includeSelf: true)?.Node;
if (point.IsMiddleButtonPressed && pressedNode is ILSpyTreeNode node)
{
OpenNodeInNewTab(node);
e.Handled = true;
return;
}
// Remember a top-level assembly row so a subsequent drag can reorder it.
if (point.IsLeftButtonPressed && pressedNode is AssemblyTreeNode { Parent: AssemblyListTreeNode, PackageEntry: null } asm)
{
pressedAssembly = asm;
dragPress = e;
dragStartPos = e.GetPosition(Tree);
}
}
void OnTreePointerReleased(object? sender, PointerReleasedEventArgs e)
{
pressedAssembly = null;
dragPress = null;
}
async void OnTreePointerMoved(object? sender, PointerEventArgs e)
{
if (pressedAssembly is not { } pressed || dragPress is not { } press)
return;
if (!e.GetCurrentPoint(Tree).Properties.IsLeftButtonPressed)
{
pressedAssembly = null;
dragPress = null;
return;
}
var delta = e.GetPosition(Tree) - dragStartPos;
if (Math.Abs(delta.X) < 4 && Math.Abs(delta.Y) < 4)
return;
var dragged = ResolveDraggedAssemblies(pressed);
pressedAssembly = null;
dragPress = null;
if (dragged.Count == 0)
return;
draggingAssemblies = dragged;
try
{
var data = new DataTransfer();
data.Add(DataTransferItem.Create(AssemblyReorderFormat, "1"));
await DragDrop.DoDragDropAsync(press, data, DragDropEffects.Move);
}
finally
{
draggingAssemblies = null;
}
}
// The dragged set: the whole selection when the pressed row is part of it, otherwise just
// the pressed row -- filtered to movable top-level assemblies.
IReadOnlyList<AssemblyTreeNode> ResolveDraggedAssemblies(AssemblyTreeNode pressed)
{
IEnumerable<AssemblyTreeNode> candidates =
DataContext is AssemblyTreeModel model && model.SelectedItems.Contains(pressed)
? model.SelectedItems.OfType<AssemblyTreeNode>()
: new[] { pressed };
return candidates
.Where(n => n.Parent is AssemblyListTreeNode && n.PackageEntry == null)
.ToList();
}
#endregion
#region Keyboard (assembly-specific: Delete, Ctrl+R)
@ -382,10 +456,19 @@ namespace ILSpy.AssemblyTree @@ -382,10 +456,19 @@ namespace ILSpy.AssemblyTree
#endregion
#region File drop
#region Drag-reorder + file drop
void OnTreeDragOver(object? sender, DragEventArgs e)
{
if (draggingAssemblies is { } dragged && e.DataTransfer.Contains(AssemblyReorderFormat))
{
var (target, position) = HitTestTopLevelRow(e);
e.DragEffects = target != null && CanReorder(dragged, target, position)
? DragDropEffects.Move
: DragDropEffects.None;
e.Handled = true;
return;
}
if (!e.DataTransfer.Contains(DataFormat.File))
return;
e.DragEffects = DragDropEffects.Copy;
@ -394,6 +477,15 @@ namespace ILSpy.AssemblyTree @@ -394,6 +477,15 @@ namespace ILSpy.AssemblyTree
void OnTreeDrop(object? sender, DragEventArgs e)
{
if (draggingAssemblies is { } dragged && e.DataTransfer.Contains(AssemblyReorderFormat))
{
var (reorderTarget, reorderPosition) = HitTestTopLevelRow(e);
if (reorderTarget != null)
ReorderAssemblies(dragged, reorderTarget, reorderPosition);
e.DragEffects = DragDropEffects.Move;
e.Handled = true;
return;
}
if (!e.DataTransfer.Contains(DataFormat.File))
return;
var storageItems = e.DataTransfer.TryGetFiles();
@ -424,6 +516,42 @@ namespace ILSpy.AssemblyTree @@ -424,6 +516,42 @@ namespace ILSpy.AssemblyTree
return (atn, position);
}
/// <summary>
/// Whether <paramref name="dragged"/> can be reordered to land Before/After
/// <paramref name="target"/>. Only top-level (non-package) assemblies reorder, never onto a
/// child or onto themselves. Mirrors the old AssemblyRowDropHandler validation.
/// </summary>
internal bool CanReorder(IReadOnlyList<AssemblyTreeNode> dragged, AssemblyTreeNode? target, DropPosition position)
{
if (position == DropPosition.Append || target is not { Parent: AssemblyListTreeNode } || dragged.Count == 0)
return false;
foreach (var node in dragged)
{
if (node.Parent is not AssemblyListTreeNode || node.PackageEntry != null || ReferenceEquals(node, target))
return false;
}
return true;
}
/// <summary>
/// Moves <paramref name="dragged"/> to the slot indicated by <paramref name="target"/> /
/// <paramref name="position"/> via <see cref="ICSharpCode.ILSpyX.AssemblyList.Move"/> (the same
/// persistence path as the rest of the app). Returns false if the move isn't valid.
/// </summary>
internal bool ReorderAssemblies(IReadOnlyList<AssemblyTreeNode> dragged, AssemblyTreeNode target, DropPosition position)
{
if (!CanReorder(dragged, target, position)
|| DataContext is not AssemblyTreeModel model || model.AssemblyList is not { } list)
return false;
var ordering = list.GetAssemblies();
int targetIndex = Array.IndexOf(ordering, target.LoadedAssembly);
if (targetIndex < 0)
return false;
int insertIndex = position == DropPosition.After ? targetIndex + 1 : targetIndex;
list.Move(dragged.Select(n => n.LoadedAssembly).ToArray(), insertIndex);
return true;
}
internal void HandleFileDrop(IReadOnlyList<string> files, AssemblyTreeNode? target, DropPosition position)
{
if (DataContext is not AssemblyTreeModel model || model.AssemblyList is not { } list)

Loading…
Cancel
Save