diff --git a/ILSpy.Tests/AssemblyList/DropSelectionBatchingTests.cs b/ILSpy.Tests/AssemblyList/DropSelectionBatchingTests.cs new file mode 100644 index 000000000..bafece793 --- /dev/null +++ b/ILSpy.Tests/AssemblyList/DropSelectionBatchingTests.cs @@ -0,0 +1,87 @@ +// 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 System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; + +using AwesomeAssertions; + +using ICSharpCode.ILSpyX.TreeView; + +using ILSpy.AssemblyTree; +using ILSpy.TreeNodes; +using ILSpy.Views; + +using Avalonia.Headless.NUnit; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +/// +/// Selecting the assemblies that were just dropped / reordered must go through the model's batched +/// SelectNodes, not a raw Clear()+Add(). A raw swap flashes a transient EMPTY selection, which the +/// model documents as poisoning the grid-sync deferred guard (the tree stops following tab +/// activation). This pins that the drop-selection callback never emits an empty selection mid-change. +/// +[TestFixture] +public class DropSelectionBatchingTests +{ + [AvaloniaTest] + public async Task Drop_Selection_Does_Not_Flash_An_Empty_Selection() + { + var (window, vm) = await TestHarness.BootAsync(3); + await window.WaitForComponent(); + var model = vm.AssemblyTreeModel; + var listRoot = (AssemblyListTreeNode)model.Root!; + + listRoot.SelectAssembliesAfterDrop.Should().NotBeNull( + "the pane wires the after-drop selection callback when it binds"); + + var assemblies = model.AssemblyList!.GetAssemblies(); + assemblies.Length.Should().BeGreaterThanOrEqualTo(2); + + // Start from a non-empty selection so a raw Clear() would transit through empty. + model.SelectedItem = listRoot.FindAssemblyNode(assemblies[0]); + + var observed = new List(); + void OnChanged(object? _, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(AssemblyTreeModel.SelectedItem)) + observed.Add(model.SelectedItem); + } + model.PropertyChanged += OnChanged; + try + { + // Drive the after-drop selection the way AssemblyListTreeNode.Drop does. + listRoot.SelectAssembliesAfterDrop!(new[] { assemblies[0], assemblies[1] }); + } + finally + { + model.PropertyChanged -= OnChanged; + } + + observed.Should().NotContainNulls( + "the drop selection must not flash an empty SelectedItem mid-change"); + observed.Should().ContainSingle( + "the batched selection change fans out exactly once, with the final set"); + model.SelectedItems.Should().HaveCount(2, "both dropped assemblies end up selected"); + } +} diff --git a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs index 9e1f4d48e..3716df141 100644 --- a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs +++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs @@ -334,9 +334,10 @@ namespace ILSpy.AssemblyTree .ToList(); if (nodes.Count == 0) return; - model.SelectedItems.Clear(); - foreach (var node in nodes) - model.SelectedItems.Add(node); + // Go through the batched setter, not a raw Clear()+Add(): the latter flashes a + // transient empty selection that poisons the grid-sync deferred guard (the tree + // stops following tab activation). + model.SelectNodes(nodes); }; }