Browse Source

Render result icons and a running-search progress strip

Two regressions from WPF parity:

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
a58cf591ae
  1. 121
      ILSpy.Tests/Search/SearchProgressTests.cs
  2. 24
      ILSpy/Search/RunningSearch.cs
  3. 40
      ILSpy/Search/SearchPane.axaml
  4. 26
      ILSpy/Search/SearchPaneModel.cs

121
ILSpy.Tests/Search/SearchProgressTests.cs

@ -0,0 +1,121 @@ @@ -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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var search = AppComposition.Current.GetExport<SearchPaneModel>();
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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var search = AppComposition.Current.GetExport<SearchPaneModel>();
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 <Image Source=\"...\"> renders it");
search.SearchTerm = string.Empty;
}
[AvaloniaTest]
public async Task SearchPane_Hosts_A_Progress_Indicator_Bound_To_IsSearching()
{
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var pane = await window.WaitForComponent<SearchPane>();
var progress = pane.FindControl<ProgressBar>("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");
}
}

24
ILSpy/Search/RunningSearch.cs

@ -79,6 +79,14 @@ namespace ILSpy.Search @@ -79,6 +79,14 @@ namespace ILSpy.Search
public bool IsCompleted => runTask is { IsCompleted: true };
/// <summary>
/// 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.
/// </summary>
public event Action<RunningSearch>? Completed;
public void Start()
{
var token = cts.Token;
@ -221,14 +229,28 @@ namespace ILSpy.Search @@ -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));
}
}
}

40
ILSpy/Search/SearchPane.axaml

@ -6,7 +6,7 @@ @@ -6,7 +6,7 @@
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="200"
x:Class="ILSpy.Search.SearchPane"
x:DataType="search:SearchPaneModel">
<Grid RowDefinitions="Auto,*" Margin="4">
<Grid RowDefinitions="Auto,Auto,*" Margin="4">
<Grid Grid.Row="0" ColumnDefinitions="*,8,Auto" Margin="0,0,0,4">
<TextBox Grid.Column="0" Name="SearchInput"
PlaceholderText="Search…"
@ -22,15 +22,45 @@ @@ -22,15 +22,45 @@
</ComboBox.ItemTemplate>
</ComboBox>
</Grid>
<ListBox Grid.Row="1" Name="SearchResults"
<!-- Indeterminate progress strip: lights up while RunningSearch is in flight. Mirrors
WPF's searchProgressBar; the height is just enough to be noticed without stealing
space from the results list. -->
<ProgressBar Grid.Row="1" Name="SearchProgress"
IsIndeterminate="True"
IsVisible="{Binding IsSearching}"
Height="3" Margin="0,0,0,2"
BorderThickness="0" />
<ListBox Grid.Row="2" Name="SearchResults"
ItemsSource="{Binding Results}"
x:CompileBindings="False">
<ListBox.ItemTemplate>
<DataTemplate>
<!-- Three icon+text cells: Name | Location | Assembly. Each cell is a
two-column Grid so the icon doesn't compress when the text gets
ellipsised. -->
<Grid ColumnDefinitions="*,8,Auto,8,Auto" Margin="2,1">
<TextBlock Grid.Column="0" Text="{Binding Name}" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Text="{Binding Location}" Foreground="Gray" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="4" Text="{Binding Assembly}" Foreground="Gray" TextTrimming="CharacterEllipsis" />
<Grid Grid.Column="0" ColumnDefinitions="Auto,4,*">
<Image Grid.Column="0" Source="{Binding Image}" Width="16" Height="16"
VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Text="{Binding Name}"
ToolTip.Tip="{Binding ToolTip}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
</Grid>
<Grid Grid.Column="2" ColumnDefinitions="Auto,4,*">
<Image Grid.Column="0" Source="{Binding LocationImage}" Width="16" Height="16"
VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Text="{Binding Location}" Foreground="Gray"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
</Grid>
<Grid Grid.Column="4" ColumnDefinitions="Auto,4,*">
<Image Grid.Column="0" Source="{Binding AssemblyImage}" Width="16" Height="16"
VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Text="{Binding Assembly}" Foreground="Gray"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
</Grid>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>

26
ILSpy/Search/SearchPaneModel.cs

@ -107,6 +107,16 @@ namespace ILSpy.Search @@ -107,6 +107,16 @@ namespace ILSpy.Search
/// </summary>
public ObservableCollection<SearchResult> Results { get; } = new();
/// <summary>
/// True while the background search is in flight. Bound to the pane's
/// <c>ProgressBar.IsIndeterminate</c> so the user sees activity for long-running
/// scans (large assembly lists can take a few seconds). Flips to true at
/// <see cref="RunningSearch.Start"/> and back to false when
/// <see cref="RunningSearch.Completed"/> fires.
/// </summary>
[ObservableProperty]
public partial bool IsSearching { get; set; }
/// <summary>
/// User clicked (or double-tapped) a result row. Walks the result's <c>Reference</c>
/// to the matching assembly-tree node via <see cref="AssemblyTreeModel.FindTreeNode"/>
@ -134,6 +144,7 @@ namespace ILSpy.Search @@ -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 @@ -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 @@ -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()

Loading…
Cancel
Save