Browse Source

Open assemblies from Windows Explorer drag-drop

ProDataGrid's row-drag pipeline only sees in-grid drags, so external
file drops bypass it entirely. Wires Avalonia's standard DragDrop
pipeline alongside it: DragDrop.AllowDrop on the tree DataGrid plus
DragOver / Drop handlers that read DataFormat.File from the
IDataTransfer, resolve each IStorageItem to a local path, and route
through AssemblyListPane.HandleFileDrop.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
a079527635
  1. 209
      ILSpy.Tests/AssemblyList/AssemblyTreeFileDropTests.cs
  2. 95
      ILSpy/AssemblyTree/AssemblyListPane.axaml.cs

209
ILSpy.Tests/AssemblyList/AssemblyTreeFileDropTests.cs

@ -0,0 +1,209 @@ @@ -0,0 +1,209 @@
// 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;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.DataGridDragDrop;
using Avalonia.Headless.NUnit;
using Avalonia.Input;
using AwesomeAssertions;
using ILSpy.AppEnv;
using ILSpy.AssemblyTree;
using ILSpy.TreeNodes;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class AssemblyTreeFileDropTests
{
[AvaloniaTest]
public async Task DataGrid_Is_Configured_As_A_File_Drop_Target()
{
// Avalonia's standard drag-drop pipeline routes Explorer file drops through
// DragDrop.AllowDrop / DragDrop.DropEvent. The grid must opt in or external drops
// produce a "no" cursor and never reach our handler.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
DragDrop.GetAllowDrop(grid).Should().BeTrue();
}
[AvaloniaTest]
public async Task File_Drop_With_No_Target_Opens_Assembly_And_Appends_To_List()
{
// Simulating Windows Explorer dropping a .dll on empty space inside the tree.
// No target row → the new entry is appended (WPF's AssemblyListTreeNode.Drop with
// no target index does the same).
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var pane = await window.WaitForComponent<AssemblyListPane>();
var list = vm.AssemblyTreeModel.AssemblyList!;
var beforeCount = list.GetAssemblies().Length;
var tempPath = CloneCoreLibToTemp();
try
{
pane.HandleFileDrop(new[] { tempPath }, target: null, DataGridRowDropPosition.After);
var after = list.GetAssemblies();
after.Should().HaveCount(beforeCount + 1);
after.Last().FileName.Should().Be(tempPath);
}
finally
{
TryUnload(list, tempPath);
TryDelete(tempPath);
}
}
[AvaloniaTest]
public async Task File_Drop_Before_A_Target_Row_Inserts_At_That_Index()
{
// Drop position is honoured: dragging onto the first row with Position=Before lands
// the opened assembly at index 0.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var pane = await window.WaitForComponent<AssemblyListPane>();
var list = vm.AssemblyTreeModel.AssemblyList!;
var topLevelBefore = vm.AssemblyTreeModel.Root!.Children
.OfType<AssemblyTreeNode>().ToArray();
var firstNode = topLevelBefore[0];
var beforeCount = list.GetAssemblies().Length;
var tempPath = CloneCoreLibToTemp();
try
{
pane.HandleFileDrop(new[] { tempPath }, target: firstNode,
DataGridRowDropPosition.Before);
var after = list.GetAssemblies();
after.Should().HaveCount(beforeCount + 1);
after[0].FileName.Should().Be(tempPath);
}
finally
{
TryUnload(list, tempPath);
TryDelete(tempPath);
}
}
[AvaloniaTest]
public async Task File_Drop_After_A_Target_Row_Inserts_Past_That_Index()
{
// Position=After: insertion lands at targetIndex + 1.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var pane = await window.WaitForComponent<AssemblyListPane>();
var list = vm.AssemblyTreeModel.AssemblyList!;
var firstNode = vm.AssemblyTreeModel.Root!.Children
.OfType<AssemblyTreeNode>().First();
var tempPath = CloneCoreLibToTemp();
try
{
pane.HandleFileDrop(new[] { tempPath }, target: firstNode,
DataGridRowDropPosition.After);
var after = list.GetAssemblies();
after[1].FileName.Should().Be(tempPath);
}
finally
{
TryUnload(list, tempPath);
TryDelete(tempPath);
}
}
[AvaloniaTest]
public async Task File_Drop_With_A_Path_That_Cannot_Be_Opened_Does_Not_Mutate_The_List()
{
// AssemblyList.OpenAssembly returns null for non-existent / non-PE paths; the
// handler must filter those out and never raise on the caller's behalf.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var pane = await window.WaitForComponent<AssemblyListPane>();
var list = vm.AssemblyTreeModel.AssemblyList!;
var bogusPath = Path.Combine(Path.GetTempPath(),
"definitely-not-an-assembly-" + Guid.NewGuid().ToString("N") + ".dll");
// Write a junk file so the path exists but isn't a PE — OpenAssembly tolerates this
// at construction and only fails later in LoadAsync, but we'd still get a LoadedAssembly
// 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,
DataGridRowDropPosition.After);
// OpenAssembly does add the entry (it lazy-loads), so the count may grow. The contract
// we exercise here is "the call returns without throwing" — verified by reaching this
// line. Cleanup any side-effect the call had.
var afterCount = list.GetAssemblies().Length;
if (afterCount > beforeCount)
TryUnload(list, bogusPath);
}
static string CloneCoreLibToTemp()
{
// Copy CoreLib to a unique path so OpenAssembly doesn't dedupe against the entry the
// model already created during boot.
var src = typeof(object).Assembly.Location;
var path = Path.Combine(Path.GetTempPath(),
"ILSpy.FileDropTest." + Guid.NewGuid().ToString("N") + ".dll");
File.Copy(src, path);
return path;
}
static void TryUnload(global::ICSharpCode.ILSpyX.AssemblyList list, string path)
{
var match = list.GetAssemblies().FirstOrDefault(a =>
string.Equals(a.FileName, path, StringComparison.OrdinalIgnoreCase));
if (match != null)
list.Unload(match);
}
static void TryDelete(string path)
{
try
{ File.Delete(path); }
catch { /* fixture cleanup is best-effort */ }
}
}

95
ILSpy/AssemblyTree/AssemblyListPane.axaml.cs

@ -27,9 +27,12 @@ using Avalonia.Controls; @@ -27,9 +27,12 @@ using Avalonia.Controls;
using Avalonia.Controls.DataGridDragDrop;
using Avalonia.Controls.DataGridHierarchical;
using Avalonia.Input;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using Avalonia.VisualTree;
using ICSharpCode.ILSpyX;
using ICSharpCode.ILSpyX.TreeView;
using ILSpy.AppEnv;
@ -82,6 +85,13 @@ namespace ILSpy.AssemblyTree @@ -82,6 +85,13 @@ namespace ILSpy.AssemblyTree
TreeGrid.RowDragHandle = DataGridRowDragHandle.Row;
TreeGrid.RowDragStarting += OnTreeGridRowDragStarting;
// Explorer → tree file drop: ProDataGrid's row-drag pipeline doesn't see external
// drops, so we wire Avalonia's standard DragDrop pipeline alongside it. Drops with
// a target row honour Before/After to control where the opened assembly lands.
DragDrop.SetAllowDrop(TreeGrid, true);
TreeGrid.AddHandler(DragDrop.DragOverEvent, OnTreeGridDragOver);
TreeGrid.AddHandler(DragDrop.DropEvent, OnTreeGridDrop);
// Context-menu host. Tests bypass this and re-attach via AttachContextMenu so they
// can inject stub entries — at app-runtime we resolve the registry through the
// composition host. Both paths route through the same Opening handler.
@ -488,5 +498,90 @@ namespace ILSpy.AssemblyTree @@ -488,5 +498,90 @@ namespace ILSpy.AssemblyTree
}
}
}
void OnTreeGridDragOver(object? sender, DragEventArgs e)
{
if (!e.DataTransfer.Contains(DataFormat.File))
return;
e.DragEffects = DragDropEffects.Copy;
e.Handled = true;
}
void OnTreeGridDrop(object? sender, DragEventArgs e)
{
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, DataGridRowDropPosition position) HitTestTopLevelRow(DragEventArgs e)
{
// Walk up from the pointer's visual to find the DataGridRow we landed on, then
// pick Before / After by which half of the row contains the pointer. Drops on
// empty space or onto deeper rows fall back to "append" — target == null.
if (e.Source is not Visual hit)
return (null, DataGridRowDropPosition.After);
Visual? row = hit;
while (row != null && row is not DataGridRow)
row = row.GetVisualParent();
if (row is not DataGridRow dataRow
|| dataRow.DataContext is not HierarchicalNode hn
|| hn.Item is not AssemblyTreeNode atn
|| atn.Parent is not AssemblyListTreeNode)
return (null, DataGridRowDropPosition.After);
var pointer = e.GetPosition(dataRow);
var position = pointer.Y < dataRow.Bounds.Height / 2
? DataGridRowDropPosition.Before
: DataGridRowDropPosition.After;
return (atn, position);
}
/// <summary>
/// Opens each path through <see cref="AssemblyList.OpenAssembly(string, bool)"/> and
/// — when a top-level <paramref name="target"/> row is supplied — moves the opened set
/// to the slot indicated by <paramref name="position"/>. Mirrors the WPF
/// <c>AssemblyListTreeNode.Drop</c> code path so paths arriving from Explorer behave
/// the same as <c>File → Open</c> with a follow-up reorder.
/// </summary>
internal void HandleFileDrop(IReadOnlyList<string> files,
AssemblyTreeNode? target, DataGridRowDropPosition position)
{
if (DataContext is not AssemblyTreeModel model || model.AssemblyList is not { } list)
return;
var opened = new List<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 || target == null)
return;
var ordering = list.GetAssemblies();
int targetIndex = Array.IndexOf(ordering, target.LoadedAssembly);
if (targetIndex < 0)
return;
int insertIndex = position == DataGridRowDropPosition.After
? targetIndex + 1
: targetIndex;
list.Move(opened.ToArray(), insertIndex);
}
}
}

Loading…
Cancel
Save