diff --git a/ILSpy.Tests/Metadata/MetadataFilterTests.cs b/ILSpy.Tests/Metadata/MetadataFilterTests.cs new file mode 100644 index 000000000..0ec80d6b7 --- /dev/null +++ b/ILSpy.Tests/Metadata/MetadataFilterTests.cs @@ -0,0 +1,118 @@ +// 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.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ILSpy.AppEnv; +using ILSpy.Metadata; +using ILSpy.Metadata.CorTables; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Metadata; + +[TestFixture] +public class MetadataFilterTests +{ + sealed class SampleEntry + { + public int RID { get; set; } + public string Name { get; set; } = ""; + public string Culture { get; set; } = ""; + } + + [Test] + public void MatchesFilter_Returns_True_For_Empty_Or_Null_Filter() + { + // An empty filter must show every row — the no-filter state is the identity case. + var entry = new SampleEntry { RID = 1, Name = "System.Runtime" }; + MetadataTablePageModel.MatchesFilter(entry, null).Should().BeTrue(); + MetadataTablePageModel.MatchesFilter(entry, "").Should().BeTrue(); + } + + [Test] + public void MatchesFilter_Returns_True_When_Any_Property_Contains_The_Substring_Case_Insensitively() + { + // Filter is a case-insensitive Contains over every property's stringified value, so + // "system" matches "System.Runtime" and "neutral" matches the Culture column. + var entry = new SampleEntry { RID = 1, Name = "System.Runtime", Culture = "neutral" }; + MetadataTablePageModel.MatchesFilter(entry, "system").Should().BeTrue(); + MetadataTablePageModel.MatchesFilter(entry, "RUNTIME").Should().BeTrue(); + MetadataTablePageModel.MatchesFilter(entry, "neutral").Should().BeTrue(); + } + + [Test] + public void MatchesFilter_Returns_False_When_No_Property_Contains_The_Substring() + { + var entry = new SampleEntry { RID = 1, Name = "System.Runtime" }; + MetadataTablePageModel.MatchesFilter(entry, "Foo").Should().BeFalse(); + } + + [Test] + public void MatchesFilter_Stringifies_Numeric_Values_So_Filter_Hits_Numeric_Columns() + { + // Without stringifying, filtering "42" against a numeric RID would silently miss. + var entry = new SampleEntry { RID = 42, Name = "X" }; + MetadataTablePageModel.MatchesFilter(entry, "42").Should().BeTrue(); + } + + [AvaloniaTest] + public async Task FilterText_Reduces_Visible_Rows_To_Those_Whose_Property_Contains_The_Substring() + { + // Integration check: navigate to the TypeDef table (guaranteed populated for any + // loaded module) and confirm setting FilterText='System' shrinks the visible-row + // set, and clearing it restores the full set. + + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var coreLibName = typeof(object).Assembly.GetName().Name!; + var assemblyNode = vm.AssemblyTreeModel.FindNode(coreLibName); + assemblyNode.EnsureLazyChildren(); + var metadataNode = assemblyNode.Children.OfType().Single(); + metadataNode.EnsureLazyChildren(); + var tablesNode = metadataNode.Children.OfType().Single(); + tablesNode.EnsureLazyChildren(); + var typeDefNode = tablesNode.Children.OfType().Single(); + + vm.AssemblyTreeModel.SelectNode(typeDefNode); + var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); + + var totalCount = tab.Items.Count; + totalCount.Should().BeGreaterThan(0); + + tab.FilterText = "System"; + var visible = tab.Items.Where(e => MetadataTablePageModel.MatchesFilter(e, tab.FilterText)).ToList(); + visible.Should().NotBeEmpty("at least one TypeDef row should mention 'System'"); + visible.Count.Should().BeLessThan(totalCount, "the filter must hide at least one row"); + + tab.FilterText = ""; + var afterClear = tab.Items.Where(e => MetadataTablePageModel.MatchesFilter(e, tab.FilterText)).Count(); + afterClear.Should().Be(totalCount); + } +} diff --git a/ILSpy/ViewModels/MetadataTablePageModel.cs b/ILSpy/ViewModels/MetadataTablePageModel.cs index fff0df400..42579ba1b 100644 --- a/ILSpy/ViewModels/MetadataTablePageModel.cs +++ b/ILSpy/ViewModels/MetadataTablePageModel.cs @@ -17,7 +17,9 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Reflection; using Avalonia.Controls; @@ -47,6 +49,14 @@ namespace ILSpy.ViewModels [ObservableProperty] private int? scrollToRow; + /// + /// Free-text filter applied to the visible rows. Empty / null shows every row; + /// otherwise the view shows the rows where any property's stringified value contains + /// the filter (case-insensitive). Bound two-way to a TextBox above the grid. + /// + [ObservableProperty] + private string? filterText; + /// /// Raised when the user clicks a hyperlink-styled token cell. The host (the dock /// workspace) resolves the (row, columnName) pair to a metadata token and navigates @@ -56,6 +66,34 @@ namespace ILSpy.ViewModels internal void RaiseNavigateToCell(object row, string columnName) => NavigateToCellRequested?.Invoke(new MetadataCellNavigationEventArgs(row, columnName)); + + static readonly ConcurrentDictionary filterPropertyCache = new(); + + /// + /// Predicate used by both the view's DataGridCollectionView.Filter and tests: + /// returns when is empty or any + /// public instance property's stringified value on contains + /// the filter case-insensitively. + /// + public static bool MatchesFilter(object item, string? filter) + { + ArgumentNullException.ThrowIfNull(item); + if (string.IsNullOrEmpty(filter)) + return true; + var props = filterPropertyCache.GetOrAdd(item.GetType(), + static t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance)); + foreach (var prop in props) + { + object? value; + try + { value = prop.GetValue(item); } + catch { continue; } + var s = value?.ToString(); + if (s is not null && s.Contains(filter, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } } /// The (row, column) pair clicked in a token cell. diff --git a/ILSpy/Views/MetadataTablePage.axaml b/ILSpy/Views/MetadataTablePage.axaml index 47a8fb480..deaff0af0 100644 --- a/ILSpy/Views/MetadataTablePage.axaml +++ b/ILSpy/Views/MetadataTablePage.axaml @@ -7,19 +7,27 @@ x:Class="ILSpy.Views.MetadataTablePage" x:DataType="vm:MetadataTablePageModel"> - - + + + + + + diff --git a/ILSpy/Views/MetadataTablePage.axaml.cs b/ILSpy/Views/MetadataTablePage.axaml.cs index 9fd96f0c0..9aeb33c33 100644 --- a/ILSpy/Views/MetadataTablePage.axaml.cs +++ b/ILSpy/Views/MetadataTablePage.axaml.cs @@ -19,6 +19,7 @@ using System; using System.ComponentModel; +using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Markup.Xaml; @@ -35,6 +36,7 @@ namespace ILSpy.Views public partial class MetadataTablePage : UserControl { MetadataTablePageModel? boundModel; + DataGridCollectionView? itemsView; public MetadataTablePage() { @@ -60,6 +62,8 @@ namespace ILSpy.Views if (e.PropertyName is nameof(MetadataTablePageModel.Columns) or nameof(MetadataTablePageModel.Items)) ApplySchema(); + else if (e.PropertyName == nameof(MetadataTablePageModel.FilterText)) + itemsView?.Refresh(); else if (e.PropertyName == nameof(MetadataTablePageModel.ScrollToRow)) ApplyScrollTarget(); } @@ -76,11 +80,15 @@ namespace ILSpy.Views // the new schema has more columns than the old one. grid.ItemsSource = Array.Empty(); grid.Columns.Clear(); + itemsView = null; if (boundModel == null) return; foreach (var c in boundModel.Columns) grid.Columns.Add(c); - grid.ItemsSource = boundModel.Items; + itemsView = new DataGridCollectionView(boundModel.Items) { + Filter = item => MetadataTablePageModel.MatchesFilter(item, boundModel?.FilterText), + }; + grid.ItemsSource = itemsView; } void ApplyScrollTarget()