diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeFileDropTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeFileDropTests.cs new file mode 100644 index 000000000..60b15718c --- /dev/null +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeFileDropTests.cs @@ -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(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + var pane = await window.WaitForComponent(); + var grid = await pane.WaitForComponent(); + + 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(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + var pane = await window.WaitForComponent(); + 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(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + var pane = await window.WaitForComponent(); + var list = vm.AssemblyTreeModel.AssemblyList!; + + var topLevelBefore = vm.AssemblyTreeModel.Root!.Children + .OfType().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(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + var pane = await window.WaitForComponent(); + var list = vm.AssemblyTreeModel.AssemblyList!; + + var firstNode = vm.AssemblyTreeModel.Root!.Children + .OfType().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(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + var pane = await window.WaitForComponent(); + 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 */ } + } +} diff --git a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs index f769d303e..051c0f03a 100644 --- a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs +++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs @@ -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 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 } } } + + 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); + } + + /// + /// Opens each path through and + /// — when a top-level row is supplied — moves the opened set + /// to the slot indicated by . Mirrors the WPF + /// AssemblyListTreeNode.Drop code path so paths arriving from Explorer behave + /// the same as File → Open with a follow-up reorder. + /// + internal void HandleFileDrop(IReadOnlyList files, + AssemblyTreeNode? target, DataGridRowDropPosition 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 || 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); + } } }