diff --git a/ILSpy.Tests/Metadata/FilterStateSerializerTests.cs b/ILSpy.Tests/Metadata/FilterStateSerializerTests.cs new file mode 100644 index 000000000..438d5c840 --- /dev/null +++ b/ILSpy.Tests/Metadata/FilterStateSerializerTests.cs @@ -0,0 +1,164 @@ +// 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.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; + +using AwesomeAssertions; + +using ILSpy.Metadata.Filters; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Metadata; + +/// +/// Round-trips a through XML — the storage shape behind +/// . Tests cover the empty case (so persistence +/// doesn't write noise to ILSpy.xml when the user clears a filter), the three mutator +/// surfaces (mutex selections, tri-state flag states, independent-mode), and schema- +/// drift resilience (XML references groups/flags the current schema no longer carries). +/// +[TestFixture] +public class FilterStateSerializerTests +{ + [Test] + public void ToXml_Empty_FilterState_Produces_Element_With_No_Constraint_Children() + { + // Newly-constructed state with every chip = Any and every flag = DontCare must + // round-trip to an empty so SessionSettings doesn't bloat with + // no-op entries. + var state = new FilterState(BuildSchema()); + + var xml = FilterStatePersistence.ToXml(state); + + xml.Name.LocalName.Should().Be("FilterState"); + xml.Elements("Mutex").Should().BeEmpty(); + xml.Elements("Flag").Should().BeEmpty(); + // IndependentMode = All (default) is implicit; only Any is written explicitly. + xml.Element("IndependentMode")?.Value.Should().NotBe("All"); + } + + [Test] + public void RoundTrip_Preserves_Mutex_Selection_Across_Multiple_Values() + { + // Mutex chip groups support multi-selection — the user can pick "Public" + "Family" + // from VisibilityMask, and that pair must survive a session restart. + var schema = BuildSchema(); + var original = new FilterState(schema); + original.SetMutexSelection("Visibility", + ImmutableHashSet.Create(2u, 4u)); + + var xml = FilterStatePersistence.ToXml(original); + var restored = new FilterState(schema); + FilterStatePersistence.ApplyXml(restored, xml); + + restored.MutexSelections["Visibility"].Should().BeEquivalentTo(new uint[] { 2u, 4u }); + } + + [Test] + public void RoundTrip_Preserves_TriState_Flag_States() + { + // Required / Excluded / DontCare each have distinct semantics in the filter + // predicate, so all three need to round-trip exactly. + var schema = BuildSchema(); + var original = new FilterState(schema); + original.SetFlagState("Sealed", TriState.Required); + original.SetFlagState("Abstract", TriState.Excluded); + // "Static" deliberately left as DontCare — must NOT be re-emitted as a Flag entry. + + var xml = FilterStatePersistence.ToXml(original); + var restored = new FilterState(schema); + FilterStatePersistence.ApplyXml(restored, xml); + + restored.FlagStates["Sealed"].Should().Be(TriState.Required); + restored.FlagStates["Abstract"].Should().Be(TriState.Excluded); + restored.FlagStates["Static"].Should().Be(TriState.DontCare); + } + + [Test] + public void RoundTrip_Preserves_IndependentMode_When_It_Differs_From_Default() + { + var schema = BuildSchema(); + var original = new FilterState(schema) { IndependentMode = MatchMode.Any }; + + var xml = FilterStatePersistence.ToXml(original); + var restored = new FilterState(schema); + FilterStatePersistence.ApplyXml(restored, xml); + + restored.IndependentMode.Should().Be(MatchMode.Any); + } + + [Test] + public void ApplyXml_Ignores_Mutex_Entries_For_Groups_Missing_From_The_Current_Schema() + { + // Schema drift: an older .ILSpy.xml might mention a group the new schema dropped. + // Apply should silently skip it instead of throwing — the user otherwise loses + // every saved filter on a schema upgrade. + var xml = new System.Xml.Linq.XElement("FilterState", + new System.Xml.Linq.XElement("Mutex", + new System.Xml.Linq.XAttribute("group", "GoneFromSchema"), + new System.Xml.Linq.XElement("Value", "1"))); + var state = new FilterState(BuildSchema()); + + var act = () => FilterStatePersistence.ApplyXml(state, xml); + + act.Should().NotThrow(); + state.IsEmpty.Should().BeTrue("the unknown group entry must be silently ignored"); + } + + [Test] + public void ApplyXml_Ignores_Flag_Entries_For_Flags_Missing_From_The_Current_Schema() + { + var xml = new System.Xml.Linq.XElement("FilterState", + new System.Xml.Linq.XElement("Flag", + new System.Xml.Linq.XAttribute("name", "FlagThatNoLongerExists"), + new System.Xml.Linq.XAttribute("state", "Required"))); + var state = new FilterState(BuildSchema()); + + var act = () => FilterStatePersistence.ApplyXml(state, xml); + + act.Should().NotThrow(); + state.IsEmpty.Should().BeTrue(); + } + + // --- helpers --------------------------------------------------------------------------- + + [System.Flags] + enum SampleAttrs : uint + { + Public = 1, + Family = 2, + Internal = 4, + Private = 8, + VisibilityMask = 0xF, + Static = 0x10, + Abstract = 0x20, + Sealed = 0x40, + } + + static FlagsSchema BuildSchema() + { + // Build the schema by inferring it off our test enum, matching what production code + // does for real metadata enums. Saves us from authoring the schema by hand and + // keeps the test contract aligned with the live FlagsSchemaInferer surface. + return FlagsSchemaInferer.For(typeof(SampleAttrs)); + } +} diff --git a/ILSpy.Tests/Options/SessionSettingsFilterStatesTests.cs b/ILSpy.Tests/Options/SessionSettingsFilterStatesTests.cs new file mode 100644 index 000000000..f86f47051 --- /dev/null +++ b/ILSpy.Tests/Options/SessionSettingsFilterStatesTests.cs @@ -0,0 +1,94 @@ +// 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.Xml.Linq; + +using AwesomeAssertions; + +using ILSpy; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +/// +/// Pins the SessionSettings ↔ XML shape that backs FilterStatePersistence's +/// cross-session cache. Without these tests a refactor of LoadFromXml / +/// SaveToXml could silently break the persistence round-trip — the schema +/// drift test alone wouldn't catch a wiring change at the SessionSettings level. +/// +[TestFixture] +public class SessionSettingsFilterStatesTests +{ + [Test] + public void Round_Trip_Preserves_Multiple_Entries_Across_Multiple_Pages() + { + // Two different metadata-table pages each with one filtered column. Verifies the + // outer // nesting AND that the inner FilterState + // payload (an opaque XElement to SessionSettings) survives unmolested. + var original = new SessionSettings(); + original.FilterStates[("ILSpy.Metadata.CorTables.TypeDefEntry", "Attributes")] = + new XElement("FilterState", + new XElement("Flag", + new XAttribute("name", "Sealed"), + new XAttribute("state", "Required"))); + original.FilterStates[("ILSpy.Metadata.CorTables.MethodDefEntry", "Attributes")] = + new XElement("FilterState", + new XElement("Mutex", + new XAttribute("group", "MemberAccess"), + new XElement("Value", "6"))); + + var xml = original.SaveToXml(); + var restored = new SessionSettings(); + restored.LoadFromXml(xml); + + restored.FilterStates.Should().HaveCount(2); + restored.FilterStates[("ILSpy.Metadata.CorTables.TypeDefEntry", "Attributes")] + .Element("Flag")!.Attribute("name")!.Value.Should().Be("Sealed"); + restored.FilterStates[("ILSpy.Metadata.CorTables.MethodDefEntry", "Attributes")] + .Element("Mutex")!.Element("Value")!.Value.Should().Be("6"); + } + + [Test] + public void Empty_FilterStates_Does_Not_Emit_The_Container_Element() + { + // We don't want to bloat ILSpy.xml with an empty wrapper every + // time a user closes the app without touching any filter dropdowns. + var settings = new SessionSettings(); + + var xml = settings.SaveToXml(); + + xml.Element("FilterStates").Should().BeNull( + "omit the container entirely when no filter has been persisted"); + } + + [Test] + public void LoadFromXml_Tolerates_Missing_FilterStates_Section() + { + // Settings files written by versions before this feature have no ; + // LoadFromXml must accept them silently and leave FilterStates empty. + var settings = new SessionSettings(); + var legacyXml = new XElement("SessionSettings", + new XElement("HideEmptyMetadataTables", "true")); + + var act = () => settings.LoadFromXml(legacyXml); + + act.Should().NotThrow(); + settings.FilterStates.Should().BeEmpty(); + } +} diff --git a/ILSpy/Metadata/FilterStatePersistence.cs b/ILSpy/Metadata/FilterStatePersistence.cs new file mode 100644 index 000000000..e6db51534 --- /dev/null +++ b/ILSpy/Metadata/FilterStatePersistence.cs @@ -0,0 +1,110 @@ +// 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.Immutable; +using System.Globalization; +using System.Linq; +using System.Xml.Linq; + +namespace ILSpy.Metadata.Filters +{ + /// + /// Round-trips a through an so the + /// schema-driven flag-filter dropdown's user-set state survives a session restart. + /// Storage is keyed externally (by page-entry type + column name); this class only + /// owns the per-state XML shape. + /// + /// + /// Wire shape (only non-default constraints are written): + /// + /// <FilterState> + /// <Mutex group="VisibilityMask"> + /// <Value>2</Value> + /// <Value>4</Value> + /// </Mutex> + /// <Flag name="Sealed" state="Required" /> + /// <IndependentMode>Any</IndependentMode> + /// </FilterState> + /// + /// Apply tolerates unknown group/flag names so an older saved XML can't break a newer + /// schema (the offending entry is silently skipped, others still apply). + /// + public static class FilterStatePersistence + { + const string Root = "FilterState"; + const string MutexElement = "Mutex"; + const string FlagElement = "Flag"; + const string ValueElement = "Value"; + const string IndependentModeElement = "IndependentMode"; + + public static XElement ToXml(FilterState state) + { + ArgumentNullException.ThrowIfNull(state); + var element = new XElement(Root); + foreach (var (groupName, selection) in state.MutexSelections) + { + if (selection is null) + continue; // "any" — write nothing + element.Add(new XElement(MutexElement, + new XAttribute("group", groupName), + selection.OrderBy(v => v).Select(v => + new XElement(ValueElement, v.ToString(CultureInfo.InvariantCulture))))); + } + foreach (var (flagName, triState) in state.FlagStates) + { + if (triState == TriState.DontCare) + continue; // default — write nothing + element.Add(new XElement(FlagElement, + new XAttribute("name", flagName), + new XAttribute("state", triState.ToString()))); + } + if (state.IndependentMode != MatchMode.All) + element.Add(new XElement(IndependentModeElement, state.IndependentMode.ToString())); + return element; + } + + public static void ApplyXml(FilterState state, XElement xml) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(xml); + foreach (var mutex in xml.Elements(MutexElement)) + { + var groupName = (string?)mutex.Attribute("group"); + if (groupName is null || !state.MutexSelections.ContainsKey(groupName)) + continue; // schema drift: group no longer present + var values = mutex.Elements(ValueElement) + .Select(v => uint.TryParse(v.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) ? n : (uint?)null) + .Where(v => v.HasValue) + .Select(v => v!.Value) + .ToImmutableHashSet(); + state.SetMutexSelection(groupName, values.IsEmpty ? null : values); + } + foreach (var flag in xml.Elements(FlagElement)) + { + var flagName = (string?)flag.Attribute("name"); + if (flagName is null || !state.FlagStates.ContainsKey(flagName)) + continue; // schema drift: flag no longer present + if (Enum.TryParse((string?)flag.Attribute("state"), out var tri)) + state.SetFlagState(flagName, tri); + } + if (Enum.TryParse(xml.Element(IndependentModeElement)?.Value, out var mode)) + state.IndependentMode = mode; + } + } +} diff --git a/ILSpy/Metadata/MetadataColumnBuilder.cs b/ILSpy/Metadata/MetadataColumnBuilder.cs index ab859dca6..faa6371e3 100644 --- a/ILSpy/Metadata/MetadataColumnBuilder.cs +++ b/ILSpy/Metadata/MetadataColumnBuilder.cs @@ -60,7 +60,7 @@ namespace ILSpy.Metadata { ArgumentNullException.ThrowIfNull(entryType); return descriptorCache.GetOrAdd(entryType, BuildDescriptors) - .Select(d => BuildColumn(d, filter: null)) + .Select(d => BuildColumn(d, filter: null, pageKey: null)) .ToArray(); } @@ -78,12 +78,17 @@ namespace ILSpy.Metadata ArgumentNullException.ThrowIfNull(entryType); var descriptors = descriptorCache.GetOrAdd(entryType, BuildDescriptors); page.ColumnFilters.Clear(); + // Per-table page key for the FilterState persistence cache. Using the entry + // type's full name means the same filter applies across all loaded assemblies' + // instances of this table — which matches the UX the schema-driven dropdowns + // already imply (predicate is column-shape-driven, not assembly-driven). + var pageKey = entryType.FullName ?? entryType.Name; var columns = new List(descriptors.Length); foreach (var d in descriptors) { var filter = new ColumnFilter(d.Name); page.ColumnFilters.Add(filter); - columns.Add(BuildColumn(d, filter)); + columns.Add(BuildColumn(d, filter, pageKey)); } page.Columns = columns; } @@ -104,12 +109,12 @@ namespace ILSpy.Metadata return list.ToArray(); } - static DataGridColumn BuildColumn(ColumnDescriptor d, ColumnFilter? filter) + static DataGridColumn BuildColumn(ColumnDescriptor d, ColumnFilter? filter, string? pageKey) { 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, d.Property.PropertyType); + column.Header = filter is null ? d.Name : (object)BuildHeader(d.Name, filter, d.Property.PropertyType, pageKey); // 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. @@ -117,7 +122,7 @@ namespace ILSpy.Metadata return column; } - static Control BuildHeader(string columnName, ColumnFilter filter, Type propertyType) + static Control BuildHeader(string columnName, ColumnFilter filter, Type propertyType, string? pageKey) { // Header is a single row: the funnel icon docks right; the remaining space is // shared by the column-name label and the filter input, with exactly one of @@ -181,7 +186,7 @@ namespace ILSpy.Metadata // popup trigger, no separate dropdown chevron needed since the popup carries // the entire filter UI. PlacementTarget is the funnel itself so the popup // drops below the icon's hit area. - popup = BuildFlagsPopup(filter, propertyType, filterIconHost); + popup = BuildFlagsPopup(filter, propertyType, filterIconHost, pageKey, columnName); headerContent = label; } else @@ -321,14 +326,20 @@ namespace ILSpy.Metadata return box; } - static Popup BuildFlagsPopup(ColumnFilter filter, Type enumType, Control placementTarget) + static Popup BuildFlagsPopup(ColumnFilter filter, Type enumType, Control placementTarget, string? pageKey, string columnName) { // Schema-driven popup: FlagsFilterPopup distinguishes mutex sub-ranges // (multi-select chips) from independent flags (tri-state pills) and drives // ColumnFilter.FlagsState. The funnel icon owns the open gesture, so this // helper only assembles the Popup itself — no separate trigger button needed. var schema = ILSpy.Metadata.Filters.FlagsSchemaInferer.For(enumType); + bool freshlyCreated = filter.FlagsState is null; filter.FlagsState ??= new ILSpy.Metadata.Filters.FilterState(schema); + // On first-popup-open, restore any persisted state for this (table, column). + // Subsequent opens already carry the live state in-memory. Then subscribe so + // later mutations write back to SessionSettings. + if (freshlyCreated && pageKey != null) + ApplyPersistedFilterState(filter.FlagsState, pageKey, columnName); var popupContent = new ILSpy.Views.Filters.FlagsFilterPopup(filter.FlagsState); var popupRoot = new Border { @@ -373,6 +384,42 @@ namespace ILSpy.Metadata return popup; } + /// + /// Restores any saved FilterState XML from SessionSettings into + /// on first creation, then subscribes to its property-changed stream so subsequent + /// mutations write back. Wrapped in a try/catch — composition isn't available in + /// design-time previews or in some isolated unit-test paths, and failing the + /// restore is strictly less bad than failing the popup build entirely. + /// + static void ApplyPersistedFilterState( + ILSpy.Metadata.Filters.FilterState state, + string pageKey, + string columnName) + { + SessionSettings settings; + try + { + settings = AppEnv.AppComposition.Current + .GetExport() + .SessionSettings; + } + catch + { + return; + } + var key = (pageKey, columnName); + if (settings.FilterStates.TryGetValue(key, out var savedXml)) + { + ILSpy.Metadata.Filters.FilterStatePersistence.ApplyXml(state, savedXml); + } + state.PropertyChanged += (_, _) => { + if (state.IsEmpty) + settings.FilterStates.Remove(key); + else + settings.FilterStates[key] = ILSpy.Metadata.Filters.FilterStatePersistence.ToXml(state); + }; + } + static DataGridTextColumn BuildTextColumn(PropertyInfo prop, ColumnInfoAttribute? info) { var binding = new Binding(prop.Name) { Mode = BindingMode.OneWay }; diff --git a/ILSpy/SessionSettings.cs b/ILSpy/SessionSettings.cs index 59f658d23..21c7f8003 100644 --- a/ILSpy/SessionSettings.cs +++ b/ILSpy/SessionSettings.cs @@ -17,6 +17,7 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Xml.Linq; @@ -77,6 +78,16 @@ namespace ILSpy public Size WindowSize { get; set; } = DefaultWindowSize; + /// + /// Per-(page-key, column-name) cache of serialised FilterStates, so the + /// schema-driven flag-filter dropdowns remember the user's selections across + /// sessions. Page key = entry-type full name (one filter set per metadata table, + /// shared across all loaded assemblies). Mutated by + /// ; round-tripped to XML below. + /// + public Dictionary<(string PageKey, string ColumnName), XElement> FilterStates { get; } + = new(); + public void LoadFromXml(XElement section) { XElement filterSettings = section.Element("FilterSettings") ?? new XElement("FilterSettings"); @@ -102,6 +113,22 @@ namespace ILSpy WindowPosition = new PixelPoint(left, top); WindowSize = new Size(width, height); } + + FilterStates.Clear(); + foreach (var page in section.Elements("FilterStates").Elements("Page")) + { + var pageKey = (string?)page.Attribute("key"); + if (string.IsNullOrEmpty(pageKey)) + continue; + foreach (var column in page.Elements("Column")) + { + var columnName = (string?)column.Attribute("name"); + var stateXml = column.Element("FilterState"); + if (string.IsNullOrEmpty(columnName) || stateXml is null) + continue; + FilterStates[(pageKey, columnName)] = new XElement(stateXml); + } + } } public XElement SaveToXml() @@ -126,6 +153,23 @@ namespace ILSpy section.Add(new XElement(nameof(Theme), Theme)); if (!string.IsNullOrEmpty(CurrentCulture)) section.Add(new XElement(nameof(CurrentCulture), CurrentCulture)); + + if (FilterStates.Count > 0) + { + var filterStates = new XElement("FilterStates"); + foreach (var byPage in FilterStates.GroupBy(kv => kv.Key.PageKey).OrderBy(g => g.Key, StringComparer.Ordinal)) + { + var page = new XElement("Page", new XAttribute("key", byPage.Key)); + foreach (var kv in byPage.OrderBy(kv => kv.Key.ColumnName, StringComparer.Ordinal)) + { + page.Add(new XElement("Column", + new XAttribute("name", kv.Key.ColumnName), + new XElement(kv.Value))); + } + filterStates.Add(page); + } + section.Add(filterStates); + } return section; }