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;
}