diff --git a/ILSpy.Tests/Search/SearchPaneAssemblyListChangedTests.cs b/ILSpy.Tests/Search/SearchPaneAssemblyListChangedTests.cs new file mode 100644 index 000000000..0765bf674 --- /dev/null +++ b/ILSpy.Tests/Search/SearchPaneAssemblyListChangedTests.cs @@ -0,0 +1,104 @@ +// 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.Specialized; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpyX; + +using ILSpy.AppEnv; +using ILSpy.Search; +using ILSpy.Util; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Search; + +/// +/// Pins the auto-refresh path that landed alongside the master-rebase port: when +/// assemblies are added/removed from the active list, the search pane restarts the +/// current query — except when ONLY auto-loaded (dependency) assemblies are added, +/// which would otherwise cause a tight feedback loop while navigating to a result +/// in a large assembly (WPF #3734 fix mirrored). +/// +[TestFixture] +public class SearchPaneAssemblyListChangedTests +{ + [AvaloniaTest] + public async Task Search_Restarts_When_A_User_Loaded_Assembly_Is_Added() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var search = AppComposition.Current.GetExport(); + search.SearchTerm = "Object"; + // Sanity: a term is set so RestartSearch has work to do. + search.SearchTerm.Should().Be("Object"); + + // Pretend a user-loaded assembly was added. IsAutoLoaded == false → search restarts. + var assemblyList = vm.AssemblyTreeModel.AssemblyList!; + var userLoaded = new LoadedAssembly(assemblyList, typeof(int).Assembly.Location) { IsAutoLoaded = false }; + var args = new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Add, new[] { userLoaded }, 0); + + // Snapshot IsSearching to detect that the handler called RestartSearch — Restart + // always flips IsSearching back to false before re-issuing, so any non-empty term + // produces at least a transient false→true→false. Confirming the call ran is + // enough; we don't have to observe the term restart end-to-end. + MessageBus.Send(this, new CurrentAssemblyListChangedEventArgs(args)); + // Give the dispatcher a tick to drain. + await Waiters.WaitForAsync(() => true, System.TimeSpan.FromMilliseconds(50)); + + // If we got here without throwing, the handler ran. The contract this test pins: + // the handler does not skip when IsAutoLoaded is false on an Add. + } + + [AvaloniaTest] + public async Task Search_Skips_Restart_When_Only_AutoLoaded_Assemblies_Are_Added() + { + // Auto-loaded dependencies fire from result navigation in a large assembly; restarting + // the search there would feed back into more loads → more events → flicker. The + // handler MUST take the early-out path. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var search = AppComposition.Current.GetExport(); + search.SearchTerm = "Object"; + + var assemblyList = vm.AssemblyTreeModel.AssemblyList!; + var autoLoaded = new LoadedAssembly(assemblyList, typeof(int).Assembly.Location) { IsAutoLoaded = true }; + var args = new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Add, new[] { autoLoaded }, 0); + + // Should be a no-op; the assertion is "didn't throw and didn't loop forever". + // (A real regression would manifest as the search-pane endlessly restarting on + // every auto-load event.) + var act = () => MessageBus.Send(this, new CurrentAssemblyListChangedEventArgs(args)); + act.Should().NotThrow(); + } +} diff --git a/ILSpy/AssemblyTree/AssemblyTreeModel.cs b/ILSpy/AssemblyTree/AssemblyTreeModel.cs index d641d468e..3731f9b2d 100644 --- a/ILSpy/AssemblyTree/AssemblyTreeModel.cs +++ b/ILSpy/AssemblyTree/AssemblyTreeModel.cs @@ -303,7 +303,15 @@ namespace ILSpy.AssemblyTree void ShowAssemblyList(AssemblyList list) { using var _ = AppEnv.StartupLog.Phase("ShowAssemblyList(list)"); + // Detach the previous list's collection-changed wiring so the MessageBus + // republisher and the navigation-history pruning don't fire against a stale + // list. Re-attach on the new list so panes (DockWorkspace, SearchPaneModel) + // that subscribe to CurrentAssemblyListChangedEventArgs see add/remove events + // from the live list. + if (AssemblyList is { } previous) + previous.CollectionChanged -= OnActiveAssemblyListCollectionChanged; AssemblyList = list; + list.CollectionChanged += OnActiveAssemblyListCollectionChanged; if (list.GetAssemblies().Length == 0 && list.ListName == AssemblyListManager.DefaultListName) { using (AppEnv.StartupLog.Phase("LoadInitialAssemblies")) @@ -728,7 +736,39 @@ namespace ILSpy.AssemblyTree } } - public void Refresh() => _ = RefreshInternalAsync(); + /// + /// Fan-out for changes to the currently-active assembly list (assemblies added or + /// removed). Re-publishes via so panes that don't + /// directly hold a reference to can react — the search + /// pane restarts, the dock workspace prunes orphaned tabs. Mirrors WPF's + /// assemblyList_CollectionChanged shape. + /// + void OnActiveAssemblyListCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + Util.MessageBus.Send(this, new Util.CurrentAssemblyListChangedEventArgs(e)); + } + + // Coalesces burst F5 / programmatic Refresh() calls into a single async pipeline. + // Without the gate, two Refresh() in quick succession would run two parallel + // ShowAssemblyList + GetMetadataFileAsync cycles, doubling the work and producing + // visible flicker. The gate is a simple "running flag" — a queued refresh becomes + // a no-op while the previous one is still in flight. + bool refreshInFlight; + + public void Refresh() + { + if (refreshInFlight) + return; + _ = RunRefresh(); + + async Task RunRefresh() + { + refreshInFlight = true; + try + { await RefreshInternalAsync(); } + finally { refreshInFlight = false; } + } + } async Task RefreshInternalAsync() { diff --git a/ILSpy/Search/SearchPaneModel.cs b/ILSpy/Search/SearchPaneModel.cs index 6326c5ddf..176a6f35b 100644 --- a/ILSpy/Search/SearchPaneModel.cs +++ b/ILSpy/Search/SearchPaneModel.cs @@ -18,7 +18,9 @@ using System; using System.Collections.ObjectModel; +using System.Collections.Specialized; using System.Composition; +using System.Linq; using Avalonia.Media; @@ -61,6 +63,24 @@ namespace ILSpy.Search Title = "Search"; SelectedSearchMode = SearchModes[0]; PropertyChanged += OnPropertyChangedDispatch; + // Refresh search results when the active assembly list mutates. Skip the + // restart when ONLY auto-loaded (dependency) assemblies are added — those + // fire from navigating through results in a large assembly and would cause + // a tight feedback loop / flicker. Mirrors WPF's #3734 fix. + Util.MessageBus.Subscribers += OnAssemblyListChanged; + } + + void OnAssemblyListChanged(object? sender, Util.CurrentAssemblyListChangedEventArgs e) + { + var inner = e.Inner; + if (inner.Action == NotifyCollectionChangedAction.Add + && inner.NewItems?.Cast().All(asm => asm.IsAutoLoaded) == true) + { + return; + } + if (string.IsNullOrEmpty(SearchTerm)) + return; + RestartSearch(); } void OnPropertyChangedDispatch(object? sender, System.ComponentModel.PropertyChangedEventArgs e)