diff --git a/ILSpy.Tests/AssemblyList/ShowMetadataTokensTests.cs b/ILSpy.Tests/AssemblyList/ShowMetadataTokensTests.cs new file mode 100644 index 000000000..ca9c67511 --- /dev/null +++ b/ILSpy.Tests/AssemblyList/ShowMetadataTokensTests.cs @@ -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().DisplaySettings; + var window = AppComposition.Current.GetExport(); + 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( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + typeNode.IsExpanded = true; + var method = typeNode.Children.OfType() + .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; + } + } +} diff --git a/ILSpy.Tests/AssemblyList/TreeViewSettingsLiveReactivityTests.cs b/ILSpy.Tests/AssemblyList/TreeViewSettingsLiveReactivityTests.cs new file mode 100644 index 000000000..681eb422a --- /dev/null +++ b/ILSpy.Tests/AssemblyList/TreeViewSettingsLiveReactivityTests.cs @@ -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().DisplaySettings; + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "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; + } + } +} diff --git a/ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesGridVerification.cs b/ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesGridVerification.cs new file mode 100644 index 000000000..42147ef5c --- /dev/null +++ b/ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesGridVerification.cs @@ -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().DisplaySettings; + settings.UseNestedNamespaceNodes = false; + + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + var pane = await window.WaitForComponent(); + var grid = pane.FindControl("TreeGrid")!; + + // Expand an assembly to force the ChildrenSelector to fire and cache its snapshot. + var assemblyNode = vm.AssemblyTreeModel.FindNode("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; + } + } +} diff --git a/ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesLiveTests.cs b/ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesLiveTests.cs new file mode 100644 index 000000000..43e51e594 --- /dev/null +++ b/ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesLiveTests.cs @@ -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().DisplaySettings; + settings.UseNestedNamespaceNodes = false; + + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + + try + { + var assemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); + assemblyNode.EnsureLazyChildren(); + + var flatNamespaces = assemblyNode.Children.OfType() + .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 + // to AssemblyTreeModel.OnSettingsChanged and rebuild the namespace subtrees. + settings.UseNestedNamespaceNodes = true; + + await Waiters.WaitForAsync(() => + assemblyNode.Children.OfType().Any(n => n.Name == "System"), + System.TimeSpan.FromSeconds(5)); + + var nestedNames = assemblyNode.Children.OfType() + .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; + } + } +} diff --git a/ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesScreenshot.cs b/ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesScreenshot.cs new file mode 100644 index 000000000..90cd4c7c8 --- /dev/null +++ b/ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesScreenshot.cs @@ -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().DisplaySettings; + settings.UseNestedNamespaceNodes = false; + + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + + var assemblyNode = vm.AssemblyTreeModel.FindNode("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}"); + } +} diff --git a/ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesTests.cs b/ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesTests.cs new file mode 100644 index 000000000..cd6a91e3a --- /dev/null +++ b/ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesTests.cs @@ -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().DisplaySettings; + var window = AppComposition.Current.GetExport(); + 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("System.Linq"); + assemblyNode.Children.Clear(); + assemblyNode.LazyLoading = true; + assemblyNode.EnsureLazyChildren(); + + var systemNode = assemblyNode.Children.OfType() + .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() + .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() + .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().DisplaySettings; + settings.UseNestedNamespaceNodes = false; + + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + + var assemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); + assemblyNode.Children.Clear(); + assemblyNode.LazyLoading = true; + assemblyNode.EnsureLazyChildren(); + + var namespaceNames = assemblyNode.Children.OfType() + .Select(n => n.Name).ToList(); + + namespaceNames.Should().Contain("System.Linq", + "in flat mode 'System.Linq' must appear as a top-level sibling"); + } +} diff --git a/ILSpy.Tests/Metadata/HideEmptyMetadataTablesTests.cs b/ILSpy.Tests/Metadata/HideEmptyMetadataTablesTests.cs index b8e97a5a8..c9b09105b 100644 --- a/ILSpy.Tests/Metadata/HideEmptyMetadataTablesTests.cs +++ b/ILSpy.Tests/Metadata/HideEmptyMetadataTablesTests.cs @@ -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().SessionSettings; + var settings = AppComposition.Current.GetExport().DisplaySettings; settings.HideEmptyMetadataTables = true; var window = AppComposition.Current.GetExport(); @@ -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().SessionSettings; + var settings = AppComposition.Current.GetExport().DisplaySettings; settings.HideEmptyMetadataTables = false; try { diff --git a/ILSpy.Tests/Metadata/HideEmptyMetadataTablesUiBindingTests.cs b/ILSpy.Tests/Metadata/HideEmptyMetadataTablesUiBindingTests.cs new file mode 100644 index 000000000..2fb44dfe5 --- /dev/null +++ b/ILSpy.Tests/Metadata/HideEmptyMetadataTablesUiBindingTests.cs @@ -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(); + var window = AppComposition.Current.GetExport(); + 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(assemblyName); + assemblyNode.Children.Clear(); + assemblyNode.LazyLoading = true; + assemblyNode.EnsureLazyChildren(); + var metadataNode = assemblyNode.Children.OfType().Single(); + metadataNode.EnsureLazyChildren(); + var tables = metadataNode.Children.OfType().Single(); + tables.EnsureLazyChildren(); + return tables.Children.OfType().Count(); + } +} diff --git a/ILSpy/AssemblyTree/AssemblyTreeModel.cs b/ILSpy/AssemblyTree/AssemblyTreeModel.cs index 6b9518b90..2c57afe53 100644 --- a/ILSpy/AssemblyTree/AssemblyTreeModel.cs +++ b/ILSpy/AssemblyTree/AssemblyTreeModel.cs @@ -116,6 +116,10 @@ namespace ILSpy.AssemblyTree // pane, metadata tables, and future decompile commands all push through this same // channel. Util.MessageBus.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.Subscribers += OnSettingsChanged; Id = PaneContentId; Title = "Assemblies"; CanClose = false; @@ -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()) + { + 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 treeReadyTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); /// diff --git a/ILSpy/Metadata/MetadataTablesTreeNode.cs b/ILSpy/Metadata/MetadataTablesTreeNode.cs index 31d7032ff..85eabd95e 100644 --- a/ILSpy/Metadata/MetadataTablesTreeNode.cs +++ b/ILSpy/Metadata/MetadataTablesTreeNode.cs @@ -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().SessionSettings.HideEmptyMetadataTables; } + { return AppComposition.Current.GetExport().DisplaySettings.HideEmptyMetadataTables; } catch { return true; } } diff --git a/ILSpy/SessionSettings.cs b/ILSpy/SessionSettings.cs index 21c7f8003..9b5bdfd14 100644 --- a/ILSpy/SessionSettings.cs +++ b/ILSpy/SessionSettings.cs @@ -44,16 +44,6 @@ namespace ILSpy public LanguageSettings LanguageSettings { get; private set; } = null!; - /// - /// When (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 - /// surfaces every CLI table including empty ones, useful when verifying that a - /// table really has no rows rather than just being filtered out. - /// - [ObservableProperty] - private bool hideEmptyMetadataTables = true; - [ObservableProperty] private string? activeAssemblyList; @@ -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 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)) diff --git a/ILSpy/TreeNodes/AssemblyTreeNode.cs b/ILSpy/TreeNodes/AssemblyTreeNode.cs index 618c567b5..34f3cac26 100644 --- a/ILSpy/TreeNodes/AssemblyTreeNode.cs +++ b/ILSpy/TreeNodes/AssemblyTreeNode.cs @@ -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; using ICSharpCode.ILSpyX.PdbProvider; using ILSpy; +using ILSpy.AppEnv; using ILSpy.Languages; 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(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 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().DisplaySettings.UseNestedNamespaceNodes; + } + catch + { + return false; + } } public override FilterResult Filter(LanguageSettings settings) diff --git a/ILSpy/TreeNodes/EventTreeNode.cs b/ILSpy/TreeNodes/EventTreeNode.cs index 1597b0a7a..0d7a72969 100644 --- a/ILSpy/TreeNodes/EventTreeNode.cs +++ b/ILSpy/TreeNodes/EventTreeNode.cs @@ -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); diff --git a/ILSpy/TreeNodes/FieldTreeNode.cs b/ILSpy/TreeNodes/FieldTreeNode.cs index ec0aaa4c1..36addfb07 100644 --- a/ILSpy/TreeNodes/FieldTreeNode.cs +++ b/ILSpy/TreeNodes/FieldTreeNode.cs @@ -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); diff --git a/ILSpy/TreeNodes/ILSpyTreeNode.cs b/ILSpy/TreeNodes/ILSpyTreeNode.cs index f429cd948..c3eaf9779 100644 --- a/ILSpy/TreeNodes/ILSpyTreeNode.cs +++ b/ILSpy/TreeNodes/ILSpyTreeNode.cs @@ -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 public Language Language => LanguageService.CurrentLanguage; + /// + /// Optional " @xNNNNNNNN" (hex) or " @NNNNN" (decimal) suffix appended to entity tree-node + /// values when the user enables Display Settings → + /// "Show metadata tokens". Mirrors WPF's ILSpyTreeNode.GetSuffixString; format + /// matches byte-for-byte so cross-tool grep on token strings keeps working. + /// + 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().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; + } + } + /// /// Long-form label used in navigation surfaces — back/forward history dropdowns, future /// breadcrumb rendering, etc. Defaults to ; override on diff --git a/ILSpy/TreeNodes/MethodTreeNode.cs b/ILSpy/TreeNodes/MethodTreeNode.cs index f7317e355..e826b5a9f 100644 --- a/ILSpy/TreeNodes/MethodTreeNode.cs +++ b/ILSpy/TreeNodes/MethodTreeNode.cs @@ -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); diff --git a/ILSpy/TreeNodes/NamespaceTreeNode.cs b/ILSpy/TreeNodes/NamespaceTreeNode.cs index 5d4aa5ce3..ea0a0faf3 100644 --- a/ILSpy/TreeNodes/NamespaceTreeNode.cs +++ b/ILSpy/TreeNodes/NamespaceTreeNode.cs @@ -32,13 +32,36 @@ namespace ILSpy.TreeNodes sealed class NamespaceTreeNode : ILSpyTreeNode { readonly string name; + readonly string fullName; readonly MetadataFile module; + /// + /// 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. + /// public string Name => name; + /// + /// Full dotted namespace path used to query metadata for member types. Equals + /// in flat mode; in nested mode it stays the dotted path + /// even though the display label is the last segment only. + /// + public string FullName => fullName; + public NamespaceTreeNode(string name, MetadataFile module) + : this(name, name, module) + { + } + + /// + /// Nested-mode constructor: separates the display label (last segment) from the + /// full dotted path used to locate types in metadata. + /// + 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 // 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 .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); diff --git a/ILSpy/TreeNodes/PropertyTreeNode.cs b/ILSpy/TreeNodes/PropertyTreeNode.cs index 32927c7e2..db76b855c 100644 --- a/ILSpy/TreeNodes/PropertyTreeNode.cs +++ b/ILSpy/TreeNodes/PropertyTreeNode.cs @@ -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); diff --git a/ILSpy/TreeNodes/TypeTreeNode.cs b/ILSpy/TreeNodes/TypeTreeNode.cs index d94753353..ae9c7c041 100644 --- a/ILSpy/TreeNodes/TypeTreeNode.cs +++ b/ILSpy/TreeNodes/TypeTreeNode.cs @@ -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); } }