diff --git a/ILSpy.Tests/Search/SearchProgressTests.cs b/ILSpy.Tests/Search/SearchProgressTests.cs new file mode 100644 index 000000000..13e5d73bb --- /dev/null +++ b/ILSpy.Tests/Search/SearchProgressTests.cs @@ -0,0 +1,121 @@ +// 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.ComponentModel; +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Media; + +using AwesomeAssertions; + +using ICSharpCode.ILSpyX.Search; + +using ILSpy.AppEnv; +using ILSpy.Search; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Search; + +[TestFixture] +public class SearchProgressTests +{ + [AvaloniaTest] + public async Task IsSearching_Flips_True_When_A_Search_Starts_And_False_When_It_Finishes() + { + 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 = string.Empty; + search.IsSearching.Should().BeFalse("baseline: nothing running"); + + var raised = false; + PropertyChangedEventHandler handler = (_, e) => { + if (e.PropertyName == nameof(SearchPaneModel.IsSearching) && search.IsSearching) + raised = true; + }; + search.PropertyChanged += handler; + try + { + search.SelectedSearchMode = search.SearchModes.First(m => m.Mode == SearchMode.Type); + search.SearchTerm = "Enumerable"; + + // Either we already captured the transition, or it'll fire on the next dispatcher + // pump — wait briefly. + await Waiters.WaitForAsync(() => raised || search.IsSearching, + timeout: TimeSpan.FromSeconds(5)); + (raised || search.IsSearching).Should().BeTrue( + "the spinner must light up while the orchestrator is running"); + + await Waiters.WaitForAsync(() => !search.IsSearching, timeout: TimeSpan.FromSeconds(30)); + search.IsSearching.Should().BeFalse("the spinner must turn off once the run completes"); + } + finally + { + search.PropertyChanged -= handler; + search.SearchTerm = string.Empty; + } + } + + [AvaloniaTest] + public async Task SearchResult_Image_Properties_Are_Non_Null_So_Row_Icons_Render() + { + 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 = string.Empty; + search.SelectedSearchMode = search.SearchModes.First(m => m.Mode == SearchMode.Type); + search.SearchTerm = "Enumerable"; + + await Waiters.WaitForAsync(() => search.Results.Any(), timeout: TimeSpan.FromSeconds(30)); + + var first = search.Results.First(); + ((object?)first.Image).Should().NotBeNull( + "every search result needs a glyph in the Name column"); + (first.Image is IImage).Should().BeTrue( + "Image must be an Avalonia IImage so the row template's renders it"); + + search.SearchTerm = string.Empty; + } + + [AvaloniaTest] + public async Task SearchPane_Hosts_A_Progress_Indicator_Bound_To_IsSearching() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var pane = await window.WaitForComponent(); + + var progress = pane.FindControl("SearchProgress"); + ((object?)progress).Should().NotBeNull( + "the pane must host a progress indicator the user can see while a search runs"); + progress!.IsIndeterminate.Should().BeTrue( + "the indicator runs in indeterminate mode — we don't know the total work up front"); + } +} diff --git a/ILSpy/Search/RunningSearch.cs b/ILSpy/Search/RunningSearch.cs index 3ae627550..db6bfebc2 100644 --- a/ILSpy/Search/RunningSearch.cs +++ b/ILSpy/Search/RunningSearch.cs @@ -79,6 +79,14 @@ namespace ILSpy.Search public bool IsCompleted => runTask is { IsCompleted: true }; + /// + /// Fires on the UI thread when the run finishes (success, error, or cancellation). + /// Always fires exactly once — the search-pane VM uses it to flip its IsSearching + /// flag back to false. Subscribers are invoked AFTER the drain loop has flushed + /// the queue so post-completion result reads see the final state. + /// + public event Action? Completed; + public void Start() { var token = cts.Token; @@ -221,14 +229,28 @@ namespace ILSpy.Search }); } if (runTask is { IsCompleted: true } && queue.IsEmpty) + { + RaiseCompletedOnUIThread(); return; + } await Task.Delay(50, ct).ConfigureAwait(false); } } catch (OperationCanceledException) { - // Expected when the run is replaced or cleared. + // Expected when the run is replaced or cleared. Still raise Completed so the + // pane's IsSearching flag flips off — cancellation is a kind of completion + // from the VM's perspective. + RaiseCompletedOnUIThread(); } } + + void RaiseCompletedOnUIThread() + { + var handler = Completed; + if (handler == null) + return; + Dispatcher.UIThread.Post(() => handler(this)); + } } } diff --git a/ILSpy/Search/SearchPane.axaml b/ILSpy/Search/SearchPane.axaml index 8a5e3117a..08af2edeb 100644 --- a/ILSpy/Search/SearchPane.axaml +++ b/ILSpy/Search/SearchPane.axaml @@ -6,7 +6,7 @@ mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="200" x:Class="ILSpy.Search.SearchPane" x:DataType="search:SearchPaneModel"> - + - + + + - - - + + + + + + + + + + + + diff --git a/ILSpy/Search/SearchPaneModel.cs b/ILSpy/Search/SearchPaneModel.cs index a0067e62d..ce19978d7 100644 --- a/ILSpy/Search/SearchPaneModel.cs +++ b/ILSpy/Search/SearchPaneModel.cs @@ -107,6 +107,16 @@ namespace ILSpy.Search /// public ObservableCollection Results { get; } = new(); + /// + /// True while the background search is in flight. Bound to the pane's + /// ProgressBar.IsIndeterminate so the user sees activity for long-running + /// scans (large assembly lists can take a few seconds). Flips to true at + /// and back to false when + /// fires. + /// + [ObservableProperty] + public partial bool IsSearching { get; set; } + /// /// User clicked (or double-tapped) a result row. Walks the result's Reference /// to the matching assembly-tree node via @@ -134,6 +144,7 @@ namespace ILSpy.Search currentSearch?.Cancel(); currentSearch = null; Results.Clear(); + IsSearching = false; var term = SearchTerm ?? string.Empty; @@ -158,7 +169,7 @@ namespace ILSpy.Search ?? ApiVisibility.PublicOnly; var factory = new AvaloniaSearchResultFactory(language); - currentSearch = new RunningSearch( + var run = new RunningSearch( assemblyList.GetAssemblies(), term, SelectedSearchMode.Mode, @@ -166,7 +177,18 @@ namespace ILSpy.Search apiVisibility, factory, Results); - currentSearch.Start(); + run.Completed += OnRunCompleted; + currentSearch = run; + IsSearching = true; + run.Start(); + } + + void OnRunCompleted(RunningSearch sender) + { + // Ignore late completions from cancelled runs — those are noise. + if (!ReferenceEquals(sender, currentSearch)) + return; + IsSearching = false; } static AssemblyTreeModel? TryGetAssemblyTreeModel()