mirror of https://github.com/icsharpcode/ILSpy.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
296 lines
12 KiB
296 lines
12 KiB
// 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.Collections.Concurrent; |
|
using System.Collections.Generic; |
|
using System.Collections.ObjectModel; |
|
using System.Collections.Specialized; |
|
using System.ComponentModel; |
|
using System.Reflection; |
|
using System.Text.RegularExpressions; |
|
|
|
using Avalonia.Controls; |
|
|
|
using CommunityToolkit.Mvvm.ComponentModel; |
|
|
|
using ILSpy.Metadata; |
|
|
|
namespace ILSpy.ViewModels |
|
{ |
|
/// <summary> |
|
/// Tab content for a metadata-table view: an item list bound to a DataGrid plus the |
|
/// column descriptors the view rebinds on every <c>DataContext</c> change. The DataGrid's |
|
/// <c>Columns</c> collection isn't an Avalonia property, so the model can't bind it |
|
/// declaratively — the view assigns it imperatively from <see cref="Columns"/>. |
|
/// </summary> |
|
public sealed partial class MetadataTablePageModel : TabPageModel |
|
{ |
|
[ObservableProperty] |
|
private IReadOnlyList<object> items = Array.Empty<object>(); |
|
|
|
[ObservableProperty] |
|
private IReadOnlyList<DataGridColumn> columns = Array.Empty<DataGridColumn>(); |
|
|
|
/// <summary> |
|
/// When set, the view scrolls the grid so the row at this 0-based index is visible |
|
/// (Phase 3 token navigation). Cleared back to <see langword="null"/> after the view |
|
/// honours it so back-navigation can re-trigger the same scroll. |
|
/// </summary> |
|
[ObservableProperty] |
|
private int? scrollToRow; |
|
|
|
/// <summary> |
|
/// One filter input per column, in the same order as <see cref="Columns"/>. The view |
|
/// renders each <see cref="ColumnFilter.Text"/> 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> |
|
public ObservableCollection<ColumnFilter> ColumnFilters { get; } = new(); |
|
|
|
/// <summary> |
|
/// Raised whenever any column's filter text changes. The view subscribes here to |
|
/// refresh its <c>DataGridCollectionView</c>; tests use it as the canonical filter |
|
/// signal too. The page wires this internally via <c>ColumnFilters.CollectionChanged</c> |
|
/// + each filter's <see cref="INotifyPropertyChanged"/> so callers don't have to chase |
|
/// individual filter instances. |
|
/// </summary> |
|
public event Action? ColumnFilterChanged; |
|
|
|
public MetadataTablePageModel() |
|
{ |
|
ColumnFilters.CollectionChanged += OnColumnFiltersCollectionChanged; |
|
} |
|
|
|
void OnColumnFiltersCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) |
|
{ |
|
if (e.OldItems is not null) |
|
foreach (ColumnFilter f in e.OldItems) |
|
f.PropertyChanged -= OnColumnFilterTextChanged; |
|
if (e.NewItems is not null) |
|
foreach (ColumnFilter f in e.NewItems) |
|
f.PropertyChanged += OnColumnFilterTextChanged; |
|
} |
|
|
|
void OnColumnFilterTextChanged(object? sender, PropertyChangedEventArgs e) |
|
{ |
|
if (e.PropertyName == nameof(ColumnFilter.Text)) |
|
ColumnFilterChanged?.Invoke(); |
|
} |
|
|
|
/// <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 |
|
/// to the target table row. |
|
/// </summary> |
|
public event Action<MetadataCellNavigationEventArgs>? NavigateToCellRequested; |
|
|
|
internal void RaiseNavigateToCell(object row, string columnName) |
|
=> NavigateToCellRequested?.Invoke(new MetadataCellNavigationEventArgs(row, columnName)); |
|
|
|
static readonly ConcurrentDictionary<(Type Type, string Column), PropertyInfo?> propertyLookupCache = new(); |
|
|
|
static readonly ConcurrentDictionary<string, Regex?> regexCache = new(); |
|
|
|
/// <summary> |
|
/// Returns true when every <see cref="ColumnFilter"/> in <paramref name="filters"/> |
|
/// either has an empty value or the matching property on <paramref name="item"/> |
|
/// satisfies the filter. The predicate is type-aware: |
|
/// <list type="bullet"> |
|
/// <item>regex when the filter is wrapped in <c>/.../</c></item> |
|
/// <item><see cref="FlagsAttribute"/> enums: comma-separated flag names with |
|
/// <c>!</c> prefix for negation; all named flags must match (AND)</item> |
|
/// <item>integer columns: optional comparison operator (<c>></c>, <c><</c>, |
|
/// <c>>=</c>, <c><=</c>, <c>=</c>) followed by a hex (<c>0x…</c>) or |
|
/// decimal literal</item> |
|
/// <item>otherwise: case-insensitive substring on the stringified value</item> |
|
/// </list> |
|
/// Type-aware modes silently fall through to the substring path on a parse failure |
|
/// so a typo can't take down the filter. |
|
/// </summary> |
|
public static bool MatchesFilters(object item, IEnumerable<ColumnFilter> filters) |
|
{ |
|
ArgumentNullException.ThrowIfNull(item); |
|
ArgumentNullException.ThrowIfNull(filters); |
|
foreach (var f in filters) |
|
{ |
|
if (string.IsNullOrEmpty(f.Text)) |
|
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; |
|
if (!MatchesOne(item, prop, f.Text)) |
|
return false; |
|
} |
|
return true; |
|
} |
|
|
|
static bool MatchesOne(object item, PropertyInfo prop, string filter) |
|
{ |
|
object? value; |
|
try |
|
{ value = prop.GetValue(item); } |
|
catch { return false; } |
|
if (value is null) |
|
return false; |
|
|
|
if (TryGetRegex(filter) is { } rx) |
|
return rx.IsMatch(value.ToString() ?? string.Empty); |
|
|
|
if (prop.PropertyType.IsEnum |
|
&& Attribute.IsDefined(prop.PropertyType, typeof(FlagsAttribute)) |
|
&& TryMatchFlags(prop.PropertyType, value, filter, out var flagsResult)) |
|
return flagsResult; |
|
|
|
if ((IsIntegerType(prop.PropertyType) || prop.PropertyType.IsEnum) |
|
&& TryMatchNumeric(value, filter, out var numericResult)) |
|
return numericResult; |
|
|
|
// Substring matches the formatted display value so a typed "06000010" hits a |
|
// "06000010" cell — the predicate sees what the user sees, not the raw int. |
|
var info = prop.GetCustomAttribute<ColumnInfoAttribute>(); |
|
var stringified = FormatForDisplay(value, info?.Format); |
|
return stringified.Contains(filter, StringComparison.OrdinalIgnoreCase); |
|
} |
|
|
|
static string FormatForDisplay(object value, string? format) |
|
{ |
|
if (string.IsNullOrEmpty(format)) |
|
return value.ToString() ?? string.Empty; |
|
object boxed = value is Enum e |
|
? Convert.ChangeType(e, e.GetTypeCode(), System.Globalization.CultureInfo.InvariantCulture) |
|
: value; |
|
return boxed is IFormattable f |
|
? f.ToString(format, System.Globalization.CultureInfo.InvariantCulture) |
|
: (value.ToString() ?? string.Empty); |
|
} |
|
|
|
static bool TryMatchFlags(Type enumType, object value, string filter, out bool result) |
|
{ |
|
result = false; |
|
var actual = Convert.ToInt64(value, System.Globalization.CultureInfo.InvariantCulture); |
|
foreach (var raw in filter.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) |
|
{ |
|
var token = raw; |
|
bool negate = false; |
|
if (token.StartsWith('!')) |
|
{ |
|
negate = true; |
|
token = token[1..].Trim(); |
|
} |
|
if (token.Length == 0) |
|
return false; |
|
if (!Enum.TryParse(enumType, token, ignoreCase: true, out var parsed) || parsed is null) |
|
return false; |
|
var bits = Convert.ToInt64(parsed, System.Globalization.CultureInfo.InvariantCulture); |
|
bool isSet = bits == 0 ? actual == 0 : (actual & bits) == bits; |
|
if (negate ? isSet : !isSet) |
|
{ |
|
result = false; |
|
return true; |
|
} |
|
} |
|
result = true; |
|
return true; |
|
} |
|
|
|
static bool TryMatchNumeric(object value, string filter, out bool result) |
|
{ |
|
result = false; |
|
var (op, operandText) = SplitComparison(filter); |
|
if (op is null) |
|
return false; |
|
if (!TryParseInt64(operandText, out var operand)) |
|
return false; |
|
long actual = value is Enum e |
|
? Convert.ToInt64(e, System.Globalization.CultureInfo.InvariantCulture) |
|
: Convert.ToInt64(value, System.Globalization.CultureInfo.InvariantCulture); |
|
result = op switch { |
|
"=" => actual == operand, |
|
"<" => actual < operand, |
|
">" => actual > operand, |
|
"<=" => actual <= operand, |
|
">=" => actual >= operand, |
|
_ => false, |
|
}; |
|
return true; |
|
} |
|
|
|
static (string? Op, string Operand) SplitComparison(string filter) |
|
{ |
|
// Numeric mode requires an explicit comparison operator. A bare hex / decimal |
|
// literal stays on the substring path so "06000010" still matches the formatted |
|
// "06000010" the column displays. |
|
if (filter.StartsWith(">=", StringComparison.Ordinal)) |
|
return (">=", filter[2..].Trim()); |
|
if (filter.StartsWith("<=", StringComparison.Ordinal)) |
|
return ("<=", filter[2..].Trim()); |
|
if (filter.Length > 0 && filter[0] is '>' or '<' or '=') |
|
return (filter[..1], filter[1..].Trim()); |
|
return (null, filter); |
|
} |
|
|
|
static bool TryParseInt64(string text, out long value) |
|
{ |
|
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) |
|
return long.TryParse(text.AsSpan(2), System.Globalization.NumberStyles.HexNumber, |
|
System.Globalization.CultureInfo.InvariantCulture, out value); |
|
return long.TryParse(text, System.Globalization.NumberStyles.Integer, |
|
System.Globalization.CultureInfo.InvariantCulture, out value); |
|
} |
|
|
|
static bool IsIntegerType(Type type) => Type.GetTypeCode(type) is |
|
TypeCode.SByte or TypeCode.Byte or TypeCode.Int16 or TypeCode.UInt16 |
|
or TypeCode.Int32 or TypeCode.UInt32 or TypeCode.Int64 or TypeCode.UInt64; |
|
|
|
static Regex? TryGetRegex(string filter) |
|
{ |
|
// `/pattern/` opts a filter into regex mode (case-insensitive). Anything else, |
|
// or an unparseable pattern, leaves the predicate on the substring path. |
|
if (filter.Length < 2 || filter[0] != '/' || filter[^1] != '/') |
|
return null; |
|
return regexCache.GetOrAdd(filter, static text => { |
|
try |
|
{ return new Regex(text[1..^1], RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); } |
|
catch { return null; } |
|
}); |
|
} |
|
} |
|
|
|
/// <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; } |
|
|
|
// Backing field is `text` rather than `value` because the latter collides with C#'s |
|
// contextual identifier for property setters and trips Avalonia's INPC binding |
|
// plugin on write (the cached accessor's target reference comes back null). |
|
[ObservableProperty] |
|
private string? text; |
|
} |
|
|
|
/// <summary>The (row, column) pair clicked in a token cell.</summary> |
|
public sealed record MetadataCellNavigationEventArgs(object Row, string ColumnName); |
|
}
|
|
|