From 0ac62ead111d208c79c26262d19225ac6baa5c68 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 5 Jun 2026 09:06:04 +0200 Subject: [PATCH] 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. --- .../AssemblyTreeDragReorderTests.cs | 87 +++--- .../AssemblyList/AssemblyTreeFileDropTests.cs | 35 ++- ILSpy/AssemblyTree/AssemblyListPane.axaml | 10 +- ILSpy/AssemblyTree/AssemblyListPane.axaml.cs | 263 ++---------------- ILSpy/Controls/TreeView/AvaloniaDragDrop.cs | 72 +++++ ILSpy/Controls/TreeView/SharpTreeView.cs | 198 +++++++++++++ .../Controls/TreeView/TreeSelectionBinder.cs | 21 +- ILSpy/TreeNodes/AssemblyListTreeNode.cs | 40 +++ ILSpy/TreeNodes/AssemblyTreeNode.cs | 16 ++ 9 files changed, 438 insertions(+), 304 deletions(-) create mode 100644 ILSpy/Controls/TreeView/AvaloniaDragDrop.cs diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeDragReorderTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeDragReorderTests.cs index 0dbe49f0c..99492035b 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyTreeDragReorderTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeDragReorderTests.cs @@ -23,78 +23,81 @@ using Avalonia.Headless.NUnit; using AwesomeAssertions; -using ILSpy.AssemblyTree; +using ILSpy.Controls.TreeView; using ILSpy.TreeNodes; using NUnit.Framework; -using DropPosition = ILSpy.AssemblyTree.AssemblyListPane.DropPosition; - namespace ICSharpCode.ILSpy.Tests; [TestFixture] public class AssemblyTreeDragReorderTests { - static AssemblyTreeNode[] TopLevel(AssemblyTreeModel model) - => model.Root!.Children.OfType().ToArray(); - - [AvaloniaTest] - public async Task CanReorder_Accepts_A_TopLevel_Assembly_Dropped_After_Another() + // Reorder + file drop are handled generically by SharpTreeView, which delegates to + // 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. + static AvaloniaPlatformDragEventArgs ReorderArgs(params string[] fileNames) { - var (window, vm) = await TestHarness.BootAsync(2); - var pane = await window.WaitForComponent(); - var top = TopLevel(vm.AssemblyTreeModel); - - pane.CanReorder(new[] { top[0] }, top[1], DropPosition.After).Should().BeTrue( - "a top-level assembly may be reordered before/after another"); + var data = new AvaloniaDataObject(); + data.SetData(AssemblyTreeNode.DataFormat, fileNames); + return new AvaloniaPlatformDragEventArgs(data); } [AvaloniaTest] 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 - // AssemblyList.Move (the same persistence path file-open / Unload use). - var (window, vm) = await TestHarness.BootAsync(2); - var pane = await window.WaitForComponent(); + var (_, vm) = await TestHarness.BootAsync(2); var list = vm.AssemblyTreeModel.AssemblyList!; + var root = (AssemblyListTreeNode)vm.AssemblyTreeModel.Root!; var before = list.GetAssemblies(); - var first = vm.AssemblyTreeModel.Root!.Children.OfType() - .First(n => n.LoadedAssembly == before[0]); - var second = vm.AssemblyTreeModel.Root!.Children.OfType() - .First(n => n.LoadedAssembly == before[1]); - - pane.ReorderAssemblies(new[] { first }, second, DropPosition.After).Should().BeTrue(); + var first = root.FindAssemblyNode(before[0])!; + var second = root.FindAssemblyNode(before[1])!; + + // "Drop first AFTER second" -> insert at second's index + 1. + var args = ReorderArgs(first.LoadedAssembly.FileName); + int afterSecond = root.Children.IndexOf(second) + 1; + root.CanDrop(args, afterSecond).Should().BeTrue(); + root.Drop(args, afterSecond); 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(); - after[0].Should().BeSameAs(before[1]); - after[1].Should().BeSameAs(before[0]); + System.Array.IndexOf(after, before[1]).Should().BeLessThan(System.Array.IndexOf(after, before[0]), + "dropping first after second puts second ahead of first"); - // Restore the original order for following tests. - list.Move(new[] { after[1] }, 0); + list.Move(new[] { before[0] }, 0); // restore original order } [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 pane = await window.WaitForComponent(); - var top = TopLevel(vm.AssemblyTreeModel); + var (_, vm) = await TestHarness.BootAsync(2); + var root = (AssemblyListTreeNode)vm.AssemblyTreeModel.Root!; + 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] - 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) - // is an open, not a reorder. - var (window, vm) = await TestHarness.BootAsync(2); - var pane = await window.WaitForComponent(); - var top = TopLevel(vm.AssemblyTreeModel); - - pane.CanReorder(new[] { top[0] }, top[1], DropPosition.Append).Should().BeFalse(); - pane.CanReorder(new[] { top[0] }, null, DropPosition.Before).Should().BeFalse(); + var (_, vm) = await TestHarness.BootAsync(2); + var root = (AssemblyListTreeNode)vm.AssemblyTreeModel.Root!; + var top = root.Children.OfType().ToArray(); + + top[0].CanDrag(new global::ICSharpCode.ILSpyX.TreeView.SharpTreeNode[] { top[0], top[1] }) + .Should().BeTrue("top-level non-package assemblies are draggable"); + + 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"); } } diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeFileDropTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeFileDropTests.cs index 7e391e7a4..68e46c917 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyTreeFileDropTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeFileDropTests.cs @@ -29,6 +29,7 @@ using Avalonia.Input; using AwesomeAssertions; using ILSpy.AssemblyTree; +using ILSpy.Controls.TreeView; using ILSpy.TreeNodes; using NUnit.Framework; @@ -38,6 +39,22 @@ namespace ICSharpCode.ILSpy.Tests; [TestFixture] 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] public async Task DataGrid_Is_Configured_As_A_File_Drop_Target() { @@ -65,7 +82,7 @@ public class AssemblyTreeFileDropTests var tempPath = CloneCoreLibToTemp(); 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"); var after = list.GetAssemblies(); @@ -96,8 +113,8 @@ public class AssemblyTreeFileDropTests var tempPath = CloneCoreLibToTemp(); try { - pane.HandleFileDrop(new[] { tempPath }, target: firstNode, - global::ILSpy.AssemblyTree.AssemblyListPane.DropPosition.Before); + Drop(vm.AssemblyTreeModel, new[] { tempPath }, target: firstNode, + DropPos.Before); TestCapture.Step("file-dropped-before-first-row"); var after = list.GetAssemblies(); @@ -125,8 +142,8 @@ public class AssemblyTreeFileDropTests var tempPath = CloneCoreLibToTemp(); try { - pane.HandleFileDrop(new[] { tempPath }, target: firstNode, - global::ILSpy.AssemblyTree.AssemblyListPane.DropPosition.After); + Drop(vm.AssemblyTreeModel, new[] { tempPath }, target: firstNode, + DropPos.After); TestCapture.Step("file-dropped-after-first-row"); var after = list.GetAssemblies(); @@ -153,8 +170,8 @@ public class AssemblyTreeFileDropTests var tempPath = CloneCoreLibToTemp(); 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-new-node-selected"); 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". var beforeCount = list.GetAssemblies().Length; - pane.HandleFileDrop(new[] { bogusPath }, target: null, - global::ILSpy.AssemblyTree.AssemblyListPane.DropPosition.After); + Drop(vm.AssemblyTreeModel, new[] { bogusPath }, target: null, + DropPos.After); TestCapture.Step("bogus-path-dropped"); // OpenAssembly does add the entry (it lazy-loads), so the count may grow. The contract diff --git a/ILSpy/AssemblyTree/AssemblyListPane.axaml b/ILSpy/AssemblyTree/AssemblyListPane.axaml index d1fecd052..e24621816 100644 --- a/ILSpy/AssemblyTree/AssemblyListPane.axaml +++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml @@ -25,13 +25,5 @@ - - - - - + diff --git a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs index 4ca0fc523..8e8fc8bc5 100644 --- a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs +++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs @@ -39,9 +39,6 @@ namespace ILSpy.AssemblyTree { 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; LanguageSettings? languageSettings; IReadOnlyList contextMenuEntries = Array.Empty(); @@ -52,16 +49,6 @@ 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(); @@ -70,19 +57,11 @@ namespace ILSpy.AssemblyTree m.MarkTreeReady(); }; // 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(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 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(); AttachContextMenu(registry?.Entries ?? Array.Empty()); @@ -237,76 +216,14 @@ namespace ILSpy.AssemblyTree // Any non-right press starts a fresh gesture -- drop a stale right-click target. contextMenuTargetNode = null; SetContextTargetItem(null); - pressedAssembly = null; - dragPress = null; - var pressedNode = hit.FindAncestorOfType(includeSelf: true)?.Node; - if (point.IsMiddleButtonPressed && pressedNode is ILSpyTreeNode node) + if (point.IsMiddleButtonPressed + && hit.FindAncestorOfType(includeSelf: true)?.Node 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) @@ -376,7 +293,10 @@ namespace ILSpy.AssemblyTree { model.PropertyChanged += Model_PropertyChanged; if (model.Root != null) + { Tree.Root = model.Root; + WireDropSelection(model); + } selectionBinder = new ILSpy.Controls.TreeView.TreeSelectionBinder(Tree, model.SelectedItems); } } @@ -387,171 +307,36 @@ namespace ILSpy.AssemblyTree && e.PropertyName == nameof(AssemblyTreeModel.Root) && model.Root != null) { Tree.Root = model.Root; + WireDropSelection(model); // The flattener rebuilt; re-apply the model selection to the new rows. selectionBinder?.Refresh(); } } - #endregion - - #region Drag-reorder + file drop - - 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(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(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; - - /// - /// 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) + // 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 + // and push them through the model selection (which the TreeSelectionBinder reflects). + void WireDropSelection(AssemblyTreeModel model) { - 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) - return; - var opened = new List(); - 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) return; - var newNodes = opened - .Select(listRoot.FindAssemblyNode) - .Where(n => n != null) - .Cast() - .ToList(); - if (newNodes.Count == 0) - return; - model.SelectedItems.Clear(); - foreach (var node in newNodes) - model.SelectedItems.Add(node); + listRoot.SelectAssembliesAfterDrop = assemblies => { + var nodes = assemblies + .Select(listRoot.FindAssemblyNode) + .Where(n => n != null) + .Cast() + .ToList(); + if (nodes.Count == 0) + return; + model.SelectedItems.Clear(); + foreach (var node in nodes) + model.SelectedItems.Add(node); + }; } #endregion + internal void OpenNodeInNewTab(ILSpyTreeNode node) { try diff --git a/ILSpy/Controls/TreeView/AvaloniaDragDrop.cs b/ILSpy/Controls/TreeView/AvaloniaDragDrop.cs new file mode 100644 index 000000000..dc73a2c38 --- /dev/null +++ b/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 +{ + /// + /// In-process -- a simple format-to-value map. A tree node's + /// Copy builds one to describe a drag; builds one wrapping an + /// incoming external file list so the same node Drop code handles both. + /// + public sealed class AvaloniaDataObject : IPlatformDataObject + { + readonly Dictionary 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; + } + + /// + /// Carries a drop's data + effect across the cross-platform + /// contract that SharpTreeNode.CanDrop/Drop consume. fills in + /// , calls the node, then reads back to set the cursor. + /// + 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; + } + } +} diff --git a/ILSpy/Controls/TreeView/SharpTreeView.cs b/ILSpy/Controls/TreeView/SharpTreeView.cs index 46092fe11..f738eba88 100644 --- a/ILSpy/Controls/TreeView/SharpTreeView.cs +++ b/ILSpy/Controls/TreeView/SharpTreeView.cs @@ -23,11 +23,16 @@ using System.Linq; using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Primitives; using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Platform.Storage; using Avalonia.Threading; using Avalonia.VisualTree; using ICSharpCode.ILSpyX.TreeView; +using ICSharpCode.ILSpyX.TreeView.PlatformAbstractions; namespace ILSpy.Controls.TreeView { @@ -69,6 +74,14 @@ namespace ILSpy.Controls.TreeView { SelectionChanged += OnSelectionChanged; 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 { @@ -328,5 +341,190 @@ namespace ILSpy.Controls.TreeView var selection = SelectedItems!.OfType().ToHashSet(); 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 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(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().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(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 } } diff --git a/ILSpy/Controls/TreeView/TreeSelectionBinder.cs b/ILSpy/Controls/TreeView/TreeSelectionBinder.cs index 7eb8a6e25..38d30573d 100644 --- a/ILSpy/Controls/TreeView/TreeSelectionBinder.cs +++ b/ILSpy/Controls/TreeView/TreeSelectionBinder.cs @@ -23,6 +23,7 @@ using System.Collections.Specialized; using System.Linq; using Avalonia.Controls; +using Avalonia.Threading; using ICSharpCode.ILSpyX.TreeView; @@ -103,17 +104,27 @@ namespace ILSpy.Controls.TreeView var items = tree.ItemsSource as IList; foreach (var node in modelSelection) { - tree.ScrollIntoNodeView(node); - // Only select rows the flattener actually contains; adding an off-list node - // corrupts the ListBox SelectionModel (it throws on later enumeration). + // Expand ancestors (no scroll) so the row exists in the flattener; only select + // rows it actually contains -- adding an off-list node corrupts the ListBox + // SelectionModel (it throws on later enumeration). + foreach (var ancestor in node.Ancestors()) + ancestor.IsExpanded = true; if (items != null && items.Contains(node)) { tree.SelectedItems.Add(node); primary = node; } } - if (primary != null) - tree.FocusNode(primary); + // Reveal + focus the primary AFTER layout settles -- a model change that also reshapes + // 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 { diff --git a/ILSpy/TreeNodes/AssemblyListTreeNode.cs b/ILSpy/TreeNodes/AssemblyListTreeNode.cs index 048b6dbea..05a1e9127 100644 --- a/ILSpy/TreeNodes/AssemblyListTreeNode.cs +++ b/ILSpy/TreeNodes/AssemblyListTreeNode.cs @@ -17,6 +17,7 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; @@ -62,5 +63,44 @@ namespace ILSpy.TreeNodes public AssemblyTreeNode? FindAssemblyNode(MetadataFile? module) => 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>? 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); + } } } diff --git a/ILSpy/TreeNodes/AssemblyTreeNode.cs b/ILSpy/TreeNodes/AssemblyTreeNode.cs index 4606ec5a1..21a1b9e6c 100644 --- a/ILSpy/TreeNodes/AssemblyTreeNode.cs +++ b/ILSpy/TreeNodes/AssemblyTreeNode.cs @@ -33,6 +33,7 @@ using ICSharpCode.Decompiler.TypeSystem; using ICSharpCode.ILSpyX; using ICSharpCode.ILSpyX.FileLoaders; using ICSharpCode.ILSpyX.PdbProvider; +using ICSharpCode.ILSpyX.TreeView; using ILSpy; 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().Select(n => n.LoadedAssembly.FileName).ToArray()); + return data; + } + internal AssemblyTreeNode(LoadedAssembly assembly, PackageEntry? packageEntry) { ArgumentNullException.ThrowIfNull(assembly);