Browse Source

Persist FilterState across sessions

Assisted-by: Claude:claude-opus-4-7:Claude Code

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
94abe36f06
  1. 164
      ILSpy.Tests/Metadata/FilterStateSerializerTests.cs
  2. 94
      ILSpy.Tests/Options/SessionSettingsFilterStatesTests.cs
  3. 110
      ILSpy/Metadata/FilterStatePersistence.cs
  4. 61
      ILSpy/Metadata/MetadataColumnBuilder.cs
  5. 44
      ILSpy/SessionSettings.cs

164
ILSpy.Tests/Metadata/FilterStateSerializerTests.cs

@ -0,0 +1,164 @@ @@ -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;
/// <summary>
/// Round-trips a <see cref="FilterState"/> through XML — the storage shape behind
/// <see cref="FilterStatePersistence"/>. 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).
/// </summary>
[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 <FilterState /> 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<uint>(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));
}
}

94
ILSpy.Tests/Options/SessionSettingsFilterStatesTests.cs

@ -0,0 +1,94 @@ @@ -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;
/// <summary>
/// Pins the SessionSettings ↔ XML shape that backs <c>FilterStatePersistence</c>'s
/// cross-session cache. Without these tests a refactor of <c>LoadFromXml</c> /
/// <c>SaveToXml</c> could silently break the persistence round-trip — the schema
/// drift test alone wouldn't catch a wiring change at the SessionSettings level.
/// </summary>
[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 <FilterStates>/<Page>/<Column> 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 <FilterStates /> 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 <FilterStates>;
// 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();
}
}

110
ILSpy/Metadata/FilterStatePersistence.cs

@ -0,0 +1,110 @@ @@ -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
{
/// <summary>
/// Round-trips a <see cref="FilterState"/> through an <see cref="XElement"/> 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.
/// </summary>
/// <remarks>
/// Wire shape (only non-default constraints are written):
/// <code>
/// &lt;FilterState&gt;
/// &lt;Mutex group="VisibilityMask"&gt;
/// &lt;Value&gt;2&lt;/Value&gt;
/// &lt;Value&gt;4&lt;/Value&gt;
/// &lt;/Mutex&gt;
/// &lt;Flag name="Sealed" state="Required" /&gt;
/// &lt;IndependentMode&gt;Any&lt;/IndependentMode&gt;
/// &lt;/FilterState&gt;
/// </code>
/// 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).
/// </remarks>
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<TriState>((string?)flag.Attribute("state"), out var tri))
state.SetFlagState(flagName, tri);
}
if (Enum.TryParse<MatchMode>(xml.Element(IndependentModeElement)?.Value, out var mode))
state.IndependentMode = mode;
}
}
}

61
ILSpy/Metadata/MetadataColumnBuilder.cs

@ -60,7 +60,7 @@ namespace ILSpy.Metadata @@ -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 @@ -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<DataGridColumn>(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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -373,6 +384,42 @@ namespace ILSpy.Metadata
return popup;
}
/// <summary>
/// Restores any saved FilterState XML from SessionSettings into <paramref name="state"/>
/// 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.
/// </summary>
static void ApplyPersistedFilterState(
ILSpy.Metadata.Filters.FilterState state,
string pageKey,
string columnName)
{
SessionSettings settings;
try
{
settings = AppEnv.AppComposition.Current
.GetExport<SettingsService>()
.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 };

44
ILSpy/SessionSettings.cs

@ -17,6 +17,7 @@ @@ -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 @@ -77,6 +78,16 @@ namespace ILSpy
public Size WindowSize { get; set; } = DefaultWindowSize;
/// <summary>
/// Per-(page-key, column-name) cache of serialised <c>FilterState</c>s, 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
/// <see cref="Metadata.Filters.FilterStatePersistence"/>; round-tripped to XML below.
/// </summary>
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 @@ -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 @@ -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;
}

Loading…
Cancel
Save