Browse Source

Move tree drag-drop into SharpTreeView, delegating to SharpTreeNode

WPF-parity reusable drag-drop: SharpTreeView now owns the drag gesture, the Before/Inside/After hit-testing, and the insert-marker (an AdornerLayer line), and delegates the actual drop to the target SharpTreeNode.CanDrop/Drop via thin Avalonia IPlatform adapters (AvaloniaDataObject / AvaloniaPlatformDragEventArgs). The drag start uses node.CanDrag + node.Copy for the payload but is orchestrated by the control (Avalonia's DnD is async/pointer-based, unlike WPF's synchronous IPlatformDragDrop).

AssemblyTreeNode gains CanDrag + Copy (drag the assemblies' file paths); AssemblyListTreeNode gains CanDrop/Drop that opens (dedupes) + Moves to the drop index -- so reorder and external file-drop unify. Post-drop selection is a view concern, delegated back to the pane via SelectAssembliesAfterDrop. AssemblyListPane sheds all its drag/file-drop code. Reorder + file-drop tests now drive the node contract directly.
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
0ac62ead11
  1. 87
      ILSpy.Tests/AssemblyList/AssemblyTreeDragReorderTests.cs
  2. 35
      ILSpy.Tests/AssemblyList/AssemblyTreeFileDropTests.cs
  3. 10
      ILSpy/AssemblyTree/AssemblyListPane.axaml
  4. 263
      ILSpy/AssemblyTree/AssemblyListPane.axaml.cs
  5. 72
      ILSpy/Controls/TreeView/AvaloniaDragDrop.cs
  6. 198
      ILSpy/Controls/TreeView/SharpTreeView.cs
  7. 21
      ILSpy/Controls/TreeView/TreeSelectionBinder.cs
  8. 40
      ILSpy/TreeNodes/AssemblyListTreeNode.cs
  9. 16
      ILSpy/TreeNodes/AssemblyTreeNode.cs

87
ILSpy.Tests/AssemblyList/AssemblyTreeDragReorderTests.cs

@ -23,78 +23,81 @@ using Avalonia.Headless.NUnit;
using AwesomeAssertions; using AwesomeAssertions;
using ILSpy.AssemblyTree; using ILSpy.Controls.TreeView;
using ILSpy.TreeNodes; using ILSpy.TreeNodes;
using NUnit.Framework; using NUnit.Framework;
using DropPosition = ILSpy.AssemblyTree.AssemblyListPane.DropPosition;
namespace ICSharpCode.ILSpy.Tests; namespace ICSharpCode.ILSpy.Tests;
[TestFixture] [TestFixture]
public class AssemblyTreeDragReorderTests public class AssemblyTreeDragReorderTests
{ {
static AssemblyTreeNode[] TopLevel(AssemblyTreeModel model) // Reorder + file drop are handled generically by SharpTreeView, which delegates to
=> model.Root!.Children.OfType<AssemblyTreeNode>().ToArray(); // AssemblyListTreeNode.CanDrop/Drop. The drag payload is always assembly file paths, so a reorder
// re-opens (deduping to the existing LoadedAssembly) and moves to the drop index.
[AvaloniaTest] static AvaloniaPlatformDragEventArgs ReorderArgs(params string[] fileNames)
public async Task CanReorder_Accepts_A_TopLevel_Assembly_Dropped_After_Another()
{ {
var (window, vm) = await TestHarness.BootAsync(2); var data = new AvaloniaDataObject();
var pane = await window.WaitForComponent<AssemblyListPane>(); data.SetData(AssemblyTreeNode.DataFormat, fileNames);
var top = TopLevel(vm.AssemblyTreeModel); return new AvaloniaPlatformDragEventArgs(data);
pane.CanReorder(new[] { top[0] }, top[1], DropPosition.After).Should().BeTrue(
"a top-level assembly may be reordered before/after another");
} }
[AvaloniaTest] [AvaloniaTest]
public async Task Dropping_An_Assembly_After_Another_Reorders_The_AssemblyList() public async Task Dropping_An_Assembly_After_Another_Reorders_The_AssemblyList()
{ {
// End-to-end on the reorder path: "drag assembly[0] After assembly[1]" must move it through var (_, vm) = await TestHarness.BootAsync(2);
// AssemblyList.Move (the same persistence path file-open / Unload use).
var (window, vm) = await TestHarness.BootAsync(2);
var pane = await window.WaitForComponent<AssemblyListPane>();
var list = vm.AssemblyTreeModel.AssemblyList!; var list = vm.AssemblyTreeModel.AssemblyList!;
var root = (AssemblyListTreeNode)vm.AssemblyTreeModel.Root!;
var before = list.GetAssemblies(); var before = list.GetAssemblies();
var first = vm.AssemblyTreeModel.Root!.Children.OfType<AssemblyTreeNode>() var first = root.FindAssemblyNode(before[0])!;
.First(n => n.LoadedAssembly == before[0]); var second = root.FindAssemblyNode(before[1])!;
var second = vm.AssemblyTreeModel.Root!.Children.OfType<AssemblyTreeNode>()
.First(n => n.LoadedAssembly == before[1]); // "Drop first AFTER second" -> insert at second's index + 1.
var args = ReorderArgs(first.LoadedAssembly.FileName);
pane.ReorderAssemblies(new[] { first }, second, DropPosition.After).Should().BeTrue(); int afterSecond = root.Children.IndexOf(second) + 1;
root.CanDrop(args, afterSecond).Should().BeTrue();
root.Drop(args, afterSecond);
TestCapture.Step("after-reorder-first-after-second"); TestCapture.Step("after-reorder-first-after-second");
// Robust to other assemblies in the shared list: second must now come before first.
var after = list.GetAssemblies(); var after = list.GetAssemblies();
after[0].Should().BeSameAs(before[1]); System.Array.IndexOf(after, before[1]).Should().BeLessThan(System.Array.IndexOf(after, before[0]),
after[1].Should().BeSameAs(before[0]); "dropping first after second puts second ahead of first");
// Restore the original order for following tests. list.Move(new[] { before[0] }, 0); // restore original order
list.Move(new[] { after[1] }, 0);
} }
[AvaloniaTest] [AvaloniaTest]
public async Task CanReorder_Rejects_Dropping_A_Node_Onto_Itself() public async Task CanDrop_Accepts_Assembly_File_Path_Payload()
{ {
var (window, vm) = await TestHarness.BootAsync(2); var (_, vm) = await TestHarness.BootAsync(2);
var pane = await window.WaitForComponent<AssemblyListPane>(); var root = (AssemblyListTreeNode)vm.AssemblyTreeModel.Root!;
var top = TopLevel(vm.AssemblyTreeModel); root.CanDrop(ReorderArgs("anything.dll"), 0).Should().BeTrue();
}
pane.CanReorder(new[] { top[0] }, top[0], DropPosition.After).Should().BeFalse(); [AvaloniaTest]
public async Task CanDrop_Rejects_When_There_Is_No_Assembly_Or_File_Payload()
{
var (_, vm) = await TestHarness.BootAsync(2);
var root = (AssemblyListTreeNode)vm.AssemblyTreeModel.Root!;
root.CanDrop(new AvaloniaPlatformDragEventArgs(new AvaloniaDataObject()), 0).Should().BeFalse();
} }
[AvaloniaTest] [AvaloniaTest]
public async Task CanReorder_Rejects_Append_Without_A_Before_After_Target() public async Task AssemblyTreeNode_CanDrag_Only_Allows_TopLevel_NonPackage_Assemblies()
{ {
// Append (a drop on empty space / a non-assembly row, where the hit-test yields no target) var (_, vm) = await TestHarness.BootAsync(2);
// is an open, not a reorder. var root = (AssemblyListTreeNode)vm.AssemblyTreeModel.Root!;
var (window, vm) = await TestHarness.BootAsync(2); var top = root.Children.OfType<AssemblyTreeNode>().ToArray();
var pane = await window.WaitForComponent<AssemblyListPane>();
var top = TopLevel(vm.AssemblyTreeModel); top[0].CanDrag(new global::ICSharpCode.ILSpyX.TreeView.SharpTreeNode[] { top[0], top[1] })
.Should().BeTrue("top-level non-package assemblies are draggable");
pane.CanReorder(new[] { top[0] }, top[1], DropPosition.Append).Should().BeFalse();
pane.CanReorder(new[] { top[0] }, null, DropPosition.Before).Should().BeFalse(); top[0].IsExpanded = true;
await Waiters.WaitForAsync(() => top[0].Children.Count > 0);
var child = top[0].Children[0];
top[0].CanDrag(new[] { child }).Should().BeFalse("sub-nodes are not draggable");
} }
} }

35
ILSpy.Tests/AssemblyList/AssemblyTreeFileDropTests.cs

@ -29,6 +29,7 @@ using Avalonia.Input;
using AwesomeAssertions; using AwesomeAssertions;
using ILSpy.AssemblyTree; using ILSpy.AssemblyTree;
using ILSpy.Controls.TreeView;
using ILSpy.TreeNodes; using ILSpy.TreeNodes;
using NUnit.Framework; using NUnit.Framework;
@ -38,6 +39,22 @@ namespace ICSharpCode.ILSpy.Tests;
[TestFixture] [TestFixture]
public class AssemblyTreeFileDropTests public class AssemblyTreeFileDropTests
{ {
enum DropPos { Before, After }
// External file drops are handled generically by SharpTreeView -> AssemblyListTreeNode.Drop with
// the dropped paths under FileDropFormat; this drives that path the way the control would.
static void Drop(AssemblyTreeModel model, string[] files, AssemblyTreeNode? target, DropPos position)
{
var root = (AssemblyListTreeNode)model.Root!;
var data = new AvaloniaDataObject();
data.SetData(AssemblyListTreeNode.FileDropFormat, files);
var args = new AvaloniaPlatformDragEventArgs(data);
int index = target == null
? root.Children.Count
: root.Children.IndexOf(target) + (position == DropPos.After ? 1 : 0);
root.Drop(args, index);
}
[AvaloniaTest] [AvaloniaTest]
public async Task DataGrid_Is_Configured_As_A_File_Drop_Target() public async Task DataGrid_Is_Configured_As_A_File_Drop_Target()
{ {
@ -65,7 +82,7 @@ public class AssemblyTreeFileDropTests
var tempPath = CloneCoreLibToTemp(); var tempPath = CloneCoreLibToTemp();
try try
{ {
pane.HandleFileDrop(new[] { tempPath }, target: null, global::ILSpy.AssemblyTree.AssemblyListPane.DropPosition.After); Drop(vm.AssemblyTreeModel, new[] { tempPath }, target: null, DropPos.After);
TestCapture.Step("file-dropped-no-target"); TestCapture.Step("file-dropped-no-target");
var after = list.GetAssemblies(); var after = list.GetAssemblies();
@ -96,8 +113,8 @@ public class AssemblyTreeFileDropTests
var tempPath = CloneCoreLibToTemp(); var tempPath = CloneCoreLibToTemp();
try try
{ {
pane.HandleFileDrop(new[] { tempPath }, target: firstNode, Drop(vm.AssemblyTreeModel, new[] { tempPath }, target: firstNode,
global::ILSpy.AssemblyTree.AssemblyListPane.DropPosition.Before); DropPos.Before);
TestCapture.Step("file-dropped-before-first-row"); TestCapture.Step("file-dropped-before-first-row");
var after = list.GetAssemblies(); var after = list.GetAssemblies();
@ -125,8 +142,8 @@ public class AssemblyTreeFileDropTests
var tempPath = CloneCoreLibToTemp(); var tempPath = CloneCoreLibToTemp();
try try
{ {
pane.HandleFileDrop(new[] { tempPath }, target: firstNode, Drop(vm.AssemblyTreeModel, new[] { tempPath }, target: firstNode,
global::ILSpy.AssemblyTree.AssemblyListPane.DropPosition.After); DropPos.After);
TestCapture.Step("file-dropped-after-first-row"); TestCapture.Step("file-dropped-after-first-row");
var after = list.GetAssemblies(); var after = list.GetAssemblies();
@ -153,8 +170,8 @@ public class AssemblyTreeFileDropTests
var tempPath = CloneCoreLibToTemp(); var tempPath = CloneCoreLibToTemp();
try try
{ {
pane.HandleFileDrop(new[] { tempPath }, target: null, Drop(vm.AssemblyTreeModel, new[] { tempPath }, target: null,
global::ILSpy.AssemblyTree.AssemblyListPane.DropPosition.After); DropPos.After);
TestCapture.Step("file-dropped-new-node-selected"); TestCapture.Step("file-dropped-new-node-selected");
var newAsm = list.GetAssemblies() var newAsm = list.GetAssemblies()
@ -189,8 +206,8 @@ public class AssemblyTreeFileDropTests
// in the list. The cleaner non-PE check is "file simply doesn't exist". // in the list. The cleaner non-PE check is "file simply doesn't exist".
var beforeCount = list.GetAssemblies().Length; var beforeCount = list.GetAssemblies().Length;
pane.HandleFileDrop(new[] { bogusPath }, target: null, Drop(vm.AssemblyTreeModel, new[] { bogusPath }, target: null,
global::ILSpy.AssemblyTree.AssemblyListPane.DropPosition.After); DropPos.After);
TestCapture.Step("bogus-path-dropped"); TestCapture.Step("bogus-path-dropped");
// OpenAssembly does add the entry (it lazy-loads), so the count may grow. The contract // OpenAssembly does add the entry (it lazy-loads), so the count may grow. The contract

10
ILSpy/AssemblyTree/AssemblyListPane.axaml

@ -25,13 +25,5 @@
</Style> </Style>
</UserControl.Styles> </UserControl.Styles>
<Panel> <tv:SharpTreeView Name="Tree" ShowRoot="False" ShowLines="True" />
<tv:SharpTreeView Name="Tree" ShowRoot="False" ShowLines="True" />
<!-- Drag-reorder drop indicator: a thin accent line at the Before/After edge of the target
row, positioned from code in UpdateInsertMarker during a reorder DragOver. -->
<Border Name="InsertMarker" IsVisible="False" IsHitTestVisible="False"
Height="2" Width="0"
HorizontalAlignment="Left" VerticalAlignment="Top"
Background="#FF0078D7" />
</Panel>
</UserControl> </UserControl>

263
ILSpy/AssemblyTree/AssemblyListPane.axaml.cs

@ -39,9 +39,6 @@ namespace ILSpy.AssemblyTree
{ {
public partial class AssemblyListPane : UserControl public partial class AssemblyListPane : UserControl
{ {
// Where a dropped set of assemblies lands relative to the target row.
internal enum DropPosition { Before, After, Append }
ILSpy.Controls.TreeView.TreeSelectionBinder? selectionBinder; ILSpy.Controls.TreeView.TreeSelectionBinder? selectionBinder;
LanguageSettings? languageSettings; LanguageSettings? languageSettings;
IReadOnlyList<IContextMenuEntryExport> contextMenuEntries = Array.Empty<IContextMenuEntryExport>(); IReadOnlyList<IContextMenuEntryExport> contextMenuEntries = Array.Empty<IContextMenuEntryExport>();
@ -52,16 +49,6 @@ namespace ILSpy.AssemblyTree
SharpTreeViewItem? contextMenuOpenItem; SharpTreeViewItem? contextMenuOpenItem;
SharpTreeNode? contextMenuTargetNode; 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() public AssemblyListPane()
{ {
InitializeComponent(); InitializeComponent();
@ -70,19 +57,11 @@ namespace ILSpy.AssemblyTree
m.MarkTreeReady(); m.MarkTreeReady();
}; };
// Right-press marks the context target without moving selection; MMB opens a new tab. // Right-press marks the context target without moving selection; MMB opens a new tab.
// Drag-reorder + file drop are owned by SharpTreeView (delegated to the tree nodes).
Tree.AddHandler(PointerPressedEvent, OnTreePointerPressed, RoutingStrategies.Tunnel); Tree.AddHandler(PointerPressedEvent, OnTreePointerPressed, RoutingStrategies.Tunnel);
Tree.AddHandler(ContextRequestedEvent, OnTreeContextRequested, RoutingStrategies.Bubble, handledEventsToo: true); 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; Tree.KeyDown += OnTreeKeyDown;
// 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);
Tree.AddHandler(DragDrop.DragLeaveEvent, (_, _) => HideInsertMarker());
var registry = TryGetContextMenuRegistry(); var registry = TryGetContextMenuRegistry();
AttachContextMenu(registry?.Entries ?? Array.Empty<IContextMenuEntryExport>()); AttachContextMenu(registry?.Entries ?? Array.Empty<IContextMenuEntryExport>());
@ -237,76 +216,14 @@ namespace ILSpy.AssemblyTree
// Any non-right press starts a fresh gesture -- drop a stale right-click target. // Any non-right press starts a fresh gesture -- drop a stale right-click target.
contextMenuTargetNode = null; contextMenuTargetNode = null;
SetContextTargetItem(null); SetContextTargetItem(null);
pressedAssembly = null; if (point.IsMiddleButtonPressed
dragPress = null; && hit.FindAncestorOfType<SharpTreeViewItem>(includeSelf: true)?.Node is ILSpyTreeNode node)
var pressedNode = hit.FindAncestorOfType<SharpTreeViewItem>(includeSelf: true)?.Node;
if (point.IsMiddleButtonPressed && pressedNode is ILSpyTreeNode node)
{ {
OpenNodeInNewTab(node); OpenNodeInNewTab(node);
e.Handled = true; 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 #endregion
#region Keyboard (assembly-specific: Delete, Ctrl+R) #region Keyboard (assembly-specific: Delete, Ctrl+R)
@ -376,7 +293,10 @@ namespace ILSpy.AssemblyTree
{ {
model.PropertyChanged += Model_PropertyChanged; model.PropertyChanged += Model_PropertyChanged;
if (model.Root != null) if (model.Root != null)
{
Tree.Root = model.Root; Tree.Root = model.Root;
WireDropSelection(model);
}
selectionBinder = new ILSpy.Controls.TreeView.TreeSelectionBinder(Tree, model.SelectedItems); selectionBinder = new ILSpy.Controls.TreeView.TreeSelectionBinder(Tree, model.SelectedItems);
} }
} }
@ -387,171 +307,36 @@ namespace ILSpy.AssemblyTree
&& e.PropertyName == nameof(AssemblyTreeModel.Root) && model.Root != null) && e.PropertyName == nameof(AssemblyTreeModel.Root) && model.Root != null)
{ {
Tree.Root = model.Root; Tree.Root = model.Root;
WireDropSelection(model);
// The flattener rebuilt; re-apply the model selection to the new rows. // The flattener rebuilt; re-apply the model selection to the new rows.
selectionBinder?.Refresh(); selectionBinder?.Refresh();
} }
} }
#endregion // The drop logic lives on AssemblyListTreeNode (open + move); selecting the result is a view
// concern, so the node delegates it back here, where we resolve the assemblies to tree nodes
#region Drag-reorder + file drop // and push them through the model selection (which the TreeSelectionBinder reflects).
void WireDropSelection(AssemblyTreeModel model)
void OnTreeDragOver(object? sender, DragEventArgs e)
{
if (draggingAssemblies is { } dragged && e.DataTransfer.Contains(AssemblyReorderFormat))
{
var (target, position) = HitTestTopLevelRow(e);
bool valid = target != null && CanReorder(dragged, target, position);
e.DragEffects = valid ? DragDropEffects.Move : DragDropEffects.None;
UpdateInsertMarker(e, valid ? position : null);
e.Handled = true;
return;
}
if (!e.DataTransfer.Contains(DataFormat.File))
return;
e.DragEffects = DragDropEffects.Copy;
e.Handled = true;
}
void OnTreeDrop(object? sender, DragEventArgs e)
{
if (draggingAssemblies is { } dragged && e.DataTransfer.Contains(AssemblyReorderFormat))
{
HideInsertMarker();
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();
if (storageItems == null || storageItems.Length == 0)
return;
var files = storageItems
.Select(f => f.TryGetLocalPath())
.Where(p => !string.IsNullOrEmpty(p))
.Select(p => p!)
.ToList();
if (files.Count == 0)
return;
var (target, position) = HitTestTopLevelRow(e);
HandleFileDrop(files, target, position);
e.DragEffects = DragDropEffects.Copy;
e.Handled = true;
}
(AssemblyTreeNode? target, DropPosition position) HitTestTopLevelRow(DragEventArgs e)
{
if (e.Source is not Visual hit)
return (null, DropPosition.Append);
var item = hit.FindAncestorOfType<SharpTreeViewItem>(includeSelf: true);
if (item?.Node is not AssemblyTreeNode atn || atn.Parent is not AssemblyListTreeNode)
return (null, DropPosition.Append);
var pointer = e.GetPosition(item);
var position = pointer.Y < item.Bounds.Height / 2 ? DropPosition.Before : DropPosition.After;
return (atn, position);
}
// Positions the drop indicator at the top (Before) or bottom (After) edge of the target row.
void UpdateInsertMarker(DragEventArgs e, DropPosition? position)
{
if (position is not { } pos || pos == DropPosition.Append
|| (e.Source as Visual)?.FindAncestorOfType<SharpTreeViewItem>(includeSelf: true) is not { } item)
{
HideInsertMarker();
return;
}
var topLeft = item.TranslatePoint(new Point(0, 0), this) ?? default;
double y = topLeft.Y + (pos == DropPosition.After ? item.Bounds.Height : 0);
InsertMarker.Margin = new Thickness(topLeft.X, y - 1, 0, 0);
InsertMarker.Width = item.Bounds.Width;
InsertMarker.IsVisible = true;
}
void HideInsertMarker() => InsertMarker.IsVisible = false;
/// <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)
return;
var opened = new List<ICSharpCode.ILSpyX.LoadedAssembly>();
foreach (var path in files)
{
if (string.IsNullOrEmpty(path))
continue;
var asm = list.OpenAssembly(path);
if (asm != null && !opened.Contains(asm))
opened.Add(asm);
}
if (opened.Count == 0)
return;
if (target != null && position != DropPosition.Append)
{
var ordering = list.GetAssemblies();
int targetIndex = Array.IndexOf(ordering, target.LoadedAssembly);
if (targetIndex >= 0)
{
int insertIndex = position == DropPosition.After ? targetIndex + 1 : targetIndex;
list.Move(opened.ToArray(), insertIndex);
}
}
if (model.Root is not AssemblyListTreeNode listRoot) if (model.Root is not AssemblyListTreeNode listRoot)
return; return;
var newNodes = opened listRoot.SelectAssembliesAfterDrop = assemblies => {
.Select(listRoot.FindAssemblyNode) var nodes = assemblies
.Where(n => n != null) .Select(listRoot.FindAssemblyNode)
.Cast<SharpTreeNode>() .Where(n => n != null)
.ToList(); .Cast<SharpTreeNode>()
if (newNodes.Count == 0) .ToList();
return; if (nodes.Count == 0)
model.SelectedItems.Clear(); return;
foreach (var node in newNodes) model.SelectedItems.Clear();
model.SelectedItems.Add(node); foreach (var node in nodes)
model.SelectedItems.Add(node);
};
} }
#endregion #endregion
internal void OpenNodeInNewTab(ILSpyTreeNode node) internal void OpenNodeInNewTab(ILSpyTreeNode node)
{ {
try try

72
ILSpy/Controls/TreeView/AvaloniaDragDrop.cs

@ -0,0 +1,72 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Collections.Generic;
using Avalonia.Input;
using ICSharpCode.ILSpyX.TreeView.PlatformAbstractions;
namespace ILSpy.Controls.TreeView
{
/// <summary>
/// In-process <see cref="IPlatformDataObject"/> -- a simple format-to-value map. A tree node's
/// <c>Copy</c> builds one to describe a drag; <see cref="SharpTreeView"/> builds one wrapping an
/// incoming external file list so the same node <c>Drop</c> code handles both.
/// </summary>
public sealed class AvaloniaDataObject : IPlatformDataObject
{
readonly Dictionary<string, object> data = new();
public bool GetDataPresent(string format) => data.ContainsKey(format);
public object GetData(string format) => data.TryGetValue(format, out var value) ? value : null!;
public void SetData(string format, object value) => data[format] = value;
public object UnderlyingDataObject => this;
}
/// <summary>
/// Carries a drop's data + effect across the cross-platform <see cref="IPlatformDragEventArgs"/>
/// contract that <c>SharpTreeNode.CanDrop/Drop</c> consume. <see cref="SharpTreeView"/> fills in
/// <see cref="Data"/>, calls the node, then reads <see cref="Effects"/> back to set the cursor.
/// </summary>
public sealed class AvaloniaPlatformDragEventArgs : IPlatformDragEventArgs
{
public AvaloniaPlatformDragEventArgs(IPlatformDataObject data)
{
Data = data;
}
public XPlatDragDropEffects Effects { get; set; }
public IPlatformDataObject Data { get; }
}
static class DragDropEffectsExtensions
{
public static DragDropEffects ToAvalonia(this XPlatDragDropEffects e)
{
var result = DragDropEffects.None;
if ((e & XPlatDragDropEffects.Copy) != 0)
result |= DragDropEffects.Copy;
if ((e & XPlatDragDropEffects.Move) != 0)
result |= DragDropEffects.Move;
if ((e & XPlatDragDropEffects.Link) != 0)
result |= DragDropEffects.Link;
return result;
}
}
}

198
ILSpy/Controls/TreeView/SharpTreeView.cs

@ -23,11 +23,16 @@ using System.Linq;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using ICSharpCode.ILSpyX.TreeView; using ICSharpCode.ILSpyX.TreeView;
using ICSharpCode.ILSpyX.TreeView.PlatformAbstractions;
namespace ILSpy.Controls.TreeView namespace ILSpy.Controls.TreeView
{ {
@ -69,6 +74,14 @@ namespace ILSpy.Controls.TreeView
{ {
SelectionChanged += OnSelectionChanged; SelectionChanged += OnSelectionChanged;
DoubleTapped += OnDoubleTapped; DoubleTapped += OnDoubleTapped;
// Drag-drop is handled generically here and delegated to SharpTreeNode.CanDrop/Drop.
AddHandler(PointerPressedEvent, OnDragPointerPressed, RoutingStrategies.Tunnel);
AddHandler(PointerMovedEvent, OnDragPointerMoved, RoutingStrategies.Bubble, handledEventsToo: true);
AddHandler(PointerReleasedEvent, OnDragPointerReleased, RoutingStrategies.Bubble, handledEventsToo: true);
DragDrop.SetAllowDrop(this, true);
AddHandler(DragDrop.DragOverEvent, OnDragOver);
AddHandler(DragDrop.DropEvent, OnDrop);
AddHandler(DragDrop.DragLeaveEvent, (_, _) => HideInsertMarker());
} }
public SharpTreeNode? Root { public SharpTreeNode? Root {
@ -328,5 +341,190 @@ namespace ILSpy.Controls.TreeView
var selection = SelectedItems!.OfType<SharpTreeNode>().ToHashSet(); var selection = SelectedItems!.OfType<SharpTreeNode>().ToHashSet();
return selection.Where(item => item.Ancestors().All(a => !selection.Contains(a))); return selection.Where(item => item.Ancestors().All(a => !selection.Contains(a)));
} }
#region Drag and drop
// External Explorer drops are presented to the node under this format (the internal reorder
// payload carries its own node-defined format from SharpTreeNode.Copy).
const string FileDropFormat = "FileDrop";
static readonly DataFormat<string> InternalDragFormat =
DataFormat.CreateStringApplicationFormat("sharptreeview-drag");
enum DropPlace { Before, Inside, After }
SharpTreeNode[]? draggedNodes;
IPlatformDataObject? dragData;
SharpTreeNode? pressedNode;
PointerPressedEventArgs? dragPress;
Point dragStartPoint;
Border? insertMarker;
void OnDragPointerReleased(object? sender, PointerReleasedEventArgs e)
{
pressedNode = null;
dragPress = null;
}
void OnDragPointerPressed(object? sender, PointerPressedEventArgs e)
{
pressedNode = null;
dragPress = null;
if (e.Source is not Visual hit || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
return;
if (hit.FindAncestorOfType<SharpTreeViewItem>(includeSelf: true)?.Node is { } node)
{
pressedNode = node;
dragPress = e;
dragStartPoint = e.GetPosition(this);
}
}
async void OnDragPointerMoved(object? sender, PointerEventArgs e)
{
if (pressedNode is null || dragPress is not { } press)
return;
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
pressedNode = null;
dragPress = null;
return;
}
var delta = e.GetPosition(this) - dragStartPoint;
if (Math.Abs(delta.X) < 4 && Math.Abs(delta.Y) < 4)
return;
var nodes = ResolveDraggedSet(pressedNode);
pressedNode = null;
dragPress = null;
if (nodes.Length == 0 || !nodes[0].CanDrag(nodes))
return;
draggedNodes = nodes;
dragData = nodes[0].Copy(nodes);
try
{
var data = new DataTransfer();
data.Add(DataTransferItem.Create(InternalDragFormat, "1"));
await DragDrop.DoDragDropAsync(press, data, DragDropEffects.Move | DragDropEffects.Copy);
}
finally
{
draggedNodes = null;
dragData = null;
HideInsertMarker();
}
}
// The dragged set: the whole selection when the pressed row is part of it, else the pressed row.
SharpTreeNode[] ResolveDraggedSet(SharpTreeNode pressed)
{
var selection = SelectedItems!.OfType<SharpTreeNode>().ToArray();
return selection.Contains(pressed) ? selection : new[] { pressed };
}
void OnDragOver(object? sender, DragEventArgs e)
{
if (ResolveDropTarget(e) is not { } target)
{
e.DragEffects = DragDropEffects.None;
HideInsertMarker();
e.Handled = true;
return;
}
var args = new AvaloniaPlatformDragEventArgs(BuildPlatformData(e));
if (target.Node.CanDrop(args, target.Index))
{
e.DragEffects = args.Effects.ToAvalonia();
ShowInsertMarker(target.Item, target.Place);
}
else
{
e.DragEffects = DragDropEffects.None;
HideInsertMarker();
}
e.Handled = true;
}
void OnDrop(object? sender, DragEventArgs e)
{
HideInsertMarker();
if (ResolveDropTarget(e) is not { } target)
return;
var args = new AvaloniaPlatformDragEventArgs(BuildPlatformData(e));
if (target.Node.CanDrop(args, target.Index))
{
target.Node.InternalDrop(args, target.Index);
e.DragEffects = args.Effects.ToAvalonia();
}
e.Handled = true;
}
readonly record struct DropTarget(SharpTreeNode Node, int Index, DropPlace Place, SharpTreeViewItem Item);
DropTarget? ResolveDropTarget(DragEventArgs e)
{
if (e.Source is not Visual hit
|| hit.FindAncestorOfType<SharpTreeViewItem>(includeSelf: true) is not { Node: { } node } item)
return null;
double h = item.Bounds.Height;
double y = e.GetPosition(item).Y;
DropPlace place = y < h * 0.25 ? DropPlace.Before : y > h * 0.75 ? DropPlace.After : DropPlace.Inside;
switch (place)
{
case DropPlace.Inside:
return new DropTarget(node, node.Children.Count, place, item);
default:
if (node.Parent is not { } parent)
return new DropTarget(node, node.Children.Count, DropPlace.Inside, item);
int idx = parent.Children.IndexOf(node);
return new DropTarget(parent, place == DropPlace.After ? idx + 1 : idx, place, item);
}
}
IPlatformDataObject BuildPlatformData(DragEventArgs e)
{
// Internal drag: the node-built payload from Copy. External: pack the dropped file paths.
if (dragData != null)
return dragData;
var data = new AvaloniaDataObject();
if (e.DataTransfer.Contains(DataFormat.File) && e.DataTransfer.TryGetFiles() is { } storageItems)
{
var files = storageItems
.Select(f => f.TryGetLocalPath())
.Where(p => !string.IsNullOrEmpty(p))
.Select(p => p!)
.ToArray();
if (files.Length > 0)
data.SetData(FileDropFormat, files);
}
return data;
}
void ShowInsertMarker(SharpTreeViewItem item, DropPlace place)
{
if (place == DropPlace.Inside || AdornerLayer.GetAdornerLayer(this) is not { } layer)
{
HideInsertMarker();
return;
}
if (insertMarker == null)
{
insertMarker = new Border { IsHitTestVisible = false, BorderBrush = Brushes.DodgerBlue };
layer.Children.Add(insertMarker);
}
AdornerLayer.SetAdornedElement(insertMarker, item);
insertMarker.BorderThickness = place == DropPlace.After
? new Thickness(0, 0, 0, 2)
: new Thickness(0, 2, 0, 0);
insertMarker.IsVisible = true;
}
void HideInsertMarker()
{
if (insertMarker != null)
insertMarker.IsVisible = false;
}
#endregion
} }
} }

21
ILSpy/Controls/TreeView/TreeSelectionBinder.cs

@ -23,6 +23,7 @@ using System.Collections.Specialized;
using System.Linq; using System.Linq;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Threading;
using ICSharpCode.ILSpyX.TreeView; using ICSharpCode.ILSpyX.TreeView;
@ -103,17 +104,27 @@ namespace ILSpy.Controls.TreeView
var items = tree.ItemsSource as IList; var items = tree.ItemsSource as IList;
foreach (var node in modelSelection) foreach (var node in modelSelection)
{ {
tree.ScrollIntoNodeView(node); // Expand ancestors (no scroll) so the row exists in the flattener; only select
// Only select rows the flattener actually contains; adding an off-list node // rows it actually contains -- adding an off-list node corrupts the ListBox
// corrupts the ListBox SelectionModel (it throws on later enumeration). // SelectionModel (it throws on later enumeration).
foreach (var ancestor in node.Ancestors())
ancestor.IsExpanded = true;
if (items != null && items.Contains(node)) if (items != null && items.Contains(node))
{ {
tree.SelectedItems.Add(node); tree.SelectedItems.Add(node);
primary = node; primary = node;
} }
} }
if (primary != null) // Reveal + focus the primary AFTER layout settles -- a model change that also reshapes
tree.FocusNode(primary); // the tree (a reorder rebuilds the flattener) leaves the panel mid-arrange, and a
// synchronous ScrollIntoView would throw "Invalid Arrange rectangle".
if (primary is { } toReveal)
{
Dispatcher.UIThread.Post(() => {
tree.ScrollIntoNodeView(toReveal);
tree.FocusNode(toReveal);
});
}
} }
finally finally
{ {

40
ILSpy/TreeNodes/AssemblyListTreeNode.cs

@ -17,6 +17,7 @@
// DEALINGS IN THE SOFTWARE. // DEALINGS IN THE SOFTWARE.
using System; using System;
using System.Collections.Generic;
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Linq; using System.Linq;
@ -62,5 +63,44 @@ namespace ILSpy.TreeNodes
public AssemblyTreeNode? FindAssemblyNode(MetadataFile? module) public AssemblyTreeNode? FindAssemblyNode(MetadataFile? module)
=> module == null ? null : FindAssemblyNode(module.GetLoadedAssembly()); => module == null ? null : FindAssemblyNode(module.GetLoadedAssembly());
// Drop target for top-level assemblies (handled generically by SharpTreeView). The payload is
// always a set of file paths -- from a reorder drag (AssemblyTreeNode.Copy) or an external
// file drop -- so both unify: open each (OpenAssembly dedupes already-loaded ones) then Move
// to the drop index. View-level selection of the result is delegated back via the seam below.
internal Action<IReadOnlyList<LoadedAssembly>>? SelectAssembliesAfterDrop;
// Set by SharpTreeView for an external Explorer file drop (the internal reorder payload uses
// AssemblyTreeNode.DataFormat); both carry a string[] of file paths.
internal const string FileDropFormat = "FileDrop";
public override bool CanDrop(
ICSharpCode.ILSpyX.TreeView.PlatformAbstractions.IPlatformDragEventArgs e, int index)
{
if (e.Data.GetDataPresent(AssemblyTreeNode.DataFormat) || e.Data.GetDataPresent(FileDropFormat))
{
e.Effects = ICSharpCode.ILSpyX.TreeView.PlatformAbstractions.XPlatDragDropEffects.Move;
return true;
}
e.Effects = ICSharpCode.ILSpyX.TreeView.PlatformAbstractions.XPlatDragDropEffects.None;
return false;
}
public override void Drop(
ICSharpCode.ILSpyX.TreeView.PlatformAbstractions.IPlatformDragEventArgs e, int index)
{
if ((e.Data.GetData(AssemblyTreeNode.DataFormat) ?? e.Data.GetData(FileDropFormat)) is not string[] files)
return;
var opened = files
.Where(f => !string.IsNullOrEmpty(f))
.Select(f => assemblyList.OpenAssembly(f))
.Where(a => a != null)
.Distinct()
.ToArray();
if (opened.Length == 0)
return;
assemblyList.Move(opened, index);
SelectAssembliesAfterDrop?.Invoke(opened);
}
} }
} }

16
ILSpy/TreeNodes/AssemblyTreeNode.cs

@ -33,6 +33,7 @@ using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpyX; using ICSharpCode.ILSpyX;
using ICSharpCode.ILSpyX.FileLoaders; using ICSharpCode.ILSpyX.FileLoaders;
using ICSharpCode.ILSpyX.PdbProvider; using ICSharpCode.ILSpyX.PdbProvider;
using ICSharpCode.ILSpyX.TreeView;
using ILSpy; using ILSpy;
using ILSpy.AppEnv; using ILSpy.AppEnv;
@ -74,6 +75,21 @@ namespace ILSpy.TreeNodes
{ {
} }
// Drag-drop (handled generically by SharpTreeView, which delegates to these). The drag
// payload is the assemblies' file paths so a reorder and an external file drop unify through
// AssemblyListTreeNode.Drop -> OpenAssembly (which dedupes) + Move.
internal const string DataFormat = "ILSpyAssemblies";
public override bool CanDrag(SharpTreeNode[] nodes)
=> nodes.All(n => n is AssemblyTreeNode { PackageEntry: null });
public override ICSharpCode.ILSpyX.TreeView.PlatformAbstractions.IPlatformDataObject Copy(SharpTreeNode[] nodes)
{
var data = new ILSpy.Controls.TreeView.AvaloniaDataObject();
data.SetData(DataFormat, nodes.OfType<AssemblyTreeNode>().Select(n => n.LoadedAssembly.FileName).ToArray());
return data;
}
internal AssemblyTreeNode(LoadedAssembly assembly, PackageEntry? packageEntry) internal AssemblyTreeNode(LoadedAssembly assembly, PackageEntry? packageEntry)
{ {
ArgumentNullException.ThrowIfNull(assembly); ArgumentNullException.ThrowIfNull(assembly);

Loading…
Cancel
Save