Browse Source

Per-column filter inputs in metadata grid headers

Replace the single text-bar above the grid with one filter input per
column, baked into each DataGridColumn's header. The page model carries
a ColumnFilters collection alongside Columns; the filter predicate ANDs
every non-empty column's case-insensitive substring match. Column
instances are built fresh per page so sibling tabs don't share filter
state, and the column name moves to Tag for cell-level lookups now that
Header is a Panel.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
102bf2cc5b
  1. 6
      ILSpy.Tests/Metadata/HeapTreeTests.cs
  2. 29
      ILSpy.Tests/Metadata/MetadataColumnBuilderTests.cs
  3. 71
      ILSpy.Tests/Metadata/MetadataFilterTests.cs
  4. 2
      ILSpy.Tests/Metadata/PEHeaderTreeTests.cs
  5. 2
      ILSpy.Tests/Metadata/TypedMetadataTableTreeTests.cs
  6. 3
      ILSpy/Commands/GoToTokenContextMenuEntry.cs
  7. 14
      ILSpy/Metadata/BlobHeapTreeNode.cs
  8. 14
      ILSpy/Metadata/CoffHeaderTreeNode.cs
  9. 14
      ILSpy/Metadata/DataDirectoriesTreeNode.cs
  10. 14
      ILSpy/Metadata/DebugDirectoryTreeNode.cs
  11. 14
      ILSpy/Metadata/DosHeaderTreeNode.cs
  12. 14
      ILSpy/Metadata/GuidHeapTreeNode.cs
  13. 112
      ILSpy/Metadata/MetadataColumnBuilder.cs
  14. 14
      ILSpy/Metadata/MetadataTableTreeNode.cs
  15. 14
      ILSpy/Metadata/OptionalHeaderTreeNode.cs
  16. 14
      ILSpy/Metadata/StringHeapTreeNode.cs
  17. 14
      ILSpy/Metadata/UserStringHeapTreeNode.cs
  18. 68
      ILSpy/ViewModels/MetadataTablePageModel.cs
  19. 38
      ILSpy/Views/MetadataTablePage.axaml
  20. 43
      ILSpy/Views/MetadataTablePage.axaml.cs

6
ILSpy.Tests/Metadata/HeapTreeTests.cs

@ -85,7 +85,7 @@ public class HeapTreeTests @@ -85,7 +85,7 @@ public class HeapTreeTests
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
tab.Title.Should().Be("String Heap");
tab.Columns.Select(c => c.Header.ToString()).Should().Equal("Offset", "Length", "Value");
tab.Columns.Select(c => c.Tag).Should().Equal("Offset", "Length", "Value");
// CoreLib's #Strings is well over a thousand entries; the grid handles them all.
tab.Items.Should().HaveCountGreaterThan(1000);
}
@ -109,7 +109,7 @@ public class HeapTreeTests @@ -109,7 +109,7 @@ public class HeapTreeTests
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
tab.Title.Should().Be("Guid Heap");
tab.Columns.Select(c => c.Header.ToString()).Should().Equal("Index", "Length", "Value");
tab.Columns.Select(c => c.Tag).Should().Equal("Index", "Length", "Value");
// Each entry's Value is the parsed GUID's lowercase canonical form.
var first = (GuidHeapTreeNode.GuidHeapEntry)tab.Items[0];
first.Value.Should().MatchRegex(@"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");
@ -134,6 +134,6 @@ public class HeapTreeTests @@ -134,6 +134,6 @@ public class HeapTreeTests
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
tab.Title.Should().Be("Blob Heap");
tab.Columns.Select(c => c.Header.ToString()).Should().Equal("Offset", "Length", "Value");
tab.Columns.Select(c => c.Tag).Should().Equal("Offset", "Length", "Value");
}
}

29
ILSpy.Tests/Metadata/MetadataColumnBuilderTests.cs

@ -24,6 +24,7 @@ using Avalonia.Headless.NUnit; @@ -24,6 +24,7 @@ using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ILSpy.Metadata;
using ILSpy.ViewModels;
using NUnit.Framework;
@ -51,7 +52,7 @@ public class MetadataColumnBuilderTests @@ -51,7 +52,7 @@ public class MetadataColumnBuilderTests
var columns = MetadataColumnBuilder.For<SampleEntry>();
columns.Should().HaveCount(3);
columns.Select(c => c.Header.ToString()).Should().Equal("RID", "Token", "Name");
columns.Select(c => c.Tag).Should().Equal("RID", "Token", "Name");
var token = (DataGridTextColumn)columns[1];
token.Binding.Should().NotBeNull();
@ -62,16 +63,30 @@ public class MetadataColumnBuilderTests @@ -62,16 +63,30 @@ public class MetadataColumnBuilderTests
}
[AvaloniaTest]
public void For_Returns_Fresh_DataGridColumn_Instances_So_They_Can_Attach_To_Distinct_Grids()
public void For_Returns_Fresh_Column_Instances_So_Per_Page_Headers_Stay_Independent()
{
// Avalonia's DataGrid tracks OwningGrid on each column; reusing column instances
// across grids throws InvalidOperationException. Each call must therefore yield
// fresh DataGridColumn instances, even though the underlying reflection metadata
// is the same.
// Per-column filter inputs live inside each column's Header, so two pages built
// from the same row type must hold *separate* DataGridColumn instances — otherwise
// a filter typed on one tab would echo into the other.
var first = MetadataColumnBuilder.For<SampleEntry>();
var second = MetadataColumnBuilder.For<SampleEntry>();
first.Should().NotBeSameAs(second);
first[0].Should().NotBeSameAs(second[0]);
first.Zip(second, (a, b) => (a, b)).Should()
.AllSatisfy(p => p.a.Should().NotBeSameAs(p.b));
}
[AvaloniaTest]
public void Populate_Sets_Columns_And_ColumnFilters_In_Matched_Order()
{
// The page model uses the ColumnFilters collection to evaluate the row predicate;
// the view binds each column header's TextBox to the filter at the same index. The
// order must match Columns exactly or filters would route to the wrong column.
var page = new MetadataTablePageModel();
MetadataColumnBuilder.Populate<SampleEntry>(page);
page.Columns.Should().HaveCount(3);
page.ColumnFilters.Should().HaveCount(3);
page.ColumnFilters.Select(f => f.ColumnName).Should().Equal("RID", "Token", "Name");
}
sealed class SampleEntryWithToken

71
ILSpy.Tests/Metadata/MetadataFilterTests.cs

@ -45,46 +45,67 @@ public class MetadataFilterTests @@ -45,46 +45,67 @@ public class MetadataFilterTests
}
[Test]
public void MatchesFilter_Returns_True_For_Empty_Or_Null_Filter()
public void MatchesFilters_Returns_True_When_All_Column_Filters_Are_Empty()
{
// An empty filter must show every row — the no-filter state is the identity case.
// An empty filter set is the identity case — every row passes.
var entry = new SampleEntry { RID = 1, Name = "System.Runtime" };
MetadataTablePageModel.MatchesFilter(entry, null).Should().BeTrue();
MetadataTablePageModel.MatchesFilter(entry, "").Should().BeTrue();
MetadataTablePageModel.MatchesFilters(entry, new[] {
new ColumnFilter("Name"),
new ColumnFilter("RID"),
}).Should().BeTrue();
}
[Test]
public void MatchesFilter_Returns_True_When_Any_Property_Contains_The_Substring_Case_Insensitively()
public void MatchesFilters_Returns_True_When_Each_Filter_Matches_Its_Column_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.
// Per-column filters AND together: every non-empty filter must hit its own column,
// not just any column. Case-insensitive substring match.
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();
MetadataTablePageModel.MatchesFilters(entry, new[] {
new ColumnFilter("Name") { Value = "system" },
new ColumnFilter("Culture") { Value = "NEUTRAL" },
}).Should().BeTrue();
}
[Test]
public void MatchesFilter_Returns_False_When_No_Property_Contains_The_Substring()
public void MatchesFilters_Returns_False_When_A_Filter_Does_Not_Match_Its_Own_Column()
{
var entry = new SampleEntry { RID = 1, Name = "System.Runtime" };
MetadataTablePageModel.MatchesFilter(entry, "Foo").Should().BeFalse();
// Anchored to a single column: "system" in Culture must not match a row whose
// Culture is "neutral", even though Name contains "System".
var entry = new SampleEntry { Name = "System.Runtime", Culture = "neutral" };
MetadataTablePageModel.MatchesFilters(entry, new[] {
new ColumnFilter("Culture") { Value = "system" },
}).Should().BeFalse();
}
[Test]
public void MatchesFilters_Returns_False_When_Any_AndEd_Filter_Misses()
{
// All filters must match — one missing column is enough to drop the row.
var entry = new SampleEntry { Name = "System.Runtime", Culture = "neutral" };
MetadataTablePageModel.MatchesFilters(entry, new[] {
new ColumnFilter("Name") { Value = "system" },
new ColumnFilter("Culture") { Value = "invariant" },
}).Should().BeFalse();
}
[Test]
public void MatchesFilter_Stringifies_Numeric_Values_So_Filter_Hits_Numeric_Columns()
public void MatchesFilters_Returns_False_When_The_Filtered_Column_Does_Not_Exist_On_The_Row()
{
// 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();
// A filter targeting a non-existent property never matches; surfacing this as "no
// match" prevents stale filter rows from silently passing rows of a different shape.
var entry = new SampleEntry { Name = "X" };
MetadataTablePageModel.MatchesFilters(entry, new[] {
new ColumnFilter("DoesNotExist") { Value = "anything" },
}).Should().BeFalse();
}
[AvaloniaTest]
public async Task FilterText_Reduces_Visible_Rows_To_Those_Whose_Property_Contains_The_Substring()
public async Task ColumnFilter_Reduces_Visible_Rows_To_Those_Whose_Column_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.
// loaded module) and confirm that setting a Name-column filter shrinks the visible
// row set to entries whose Name contains the filter, and clearing it restores all.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
@ -106,13 +127,15 @@ public class MetadataFilterTests @@ -106,13 +127,15 @@ public class MetadataFilterTests
var totalCount = tab.Items.Count;
totalCount.Should().BeGreaterThan(0);
tab.FilterText = "System";
var visible = tab.Items.Where(e => MetadataTablePageModel.MatchesFilter(e, tab.FilterText)).ToList();
var nameFilter = tab.ColumnFilters.Single(f => f.ColumnName == "Name");
nameFilter.Value = "System";
var visible = tab.Items.Where(e => MetadataTablePageModel.MatchesFilters(e, tab.ColumnFilters)).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();
nameFilter.Value = "";
var afterClear = tab.Items.Where(e => MetadataTablePageModel.MatchesFilters(e, tab.ColumnFilters)).Count();
afterClear.Should().Be(totalCount);
}
}

2
ILSpy.Tests/Metadata/PEHeaderTreeTests.cs

@ -95,7 +95,7 @@ public class PEHeaderTreeTests @@ -95,7 +95,7 @@ public class PEHeaderTreeTests
tab.Title.Should().Be("DOS Header");
tab.Items.Should().HaveCount(31);
tab.Columns.Select(c => c.Header.ToString()).Should().Contain(["Member", "Offset", "Size", "Value", "Meaning"]);
tab.Columns.Select(c => c.Tag).Should().Contain(["Member", "Offset", "Size", "Value", "Meaning"]);
var firstRow = (Entry)tab.Items[0];
firstRow.Member.Should().Be("e_magic");

2
ILSpy.Tests/Metadata/TypedMetadataTableTreeTests.cs

@ -131,7 +131,7 @@ public class TypedMetadataTableTreeTests @@ -131,7 +131,7 @@ public class TypedMetadataTableTreeTests
vm.AssemblyTreeModel.SelectNode(methodNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
tab.Title.Should().StartWith("MethodDef");
tab.Columns.Select(c => c.Header.ToString()).Should().Contain("RID");
tab.Columns.Select(c => c.Tag).Should().Contain("RID");
}
[AvaloniaTest]

3
ILSpy/Commands/GoToTokenContextMenuEntry.cs

@ -60,7 +60,8 @@ namespace ILSpy.Commands @@ -60,7 +60,8 @@ namespace ILSpy.Commands
return null;
if (cell.OwningColumn is null)
return null;
var columnName = cell.OwningColumn.Header?.ToString();
var columnName = cell.OwningColumn.Tag as string
?? cell.OwningColumn.Header?.ToString();
if (string.IsNullOrEmpty(columnName))
return null;
var row = cell.DataContext;

14
ILSpy/Metadata/BlobHeapTreeNode.cs

@ -45,11 +45,15 @@ namespace ILSpy.Metadata @@ -45,11 +45,15 @@ namespace ILSpy.Metadata
public override object Text => $"Blob Heap ({EnsureEntries().Count})";
public override string ToString() => "Blob Heap";
public override TabPageModel CreateTab() => new MetadataTablePageModel {
Title = "Blob Heap",
Items = EnsureEntries().Cast<object>().ToList(),
Columns = MetadataColumnBuilder.For<BlobHeapEntry>(),
};
public override TabPageModel CreateTab()
{
var page = new MetadataTablePageModel {
Title = "Blob Heap",
Items = EnsureEntries().Cast<object>().ToList(),
};
MetadataColumnBuilder.Populate<BlobHeapEntry>(page);
return page;
}
List<BlobHeapEntry> EnsureEntries()
{

14
ILSpy/Metadata/CoffHeaderTreeNode.cs

@ -45,11 +45,15 @@ namespace ILSpy.Metadata @@ -45,11 +45,15 @@ namespace ILSpy.Metadata
public override object Icon => Images.Images.MetadataTable;
public override string ToString() => "COFF Header";
public override TabPageModel CreateTab() => new MetadataTablePageModel {
Title = "COFF Header",
Items = BuildEntries().Cast<object>().ToList(),
Columns = MetadataColumnBuilder.For<Entry>(),
};
public override TabPageModel CreateTab()
{
var page = new MetadataTablePageModel {
Title = "COFF Header",
Items = BuildEntries().Cast<object>().ToList(),
};
MetadataColumnBuilder.Populate<Entry>(page);
return page;
}
IReadOnlyList<Entry> BuildEntries()
{

14
ILSpy/Metadata/DataDirectoriesTreeNode.cs

@ -46,11 +46,15 @@ namespace ILSpy.Metadata @@ -46,11 +46,15 @@ namespace ILSpy.Metadata
public override object Icon => Images.Images.MetadataTable;
public override string ToString() => "Data Directories";
public override TabPageModel CreateTab() => new MetadataTablePageModel {
Title = "Data Directories",
Items = BuildEntries().Cast<object>().ToList(),
Columns = MetadataColumnBuilder.For<DataDirectoryEntry>(),
};
public override TabPageModel CreateTab()
{
var page = new MetadataTablePageModel {
Title = "Data Directories",
Items = BuildEntries().Cast<object>().ToList(),
};
MetadataColumnBuilder.Populate<DataDirectoryEntry>(page);
return page;
}
IReadOnlyList<DataDirectoryEntry> BuildEntries()
{

14
ILSpy/Metadata/DebugDirectoryTreeNode.cs

@ -47,11 +47,15 @@ namespace ILSpy.Metadata @@ -47,11 +47,15 @@ namespace ILSpy.Metadata
public override object Icon => Images.Images.MetadataTable;
public override string ToString() => "Debug Directory";
public override TabPageModel CreateTab() => new MetadataTablePageModel {
Title = "Debug Directory",
Items = BuildEntries().Cast<object>().ToList(),
Columns = MetadataColumnBuilder.For<DebugDirectoryEntryView>(),
};
public override TabPageModel CreateTab()
{
var page = new MetadataTablePageModel {
Title = "Debug Directory",
Items = BuildEntries().Cast<object>().ToList(),
};
MetadataColumnBuilder.Populate<DebugDirectoryEntryView>(page);
return page;
}
IReadOnlyList<DebugDirectoryEntryView> BuildEntries()
{

14
ILSpy/Metadata/DosHeaderTreeNode.cs

@ -44,11 +44,15 @@ namespace ILSpy.Metadata @@ -44,11 +44,15 @@ namespace ILSpy.Metadata
public override object Icon => Images.Images.MetadataTable;
public override string ToString() => "DOS Header";
public override TabPageModel CreateTab() => new MetadataTablePageModel {
Title = "DOS Header",
Items = BuildEntries().Cast<object>().ToList(),
Columns = MetadataColumnBuilder.For<Entry>(),
};
public override TabPageModel CreateTab()
{
var page = new MetadataTablePageModel {
Title = "DOS Header",
Items = BuildEntries().Cast<object>().ToList(),
};
MetadataColumnBuilder.Populate<Entry>(page);
return page;
}
IReadOnlyList<Entry> BuildEntries()
{

14
ILSpy/Metadata/GuidHeapTreeNode.cs

@ -44,11 +44,15 @@ namespace ILSpy.Metadata @@ -44,11 +44,15 @@ namespace ILSpy.Metadata
public override object Text => $"Guid Heap ({EnsureEntries().Count})";
public override string ToString() => "Guid Heap";
public override TabPageModel CreateTab() => new MetadataTablePageModel {
Title = "Guid Heap",
Items = EnsureEntries().Cast<object>().ToList(),
Columns = MetadataColumnBuilder.For<GuidHeapEntry>(),
};
public override TabPageModel CreateTab()
{
var page = new MetadataTablePageModel {
Title = "Guid Heap",
Items = EnsureEntries().Cast<object>().ToList(),
};
MetadataColumnBuilder.Populate<GuidHeapEntry>(page);
return page;
}
List<GuidHeapEntry> EnsureEntries()
{

112
ILSpy/Metadata/MetadataColumnBuilder.cs

@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@ -36,28 +37,59 @@ using ILSpy.ViewModels; @@ -36,28 +37,59 @@ using ILSpy.ViewModels;
namespace ILSpy.Metadata
{
/// <summary>
/// Builds DataGrid column descriptors for a metadata-row type by reflecting over its public
/// properties. Returns fresh <see cref="DataGridColumn"/> instances per call — Avalonia's
/// DataGrid tracks an OwningGrid reference on each column, so reusing the same instances
/// across tabs (or even across rebinds of one tab) throws InvalidOperationException
/// "Column already belongs to a DataGrid instance and cannot be reassigned." Reflection
/// cost per tab-open is negligible against the layout pass that follows.
/// Token / heap-offset columns render as plain hex text in Phase 2; Phase 3 swaps them for
/// hyperlink-styled buttons that emit a token-navigation request.
/// Builds DataGrid columns for a metadata-row type by reflecting over its public
/// properties. Reflection metadata is cached as <c>ColumnDescriptor</c>s; live
/// <see cref="DataGridColumn"/> instances are constructed fresh on every call so each
/// page can carry its own per-column filter inputs in the headers without sharing state
/// with sibling pages.
/// </summary>
public static class MetadataColumnBuilder
{
static readonly ConcurrentDictionary<Type, ColumnDescriptor[]> descriptorCache = new();
/// <summary>
/// Convenience overload for tests and simple consumers — returns columns with plain
/// string headers and no per-column filter wiring. Production code should call
/// <see cref="Populate{TEntry}(MetadataTablePageModel)"/>, which also seeds the page's
/// <c>ColumnFilters</c> collection and bakes filter inputs into each header.
/// </summary>
public static IReadOnlyList<DataGridColumn> For<TEntry>() => For(typeof(TEntry));
public static IReadOnlyList<DataGridColumn> For(Type entryType)
{
ArgumentNullException.ThrowIfNull(entryType);
return BuildColumns(entryType);
return descriptorCache.GetOrAdd(entryType, BuildDescriptors)
.Select(d => BuildColumn(d, filter: null))
.ToArray();
}
static IReadOnlyList<DataGridColumn> BuildColumns(Type entryType)
/// <summary>
/// Populates <paramref name="page"/> with both <c>Columns</c> and
/// <c>ColumnFilters</c> in matched order. Each column's header is a vertical pair —
/// the column name above a small TextBox bound two-way to the column's filter — so
/// the user can filter every column independently and the predicate ANDs the lot.
/// </summary>
public static void Populate<TEntry>(MetadataTablePageModel page) => Populate(page, typeof(TEntry));
public static void Populate(MetadataTablePageModel page, Type entryType)
{
var columns = new List<DataGridColumn>();
ArgumentNullException.ThrowIfNull(page);
ArgumentNullException.ThrowIfNull(entryType);
var descriptors = descriptorCache.GetOrAdd(entryType, BuildDescriptors);
page.ColumnFilters.Clear();
var columns = new List<DataGridColumn>(descriptors.Length);
foreach (var d in descriptors)
{
var filter = new ColumnFilter(d.Name);
page.ColumnFilters.Add(filter);
columns.Add(BuildColumn(d, filter));
}
page.Columns = columns;
}
static ColumnDescriptor[] BuildDescriptors(Type entryType)
{
var list = new List<ColumnDescriptor>();
foreach (var prop in entryType.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (prop.GetIndexParameters().Length > 0)
@ -66,18 +98,47 @@ namespace ILSpy.Metadata @@ -66,18 +98,47 @@ namespace ILSpy.Metadata
continue;
if (prop.PropertyType == typeof(IList<BitEntry>))
continue;
var info = prop.GetCustomAttribute<ColumnInfoAttribute>();
if (info?.Kind == ColumnKind.Token)
{
columns.Add(BuildTokenColumn(prop, info));
}
else
{
columns.Add(BuildTextColumn(prop, info));
}
list.Add(new ColumnDescriptor(prop, prop.GetCustomAttribute<ColumnInfoAttribute>()));
}
return columns;
return list.ToArray();
}
static DataGridColumn BuildColumn(ColumnDescriptor d, ColumnFilter? filter)
{
var column = d.Info?.Kind == ColumnKind.Token
? (DataGridColumn)BuildTokenColumn(d.Property, d.Info)
: BuildTextColumn(d.Property, d.Info);
column.Header = filter is null ? d.Name : (object)BuildHeader(d.Name, filter);
// Header is a Panel when filters are wired, so callers can't recover the column
// name via Header.ToString(). Stash the name on Tag for hover-tooltip lookup,
// "Go to token" context-menu resolution, and any future cell-level navigation.
column.Tag = d.Name;
return column;
}
static Control BuildHeader(string columnName, ColumnFilter filter)
{
var label = new TextBlock {
Text = columnName,
FontWeight = FontWeight.SemiBold,
HorizontalAlignment = HorizontalAlignment.Stretch,
};
var box = new TextBox {
MinHeight = 0,
Padding = new Thickness(2, 1),
Margin = new Thickness(0, 2, 0, 0),
FontWeight = FontWeight.Normal,
HorizontalAlignment = HorizontalAlignment.Stretch,
};
box.Bind(TextBox.TextProperty, new Binding(nameof(ColumnFilter.Value)) {
Source = filter,
Mode = BindingMode.TwoWay,
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged,
});
return new StackPanel {
Orientation = Orientation.Vertical,
Children = { label, box },
};
}
static DataGridTextColumn BuildTextColumn(PropertyInfo prop, ColumnInfoAttribute? info)
@ -86,7 +147,6 @@ namespace ILSpy.Metadata @@ -86,7 +147,6 @@ namespace ILSpy.Metadata
if (!string.IsNullOrEmpty(info?.Format))
binding.Converter = new HexFormatConverter(info.Format);
return new DataGridTextColumn {
Header = prop.Name,
IsReadOnly = true,
Binding = binding,
SortMemberPath = prop.Name,
@ -114,7 +174,6 @@ namespace ILSpy.Metadata @@ -114,7 +174,6 @@ namespace ILSpy.Metadata
return btn;
}, supportsRecycling: true);
return new DataGridTemplateColumn {
Header = prop.Name,
IsReadOnly = true,
CellTemplate = template,
SortMemberPath = prop.Name,
@ -132,6 +191,11 @@ namespace ILSpy.Metadata @@ -132,6 +191,11 @@ namespace ILSpy.Metadata
return formattable.ToString(format, CultureInfo.InvariantCulture);
return value.ToString() ?? string.Empty;
}
sealed record ColumnDescriptor(PropertyInfo Property, ColumnInfoAttribute? Info)
{
public string Name => Property.Name;
}
}
/// <summary>

14
ILSpy/Metadata/MetadataTableTreeNode.cs

@ -71,10 +71,14 @@ namespace ILSpy.Metadata @@ -71,10 +71,14 @@ namespace ILSpy.Metadata
protected abstract IReadOnlyList<TEntry> LoadTable();
public override TabPageModel CreateTab() => new MetadataTablePageModel {
Title = $"{Kind} ({RowCount})",
Items = (cached ??= LoadTable()).Cast<object>().ToList(),
Columns = MetadataColumnBuilder.For<TEntry>(),
};
public override TabPageModel CreateTab()
{
var page = new MetadataTablePageModel {
Title = $"{Kind} ({RowCount})",
Items = (cached ??= LoadTable()).Cast<object>().ToList(),
};
MetadataColumnBuilder.Populate<TEntry>(page);
return page;
}
}
}

14
ILSpy/Metadata/OptionalHeaderTreeNode.cs

@ -46,11 +46,15 @@ namespace ILSpy.Metadata @@ -46,11 +46,15 @@ namespace ILSpy.Metadata
public override object Icon => Images.Images.MetadataTable;
public override string ToString() => "Optional Header";
public override TabPageModel CreateTab() => new MetadataTablePageModel {
Title = "Optional Header",
Items = BuildEntries().Cast<object>().ToList(),
Columns = MetadataColumnBuilder.For<Entry>(),
};
public override TabPageModel CreateTab()
{
var page = new MetadataTablePageModel {
Title = "Optional Header",
Items = BuildEntries().Cast<object>().ToList(),
};
MetadataColumnBuilder.Populate<Entry>(page);
return page;
}
IReadOnlyList<Entry> BuildEntries()
{

14
ILSpy/Metadata/StringHeapTreeNode.cs

@ -45,11 +45,15 @@ namespace ILSpy.Metadata @@ -45,11 +45,15 @@ namespace ILSpy.Metadata
public override object Text => $"String Heap ({EnsureEntries().Count})";
public override string ToString() => "String Heap";
public override TabPageModel CreateTab() => new MetadataTablePageModel {
Title = "String Heap",
Items = EnsureEntries().Cast<object>().ToList(),
Columns = MetadataColumnBuilder.For<StringHeapEntry>(),
};
public override TabPageModel CreateTab()
{
var page = new MetadataTablePageModel {
Title = "String Heap",
Items = EnsureEntries().Cast<object>().ToList(),
};
MetadataColumnBuilder.Populate<StringHeapEntry>(page);
return page;
}
List<StringHeapEntry> EnsureEntries()
{

14
ILSpy/Metadata/UserStringHeapTreeNode.cs

@ -44,11 +44,15 @@ namespace ILSpy.Metadata @@ -44,11 +44,15 @@ namespace ILSpy.Metadata
public override object Text => $"UserString Heap ({EnsureEntries().Count})";
public override string ToString() => "UserString Heap";
public override TabPageModel CreateTab() => new MetadataTablePageModel {
Title = "UserString Heap",
Items = EnsureEntries().Cast<object>().ToList(),
Columns = MetadataColumnBuilder.For<UserStringHeapEntry>(),
};
public override TabPageModel CreateTab()
{
var page = new MetadataTablePageModel {
Title = "UserString Heap",
Items = EnsureEntries().Cast<object>().ToList(),
};
MetadataColumnBuilder.Populate<UserStringHeapEntry>(page);
return page;
}
List<UserStringHeapEntry> EnsureEntries()
{

68
ILSpy/ViewModels/MetadataTablePageModel.cs

@ -19,6 +19,7 @@ @@ -19,6 +19,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Reflection;
using Avalonia.Controls;
@ -50,12 +51,21 @@ namespace ILSpy.ViewModels @@ -50,12 +51,21 @@ namespace ILSpy.ViewModels
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.
/// One filter input per column, in the same order as <see cref="Columns"/>. The view
/// renders each <see cref="ColumnFilter.Value"/> as a TextBox baked into that column's
/// header; the filter predicate ANDs every non-empty entry, requiring each row's
/// matching property's stringified value to contain its filter (case-insensitive).
/// </summary>
[ObservableProperty]
private string? filterText;
public ObservableCollection<ColumnFilter> ColumnFilters { get; } = new();
/// <summary>
/// Raised whenever any column's filter text changes. The view subscribes to this so
/// it can refresh the underlying <c>DataGridCollectionView</c> without each column
/// having to know how to find the live view instance.
/// </summary>
public event Action? ColumnFilterChanged;
internal void RaiseColumnFilterChanged() => ColumnFilterChanged?.Invoke();
/// <summary>
/// Raised when the user clicks a hyperlink-styled token cell. The host (the dock
@ -67,35 +77,53 @@ namespace ILSpy.ViewModels @@ -67,35 +77,53 @@ namespace ILSpy.ViewModels
internal void RaiseNavigateToCell(object row, string columnName)
=> NavigateToCellRequested?.Invoke(new MetadataCellNavigationEventArgs(row, columnName));
static readonly ConcurrentDictionary<Type, PropertyInfo[]> filterPropertyCache = new();
static readonly ConcurrentDictionary<(Type Type, string Column), PropertyInfo?> propertyLookupCache = 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.
/// Returns true when every <see cref="ColumnFilter"/> in <paramref name="filters"/>
/// either has an empty value or the matching property on <paramref name="item"/>
/// stringifies to a value that contains the filter case-insensitively. Used by the
/// view's <c>DataGridCollectionView.Filter</c> and by tests.
/// </summary>
public static bool MatchesFilter(object item, string? filter)
public static bool MatchesFilters(object item, IEnumerable<ColumnFilter> filters)
{
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)
ArgumentNullException.ThrowIfNull(filters);
foreach (var f in filters)
{
if (string.IsNullOrEmpty(f.Value))
continue;
var prop = propertyLookupCache.GetOrAdd((item.GetType(), f.ColumnName),
static key => key.Type.GetProperty(key.Column,
BindingFlags.Public | BindingFlags.Instance));
if (prop is null)
return false;
object? value;
try
{ value = prop.GetValue(item); }
catch { continue; }
catch { return false; }
var s = value?.ToString();
if (s is not null && s.Contains(filter, StringComparison.OrdinalIgnoreCase))
return true;
if (s is null || !s.Contains(f.Value, StringComparison.OrdinalIgnoreCase))
return false;
}
return false;
return true;
}
}
/// <summary>One column's filter entry — the column name plus its current filter text.</summary>
public sealed partial class ColumnFilter : ObservableObject
{
public ColumnFilter(string columnName)
{
ColumnName = columnName;
}
public string ColumnName { get; }
[ObservableProperty]
private string? value;
}
/// <summary>The (row, column) pair clicked in a token cell.</summary>
public sealed record MetadataCellNavigationEventArgs(object Row, string ColumnName);
}

38
ILSpy/Views/MetadataTablePage.axaml

@ -7,27 +7,21 @@ @@ -7,27 +7,21 @@
x:Class="ILSpy.Views.MetadataTablePage"
x:DataType="vm:MetadataTablePageModel">
<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>
<!-- 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. Per-column
filter inputs live inside each column's Header (built by MetadataColumnBuilder),
not in a separate row above the grid. -->
<DataGrid Name="Grid"
CanUserResizeColumns="True"
CanUserSortColumns="True"
CanUserReorderColumns="False"
IsReadOnly="True"
GridLinesVisibility="None"
HeadersVisibility="Column"
SelectionMode="Single"
AutoGenerateColumns="False" />
</UserControl>

43
ILSpy/Views/MetadataTablePage.axaml.cs

@ -117,7 +117,8 @@ namespace ILSpy.Views @@ -117,7 +117,8 @@ namespace ILSpy.Views
hoveredCell = cell;
if (cell is null)
return;
var columnName = cell.OwningColumn?.Header?.ToString();
var columnName = cell.OwningColumn?.Tag as string
?? cell.OwningColumn?.Header?.ToString();
if (columnName is null)
return;
var tip = MetadataCellTooltip.Resolve(cell.DataContext!, columnName);
@ -127,10 +128,16 @@ namespace ILSpy.Views @@ -127,10 +128,16 @@ namespace ILSpy.Views
void RebindModel()
{
if (boundModel != null)
{
boundModel.PropertyChanged -= OnModelPropertyChanged;
DetachColumnFilterListeners(boundModel);
}
boundModel = DataContext as MetadataTablePageModel;
if (boundModel != null)
{
boundModel.PropertyChanged += OnModelPropertyChanged;
AttachColumnFilterListeners(boundModel);
}
ApplySchema();
ApplyScrollTarget();
}
@ -139,11 +146,36 @@ namespace ILSpy.Views @@ -139,11 +146,36 @@ namespace ILSpy.Views
{
if (e.PropertyName is nameof(MetadataTablePageModel.Columns)
or nameof(MetadataTablePageModel.Items))
{
if (sender is MetadataTablePageModel m)
{
DetachColumnFilterListeners(m);
AttachColumnFilterListeners(m);
}
ApplySchema();
else if (e.PropertyName == nameof(MetadataTablePageModel.FilterText))
itemsView?.Refresh();
}
else if (e.PropertyName == nameof(MetadataTablePageModel.ScrollToRow))
{
ApplyScrollTarget();
}
}
void OnColumnFilterChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(ColumnFilter.Value))
itemsView?.Refresh();
}
void AttachColumnFilterListeners(MetadataTablePageModel model)
{
foreach (var f in model.ColumnFilters)
f.PropertyChanged += OnColumnFilterChanged;
}
void DetachColumnFilterListeners(MetadataTablePageModel model)
{
foreach (var f in model.ColumnFilters)
f.PropertyChanged -= OnColumnFilterChanged;
}
void ApplySchema()
@ -163,8 +195,9 @@ namespace ILSpy.Views @@ -163,8 +195,9 @@ namespace ILSpy.Views
return;
foreach (var c in boundModel.Columns)
grid.Columns.Add(c);
itemsView = new DataGridCollectionView(boundModel.Items) {
Filter = item => MetadataTablePageModel.MatchesFilter(item, boundModel?.FilterText),
var model = boundModel;
itemsView = new DataGridCollectionView(model.Items) {
Filter = item => MetadataTablePageModel.MatchesFilters(item, model.ColumnFilters),
};
grid.ItemsSource = itemsView;
}

Loading…
Cancel
Save