Browse Source

Filter row above the metadata grid

FilterText on MetadataTablePageModel drives a case-insensitive Contains
predicate over each row's stringified property values; the view wraps
Items in a DataGridCollectionView so the filter applies without rebinding.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
5af7943563
  1. 118
      ILSpy.Tests/Metadata/MetadataFilterTests.cs
  2. 38
      ILSpy/ViewModels/MetadataTablePageModel.cs
  3. 36
      ILSpy/Views/MetadataTablePage.axaml
  4. 10
      ILSpy/Views/MetadataTablePage.axaml.cs

118
ILSpy.Tests/Metadata/MetadataFilterTests.cs

@ -0,0 +1,118 @@ @@ -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<MainWindow>();
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<AssemblyTreeNode>(coreLibName);
assemblyNode.EnsureLazyChildren();
var metadataNode = assemblyNode.Children.OfType<MetadataTreeNode>().Single();
metadataNode.EnsureLazyChildren();
var tablesNode = metadataNode.Children.OfType<MetadataTablesTreeNode>().Single();
tablesNode.EnsureLazyChildren();
var typeDefNode = tablesNode.Children.OfType<TypeDefTableTreeNode>().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);
}
}

38
ILSpy/ViewModels/MetadataTablePageModel.cs

@ -17,7 +17,9 @@ @@ -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 @@ -47,6 +49,14 @@ namespace ILSpy.ViewModels
[ObservableProperty]
private int? scrollToRow;
/// <summary>
/// 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.
/// </summary>
[ObservableProperty]
private string? filterText;
/// <summary>
/// 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 @@ -56,6 +66,34 @@ namespace ILSpy.ViewModels
internal void RaiseNavigateToCell(object row, string columnName)
=> NavigateToCellRequested?.Invoke(new MetadataCellNavigationEventArgs(row, columnName));
static readonly ConcurrentDictionary<Type, PropertyInfo[]> filterPropertyCache = new();
/// <summary>
/// Predicate used by both the view's <c>DataGridCollectionView.Filter</c> and tests:
/// returns <see langword="true"/> when <paramref name="filter"/> is empty or any
/// public instance property's stringified value on <paramref name="item"/> contains
/// the filter case-insensitively.
/// </summary>
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;
}
}
/// <summary>The (row, column) pair clicked in a token cell.</summary>

36
ILSpy/Views/MetadataTablePage.axaml

@ -7,19 +7,27 @@ @@ -7,19 +7,27 @@
x:Class="ILSpy.Views.MetadataTablePage"
x:DataType="vm:MetadataTablePageModel">
<!-- ItemsSource and Columns are wired imperatively in code-behind so the (clear-cols /
add-cols / set-items) sequence runs as a single transaction. Binding ItemsSource
declaratively crashes the DataGrid's measure pass when the view is reused across
content types whose schemas have a different column count, because Avalonia's
binding-system rebind happens after the imperative column rewrite. -->
<DataGrid Name="Grid"
CanUserResizeColumns="True"
CanUserSortColumns="True"
CanUserReorderColumns="False"
IsReadOnly="True"
GridLinesVisibility="None"
HeadersVisibility="Column"
SelectionMode="Single"
AutoGenerateColumns="False" />
<DockPanel LastChildFill="True">
<TextBox Name="FilterBox"
DockPanel.Dock="Top"
PlaceholderText="Filter (case-insensitive substring match across columns)"
Margin="4,4,4,2"
Text="{Binding FilterText, Mode=TwoWay}" />
<!-- ItemsSource and Columns are wired imperatively in code-behind so the (clear-cols /
add-cols / set-items) sequence runs as a single transaction. Binding ItemsSource
declaratively crashes the DataGrid's measure pass when the view is reused across
content types whose schemas have a different column count, because Avalonia's
binding-system rebind happens after the imperative column rewrite. -->
<DataGrid Name="Grid"
CanUserResizeColumns="True"
CanUserSortColumns="True"
CanUserReorderColumns="False"
IsReadOnly="True"
GridLinesVisibility="None"
HeadersVisibility="Column"
SelectionMode="Single"
AutoGenerateColumns="False" />
</DockPanel>
</UserControl>

10
ILSpy/Views/MetadataTablePage.axaml.cs

@ -19,6 +19,7 @@ @@ -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 @@ -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 @@ -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 @@ -76,11 +80,15 @@ namespace ILSpy.Views
// the new schema has more columns than the old one.
grid.ItemsSource = Array.Empty<object>();
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()

Loading…
Cancel
Save