Browse Source

Tree-view options reach the assembly tree

Wires the four Display Settings "Tree view options" through to the rendered tree:

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
2f6536d8ae
  1. 86
      ILSpy.Tests/AssemblyList/ShowMetadataTokensTests.cs
  2. 93
      ILSpy.Tests/AssemblyList/TreeViewSettingsLiveReactivityTests.cs
  3. 84
      ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesGridVerification.cs
  4. 83
      ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesLiveTests.cs
  5. 84
      ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesScreenshot.cs
  6. 107
      ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesTests.cs
  7. 6
      ILSpy.Tests/Metadata/HideEmptyMetadataTablesTests.cs
  8. 90
      ILSpy.Tests/Metadata/HideEmptyMetadataTablesUiBindingTests.cs
  9. 64
      ILSpy/AssemblyTree/AssemblyTreeModel.cs
  10. 4
      ILSpy/Metadata/MetadataTablesTreeNode.cs
  11. 12
      ILSpy/SessionSettings.cs
  12. 56
      ILSpy/TreeNodes/AssemblyTreeNode.cs
  13. 2
      ILSpy/TreeNodes/EventTreeNode.cs
  14. 2
      ILSpy/TreeNodes/FieldTreeNode.cs
  15. 32
      ILSpy/TreeNodes/ILSpyTreeNode.cs
  16. 2
      ILSpy/TreeNodes/MethodTreeNode.cs
  17. 31
      ILSpy/TreeNodes/NamespaceTreeNode.cs
  18. 2
      ILSpy/TreeNodes/PropertyTreeNode.cs
  19. 7
      ILSpy/TreeNodes/TypeTreeNode.cs

86
ILSpy.Tests/AssemblyList/ShowMetadataTokensTests.cs

@ -0,0 +1,86 @@ @@ -0,0 +1,86 @@
// 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.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ILSpy;
using ILSpy.AppEnv;
using ILSpy.TreeNodes;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class ShowMetadataTokensTests
{
[AvaloniaTest]
public async Task Member_Text_Carries_Hex_Or_Decimal_Token_Suffix_Per_DisplaySettings()
{
// Display Settings "Show metadata tokens" + "Show metadata tokens in base 10" must
// reach the tree-node Text values. Mirrors WPF ILSpyTreeNode.GetSuffixString —
// suffix is " @xNNNNNNNN" (hex, 8 digits) or " @NNNNNNNNN" (decimal) when enabled,
// empty otherwise.
var settings = AppComposition.Current.GetExport<SettingsService>().DisplaySettings;
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
// Drill into a known type and grab a method node.
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
typeNode.IsExpanded = true;
var method = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "AsEnumerable");
try
{
settings.ShowMetadataTokens = false;
((string)method.Text).Should().NotContain(" @",
"with ShowMetadataTokens=false the Text must have no token suffix");
settings.ShowMetadataTokens = true;
settings.ShowMetadataTokensInBase10 = false;
var hexText = (string)method.Text;
hexText.Should().MatchRegex(@" @[0-9a-fA-F]{8}$",
"with ShowMetadataTokens=true and Base10=false the suffix is ' @' + 8 hex digits");
settings.ShowMetadataTokensInBase10 = true;
var decText = (string)method.Text;
decText.Should().MatchRegex(@" @\d+$",
"with Base10=true the suffix switches to ' @' + decimal digits");
decText.Should().NotMatchRegex(@" @[0-9a-fA-F]{8}$",
"decimal form must not coincidentally match the hex regex (token > 16777215 makes them distinguishable)");
}
finally
{
settings.ShowMetadataTokens = false;
settings.ShowMetadataTokensInBase10 = false;
}
}
}

93
ILSpy.Tests/AssemblyList/TreeViewSettingsLiveReactivityTests.cs

@ -0,0 +1,93 @@ @@ -0,0 +1,93 @@
// 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.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ICSharpCode.ILSpyX.TreeView;
using ILSpy;
using ILSpy.AppEnv;
using ILSpy.TreeNodes;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class TreeViewSettingsLiveReactivityTests
{
[AvaloniaTest]
public async Task Toggling_ShowMetadataTokens_Raises_PropertyChanged_On_Tree_Node_Text()
{
// Live re-render contract: when the user flips Display Settings → "Show metadata
// tokens" the visible tree nodes must re-fetch their Text (which now includes the
// suffix). Verified by subscribing to a tree node's PropertyChanged and asserting
// 'Text' fires when the setting flips.
var settings = AppComposition.Current.GetExport<SettingsService>().DisplaySettings;
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
try
{
settings.ShowMetadataTokens = false;
settings.ShowMetadataTokensInBase10 = false;
int textFiredCount = 0;
PropertyChangedEventHandler handler = (_, e) => {
if (e.PropertyName == nameof(SharpTreeNode.Text))
textFiredCount++;
};
typeNode.PropertyChanged += handler;
try
{
settings.ShowMetadataTokens = true;
textFiredCount.Should().BeGreaterThan(0,
"flipping ShowMetadataTokens must raise PropertyChanged(Text) on visible tree nodes");
int baseline = textFiredCount;
settings.ShowMetadataTokensInBase10 = true;
textFiredCount.Should().BeGreaterThan(baseline,
"flipping Base10 must also raise PropertyChanged(Text)");
}
finally
{
typeNode.PropertyChanged -= handler;
}
}
finally
{
settings.ShowMetadataTokens = false;
settings.ShowMetadataTokensInBase10 = false;
}
}
}

84
ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesGridVerification.cs

@ -0,0 +1,84 @@ @@ -0,0 +1,84 @@
// 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.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ILSpy;
using ILSpy.AppEnv;
using ILSpy.AssemblyTree;
using ILSpy.TreeNodes;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class UseNestedNamespaceNodesGridVerification
{
[AvaloniaTest]
public async Task Toggling_UseNestedNamespaceNodes_Triggers_BindTree_So_The_Grid_Sees_The_New_Shape()
{
// The pane caches a snapshot of each expanded node's children via the
// HierarchicalOptions.ChildrenSelector — mid-expand mutations to AssemblyTreeNode.
// Children aren't observed by the live grid. The fix raises AssemblyTreeModel.Root
// PropertyChanged so AssemblyListPane.BindTree fires and replaces the
// HierarchicalModel; this test verifies that re-bind happens.
var settings = AppComposition.Current.GetExport<SettingsService>().DisplaySettings;
settings.UseNestedNamespaceNodes = false;
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = pane.FindControl<DataGrid>("TreeGrid")!;
// Expand an assembly to force the ChildrenSelector to fire and cache its snapshot.
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.IsExpanded = true;
await Waiters.WaitForAsync(() => grid.HierarchicalModel != null);
try
{
var modelBefore = grid.HierarchicalModel;
((object?)modelBefore).Should().NotBeNull("baseline: the grid must have a HierarchicalModel before the toggle");
settings.UseNestedNamespaceNodes = true;
await Waiters.WaitForAsync(
() => !ReferenceEquals(grid.HierarchicalModel, modelBefore),
System.TimeSpan.FromSeconds(5));
grid.HierarchicalModel.Should().NotBeSameAs(modelBefore,
"toggling UseNestedNamespaceNodes must force AssemblyListPane.BindTree, "
+ "replacing the HierarchicalModel with one that reads the rebuilt children");
}
finally
{
settings.UseNestedNamespaceNodes = false;
}
}
}

83
ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesLiveTests.cs

@ -0,0 +1,83 @@ @@ -0,0 +1,83 @@
// 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.Linq;
using System.Threading.Tasks;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ILSpy;
using ILSpy.AppEnv;
using ILSpy.TreeNodes;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class UseNestedNamespaceNodesLiveTests
{
[AvaloniaTest]
public async Task Toggling_UseNestedNamespaceNodes_Live_Refreshes_The_Tree_Shape()
{
// Drives the live-reactivity path: toggle the Display-Settings flag and observe
// that the AssemblyTreeNode's namespace children switch between flat and nested
// layouts without a manual rebuild.
var settings = AppComposition.Current.GetExport<SettingsService>().DisplaySettings;
settings.UseNestedNamespaceNodes = false;
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
try
{
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.EnsureLazyChildren();
var flatNamespaces = assemblyNode.Children.OfType<NamespaceTreeNode>()
.Select(n => n.Name).ToList();
flatNamespaces.Should().Contain("System.Linq",
"baseline: flat mode lists 'System.Linq' as a top-level sibling");
// Toggle the live setting — should fan out through MessageBus<SettingsChangedEventArgs>
// to AssemblyTreeModel.OnSettingsChanged and rebuild the namespace subtrees.
settings.UseNestedNamespaceNodes = true;
await Waiters.WaitForAsync(() =>
assemblyNode.Children.OfType<NamespaceTreeNode>().Any(n => n.Name == "System"),
System.TimeSpan.FromSeconds(5));
var nestedNames = assemblyNode.Children.OfType<NamespaceTreeNode>()
.Select(n => n.Name).ToList();
nestedNames.Should().Contain("System",
"after live toggle: nested mode must surface 'System' as top-level");
nestedNames.Should().NotContain("System.Linq",
"after live toggle: the flat 'System.Linq' sibling must disappear");
}
finally
{
settings.UseNestedNamespaceNodes = false;
}
}
}

84
ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesScreenshot.cs

@ -0,0 +1,84 @@ @@ -0,0 +1,84 @@
// 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.IO;
using System.Threading.Tasks;
using Avalonia.Headless;
using Avalonia.Headless.NUnit;
using Avalonia.Threading;
using ILSpy;
using ILSpy.AppEnv;
using ILSpy.TreeNodes;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class UseNestedNamespaceNodesScreenshot
{
[AvaloniaTest]
[Explicit("Visual evidence — requires ILSPY_TESTS_VISIBLE=1 so the headless platform renders.")]
public async Task Capture_Before_And_After_Toggle()
{
var settings = AppComposition.Current.GetExport<SettingsService>().DisplaySettings;
settings.UseNestedNamespaceNodes = false;
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.IsExpanded = true;
await Waiters.WaitForAsync(() => assemblyNode.Children.Count > 0);
Dispatcher.UIThread.RunJobs();
SaveFrame(window, "before-flat");
settings.UseNestedNamespaceNodes = true;
await Waiters.WaitForAsync(() =>
assemblyNode.Children.Count > 0
&& assemblyNode.Children[assemblyNode.Children.Count - 1] is NamespaceTreeNode);
// Re-expand — BindTree replaces the HierarchicalModel and resets visible expansion.
assemblyNode.IsExpanded = true;
Dispatcher.UIThread.RunJobs();
SaveFrame(window, "after-nested");
settings.UseNestedNamespaceNodes = false;
}
static void SaveFrame(global::Avalonia.Controls.Window window, string label)
{
var frame = window.CaptureRenderedFrame();
if (frame is null)
{
TestContext.WriteLine($"[{label}] no frame (headless drawing on)");
return;
}
var path = Path.Combine(Path.GetTempPath(),
$"ilspy-tree-{label}-{System.DateTime.Now:yyyyMMdd-HHmmss}.png");
frame.Save(path);
TestContext.WriteLine($"[{label}] saved {path}");
}
}

107
ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesTests.cs

@ -0,0 +1,107 @@ @@ -0,0 +1,107 @@
// 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.Linq;
using System.Threading.Tasks;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ILSpy;
using ILSpy.AppEnv;
using ILSpy.TreeNodes;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class UseNestedNamespaceNodesTests
{
[AvaloniaTest]
public async Task When_UseNestedNamespaceNodes_True_Namespaces_Are_Hierarchical()
{
// With the setting on, "System" becomes a single root node holding "Collections",
// "IO", "Linq", … as descendants — not the flat "System", "System.Collections",
// "System.IO" siblings the default flat mode produces.
var settings = AppComposition.Current.GetExport<SettingsService>().DisplaySettings;
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
try
{
settings.UseNestedNamespaceNodes = true;
// Use System.Linq's assembly — it has System and System.Linq as namespaces.
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.Children.Clear();
assemblyNode.LazyLoading = true;
assemblyNode.EnsureLazyChildren();
var systemNode = assemblyNode.Children.OfType<NamespaceTreeNode>()
.SingleOrDefault(ns => ns.Name == "System");
((object?)systemNode).Should().NotBeNull(
"in nested mode the top-level node for the System namespace must exist as 'System' (last segment), not 'System.Linq'");
var nestedLinq = systemNode!.Children.OfType<NamespaceTreeNode>()
.SingleOrDefault(ns => ns.Name == "Linq");
((object?)nestedLinq).Should().NotBeNull(
"the System.Linq namespace must nest under the System node in nested mode");
// Sanity: there is NO sibling "System.Linq" at the assembly-node level.
assemblyNode.Children.OfType<NamespaceTreeNode>()
.Select(n => n.Name).Should().NotContain("System.Linq",
"flat-style 'System.Linq' sibling must not appear when nesting is on");
}
finally
{
settings.UseNestedNamespaceNodes = false;
}
}
[AvaloniaTest]
public async Task When_UseNestedNamespaceNodes_False_Namespaces_Are_Flat()
{
// Baseline: the default flat layout keeps every distinct namespace string as a
// sibling under the AssemblyTreeNode.
var settings = AppComposition.Current.GetExport<SettingsService>().DisplaySettings;
settings.UseNestedNamespaceNodes = false;
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.Children.Clear();
assemblyNode.LazyLoading = true;
assemblyNode.EnsureLazyChildren();
var namespaceNames = assemblyNode.Children.OfType<NamespaceTreeNode>()
.Select(n => n.Name).ToList();
namespaceNames.Should().Contain("System.Linq",
"in flat mode 'System.Linq' must appear as a top-level sibling");
}
}

6
ILSpy.Tests/Metadata/HideEmptyMetadataTablesTests.cs

@ -41,10 +41,10 @@ public class HideEmptyMetadataTablesTests @@ -41,10 +41,10 @@ public class HideEmptyMetadataTablesTests
[AvaloniaTest]
public async Task Default_Setting_Hides_Tables_With_Zero_Rows()
{
// Default SessionSettings.HideEmptyMetadataTables = true: every child of the
// Default DisplaySettings.HideEmptyMetadataTables = true: every child of the
// Tables sub-tree must correspond to a non-zero CLI table.
var settings = AppComposition.Current.GetExport<SettingsService>().SessionSettings;
var settings = AppComposition.Current.GetExport<SettingsService>().DisplaySettings;
settings.HideEmptyMetadataTables = true;
var window = AppComposition.Current.GetExport<MainWindow>();
@ -67,7 +67,7 @@ public class HideEmptyMetadataTablesTests @@ -67,7 +67,7 @@ public class HideEmptyMetadataTablesTests
// CoreLib (FieldRva, which is rare in modern managed code) shows up among the
// children.
var settings = AppComposition.Current.GetExport<SettingsService>().SessionSettings;
var settings = AppComposition.Current.GetExport<SettingsService>().DisplaySettings;
settings.HideEmptyMetadataTables = false;
try
{

90
ILSpy.Tests/Metadata/HideEmptyMetadataTablesUiBindingTests.cs

@ -0,0 +1,90 @@ @@ -0,0 +1,90 @@
// 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.Linq;
using System.Threading.Tasks;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ILSpy;
using ILSpy.AppEnv;
using ILSpy.Metadata;
using ILSpy.TreeNodes;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Metadata;
[TestFixture]
public class HideEmptyMetadataTablesUiBindingTests
{
[AvaloniaTest]
public async Task Toggling_DisplaySettings_HideEmptyMetadataTables_Affects_The_Tree()
{
// The Display Settings "Hide empty metadata tables" checkbox binds to
// DisplaySettings.HideEmptyMetadataTables. The tree must read from the SAME source —
// otherwise the checkbox is dead UI. Regression: prior to this fix the tree read
// SessionSettings.HideEmptyMetadataTables (an orphaned parallel setting).
var settings = AppComposition.Current.GetExport<SettingsService>();
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var coreLibName = typeof(object).Assembly.GetName().Name!;
try
{
// Disable via the same property the checkbox writes to.
settings.DisplaySettings.HideEmptyMetadataTables = false;
var withAll = CountVisibleTables(vm, coreLibName);
settings.DisplaySettings.HideEmptyMetadataTables = true;
var withoutEmpty = CountVisibleTables(vm, coreLibName);
withAll.Should().BeGreaterThan(withoutEmpty,
"flipping the Display-Settings flag must cause additional empty tables to surface in the tree");
}
finally
{
settings.DisplaySettings.HideEmptyMetadataTables = true;
}
}
static int CountVisibleTables(MainWindowViewModel vm, string assemblyName)
{
// Each test phase needs a fresh tree fragment because LoadChildren is idempotent —
// the children dictionary is captured the first time the node is expanded, not on
// every read. Re-finding the assembly node + re-walking is the lightweight way to
// re-trigger the metadata-table enumeration against the current setting.
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(assemblyName);
assemblyNode.Children.Clear();
assemblyNode.LazyLoading = true;
assemblyNode.EnsureLazyChildren();
var metadataNode = assemblyNode.Children.OfType<MetadataTreeNode>().Single();
metadataNode.EnsureLazyChildren();
var tables = metadataNode.Children.OfType<MetadataTablesTreeNode>().Single();
tables.EnsureLazyChildren();
return tables.Children.OfType<MetadataTableTreeNode>().Count();
}
}

64
ILSpy/AssemblyTree/AssemblyTreeModel.cs

@ -116,6 +116,10 @@ namespace ILSpy.AssemblyTree @@ -116,6 +116,10 @@ namespace ILSpy.AssemblyTree
// pane, metadata tables, and future decompile commands all push through this same
// channel.
Util.MessageBus<Util.NavigateToReferenceEventArgs>.Subscribers += OnNavigateToReference;
// Live re-render when Display Settings change. WPF leaves these as apply-on-next-
// load; Avalonia opts into reactivity because the Options dialog stays open while
// the user toggles. Property dispatch keeps the work narrow to the affected nodes.
Util.MessageBus<Util.SettingsChangedEventArgs>.Subscribers += OnSettingsChanged;
Id = PaneContentId;
Title = "Assemblies";
CanClose = false;
@ -179,6 +183,66 @@ namespace ILSpy.AssemblyTree @@ -179,6 +183,66 @@ namespace ILSpy.AssemblyTree
NotifyTextChanged(child);
}
void OnSettingsChanged(object? sender, Util.SettingsChangedEventArgs e)
{
if (sender is not Options.DisplaySettings)
return;
if (Root == null)
return;
switch (e.Inner.PropertyName)
{
case nameof(Options.DisplaySettings.ShowMetadataTokens):
case nameof(Options.DisplaySettings.ShowMetadataTokensInBase10):
// Text suffix on member nodes is computed at read time — just fire the
// notification so bound cell templates re-pull.
NotifyTextChanged(Root);
break;
case nameof(Options.DisplaySettings.UseNestedNamespaceNodes):
// Tree shape changes — every loaded AssemblyTreeNode's namespace subtree
// needs rebuilding. Reset Children + LazyLoading and re-trigger the load.
foreach (var asm in Root.Children.OfType<TreeNodes.AssemblyTreeNode>())
{
if (asm.LazyLoading)
continue;
asm.Children.Clear();
asm.LazyLoading = true;
asm.EnsureLazyChildren();
}
// AssemblyListPane caches a snapshot of each expanded node's children via
// the HierarchicalOptions.ChildrenSelector — mid-expand mutations of
// node.Children aren't observed. Re-raising Root forces BindTree to fire,
// which creates a fresh HierarchicalModel that re-reads children on demand.
OnPropertyChanged(nameof(Root));
break;
case nameof(Options.DisplaySettings.HideEmptyMetadataTables):
// Visible MetadataTablesTreeNode instances get their children regenerated;
// untouched (lazy) ones already pick up the new value on first expand.
RebuildMetadataTablesIn(Root);
// Same DataGrid-snapshot problem as the UseNestedNamespaceNodes branch —
// force a re-bind so the rebuilt children make it into the visible grid.
OnPropertyChanged(nameof(Root));
break;
}
}
static void RebuildMetadataTablesIn(SharpTreeNode node)
{
if (node is Metadata.MetadataTablesTreeNode tables)
{
if (!tables.LazyLoading)
{
tables.Children.Clear();
tables.LazyLoading = true;
tables.EnsureLazyChildren();
}
return;
}
if (node.LazyLoading)
return;
foreach (var child in node.Children)
RebuildMetadataTablesIn(child);
}
readonly TaskCompletionSource<bool> treeReadyTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>

4
ILSpy/Metadata/MetadataTablesTreeNode.cs

@ -77,9 +77,9 @@ namespace ILSpy.Metadata @@ -77,9 +77,9 @@ namespace ILSpy.Metadata
{
// Composition isn't always available (design-time previews, isolated tests that
// build the tree directly without booting the app); fall back to the same default
// SessionSettings exposes — keep empty tables hidden to match decade-old WPF UX.
// DisplaySettings exposes — keep empty tables hidden to match decade-old WPF UX.
try
{ return AppComposition.Current.GetExport<SettingsService>().SessionSettings.HideEmptyMetadataTables; }
{ return AppComposition.Current.GetExport<SettingsService>().DisplaySettings.HideEmptyMetadataTables; }
catch { return true; }
}

12
ILSpy/SessionSettings.cs

@ -44,16 +44,6 @@ namespace ILSpy @@ -44,16 +44,6 @@ namespace ILSpy
public LanguageSettings LanguageSettings { get; private set; } = null!;
/// <summary>
/// When <see langword="true"/> (default), the Metadata Tables sub-tree only lists
/// tables whose row count is non-zero — keeps the tree compact for assemblies that
/// don't use, say, GenericParam or ImplMap. Setting it to <see langword="false"/>
/// surfaces every CLI table including empty ones, useful when verifying that a
/// table really has no rows rather than just being filtered out.
/// </summary>
[ObservableProperty]
private bool hideEmptyMetadataTables = true;
[ObservableProperty]
private string? activeAssemblyList;
@ -94,7 +84,6 @@ namespace ILSpy @@ -94,7 +84,6 @@ namespace ILSpy
LanguageSettings = new LanguageSettings(filterSettings, this);
LanguageSettings.PropertyChanged += (s, e) => OnPropertyChanged(nameof(LanguageSettings));
HideEmptyMetadataTables = (bool?)section.Element(nameof(HideEmptyMetadataTables)) ?? true;
ActiveAssemblyList = (string?)section.Element("ActiveAssemblyList");
ActiveLanguageName = (string?)section.Element("ActiveLanguageName");
ActiveTreeViewPath = section.Element("ActiveTreeViewPath")?.Elements().Select(e => (string)e).ToArray();
@ -136,7 +125,6 @@ namespace ILSpy @@ -136,7 +125,6 @@ namespace ILSpy
var section = new XElement(SectionName);
if (LanguageSettings != null)
section.Add(LanguageSettings.SaveAsXml());
section.Add(new XElement(nameof(HideEmptyMetadataTables), HideEmptyMetadataTables));
if (!string.IsNullOrEmpty(ActiveAssemblyList))
section.Add(new XElement("ActiveAssemblyList", ActiveAssemblyList));
if (!string.IsNullOrEmpty(ActiveLanguageName))

56
ILSpy/TreeNodes/AssemblyTreeNode.cs

@ -18,6 +18,7 @@ @@ -18,6 +18,7 @@
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
@ -34,6 +35,7 @@ using ICSharpCode.ILSpyX.FileLoaders; @@ -34,6 +35,7 @@ using ICSharpCode.ILSpyX.FileLoaders;
using ICSharpCode.ILSpyX.PdbProvider;
using ILSpy;
using ILSpy.AppEnv;
using ILSpy.Languages;
namespace ILSpy.TreeNodes
@ -367,8 +369,58 @@ namespace ILSpy.TreeNodes @@ -367,8 +369,58 @@ namespace ILSpy.TreeNodes
.Distinct()
.OrderBy(ns => ns, NaturalStringComparer.Instance);
foreach (var ns in namespaces)
Children.Add(new NamespaceTreeNode(ns, module));
if (TryGetUseNestedNamespaceNodes())
{
// Build the nested chain: every dotted namespace string becomes a node whose
// parent is the namespace one segment shorter. Intermediate ancestors that
// don't appear in the namespaces list themselves (e.g. an assembly that has
// "System.Collections.Generic" but no types directly in "System") still get
// created — EnsureNested walks the parent chain.
var byFullName = new Dictionary<string, NamespaceTreeNode>(StringComparer.Ordinal);
foreach (var ns in namespaces)
EnsureNested(ns, byFullName);
}
else
{
foreach (var ns in namespaces)
Children.Add(new NamespaceTreeNode(ns, module));
}
NamespaceTreeNode EnsureNested(string fullNs, Dictionary<string, NamespaceTreeNode> byFullName)
{
if (byFullName.TryGetValue(fullNs, out var existing))
return existing;
int dot = fullNs.LastIndexOf('.');
NamespaceTreeNode node;
if (dot < 0)
{
// Top-level: display equals full name. The empty-namespace case lands here
// too, mapping to the "-" display.
node = new NamespaceTreeNode(fullNs, module);
Children.Add(node);
}
else
{
var parent = EnsureNested(fullNs.Substring(0, dot), byFullName);
var displayName = fullNs.Substring(dot + 1);
node = new NamespaceTreeNode(displayName, fullNs, module);
parent.Children.Add(node);
}
byFullName[fullNs] = node;
return node;
}
}
static bool TryGetUseNestedNamespaceNodes()
{
try
{
return AppComposition.Current.GetExport<SettingsService>().DisplaySettings.UseNestedNamespaceNodes;
}
catch
{
return false;
}
}
public override FilterResult Filter(LanguageSettings settings)

2
ILSpy/TreeNodes/EventTreeNode.cs

@ -45,7 +45,7 @@ namespace ILSpy.TreeNodes @@ -45,7 +45,7 @@ namespace ILSpy.TreeNodes
Children.Add(new MethodTreeNode(ev.InvokeAccessor));
}
public override object Text => Language.EntityToString(EventDefinition, ConversionFlags.None);
public override object Text => Language.EntityToString(EventDefinition, ConversionFlags.None) + GetSuffixString(EventDefinition);
public override object NavigationText => Language.EntityToString(EventDefinition, ConversionFlags.ShowDeclaringType);

2
ILSpy/TreeNodes/FieldTreeNode.cs

@ -39,7 +39,7 @@ namespace ILSpy.TreeNodes @@ -39,7 +39,7 @@ namespace ILSpy.TreeNodes
FieldDefinition = field ?? throw new ArgumentNullException(nameof(field));
}
public override object Text => Language.EntityToString(FieldDefinition, ConversionFlags.None);
public override object Text => Language.EntityToString(FieldDefinition, ConversionFlags.None) + GetSuffixString(FieldDefinition);
public override object NavigationText => Language.EntityToString(FieldDefinition, ConversionFlags.ShowDeclaringType);

32
ILSpy/TreeNodes/ILSpyTreeNode.cs

@ -19,8 +19,11 @@ @@ -19,8 +19,11 @@
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Reflection.Metadata;
using System.Reflection.Metadata.Ecma335;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpyX.Abstractions;
using ICSharpCode.ILSpyX.TreeView;
@ -76,6 +79,35 @@ namespace ILSpy.TreeNodes @@ -76,6 +79,35 @@ namespace ILSpy.TreeNodes
public Language Language => LanguageService.CurrentLanguage;
/// <summary>
/// Optional " @xNNNNNNNN" (hex) or " @NNNNN" (decimal) suffix appended to entity tree-node
/// <see cref="SharpTreeNode.Text"/> values when the user enables Display Settings →
/// "Show metadata tokens". Mirrors WPF's <c>ILSpyTreeNode.GetSuffixString</c>; format
/// matches byte-for-byte so cross-tool grep on token strings keeps working.
/// </summary>
protected static string GetSuffixString(IMember member) => GetSuffixString(member.MetadataToken);
protected static string GetSuffixString(EntityHandle handle)
{
try
{
// Resolve fresh each call — the static cachedSettingsService can hold a
// previous test's composition root in headless test sweeps, and the cost of
// MEF GetExport on a warm composition is a dictionary lookup.
var settings = AppComposition.Current.GetExport<SettingsService>().DisplaySettings;
if (!settings.ShowMetadataTokens)
return string.Empty;
int token = MetadataTokens.GetToken(handle);
if (settings.ShowMetadataTokensInBase10)
return " @" + token;
return " @" + token.ToString("x8");
}
catch
{
return string.Empty;
}
}
/// <summary>
/// Long-form label used in navigation surfaces — back/forward history dropdowns, future
/// breadcrumb rendering, etc. Defaults to <see cref="SharpTreeNode.Text"/>; override on

2
ILSpy/TreeNodes/MethodTreeNode.cs

@ -39,7 +39,7 @@ namespace ILSpy.TreeNodes @@ -39,7 +39,7 @@ namespace ILSpy.TreeNodes
MethodDefinition = method ?? throw new ArgumentNullException(nameof(method));
}
public override object Text => Language.EntityToString(MethodDefinition, ConversionFlags.None);
public override object Text => Language.EntityToString(MethodDefinition, ConversionFlags.None) + GetSuffixString(MethodDefinition);
public override object NavigationText => Language.EntityToString(MethodDefinition, ConversionFlags.ShowDeclaringType);

31
ILSpy/TreeNodes/NamespaceTreeNode.cs

@ -32,13 +32,36 @@ namespace ILSpy.TreeNodes @@ -32,13 +32,36 @@ namespace ILSpy.TreeNodes
sealed class NamespaceTreeNode : ILSpyTreeNode
{
readonly string name;
readonly string fullName;
readonly MetadataFile module;
/// <summary>
/// Display label for this namespace node. In flat mode this is the full dotted
/// namespace path; in nested mode it's just the last segment (the parent chain
/// supplies the rest). Tests use this to distinguish the two modes.
/// </summary>
public string Name => name;
/// <summary>
/// Full dotted namespace path used to query metadata for member types. Equals
/// <see cref="Name"/> in flat mode; in nested mode it stays the dotted path
/// even though the display label is the last segment only.
/// </summary>
public string FullName => fullName;
public NamespaceTreeNode(string name, MetadataFile module)
: this(name, name, module)
{
}
/// <summary>
/// Nested-mode constructor: separates the display label (last segment) from the
/// full dotted path used to locate types in metadata.
/// </summary>
public NamespaceTreeNode(string displayName, string fullName, MetadataFile module)
{
this.name = name ?? throw new ArgumentNullException(nameof(name));
this.name = displayName ?? throw new ArgumentNullException(nameof(displayName));
this.fullName = fullName ?? throw new ArgumentNullException(nameof(fullName));
this.module = module ?? throw new ArgumentNullException(nameof(module));
LazyLoading = true;
}
@ -50,7 +73,9 @@ namespace ILSpy.TreeNodes @@ -50,7 +73,9 @@ namespace ILSpy.TreeNodes
// Stable identity for SessionSettings.ActiveTreeViewPath (used by AssemblyTreeModel.
// FindNodeByPath / GetPathForNode). Without this override the default Object.ToString
// returns the type name, which makes save/restore of namespace selections silently fail.
public override string ToString() => name;
// Uses the full dotted path so saved-and-restored selections survive a toggle of the
// nested-namespace setting.
public override string ToString() => fullName;
protected override void LoadChildren()
{
@ -59,7 +84,7 @@ namespace ILSpy.TreeNodes @@ -59,7 +84,7 @@ namespace ILSpy.TreeNodes
.Where(t => {
var td = metadata.GetTypeDefinition(t);
return td.GetDeclaringType().IsNil
&& metadata.GetString(td.Namespace) == name;
&& metadata.GetString(td.Namespace) == fullName;
})
.OrderBy(t => metadata.GetString(metadata.GetTypeDefinition(t).Name), NaturalStringComparer.Instance);

2
ILSpy/TreeNodes/PropertyTreeNode.cs

@ -43,7 +43,7 @@ namespace ILSpy.TreeNodes @@ -43,7 +43,7 @@ namespace ILSpy.TreeNodes
Children.Add(new MethodTreeNode(property.Setter));
}
public override object Text => Language.EntityToString(PropertyDefinition, ConversionFlags.None);
public override object Text => Language.EntityToString(PropertyDefinition, ConversionFlags.None) + GetSuffixString(PropertyDefinition);
public override object NavigationText => Language.EntityToString(PropertyDefinition, ConversionFlags.ShowDeclaringType);

7
ILSpy/TreeNodes/TypeTreeNode.cs

@ -55,9 +55,10 @@ namespace ILSpy.TreeNodes @@ -55,9 +55,10 @@ namespace ILSpy.TreeNodes
public override object Text {
get {
var typeDef = ResolveTypeDefinition();
if (typeDef != null)
return Language.TypeToString(typeDef, ConversionFlags.None);
return module.Metadata.GetString(module.Metadata.GetTypeDefinition(handle).Name);
string baseText = typeDef != null
? Language.TypeToString(typeDef, ConversionFlags.None)
: module.Metadata.GetString(module.Metadata.GetTypeDefinition(handle).Name);
return baseText + GetSuffixString(handle);
}
}

Loading…
Cancel
Save