diff --git a/ILSpy.Tests/Metadata/MetadataProtocolHandlerDrillDownTests.cs b/ILSpy.Tests/Metadata/MetadataProtocolHandlerDrillDownTests.cs
new file mode 100644
index 000000000..b1c70c1d1
--- /dev/null
+++ b/ILSpy.Tests/Metadata/MetadataProtocolHandlerDrillDownTests.cs
@@ -0,0 +1,151 @@
+// 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.Reflection.Metadata;
+using System.Reflection.Metadata.Ecma335;
+using System.Threading.Tasks;
+
+using Avalonia.Headless.NUnit;
+
+using AwesomeAssertions;
+
+using ILSpy.AppEnv;
+using ILSpy.Metadata;
+using ILSpy.Metadata.CorTables;
+using ILSpy.TreeNodes;
+using ILSpy.ViewModels;
+using ILSpy.Views;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Metadata;
+
+///
+/// Drill-down half of the metadata:// protocol handler: when the supplied handle
+/// belongs to a metadata table, the handler should resolve to that specific
+/// rather than just landing on the per-assembly
+/// . WPF's FindNodeByHandleKind is the helper that
+/// turns a into the matching table tree node; this test fixture
+/// pins both the helper and the protocol-handler wiring.
+///
+[TestFixture]
+public class MetadataProtocolHandlerDrillDownTests
+{
+ [AvaloniaTest]
+ public async Task FindNodeByHandleKind_Returns_The_TypeDef_Table_For_A_TypeDefinition_Handle()
+ {
+ // Direct exercise of the helper. CoreLib is guaranteed to contain a TypeDef table —
+ // the assert simply checks Kind == TableIndex.TypeDef. The HandleKind→TableIndex cast
+ // is what the helper relies on; if the underlying enums ever diverge we want a loud
+ // failure here rather than a silent "always returns null".
+ var metadataNode = await GetMetadataTreeNodeForCoreLib();
+
+ var result = metadataNode.FindNodeByHandleKind(HandleKind.TypeDefinition);
+
+ ((object?)result).Should().NotBeNull();
+ result!.Kind.Should().Be(TableIndex.TypeDef, "the TypeDef table backs HandleKind.TypeDefinition");
+ }
+
+ [AvaloniaTest]
+ public async Task FindNodeByHandleKind_Returns_Null_For_A_Heap_Handle_Kind()
+ {
+ // Heap handles (UserString / Blob / Guid / String) don't have a backing
+ // MetadataTableTreeNode — they live as siblings under the MetadataTreeNode itself.
+ // The helper must return null in that case so the caller can fall back to the
+ // per-assembly Metadata node instead of drilling into a wrong table.
+ var metadataNode = await GetMetadataTreeNodeForCoreLib();
+
+ var result = metadataNode.FindNodeByHandleKind(HandleKind.UserString);
+
+ ((object?)result).Should().BeNull();
+ }
+
+ [AvaloniaTest]
+ public async Task MetadataProtocolHandler_Drills_Into_TypeDef_Table_For_A_TypeDefinition_Handle()
+ {
+ // Full integration: hand a TypeDefinitionHandle to the protocol handler and expect
+ // the resolved node to be the TypeDef MetadataTableTreeNode under the per-assembly
+ // Metadata folder. Without the drill-down patch the handler would return the parent
+ // MetadataTreeNode itself, which makes the user scroll-find the right table by hand.
+ var (handler, module, _) = await SetUpHandler();
+ var someTypeDef = module.Metadata.TypeDefinitions.First();
+
+ var result = handler.Resolve("metadata", module, someTypeDef, out _);
+
+ // SharpTreeNode has a custom .Should() extension in this assembly that hides BeOfType,
+ // so compare exact types directly.
+ ((object?)result).Should().NotBeNull();
+ result!.GetType().Should().Be(typeof(TypeDefTableTreeNode),
+ "TypeDefinition handles must drill into the TypeDef table");
+ }
+
+ [AvaloniaTest]
+ public async Task MetadataProtocolHandler_Falls_Back_To_MetadataTreeNode_For_Heap_Handles()
+ {
+ // String / UserString / Blob / Guid handles don't map to a per-table tree node, so
+ // the handler stays at the Metadata folder level. Users can still expand to the right
+ // heap from there; the alternative (returning null) would suppress navigation entirely.
+ var (handler, module, _) = await SetUpHandler();
+
+ // Construct a UserStringHandle via MetadataTokens (the public ctor is internal).
+ var heapHandle = MetadataTokens.UserStringHandle(1);
+
+ var result = handler.Resolve("metadata", module, heapHandle, out _);
+
+ ((object?)result).Should().NotBeNull();
+ result!.GetType().Should().Be(typeof(MetadataTreeNode),
+ "heap handles have no table match, so the handler should keep the user at the Metadata folder");
+ }
+
+ // --- helpers ---------------------------------------------------------------------------
+
+ static async Task GetMetadataTreeNodeForCoreLib()
+ {
+ 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!;
+ var assemblyNode = vm.AssemblyTreeModel.FindNode(coreLibName);
+ assemblyNode.EnsureLazyChildren();
+ var metadataNode = assemblyNode.Children.OfType().Single();
+ // Drill-down needs the children realised; EnsureLazyChildren cascades only one level.
+ metadataNode.EnsureLazyChildren();
+ var tablesNode = metadataNode.Children.OfType().Single();
+ tablesNode.EnsureLazyChildren();
+ return metadataNode;
+ }
+
+ static async Task<(MetadataProtocolHandler handler, ICSharpCode.Decompiler.Metadata.MetadataFile module, MetadataTreeNode metadataNode)> SetUpHandler()
+ {
+ var metadataNode = await GetMetadataTreeNodeForCoreLib();
+ var vm = (MainWindowViewModel)AppComposition.Current.GetExport().DataContext!;
+ var coreLibName = typeof(object).Assembly.GetName().Name!;
+ var assemblyNode = vm.AssemblyTreeModel.FindNode(coreLibName);
+ var module = assemblyNode.LoadedAssembly.GetMetadataFileOrNull()!;
+ // The handler is exported as IProtocolHandler, not as itself — fish out the
+ // MetadataProtocolHandler concrete impl from the contract list.
+ var handler = AppComposition.Current
+ .GetExports()
+ .OfType()
+ .Single();
+ return (handler, module, metadataNode);
+ }
+}
diff --git a/ILSpy/Metadata/MetadataProtocolHandler.cs b/ILSpy/Metadata/MetadataProtocolHandler.cs
index 9ea731507..e9b6bddde 100644
--- a/ILSpy/Metadata/MetadataProtocolHandler.cs
+++ b/ILSpy/Metadata/MetadataProtocolHandler.cs
@@ -52,18 +52,23 @@ namespace ILSpy.Metadata
newTabPage = true;
if (protocol != "metadata")
return null;
- var assemblyNode = assemblyTreeModel.FindTreeNode(module) as AssemblyTreeNode;
+ // AssemblyTreeModel.FindTreeNode only resolves EntityReference/ITypeDefinition/IMember,
+ // not MetadataFile — walk the assembly-tree root manually here. Same lookup pattern
+ // FindTypeNode uses internally.
+ var assemblyNode = (assemblyTreeModel.Root as AssemblyListTreeNode)?.Children
+ .OfType()
+ .FirstOrDefault(a => a.LoadedAssembly.GetMetadataFileOrNull() == module);
if (assemblyNode == null)
return null;
assemblyNode.EnsureLazyChildren();
var metadataNode = assemblyNode.Children.OfType().FirstOrDefault();
if (metadataNode == null)
return null;
- // WPF additionally drills into the matching table for the handle kind. The
- // Avalonia MetadataTreeNode doesn't yet expose a FindNodeByHandleKind helper;
- // surfacing the MetadataTreeNode itself is enough to land the user inside the
- // per-assembly metadata view. Specific-table drill-down is a follow-up.
- return metadataNode;
+ // Drill into the matching metadata table when the handle is table-backed. Heap
+ // handles (String / UserString / Blob / Guid) have no per-table node, so we fall
+ // back to the per-assembly Metadata folder — the user can expand the right heap
+ // sibling from there.
+ return (ILSpyTreeNode?)metadataNode.FindNodeByHandleKind(handle.Kind) ?? metadataNode;
}
}
}
diff --git a/ILSpy/Metadata/MetadataTreeNode.cs b/ILSpy/Metadata/MetadataTreeNode.cs
index 6002351d1..55a9bbad5 100644
--- a/ILSpy/Metadata/MetadataTreeNode.cs
+++ b/ILSpy/Metadata/MetadataTreeNode.cs
@@ -17,6 +17,7 @@
// DEALINGS IN THE SOFTWARE.
using System;
+using System.Linq;
using System.Reflection.Metadata;
using System.Reflection.Metadata.Ecma335;
@@ -59,6 +60,26 @@ namespace ILSpy.Metadata
DumpMetadataInfo(language, output, metadataFile.Metadata);
}
+ ///
+ /// Resolves a to the per-table tree node under this metadata
+ /// folder, or null when the handle has no table (e.g. heap handles: String /
+ /// UserString / Blob / Guid). Used by MetadataProtocolHandler to drill into
+ /// the right table when navigating a metadata:// reference. Cast from
+ /// to relies on the two enums sharing
+ /// numeric values for the 0..44 metadata-table range.
+ ///
+ public MetadataTableTreeNode? FindNodeByHandleKind(HandleKind kind)
+ {
+ EnsureLazyChildren();
+ var tables = Children.OfType().FirstOrDefault();
+ if (tables is null)
+ return null;
+ tables.EnsureLazyChildren();
+ return tables.Children
+ .OfType()
+ .FirstOrDefault(x => x.Kind == (TableIndex)kind);
+ }
+
internal static void DumpMetadataInfo(Language language, ITextOutput output, MetadataReader metadata)
{
language.WriteCommentLine(output, "MetadataKind: " + metadata.MetadataKind);