From 281c84e16008430d95cd415d4a8077de5dfe7f30 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 5 Jun 2026 08:24:45 +0200 Subject: [PATCH] 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). --- .../AssemblyTreeDragReorderTests.cs | 129 +++------------- ILSpy/AssemblyTree/AssemblyListPane.axaml.cs | 138 +++++++++++++++++- 2 files changed, 155 insertions(+), 112 deletions(-) diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeDragReorderTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeDragReorderTests.cs index 444af465d..0dbe49f0c 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyTreeDragReorderTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeDragReorderTests.cs @@ -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(); - var grid = await pane.WaitForComponent(); - - 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().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(); - var grid = await pane.WaitForComponent(); + var top = TopLevel(vm.AssemblyTreeModel); - grid.RowDropHandler.Should().BeOfType(); + 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(); - var grid = await pane.WaitForComponent(); var list = vm.AssemblyTreeModel.AssemblyList!; var before = list.GetAssemblies(); @@ -93,93 +64,37 @@ public class AssemblyTreeDragReorderTests var second = vm.AssemblyTreeModel.Root!.Children.OfType() .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(); - var grid = await pane.WaitForComponent(); + var top = TopLevel(vm.AssemblyTreeModel); - var topLevel = vm.AssemblyTreeModel.Root!.Children.OfType().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(); - var grid = await pane.WaitForComponent(); - - var topLevel = vm.AssemblyTreeModel.Root!.Children.OfType().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(); - var grid = await pane.WaitForComponent(); + var top = TopLevel(vm.AssemblyTreeModel); - var topLevel = vm.AssemblyTreeModel.Root!.Children.OfType().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(), - targetItem: target, - targetIndex: 0, - insertIndex: 0, - targetRow: null, - position: position, - isSameGrid: true, - requestedEffect: DragDropEffects.Move, - dragEventArgs: null!); } diff --git a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs index 121ce388b..b7917d308 100644 --- a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs +++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs @@ -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 AssemblyReorderFormat = + DataFormat.CreateStringApplicationFormat("ilspy-assembly-reorder"); + IReadOnlyList? draggingAssemblies; + AssemblyTreeNode? pressedAssembly; + PointerPressedEventArgs? dragPress; + Point dragStartPos; + public AssemblyListPane() { InitializeComponent(); @@ -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 // Any non-right press starts a fresh gesture -- drop a stale right-click target. contextMenuTargetNode = null; SetContextTargetItem(null); - if (point.IsMiddleButtonPressed - && hit.FindAncestorOfType(includeSelf: true)?.Node is ILSpyTreeNode node) + pressedAssembly = null; + dragPress = null; + var pressedNode = hit.FindAncestorOfType(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 ResolveDraggedAssemblies(AssemblyTreeNode pressed) + { + IEnumerable candidates = + DataContext is AssemblyTreeModel model && model.SelectedItems.Contains(pressed) + ? model.SelectedItems.OfType() + : 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 #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 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 return (atn, position); } + /// + /// Whether can be reordered to land Before/After + /// . Only top-level (non-package) assemblies reorder, never onto a + /// child or onto themselves. Mirrors the old AssemblyRowDropHandler validation. + /// + internal bool CanReorder(IReadOnlyList 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; + } + + /// + /// Moves to the slot indicated by / + /// via (the same + /// persistence path as the rest of the app). Returns false if the move isn't valid. + /// + internal bool ReorderAssemblies(IReadOnlyList 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 files, AssemblyTreeNode? target, DropPosition position) { if (DataContext is not AssemblyTreeModel model || model.AssemblyList is not { } list)