diff --git a/ICSharpCode.ILSpyX/Analyzers/Builtin/ModuleDependentCodeAnalyzer.cs b/ICSharpCode.ILSpyX/Analyzers/Builtin/ModuleDependentCodeAnalyzer.cs new file mode 100644 index 00000000000..9b22c0e1791 --- /dev/null +++ b/ICSharpCode.ILSpyX/Analyzers/Builtin/ModuleDependentCodeAnalyzer.cs @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Piero Viano +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.Composition; + +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpyX.Dependencies; + +namespace ICSharpCode.ILSpyX.Analyzers.Builtin +{ + /// + /// For an assembly, lists the code in the other assemblies of the list that depends on it: every + /// type or member whose signature, base types, attributes or body uses a type or member of the + /// analyzed assembly. Results are grouped by referencing assembly (assemblies in list order). + /// + [ExportAnalyzer(Header = ModuleAnalyzerHeaders.DependentCode, Order = 20)] + [Shared] + class ModuleDependentCodeAnalyzer : IAnalyzer + { + public bool Show(ISymbol? symbol) => symbol is IModule { MetadataFile.IsAssembly: true }; + + public IEnumerable Analyze(ISymbol analyzedSymbol, AnalyzerContext context) + { + ArgumentNullException.ThrowIfNull(analyzedSymbol); + ArgumentNullException.ThrowIfNull(context); + if (analyzedSymbol is not IModule { MetadataFile: { } target }) + throw new ArgumentException("A module with metadata is required.", nameof(analyzedSymbol)); + return AnalyzeCore(target, context); + } + + static IEnumerable AnalyzeCore(MetadataFile target, AnalyzerContext context) + { + var ct = context.CancellationToken; + var assemblies = context.AssemblyList.GetAllAssemblies().GetAwaiter().GetResult(); + foreach (var assembly in assemblies) + { + ct.ThrowIfCancellationRequested(); + var module = assembly.GetMetadataFileOrNull(); + if (module == null || module.IsMetadataOnly || ReferenceEquals(module, target)) + continue; + var references = DependentCodeScanner.FindReferencesTo(module, target); + if (references.Count == 0) + continue; + var typeSystem = context.GetOrCreateTypeSystem(module); + foreach (var symbol in DependentCodeScanner.FindDependentSymbols(module, typeSystem, references, ct)) + yield return symbol; + } + } + } +} diff --git a/ICSharpCode.ILSpyX/Analyzers/Builtin/ModuleReferencedByAnalyzer.cs b/ICSharpCode.ILSpyX/Analyzers/Builtin/ModuleReferencedByAnalyzer.cs new file mode 100644 index 00000000000..c3d2828da23 --- /dev/null +++ b/ICSharpCode.ILSpyX/Analyzers/Builtin/ModuleReferencedByAnalyzer.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Piero Viano +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.Composition; + +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpyX.Dependencies; + +namespace ICSharpCode.ILSpyX.Analyzers.Builtin +{ + /// + /// For an assembly, lists the assemblies of the list that reference it (by name and public key + /// token; the referenced version may differ). Each result is itself a module, so it can be + /// expanded to follow the chain of referencing assemblies further. + /// + [ExportAnalyzer(Header = ModuleAnalyzerHeaders.ReferencedBy, Order = 10)] + [Shared] + class ModuleReferencedByAnalyzer : IAnalyzer + { + public bool Show(ISymbol? symbol) => symbol is IModule { MetadataFile.IsAssembly: true }; + + public IEnumerable Analyze(ISymbol analyzedSymbol, AnalyzerContext context) + { + ArgumentNullException.ThrowIfNull(analyzedSymbol); + ArgumentNullException.ThrowIfNull(context); + if (analyzedSymbol is not IModule { MetadataFile: { } target }) + throw new ArgumentException("A module with metadata is required.", nameof(analyzedSymbol)); + return AnalyzeCore(target, context); + } + + static IEnumerable AnalyzeCore(MetadataFile target, AnalyzerContext context) + { + var assemblies = context.AssemblyList.GetAllAssemblies().GetAwaiter().GetResult(); + foreach (var referencing in ReferencedByFinder.FindReferencingAssemblies(assemblies, target, context.CancellationToken)) + { + context.CancellationToken.ThrowIfCancellationRequested(); + yield return context.GetOrCreateTypeSystem(referencing.Module).MainModule; + } + } + } +} diff --git a/ICSharpCode.ILSpyX/Analyzers/ModuleAnalyzerHeaders.cs b/ICSharpCode.ILSpyX/Analyzers/ModuleAnalyzerHeaders.cs new file mode 100644 index 00000000000..ba821fd3354 --- /dev/null +++ b/ICSharpCode.ILSpyX/Analyzers/ModuleAnalyzerHeaders.cs @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Piero Viano +// +// 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. + +namespace ICSharpCode.ILSpyX.Analyzers +{ + /// + /// Headers of the built-in analyzers that apply to whole modules, so hosts can locate the + /// matching analyzer row of an analyzed module. + /// + public static class ModuleAnalyzerHeaders + { + public const string ReferencedBy = "Referenced By"; + public const string DependentCode = "Dependent Code"; + } +} diff --git a/ICSharpCode.ILSpyX/Dependencies/AssemblyDependencyGraph.cs b/ICSharpCode.ILSpyX/Dependencies/AssemblyDependencyGraph.cs new file mode 100644 index 00000000000..58d9c09ae09 --- /dev/null +++ b/ICSharpCode.ILSpyX/Dependencies/AssemblyDependencyGraph.cs @@ -0,0 +1,248 @@ +// Copyright (c) 2026 Piero Viano +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +using ICSharpCode.Decompiler.Metadata; + +namespace ICSharpCode.ILSpyX.Dependencies +{ + /// + /// An assembly in an : either a resolved assembly (it has + /// a ) or a reference that could not be resolved. + /// + public sealed class AssemblyDependencyNode + { + public AssemblyDependencyNode(int index, string name, string fullName, Version? version, + MetadataFile? metadataFile, bool isRoot) + { + Index = index; + Name = name ?? throw new ArgumentNullException(nameof(name)); + FullName = fullName ?? throw new ArgumentNullException(nameof(fullName)); + Version = version; + MetadataFile = metadataFile; + IsRoot = isRoot; + } + + /// Position of this node in . + public int Index { get; } + + /// Simple assembly name. + public string Name { get; } + + /// Full assembly name (the definition's for a resolved node, the reference's otherwise). + public string FullName { get; } + + public Version? Version { get; } + + /// The resolved metadata, or for an unresolved reference. + public MetadataFile? MetadataFile { get; } + + /// The file the assembly was loaded from, or null when unresolved. + public string? FileName => MetadataFile?.FileName; + + public bool IsResolved => MetadataFile != null; + + /// True for the assemblies the graph was built from. + public bool IsRoot { get; } + + public override string ToString() => FullName; + } + + /// + /// A "references" edge between two nodes of an . + /// + public sealed class AssemblyDependencyEdge + { + public AssemblyDependencyEdge(int from, int to, string referenceFullName, Version? referencedVersion, bool isVersionMismatch) + { + From = from; + To = to; + ReferenceFullName = referenceFullName ?? throw new ArgumentNullException(nameof(referenceFullName)); + ReferencedVersion = referencedVersion; + IsVersionMismatch = isVersionMismatch; + } + + /// Index of the referencing node. + public int From { get; } + + /// Index of the referenced node. + public int To { get; } + + /// The full name the reference row asks for. + public string ReferenceFullName { get; } + + public Version? ReferencedVersion { get; } + + /// True when the reference was satisfied by a different version. + public bool IsVersionMismatch { get; } + } + + /// + /// The assembly-reference graph reachable from a set of root assemblies: one node per distinct + /// assembly (resolved assemblies keyed by file, unresolved references keyed by simple name) and + /// one edge per referencing/referenced pair. Node order is breadth-first from the roots, with + /// each assembly's references visited by name, so the same input always yields the same graph. + /// + public sealed class AssemblyDependencyGraph + { + internal AssemblyDependencyGraph(IReadOnlyList nodes, IReadOnlyList edges) + { + Nodes = nodes ?? throw new ArgumentNullException(nameof(nodes)); + Edges = edges ?? throw new ArgumentNullException(nameof(edges)); + } + + public IReadOnlyList Nodes { get; } + + public IReadOnlyList Edges { get; } + + /// Edges in (From, To) form, as consumed by . + public IReadOnlyList<(int From, int To)> EdgePairs => Edges.Select(e => (e.From, e.To)).ToList(); + + /// + /// Builds the graph for . Each reference is resolved with the + /// resolver of the loaded assembly that owns the referencing file (or, for a file that is not + /// in the list, with the resolver of the root it was reached from), exactly as the + /// References folder of the assembly tree resolves it. + /// + /// When false only the roots' direct references are added. + public static AssemblyDependencyGraph Build(IEnumerable roots, bool transitive = true, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(roots); + var rootList = roots.ToList(); + var owners = new Dictionary(); + var rootFiles = new List(); + foreach (var root in rootList) + { + var file = root.GetMetadataFileOrNull(); + if (file == null) + continue; + owners[file] = root; + rootFiles.Add(file); + } + var resolvers = new Dictionary(); + + return Build(rootFiles, Resolve, transitive, cancellationToken); + + MetadataFile? Resolve(MetadataFile referencing, AssemblyReference reference, MetadataFile reachedFrom) + { + if (!owners.TryGetValue(referencing, out var owner)) + { + var root = owners.GetValueOrDefault(reachedFrom) ?? rootList[0]; + owner = root.AssemblyList.FindAssembly(referencing.FileName) ?? root; + owners[referencing] = owner; + } + if (!resolvers.TryGetValue(owner, out var resolver)) + { + resolver = owner.GetAssemblyResolver(); + resolvers[owner] = resolver; + } + try + { + return resolver.Resolve(reference); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // A reference whose file exists but cannot be read is shown as unresolved. + return null; + } + } + } + + /// + /// Core graph construction over raw metadata. receives the + /// referencing file, the reference and the root the referencing file was reached from. + /// + internal static AssemblyDependencyGraph Build(IReadOnlyList roots, + Func resolve, bool transitive, + CancellationToken cancellationToken) + { + var nodes = new List(); + var edges = new List(); + var edgeKeys = new HashSet<(int, int)>(); + var byKey = new Dictionary(StringComparer.OrdinalIgnoreCase); + var queue = new Queue<(MetadataFile File, int Index, MetadataFile Root)>(); + + foreach (var root in roots) + { + if (byKey.ContainsKey(FileKey(root))) + continue; + int index = AddResolved(root, isRoot: true); + queue.Enqueue((root, index, root)); + } + + while (queue.Count > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + var (file, index, root) = queue.Dequeue(); + foreach (var reference in file.AssemblyReferences.OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase)) + { + cancellationToken.ThrowIfCancellationRequested(); + var resolved = resolve(file, reference, root); + int target; + bool mismatch = false; + if (resolved != null) + { + if (!byKey.TryGetValue(FileKey(resolved), out target)) + { + target = AddResolved(resolved, isRoot: false); + if (transitive) + queue.Enqueue((resolved, target, root)); + } + mismatch = AssemblyReferenceMatcher.IsVersionMismatch(reference, resolved); + } + else + { + string key = UnresolvedKey(reference.Name); + if (!byKey.TryGetValue(key, out target)) + { + target = nodes.Count; + nodes.Add(new AssemblyDependencyNode(target, reference.Name, reference.FullName, + reference.Version, metadataFile: null, isRoot: false)); + byKey.Add(key, target); + } + } + if (target != index && edgeKeys.Add((index, target))) + edges.Add(new AssemblyDependencyEdge(index, target, reference.FullName, reference.Version, mismatch)); + } + } + + return new AssemblyDependencyGraph(nodes, edges); + + int AddResolved(MetadataFile file, bool isRoot) + { + int index = nodes.Count; + var metadata = file.Metadata; + string name = AssemblyReferenceMatcher.GetAssemblyName(metadata) ?? file.Name; + string fullName = metadata.IsAssembly ? file.FullName : file.Name; + nodes.Add(new AssemblyDependencyNode(index, name, fullName, + AssemblyReferenceMatcher.GetAssemblyVersion(metadata), file, isRoot)); + byKey.Add(FileKey(file), index); + return index; + } + } + + static string FileKey(MetadataFile file) => "file:" + file.FileName; + + static string UnresolvedKey(string name) => "unresolved:" + name; + } +} diff --git a/ICSharpCode.ILSpyX/Dependencies/AssemblyReferenceMatcher.cs b/ICSharpCode.ILSpyX/Dependencies/AssemblyReferenceMatcher.cs new file mode 100644 index 00000000000..d8a843df7df --- /dev/null +++ b/ICSharpCode.ILSpyX/Dependencies/AssemblyReferenceMatcher.cs @@ -0,0 +1,107 @@ +// Copyright (c) 2026 Piero Viano +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Reflection.Metadata; + +using ICSharpCode.Decompiler.Metadata; + +namespace ICSharpCode.ILSpyX.Dependencies +{ + /// + /// Decides whether an assembly reference denotes a given assembly definition by its identity: + /// the simple name (case-insensitive) and the public key token. The version is deliberately not + /// part of the identity -- binding redirects and unified frameworks routinely satisfy a + /// reference with a different version -- callers report a version mismatch separately. + /// + public static class AssemblyReferenceMatcher + { + /// The token string used when an assembly has no public key. + public const string NullToken = "null"; + + /// + /// The public key token of an assembly reference as lower-case hex, or . + /// + public static string GetPublicKeyToken(IAssemblyReference reference) + { + ArgumentNullException.ThrowIfNull(reference); + var token = reference.PublicKeyToken; + if (token == null || token.Length == 0) + return NullToken; + return Convert.ToHexString(token).ToLowerInvariant(); + } + + /// + /// The public key token of the assembly defined by as lower-case + /// hex, when it has no public key, or when the + /// metadata does not define an assembly (a netmodule). + /// + public static string? GetPublicKeyToken(MetadataReader metadata) + { + ArgumentNullException.ThrowIfNull(metadata); + if (!metadata.IsAssembly) + return null; + return metadata.GetPublicKeyToken().ToLowerInvariant(); + } + + /// The simple name of the assembly defined by , or null for a netmodule. + public static string? GetAssemblyName(MetadataReader metadata) + { + ArgumentNullException.ThrowIfNull(metadata); + if (!metadata.IsAssembly) + return null; + return metadata.GetString(metadata.GetAssemblyDefinition().Name); + } + + /// The version of the assembly defined by , or null for a netmodule. + public static Version? GetAssemblyVersion(MetadataReader metadata) + { + ArgumentNullException.ThrowIfNull(metadata); + if (!metadata.IsAssembly) + return null; + return metadata.GetAssemblyDefinition().Version; + } + + /// + /// True when names the assembly defined by : + /// same simple name (ignoring case) and same public key token. + /// + public static bool Matches(IAssemblyReference reference, MetadataFile target) + { + ArgumentNullException.ThrowIfNull(reference); + ArgumentNullException.ThrowIfNull(target); + var metadata = target.Metadata; + var name = GetAssemblyName(metadata); + if (name == null || !string.Equals(reference.Name, name, StringComparison.OrdinalIgnoreCase)) + return false; + return string.Equals(GetPublicKeyToken(reference), GetPublicKeyToken(metadata), StringComparison.OrdinalIgnoreCase); + } + + /// + /// True when asks for a different version than the one + /// defines. Unknown versions never count as a mismatch. + /// + public static bool IsVersionMismatch(IAssemblyReference reference, MetadataFile target) + { + ArgumentNullException.ThrowIfNull(reference); + ArgumentNullException.ThrowIfNull(target); + var defined = GetAssemblyVersion(target.Metadata); + return reference.Version != null && defined != null && reference.Version != defined; + } + } +} diff --git a/ICSharpCode.ILSpyX/Dependencies/DependencyGraphMermaidWriter.cs b/ICSharpCode.ILSpyX/Dependencies/DependencyGraphMermaidWriter.cs new file mode 100644 index 00000000000..d87469ab529 --- /dev/null +++ b/ICSharpCode.ILSpyX/Dependencies/DependencyGraphMermaidWriter.cs @@ -0,0 +1,146 @@ +// Copyright (c) 2026 Piero Viano +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Globalization; +using System.Net; +using System.Text; + +namespace ICSharpCode.ILSpyX.Dependencies +{ + /// + /// Renders an as a Mermaid flowchart (graph LR), + /// either as raw Mermaid text or as a self-contained HTML page that loads mermaid.js from the + /// same CDN location the HTML class diagrammer (MermaidDiagrammer/html/template.html) uses. + /// + public static class DependencyGraphMermaidWriter + { + /// The mermaid.js build the generated page loads, shared with the class diagrammer. + public const string MermaidScriptUrl = "https://cdn.jsdelivr.net/npm/mermaid@11.4.0/dist/mermaid.min.js"; + + /// Mermaid node id of the node at . + public static string NodeId(int index) => "n" + index.ToString(CultureInfo.InvariantCulture); + + /// + /// Mermaid flowchart text for : one quoted, escaped node per assembly + /// (name and version), one arrow per reference (labelled with the referenced version when it + /// differs from the resolved one), and style classes for root and unresolved nodes. + /// + public static string ToMermaid(AssemblyDependencyGraph graph) + { + ArgumentNullException.ThrowIfNull(graph); + var sb = new StringBuilder(); + sb.Append("graph LR\n"); + foreach (var node in graph.Nodes) + { + string label = node.Version != null ? node.Name + " " + node.Version : node.Name; + if (!node.IsResolved) + label += " (unresolved)"; + sb.Append(" ").Append(NodeId(node.Index)).Append("[\"").Append(EscapeLabel(label)).Append("\"]\n"); + } + foreach (var edge in graph.Edges) + { + sb.Append(" ").Append(NodeId(edge.From)).Append(" -->"); + if (edge.IsVersionMismatch && edge.ReferencedVersion != null) + sb.Append("|\"").Append(EscapeLabel("v" + edge.ReferencedVersion)).Append("\"|"); + sb.Append(' ').Append(NodeId(edge.To)).Append('\n'); + } + sb.Append(" classDef root font-weight:bold,stroke-width:3px;\n"); + sb.Append(" classDef unresolved fill:#fde2e2,stroke:#c0392b,stroke-dasharray:4 3,color:#c0392b;\n"); + foreach (var node in graph.Nodes) + { + if (node.IsRoot) + sb.Append(" class ").Append(NodeId(node.Index)).Append(" root;\n"); + if (!node.IsResolved) + sb.Append(" class ").Append(NodeId(node.Index)).Append(" unresolved;\n"); + } + return sb.ToString(); + } + + /// + /// A standalone HTML page that renders with mermaid.js. + /// is shown as the page title and heading. + /// + public static string ToHtml(AssemblyDependencyGraph graph, string title) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentNullException.ThrowIfNull(title); + string encodedTitle = WebUtility.HtmlEncode(title); + var sb = new StringBuilder(); + sb.Append("\n"); + sb.Append("\n\n\n"); + sb.Append("").Append(encodedTitle).Append("\n"); + sb.Append("\n"); + sb.Append("\n\n"); + sb.Append("

").Append(encodedTitle).Append("

\n"); + // mermaid reads the element's text content, so HTML-encoding here is undone before parsing. + sb.Append("
\n").Append(WebUtility.HtmlEncode(ToMermaid(graph))).Append("
\n"); + sb.Append("\n"); + sb.Append("\n"); + sb.Append("\n\n"); + return sb.ToString(); + } + + /// + /// Escapes text for use inside a double-quoted Mermaid label. Mermaid decodes + /// #name; / #code; entity codes, so every character that could end the label, + /// open markup or start an entity is replaced by one; line breaks become spaces. + /// + public static string EscapeLabel(string text) + { + ArgumentNullException.ThrowIfNull(text); + var sb = new StringBuilder(text.Length); + foreach (char c in text) + { + switch (c) + { + case '#': + sb.Append("#35;"); + break; + case '"': + sb.Append("#quot;"); + break; + case '<': + sb.Append("#lt;"); + break; + case '>': + sb.Append("#gt;"); + break; + case '&': + sb.Append("#amp;"); + break; + case '|': + sb.Append("#124;"); + break; + case '`': + sb.Append("#96;"); + break; + case '\r': + case '\n': + case '\t': + sb.Append(' '); + break; + default: + sb.Append(c); + break; + } + } + return sb.ToString(); + } + } +} diff --git a/ICSharpCode.ILSpyX/Dependencies/DependentCodeScanner.cs b/ICSharpCode.ILSpyX/Dependencies/DependentCodeScanner.cs new file mode 100644 index 00000000000..26026e511ac --- /dev/null +++ b/ICSharpCode.ILSpyX/Dependencies/DependentCodeScanner.cs @@ -0,0 +1,310 @@ +// Copyright (c) 2026 Piero Viano +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection.Metadata; +using System.Threading; + +using ICSharpCode.Decompiler; +using ICSharpCode.Decompiler.Disassembler; +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpyX.Analyzers; + +namespace ICSharpCode.ILSpyX.Dependencies +{ + /// + /// Finds the code of one module that depends on another assembly: the types, methods, fields, + /// properties and events whose signatures, base types, interfaces, generic constraints, custom + /// attributes, locals or IL operands mention a type or member whose resolution scope is one of + /// a given set of assembly references. Works on raw metadata, so nothing is resolved across + /// assemblies and an unresolvable target still counts as a dependency. + /// + sealed class DependentCodeScanner : ISignatureTypeProvider + { + readonly MetadataFile module; + readonly MetadataReader metadata; + readonly HashSet targetReferences; + readonly Dictionary typeReferenceCache = new(); + readonly Dictionary typeSpecificationCache = new(); + + DependentCodeScanner(MetadataFile module, IEnumerable targetReferences) + { + this.module = module; + this.metadata = module.Metadata; + this.targetReferences = new HashSet(targetReferences); + } + + /// + /// The assembly references of that denote + /// (same name and public key token). + /// + public static IReadOnlyList FindReferencesTo(MetadataFile module, MetadataFile target) + { + return module.AssemblyReferences + .Where(r => AssemblyReferenceMatcher.Matches(r, target)) + .Select(r => r.Handle) + .ToList(); + } + + /// + /// Entities of 's main module (which must be built over + /// ) that depend on any of , + /// each reported once, in metadata order (types, then members, then attribute owners). + /// Accessors are reported as their owning property or event. + /// + public static IEnumerable FindDependentSymbols(MetadataFile module, DecompilerTypeSystem typeSystem, + IReadOnlyCollection targetReferences, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(module); + ArgumentNullException.ThrowIfNull(typeSystem); + ArgumentNullException.ThrowIfNull(targetReferences); + if (targetReferences.Count == 0) + return Array.Empty(); + var scanner = new DependentCodeScanner(module, targetReferences); + return scanner.Scan(typeSystem.MainModule, typeSystem, cancellationToken).Distinct(); + } + + IEnumerable Scan(MetadataModule mainModule, DecompilerTypeSystem typeSystem, CancellationToken ct) + { + foreach (var h in metadata.TypeDefinitions) + { + ct.ThrowIfCancellationRequested(); + var td = metadata.GetTypeDefinition(h); + bool found = UsesTarget(td.BaseType); + foreach (var ih in td.GetInterfaceImplementations()) + found |= UsesTarget(metadata.GetInterfaceImplementation(ih).Interface); + found |= UsesTargetInConstraints(td.GetGenericParameters()); + if (found && mainModule.GetDefinition(h) is { } type) + yield return type; + } + + foreach (var h in metadata.MethodDefinitions) + { + ct.ThrowIfCancellationRequested(); + var md = metadata.GetMethodDefinition(h); + bool found = Safe(() => AnyInSignature(md.DecodeSignature(this, null))) + || UsesTargetInConstraints(md.GetGenericParameters()) + || UsesTargetInBody(md); + if (found && mainModule.GetDefinition(h) is { } method) + yield return method.AccessorOwner ?? method; + } + + foreach (var h in metadata.FieldDefinitions) + { + ct.ThrowIfCancellationRequested(); + var fd = metadata.GetFieldDefinition(h); + if (Safe(() => fd.DecodeSignature(this, null)) && mainModule.GetDefinition(h) is { } field) + yield return field; + } + + foreach (var h in metadata.PropertyDefinitions) + { + ct.ThrowIfCancellationRequested(); + var pd = metadata.GetPropertyDefinition(h); + if (Safe(() => AnyInSignature(pd.DecodeSignature(this, null))) && mainModule.GetDefinition(h) is { } property) + yield return property; + } + + foreach (var h in metadata.EventDefinitions) + { + ct.ThrowIfCancellationRequested(); + var ed = metadata.GetEventDefinition(h); + if (UsesTarget(ed.Type) && mainModule.GetDefinition(h) is { } ev) + yield return ev; + } + + foreach (var h in metadata.CustomAttributes) + { + ct.ThrowIfCancellationRequested(); + var attribute = metadata.GetCustomAttribute(h); + if (!UsesTarget(attribute.Constructor)) + continue; + var parent = AnalyzerHelpers.GetParentEntity(typeSystem, attribute); + if (parent != null) + yield return parent; + } + } + + static bool Safe(Func check) + { + try + { + return check(); + } + catch (BadImageFormatException) + { + return false; + } + catch (EnumUnderlyingTypeResolveException) + { + return false; + } + } + + static bool AnyInSignature(MethodSignature signature) + => signature.ReturnType || signature.ParameterTypes.Any(p => p); + + bool UsesTargetInConstraints(GenericParameterHandleCollection parameters) + { + foreach (var h in parameters) + { + var gp = metadata.GetGenericParameter(h); + foreach (var ch in gp.GetConstraints()) + { + if (UsesTarget(metadata.GetGenericParameterConstraint(ch).Type)) + return true; + } + } + return false; + } + + bool UsesTargetInBody(MethodDefinition md) + { + if (md.RelativeVirtualAddress == 0) + return false; + MethodBodyBlock body; + try + { + body = module.GetMethodBody(md.RelativeVirtualAddress); + } + catch (BadImageFormatException) + { + return false; + } + if (!body.LocalSignature.IsNil && UsesTarget(body.LocalSignature)) + return true; + var reader = body.GetILReader(); + try + { + while (reader.RemainingBytes > 0) + { + var opCode = reader.DecodeOpCode(); + switch (opCode.GetOperandType()) + { + case OperandType.Field: + case OperandType.Method: + case OperandType.Sig: + case OperandType.Tok: + case OperandType.Type: + if (UsesTarget(MetadataTokenHelpers.EntityHandleOrNil(reader.ReadInt32()))) + return true; + break; + default: + reader.SkipOperand(opCode); + break; + } + } + } + catch (BadImageFormatException) + { + // A truncated or malformed body is scanned up to the point where it breaks. + } + return false; + } + + /// + /// True when (a type or member reference, a type or method + /// specification, or a standalone signature) mentions the target assembly. + /// + bool UsesTarget(EntityHandle handle) + { + if (handle.IsNil) + return false; + return Safe(() => { + switch (handle.Kind) + { + case HandleKind.TypeReference: + return IsTargetTypeReference((TypeReferenceHandle)handle); + case HandleKind.TypeSpecification: + return IsTargetTypeSpecification((TypeSpecificationHandle)handle); + case HandleKind.MemberReference: + var mr = metadata.GetMemberReference((MemberReferenceHandle)handle); + if (UsesTarget(mr.Parent)) + return true; + return mr.GetKind() switch { + MemberReferenceKind.Method => AnyInSignature(mr.DecodeMethodSignature(this, null)), + MemberReferenceKind.Field => mr.DecodeFieldSignature(this, null), + _ => false, + }; + case HandleKind.MethodSpecification: + var ms = metadata.GetMethodSpecification((MethodSpecificationHandle)handle); + return UsesTarget(ms.Method) || ms.DecodeSignature(this, null).Any(t => t); + case HandleKind.StandaloneSignature: + var ss = metadata.GetStandaloneSignature((StandaloneSignatureHandle)handle); + return ss.GetKind() switch { + StandaloneSignatureKind.Method => AnyInSignature(ss.DecodeMethodSignature(this, null)), + StandaloneSignatureKind.LocalVariables => ss.DecodeLocalSignature(this, null).Any(t => t), + _ => false, + }; + default: + // Definitions in this module and module references are not the target. + return false; + } + }); + } + + bool IsTargetTypeReference(TypeReferenceHandle handle) + { + if (typeReferenceCache.TryGetValue(handle, out bool cached)) + return cached; + var scope = metadata.GetTypeReference(handle).ResolutionScope; + bool result = scope.Kind switch { + HandleKind.AssemblyReference => targetReferences.Contains((AssemblyReferenceHandle)scope), + HandleKind.TypeReference => IsTargetTypeReference((TypeReferenceHandle)scope), + _ => false, + }; + typeReferenceCache[handle] = result; + return result; + } + + bool IsTargetTypeSpecification(TypeSpecificationHandle handle) + { + if (typeSpecificationCache.TryGetValue(handle, out bool cached)) + return cached; + // Guards against a (malformed) self-referencing specification while it is decoded. + typeSpecificationCache[handle] = false; + bool result = metadata.GetTypeSpecification(handle).DecodeSignature(this, null); + typeSpecificationCache[handle] = result; + return result; + } + + #region ISignatureTypeProvider + public bool GetArrayType(bool elementType, ArrayShape shape) => elementType; + public bool GetByReferenceType(bool elementType) => elementType; + public bool GetFunctionPointerType(MethodSignature signature) => AnyInSignature(signature); + public bool GetGenericInstantiation(bool genericType, ImmutableArray typeArguments) + => genericType || typeArguments.Any(t => t); + public bool GetGenericMethodParameter(object? genericContext, int index) => false; + public bool GetGenericTypeParameter(object? genericContext, int index) => false; + public bool GetModifiedType(bool modifier, bool unmodifiedType, bool isRequired) => modifier || unmodifiedType; + public bool GetPinnedType(bool elementType) => elementType; + public bool GetPointerType(bool elementType) => elementType; + public bool GetPrimitiveType(PrimitiveTypeCode typeCode) => false; + public bool GetSZArrayType(bool elementType) => elementType; + public bool GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) => false; + public bool GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind) + => IsTargetTypeReference(handle); + public bool GetTypeFromSpecification(MetadataReader reader, object? genericContext, TypeSpecificationHandle handle, byte rawTypeKind) + => IsTargetTypeSpecification(handle); + #endregion + } +} diff --git a/ICSharpCode.ILSpyX/Dependencies/LayeredLayout.cs b/ICSharpCode.ILSpyX/Dependencies/LayeredLayout.cs new file mode 100644 index 00000000000..a2605c55a41 --- /dev/null +++ b/ICSharpCode.ILSpyX/Dependencies/LayeredLayout.cs @@ -0,0 +1,418 @@ +// Copyright (c) 2026 Piero Viano +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ICSharpCode.ILSpyX.Dependencies +{ + /// A point in layout coordinates. + public readonly record struct LayoutPoint(double X, double Y); + + /// An axis-aligned rectangle in layout coordinates. + public readonly record struct LayoutRect(double X, double Y, double Width, double Height) + { + public double Right => X + Width; + public double Bottom => Y + Height; + public double CenterY => Y + Height / 2; + + public bool Contains(double x, double y) => x >= X && x <= Right && y >= Y && y <= Bottom; + } + + /// Spacing parameters for . + public sealed class LayeredLayoutOptions + { + public double NodeHeight { get; init; } = 28; + public double DefaultNodeWidth { get; init; } = 120; + /// Horizontal gap between two adjacent layers. + public double LayerSpacing { get; init; } = 80; + /// Vertical gap between two adjacent nodes of the same layer. + public double NodeSpacing { get; init; } = 16; + /// Empty border around the drawing. + public double Margin { get; init; } = 16; + /// Number of alternating barycenter sweeps used to reduce edge crossings. + public int SweepIterations { get; init; } = 24; + } + + /// The routed polyline of one input edge. + public sealed class LayeredLayoutEdge + { + internal LayeredLayoutEdge(int from, int to, IReadOnlyList points, bool isReversed) + { + From = from; + To = to; + Points = points; + IsReversed = isReversed; + } + + public int From { get; } + public int To { get; } + + /// + /// Polyline from the node to the node: the first point + /// lies on the border of the source rectangle, the last on the border of the target. + /// + public IReadOnlyList Points { get; } + + /// True when the edge closes a cycle and therefore points against the layer direction. + public bool IsReversed { get; } + } + + /// Result of . + public sealed class LayeredLayoutResult + { + internal LayeredLayoutResult(IReadOnlyList layers, IReadOnlyList nodes, + IReadOnlyList edges, int layerCount, int crossingCount, double width, double height) + { + Layers = layers; + Nodes = nodes; + Edges = edges; + LayerCount = layerCount; + CrossingCount = crossingCount; + Width = width; + Height = height; + } + + /// Layer (column) index of each input node. + public IReadOnlyList Layers { get; } + + /// Rectangle of each input node. + public IReadOnlyList Nodes { get; } + + /// One routed edge per distinct, non-self-loop input edge, in input order. + public IReadOnlyList Edges { get; } + + public int LayerCount { get; } + + /// Number of segment crossings between adjacent layers in the chosen ordering. + public int CrossingCount { get; } + + public double Width { get; } + public double Height { get; } + + /// Index of the node whose rectangle contains the point, or -1. + public int HitTest(double x, double y) + { + for (int i = 0; i < Nodes.Count; i++) + { + if (Nodes[i].Contains(x, y)) + return i; + } + return -1; + } + } + + /// + /// A simple Sugiyama-style layered layout, laid out left to right: cycles are broken by + /// reversing DFS back edges, nodes are assigned to layers by longest path from the sources, + /// long edges are split by virtual nodes, and the order inside each layer is improved by + /// alternating barycenter sweeps that keep the ordering with the fewest crossings. The result + /// depends only on the input (node and edge order included), so it is deterministic. + /// + public static class LayeredLayout + { + public static LayeredLayoutResult Compute(int nodeCount, IReadOnlyList<(int From, int To)> edges, + IReadOnlyList? nodeWidths = null, LayeredLayoutOptions? options = null) + { + if (nodeCount < 0) + throw new ArgumentOutOfRangeException(nameof(nodeCount)); + ArgumentNullException.ThrowIfNull(edges); + if (nodeWidths != null && nodeWidths.Count != nodeCount) + throw new ArgumentException("One width per node is required.", nameof(nodeWidths)); + options ??= new LayeredLayoutOptions(); + + // Distinct, non-self-loop edges in input order. + var inputEdges = new List<(int From, int To)>(); + var seen = new HashSet<(int, int)>(); + foreach (var (from, to) in edges) + { + if ((uint)from >= (uint)nodeCount || (uint)to >= (uint)nodeCount) + throw new ArgumentOutOfRangeException(nameof(edges), $"Edge {from}->{to} references a missing node."); + if (from != to && seen.Add((from, to))) + inputEdges.Add((from, to)); + } + + bool[] reversed = FindBackEdges(nodeCount, inputEdges); + var dag = inputEdges.Select((e, i) => reversed[i] ? (From: e.To, To: e.From) : e).ToList(); + int[] layer = AssignLayers(nodeCount, dag); + int layerCount = nodeCount == 0 ? 0 : layer.Max() + 1; + + // Vertex ids: real nodes first, then virtual nodes splitting edges that span several layers. + var vertexLayer = new List(layer); + var chains = new List>(); + var preds = new List>(); + var succs = new List>(); + for (int i = 0; i < nodeCount; i++) + { + preds.Add(new List()); + succs.Add(new List()); + } + foreach (var (from, to) in dag) + { + var chain = new List { from }; + for (int l = layer[from] + 1; l < layer[to]; l++) + { + int dummy = vertexLayer.Count; + vertexLayer.Add(l); + preds.Add(new List()); + succs.Add(new List()); + chain.Add(dummy); + } + chain.Add(to); + for (int k = 0; k + 1 < chain.Count; k++) + { + succs[chain[k]].Add(chain[k + 1]); + preds[chain[k + 1]].Add(chain[k]); + } + chains.Add(chain); + } + + var order = new List>(); + for (int l = 0; l < layerCount; l++) + order.Add(new List()); + for (int v = 0; v < vertexLayer.Count; v++) + order[vertexLayer[v]].Add(v); + + order = ReduceCrossings(order, preds, succs, vertexLayer.Count, options.SweepIterations, out int crossings); + + // Coordinates. + double Width(int v) => v < nodeCount ? (nodeWidths?[v] ?? options.DefaultNodeWidth) : 0; + double Height(int v) => v < nodeCount ? options.NodeHeight : 0; + + var layerX = new double[layerCount]; + var layerWidth = new double[layerCount]; + double x = options.Margin; + for (int l = 0; l < layerCount; l++) + { + layerX[l] = x; + layerWidth[l] = order[l].Count == 0 ? 0 : order[l].Max(Width); + x += layerWidth[l] + options.LayerSpacing; + } + double totalWidth = layerCount == 0 ? 0 : layerX[layerCount - 1] + layerWidth[layerCount - 1] + options.Margin; + + var stackHeight = new double[layerCount]; + for (int l = 0; l < layerCount; l++) + { + var vs = order[l]; + stackHeight[l] = vs.Sum(Height) + Math.Max(0, vs.Count - 1) * options.NodeSpacing; + } + double maxStack = layerCount == 0 ? 0 : stackHeight.Max(); + double totalHeight = layerCount == 0 ? 0 : maxStack + 2 * options.Margin; + + var vertexY = new double[vertexLayer.Count]; + var rects = new LayoutRect[nodeCount]; + for (int l = 0; l < layerCount; l++) + { + double y = options.Margin + (maxStack - stackHeight[l]) / 2; + foreach (int v in order[l]) + { + double h = Height(v); + vertexY[v] = y + h / 2; + if (v < nodeCount) + rects[v] = new LayoutRect(layerX[l], y, Width(v), h); + y += h + options.NodeSpacing; + } + } + + var routed = new List(inputEdges.Count); + for (int i = 0; i < inputEdges.Count; i++) + { + var chain = chains[i]; + var points = new List(); + var source = rects[chain[0]]; + points.Add(new LayoutPoint(source.Right, source.CenterY)); + for (int k = 1; k + 1 < chain.Count; k++) + { + int d = chain[k]; + int l = vertexLayer[d]; + points.Add(new LayoutPoint(layerX[l], vertexY[d])); + points.Add(new LayoutPoint(layerX[l] + layerWidth[l], vertexY[d])); + } + var target = rects[chain[chain.Count - 1]]; + points.Add(new LayoutPoint(target.X, target.CenterY)); + if (reversed[i]) + points.Reverse(); + routed.Add(new LayeredLayoutEdge(inputEdges[i].From, inputEdges[i].To, points, reversed[i])); + } + + return new LayeredLayoutResult(layer, rects, routed, layerCount, crossings, totalWidth, totalHeight); + } + + /// + /// Marks the edges that point back to a node on the current DFS path. Reversing exactly + /// those makes the graph acyclic. Nodes and their out-edges are visited in input order. + /// + static bool[] FindBackEdges(int nodeCount, List<(int From, int To)> edges) + { + var outEdges = new List[nodeCount]; + for (int i = 0; i < nodeCount; i++) + outEdges[i] = new List(); + for (int i = 0; i < edges.Count; i++) + outEdges[edges[i].From].Add(i); + + var reversed = new bool[edges.Count]; + var state = new byte[nodeCount]; // 0 = unvisited, 1 = on stack, 2 = done + var stack = new Stack<(int Node, int Next)>(); + for (int start = 0; start < nodeCount; start++) + { + if (state[start] != 0) + continue; + stack.Push((start, 0)); + state[start] = 1; + while (stack.Count > 0) + { + var (node, next) = stack.Pop(); + if (next < outEdges[node].Count) + { + stack.Push((node, next + 1)); + int edge = outEdges[node][next]; + int to = edges[edge].To; + if (state[to] == 1) + { + reversed[edge] = true; + } + else if (state[to] == 0) + { + state[to] = 1; + stack.Push((to, 0)); + } + } + else + { + state[node] = 2; + } + } + } + return reversed; + } + + /// Longest-path layering: a node sits one layer right of its furthest predecessor. + static int[] AssignLayers(int nodeCount, List<(int From, int To)> dag) + { + var layer = new int[nodeCount]; + var inDegree = new int[nodeCount]; + var outEdges = new List[nodeCount]; + for (int i = 0; i < nodeCount; i++) + outEdges[i] = new List(); + foreach (var (from, to) in dag) + { + outEdges[from].Add(to); + inDegree[to]++; + } + var ready = new Queue(); + for (int i = 0; i < nodeCount; i++) + { + if (inDegree[i] == 0) + ready.Enqueue(i); + } + while (ready.Count > 0) + { + int node = ready.Dequeue(); + foreach (int to in outEdges[node]) + { + layer[to] = Math.Max(layer[to], layer[node] + 1); + if (--inDegree[to] == 0) + ready.Enqueue(to); + } + } + return layer; + } + + static List> ReduceCrossings(List> order, List> preds, List> succs, + int vertexCount, int iterations, out int bestCrossings) + { + var position = new int[vertexCount]; + UpdatePositions(order, position); + var best = Copy(order); + bestCrossings = CountCrossings(order, succs, position); + for (int iter = 0; iter < iterations && bestCrossings > 0; iter++) + { + bool down = iter % 2 == 0; + if (down) + { + for (int l = 1; l < order.Count; l++) + SortByBarycenter(order[l], preds, position); + } + else + { + for (int l = order.Count - 2; l >= 0; l--) + SortByBarycenter(order[l], succs, position); + } + int crossings = CountCrossings(order, succs, position); + if (crossings < bestCrossings) + { + bestCrossings = crossings; + best = Copy(order); + } + } + return best; + } + + static void SortByBarycenter(List layer, List> neighbors, int[] position) + { + var keyed = layer + .Select(v => (Vertex: v, Key: neighbors[v].Count == 0 ? position[v] : neighbors[v].Average(n => (double)position[n]))) + .OrderBy(t => t.Key) + .ThenBy(t => position[t.Vertex]) + .Select(t => t.Vertex) + .ToList(); + for (int i = 0; i < keyed.Count; i++) + { + layer[i] = keyed[i]; + position[keyed[i]] = i; + } + } + + static int CountCrossings(List> order, List> succs, int[] position) + { + int crossings = 0; + for (int l = 0; l + 1 < order.Count; l++) + { + var segments = new List<(int Upper, int Lower)>(); + foreach (int v in order[l]) + { + foreach (int w in succs[v]) + segments.Add((position[v], position[w])); + } + for (int i = 0; i < segments.Count; i++) + { + for (int j = i + 1; j < segments.Count; j++) + { + var a = segments[i]; + var b = segments[j]; + if ((a.Upper < b.Upper && a.Lower > b.Lower) || (a.Upper > b.Upper && a.Lower < b.Lower)) + crossings++; + } + } + } + return crossings; + } + + static void UpdatePositions(List> order, int[] position) + { + foreach (var layer in order) + { + for (int i = 0; i < layer.Count; i++) + position[layer[i]] = i; + } + } + + static List> Copy(List> order) => order.Select(l => new List(l)).ToList(); + } +} diff --git a/ICSharpCode.ILSpyX/Dependencies/ReferencedByFinder.cs b/ICSharpCode.ILSpyX/Dependencies/ReferencedByFinder.cs new file mode 100644 index 00000000000..e1416c8177a --- /dev/null +++ b/ICSharpCode.ILSpyX/Dependencies/ReferencedByFinder.cs @@ -0,0 +1,99 @@ +// Copyright (c) 2026 Piero Viano +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +using ICSharpCode.Decompiler.Metadata; + +namespace ICSharpCode.ILSpyX.Dependencies +{ + /// + /// One loaded assembly that references a given target assembly, together with the reference + /// row that names it. + /// + public sealed class ReferencingAssembly + { + public ReferencingAssembly(LoadedAssembly assembly, MetadataFile module, AssemblyReference reference, bool isVersionMismatch) + { + Assembly = assembly ?? throw new ArgumentNullException(nameof(assembly)); + Module = module ?? throw new ArgumentNullException(nameof(module)); + Reference = reference ?? throw new ArgumentNullException(nameof(reference)); + IsVersionMismatch = isVersionMismatch; + } + + /// The referencing assembly. + public LoadedAssembly Assembly { get; } + + /// The metadata of . + public MetadataFile Module { get; } + + /// The assembly reference in that names the target. + public AssemblyReference Reference { get; } + + /// True when the reference asks for a different version than the target defines. + public bool IsVersionMismatch { get; } + } + + /// + /// Finds the assemblies of a list that reference a given assembly (the reverse direction of the + /// References folder). A reference matches by simple name and public key token; the version may + /// differ and is reported through . + /// + public static class ReferencedByFinder + { + /// + /// Returns every assembly in other than + /// itself that has an assembly reference matching , ordered by + /// short name. Assemblies that failed to load are skipped. + /// + public static IReadOnlyList FindReferencingAssemblies( + IEnumerable candidates, MetadataFile target, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(candidates); + ArgumentNullException.ThrowIfNull(target); + var results = new List(); + if (!target.IsAssembly) + return results; + foreach (var candidate in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + var module = candidate.GetMetadataFileOrNull(); + if (module == null || ReferenceEquals(module, target) || !module.IsAssembly) + continue; + if (string.Equals(module.FileName, target.FileName, StringComparison.OrdinalIgnoreCase)) + continue; + foreach (var reference in module.AssemblyReferences) + { + if (AssemblyReferenceMatcher.Matches(reference, target)) + { + results.Add(new ReferencingAssembly(candidate, module, reference, + AssemblyReferenceMatcher.IsVersionMismatch(reference, target))); + break; + } + } + } + return results + .OrderBy(r => r.Assembly.ShortName, StringComparer.OrdinalIgnoreCase) + .ThenBy(r => r.Assembly.FileName, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + } +} diff --git a/ICSharpCode.ILSpyX/Dependencies/UnresolvedReferencesReport.cs b/ICSharpCode.ILSpyX/Dependencies/UnresolvedReferencesReport.cs new file mode 100644 index 00000000000..35680ce024f --- /dev/null +++ b/ICSharpCode.ILSpyX/Dependencies/UnresolvedReferencesReport.cs @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Piero Viano +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +using ICSharpCode.Decompiler.Metadata; + +namespace ICSharpCode.ILSpyX.Dependencies +{ + /// An assembly reference that could not be resolved, with its resolution log. + public sealed class UnresolvedReference + { + public UnresolvedReference(AssemblyReference reference, IReadOnlyList<(MessageKind Kind, string Message)> messages) + { + Reference = reference ?? throw new ArgumentNullException(nameof(reference)); + Messages = messages ?? throw new ArgumentNullException(nameof(messages)); + } + + public AssemblyReference Reference { get; } + + /// The probing messages the assembly resolver logged for this reference. + public IReadOnlyList<(MessageKind Kind, string Message)> Messages { get; } + } + + /// The unresolved references of one referencing assembly. + public sealed class UnresolvedReferenceGroup + { + public UnresolvedReferenceGroup(LoadedAssembly assembly, IReadOnlyList references) + { + Assembly = assembly ?? throw new ArgumentNullException(nameof(assembly)); + References = references ?? throw new ArgumentNullException(nameof(references)); + } + + public LoadedAssembly Assembly { get; } + + public IReadOnlyList References { get; } + } + + /// + /// Collects every assembly reference that does not resolve, grouped by the referencing + /// assembly. Resolution goes through each assembly's own resolver (the same one the References + /// folder uses), so the probing log it records is what the report shows. + /// + public static class UnresolvedReferencesReport + { + /// + /// Resolves every assembly reference of and returns the groups + /// that have at least one unresolved reference, in input order. Assemblies that failed to + /// load are skipped. Must not run on the UI thread: resolving may probe the file system. + /// + public static IReadOnlyList Collect(IEnumerable assemblies, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(assemblies); + var groups = new List(); + foreach (var assembly in assemblies.ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + var module = assembly.GetMetadataFileOrNull(); + if (module == null) + continue; + var resolver = assembly.GetAssemblyResolver(); + var unresolved = new List(); + foreach (var reference in module.AssemblyReferences) + { + cancellationToken.ThrowIfCancellationRequested(); + MetadataFile? resolved; + try + { + resolved = resolver.Resolve(reference); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + resolved = null; + } + if (resolved != null) + continue; + IReadOnlyList<(MessageKind, string)> messages = + assembly.LoadedAssemblyReferencesInfo.TryGetInfo(reference.FullName, out var info) + ? info.Messages.ToList() + : Array.Empty<(MessageKind, string)>(); + unresolved.Add(new UnresolvedReference(reference, messages)); + } + if (unresolved.Count > 0) + groups.Add(new UnresolvedReferenceGroup(assembly, unresolved)); + } + return groups; + } + } +} diff --git a/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs b/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs index 2ec2be441f9..a8567ac8b07 100644 --- a/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs @@ -58,10 +58,11 @@ public async Task Analyze_Entry_Is_Registered_With_The_Localised_Header() } [AvaloniaTest] - public async Task Analyze_Entry_Is_Visible_For_Member_Tree_Nodes_And_Hidden_For_AssemblyTreeNode() + public async Task Analyze_Entry_Is_Visible_For_Member_And_Assembly_Tree_Nodes() { // AnalyzeContextMenuEntry visibility contract: visible when every selected node is - // an IMemberTreeNode (types, methods, fields, properties, events), hidden otherwise. + // an IMemberTreeNode (types, methods, fields, properties, events) or an assembly + // (analyzed as a whole module), hidden otherwise. var (_, vm) = await TestHarness.BootAsync(); @@ -75,7 +76,7 @@ public async Task Analyze_Entry_Is_Visible_For_Member_Tree_Nodes_And_Hidden_For_ var assemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); entry.IsVisible(new TextViewContext { SelectedTreeNodes = new[] { (SharpTreeNode)assemblyNode } }) - .Should().BeFalse("AssemblyTreeNode isn't an IMemberTreeNode — Analyze must hide"); + .Should().BeTrue("an assembly is analyzed as a module (Referenced By, Dependent Code)"); entry.IsVisible(new TextViewContext { SelectedTreeNodes = null }) .Should().BeFalse("no selection means no entity to analyse"); @@ -149,7 +150,12 @@ public async Task Analyze_Command_CanExecute_Tracks_The_Assembly_Tree_Selection( var assemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); vm.AssemblyTreeModel.SelectNode(assemblyNode); - command.CanExecute(null).Should().BeFalse("assembly nodes are not analyzable members"); + command.CanExecute(null).Should().BeTrue("assembly nodes are analyzed as whole modules"); + + var referencesFolder = assemblyNode.Children.First(c => c.GetType().Name == "ReferenceFolderTreeNode"); + vm.AssemblyTreeModel.SelectNode(referencesFolder); + + command.CanExecute(null).Should().BeFalse("a references folder is neither a member nor an assembly"); } [AvaloniaTest] diff --git a/ILSpy.Tests/Dependencies/AssemblyDependencyGraphTests.cs b/ILSpy.Tests/Dependencies/AssemblyDependencyGraphTests.cs new file mode 100644 index 00000000000..87c2eaa892b --- /dev/null +++ b/ILSpy.Tests/Dependencies/AssemblyDependencyGraphTests.cs @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Piero Viano +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using AwesomeAssertions; + +using ICSharpCode.ILSpyX; +using ICSharpCode.ILSpyX.Dependencies; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Dependencies; + +[TestFixture] +public class AssemblyDependencyGraphTests +{ + static async Task OpenAsync(AssemblyList list, string path) + { + var loaded = list.OpenAssembly(path); + await loaded.GetLoadResultAsync(); + return loaded; + } + + [Test] + public async Task Graph_Contains_The_Root_Its_Library_And_The_Edge_Between_Them() + { + var (_, consumerPath, libraryName, consumerName) = DependencyFixtures.EmitPair("Graph"); + var list = new AssemblyList(); + var consumer = await OpenAsync(list, consumerPath); + + var graph = AssemblyDependencyGraph.Build([consumer]); + + var root = graph.Nodes[0]; + root.Name.Should().Be(consumerName); + root.IsRoot.Should().BeTrue(); + root.IsResolved.Should().BeTrue(); + var library = graph.Nodes.Single(n => n.Name == libraryName); + library.IsResolved.Should().BeTrue("the library sits next to the consumer"); + library.IsRoot.Should().BeFalse(); + graph.Edges.Should().Contain(e => e.From == root.Index && e.To == library.Index && !e.IsVersionMismatch); + graph.Nodes.Should().Contain(n => n.Name == TreeNavigation.CoreLibName, "the emitted code references the core library"); + } + + [Test] + public async Task Transitive_Build_Follows_The_Library_References_While_Direct_Build_Stops() + { + var (_, consumerPath, libraryName, _) = DependencyFixtures.EmitPair("Graph"); + var list = new AssemblyList(); + var consumer = await OpenAsync(list, consumerPath); + + var direct = AssemblyDependencyGraph.Build([consumer], transitive: false); + var transitive = AssemblyDependencyGraph.Build([consumer], transitive: true); + + direct.Edges.Should().OnlyContain(e => e.From == 0, "only the root's references are followed"); + var library = transitive.Nodes.Single(n => n.Name == libraryName); + transitive.Edges.Should().Contain(e => e.From == library.Index, "the library's own references are followed"); + } + + [Test] + public async Task Unresolved_Reference_Becomes_An_Unresolved_Node() + { + var (consumerPath, missingName) = DependencyFixtures.EmitConsumerWithMissingReference("Graph"); + var list = new AssemblyList(); + var consumer = await OpenAsync(list, consumerPath); + + var graph = AssemblyDependencyGraph.Build([consumer]); + + var missing = graph.Nodes.Single(n => n.Name == missingName); + missing.IsResolved.Should().BeFalse(); + missing.FileName.Should().BeNull(); + missing.FullName.Should().StartWith(missingName + ","); + graph.Edges.Should().Contain(e => e.From == 0 && e.To == missing.Index); + } + + [Test] + public async Task Version_Mismatch_Is_Flagged_On_The_Edge() + { + var dir = DependencyFixtures.NewDirectory(); + var libraryName = DependencyFixtures.UniqueName("GraphLib"); + var libraryPath = DependencyFixtures.EmitLibrary(dir, libraryName, new Version(1, 0, 0, 0)); + var consumerPath = DependencyFixtures.EmitConsumer(dir, DependencyFixtures.UniqueName("GraphApp"), libraryPath); + DependencyFixtures.EmitLibrary(dir, libraryName, new Version(2, 0, 0, 0)); + var list = new AssemblyList(); + var consumer = await OpenAsync(list, consumerPath); + + var graph = AssemblyDependencyGraph.Build([consumer]); + + var library = graph.Nodes.Single(n => n.Name == libraryName); + library.Version.Should().Be(new Version(2, 0, 0, 0)); + var edge = graph.Edges.Single(e => e.To == library.Index); + edge.IsVersionMismatch.Should().BeTrue(); + edge.ReferencedVersion.Should().Be(new Version(1, 0, 0, 0)); + } + + [Test] + public async Task Build_Is_Deterministic_And_Cancellable() + { + var (_, consumerPath, _, _) = DependencyFixtures.EmitPair("Graph"); + var list = new AssemblyList(); + var consumer = await OpenAsync(list, consumerPath); + + var first = AssemblyDependencyGraph.Build([consumer]); + var second = AssemblyDependencyGraph.Build([consumer]); + second.Nodes.Select(n => n.FullName).Should().Equal(first.Nodes.Select(n => n.FullName)); + second.Edges.Select(e => (e.From, e.To)).Should().Equal(first.Edges.Select(e => (e.From, e.To))); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var act = () => AssemblyDependencyGraph.Build([consumer], cancellationToken: cts.Token); + act.Should().Throw(); + } +} diff --git a/ILSpy.Tests/Dependencies/DependencyFixtures.cs b/ILSpy.Tests/Dependencies/DependencyFixtures.cs new file mode 100644 index 00000000000..a56fd295da2 --- /dev/null +++ b/ILSpy.Tests/Dependencies/DependencyFixtures.cs @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Piero Viano +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.IO; +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.Loader; + +namespace ICSharpCode.ILSpy.Tests.Dependencies; + +/// +/// Emits small library/consumer assembly pairs for the dependency features. The library defines +/// Api.Compute(int) and Model; the consumer uses them in a method body +/// (), in a signature () and as a base +/// type (), and has one type that does not touch the library at all +/// (). +/// +static class DependencyFixtures +{ + public const string BodyUserType = "UsesInBody"; + public const string BodyUserMethod = "Run"; + public const string SignatureUserType = "UsesInSignature"; + public const string SignatureUserMethod = "Echo"; + public const string DerivedType = "DerivedModel"; + public const string IndependentType = "Independent"; + public const string IndependentMethod = "Twice"; + + /// A fresh, empty temp directory. + public static string NewDirectory() + { + var dir = Path.Combine(Path.GetTempPath(), $"ILSpyDependencyFixture_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + return dir; + } + + /// A unique simple assembly name with the given prefix. + public static string UniqueName(string prefix) => prefix + Guid.NewGuid().ToString("N").Substring(0, 8); + + /// Emits the library <directory>/<name>.dll and returns its path. + public static string EmitLibrary(string directory, string name, Version? version = null) + { + var assemblyName = new AssemblyName(name) { Version = version ?? new Version(1, 0, 0, 0) }; + var ab = new PersistedAssemblyBuilder(assemblyName, typeof(object).Assembly); + var module = ab.DefineDynamicModule(name); + + var api = module.DefineType($"{name}.Api", TypeAttributes.Public | TypeAttributes.Class); + var compute = api.DefineMethod("Compute", MethodAttributes.Public | MethodAttributes.Static, typeof(int), [typeof(int)]); + var il = compute.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ret); + api.CreateType(); + + var model = module.DefineType($"{name}.Model", TypeAttributes.Public | TypeAttributes.Class); + model.DefineDefaultConstructor(MethodAttributes.Public); + model.CreateType(); + + var path = Path.Combine(directory, name + ".dll"); + ab.Save(path); + return path; + } + + /// + /// Emits the consumer <directory>/<name>.dll compiled against the library at + /// and returns its path. The library is read into a collectible + /// load context from memory, so its file is neither locked nor kept loaded. + /// + public static string EmitConsumer(string directory, string name, string libraryPath) + { + var context = new AssemblyLoadContext(name, isCollectible: true); + try + { + using var stream = new MemoryStream(File.ReadAllBytes(libraryPath)); + var library = context.LoadFromStream(stream); + string libraryName = library.GetName().Name!; + var apiType = library.GetType($"{libraryName}.Api", throwOnError: true)!; + var modelType = library.GetType($"{libraryName}.Model", throwOnError: true)!; + + var ab = new PersistedAssemblyBuilder(new AssemblyName(name) { Version = new Version(1, 0, 0, 0) }, typeof(object).Assembly); + var module = ab.DefineDynamicModule(name); + + var bodyUser = module.DefineType($"{name}.{BodyUserType}", TypeAttributes.Public | TypeAttributes.Class); + var run = bodyUser.DefineMethod(BodyUserMethod, MethodAttributes.Public | MethodAttributes.Static, typeof(int), Type.EmptyTypes); + var il = run.GetILGenerator(); + il.Emit(OpCodes.Ldc_I4_1); + il.Emit(OpCodes.Call, apiType.GetMethod("Compute")!); + il.Emit(OpCodes.Ret); + bodyUser.CreateType(); + + var signatureUser = module.DefineType($"{name}.{SignatureUserType}", TypeAttributes.Public | TypeAttributes.Class); + var echo = signatureUser.DefineMethod(SignatureUserMethod, MethodAttributes.Public | MethodAttributes.Static, modelType, [modelType]); + il = echo.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ret); + signatureUser.CreateType(); + + var derived = module.DefineType($"{name}.{DerivedType}", TypeAttributes.Public | TypeAttributes.Class, modelType); + derived.CreateType(); + + var independent = module.DefineType($"{name}.{IndependentType}", TypeAttributes.Public | TypeAttributes.Class); + var twice = independent.DefineMethod(IndependentMethod, MethodAttributes.Public | MethodAttributes.Static, typeof(int), [typeof(int)]); + il = twice.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Add); + il.Emit(OpCodes.Ret); + independent.CreateType(); + + var path = Path.Combine(directory, name + ".dll"); + ab.Save(path); + return path; + } + finally + { + context.Unload(); + } + } + + /// + /// Emits a library and a consumer next to each other in a fresh directory, so the consumer's + /// reference resolves from its own folder. + /// + public static (string LibraryPath, string ConsumerPath, string LibraryName, string ConsumerName) EmitPair(string prefix = "Dep") + { + var dir = NewDirectory(); + var libraryName = UniqueName(prefix + "Lib"); + var consumerName = UniqueName(prefix + "App"); + var library = EmitLibrary(dir, libraryName); + var consumer = EmitConsumer(dir, consumerName, library); + return (library, consumer, libraryName, consumerName); + } + + /// + /// Emits a consumer whose library reference cannot be resolved: the library is built in a + /// separate directory that is deleted afterwards. + /// + public static (string ConsumerPath, string MissingLibraryName) EmitConsumerWithMissingReference(string prefix = "Dep") + { + var libraryDir = NewDirectory(); + var consumerDir = NewDirectory(); + var libraryName = UniqueName(prefix + "Missing"); + var library = EmitLibrary(libraryDir, libraryName); + var consumer = EmitConsumer(consumerDir, UniqueName(prefix + "Orphan"), library); + Directory.Delete(libraryDir, recursive: true); + return (consumer, libraryName); + } +} diff --git a/ILSpy.Tests/Dependencies/DependencyGraphMermaidWriterTests.cs b/ILSpy.Tests/Dependencies/DependencyGraphMermaidWriterTests.cs new file mode 100644 index 00000000000..3dd7a1ee19a --- /dev/null +++ b/ILSpy.Tests/Dependencies/DependencyGraphMermaidWriterTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Piero Viano +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Linq; + +using AwesomeAssertions; + +using ICSharpCode.ILSpyX.Dependencies; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Dependencies; + +[TestFixture] +public class DependencyGraphMermaidWriterTests +{ + // Unresolved nodes need no metadata, which lets the graph be built by hand. + static AssemblyDependencyGraph CreateGraph(params string[] names) + { + var nodes = names.Select((n, i) => new AssemblyDependencyNode(i, n, n + ", Version=1.0.0.0", new Version(1, 0, 0, 0), null, isRoot: i == 0)).ToList(); + var edges = Enumerable.Range(1, names.Length - 1) + .Select(i => new AssemblyDependencyEdge(0, i, names[i], new Version(0, 9, 0, 0), isVersionMismatch: i == 1)) + .ToList(); + return new AssemblyDependencyGraph(nodes, edges); + } + + [Test] + public void Mermaid_Text_Is_A_Left_To_Right_Graph_With_Nodes_Edges_And_Classes() + { + var mermaid = DependencyGraphMermaidWriter.ToMermaid(CreateGraph("App", "Lib", "Other")); + var lines = mermaid.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.Trim()).ToList(); + + lines[0].Should().Be("graph LR"); + lines.Should().Contain("n0[\"App 1.0.0.0 (unresolved)\"]"); + lines.Should().Contain("n0 -->|\"v0.9.0.0\"| n1", "a version mismatch labels the edge with the referenced version"); + lines.Should().Contain("n0 --> n2", "a matching version leaves the edge unlabelled"); + lines.Should().Contain("class n0 root;"); + lines.Should().Contain("class n2 unresolved;"); + } + + [Test] + public void Labels_Escape_Characters_That_Would_Break_The_Mermaid_Syntax() + { + DependencyGraphMermaidWriter.EscapeLabel("a\"bd&e#f|g`h\ni") + .Should().Be("a#quot;b#lt;c#gt;d#amp;e#35;f#124;g#96;h i"); + + var mermaid = DependencyGraphMermaidWriter.ToMermaid(CreateGraph("Evil\"]--> x[\"pwn", "Lib")); + mermaid.Should().Contain("n0[\"Evil#quot;]--#gt; x[#quot;pwn 1.0.0.0 (unresolved)\"]"); + mermaid.Should().NotContain("x[\"pwn"); + } + + [Test] + public void Html_Page_Embeds_The_Encoded_Diagram_And_Loads_Mermaid() + { + var graph = CreateGraph("A", "Lib"); + var html = DependencyGraphMermaidWriter.ToHtml(graph, "Deps of "); + + html.Should().StartWith(""); + html.Should().Contain("Deps of <A&B>"); + html.Should().Contain("
");
+		html.Should().Contain("graph LR");
+		html.Should().Contain("n0 -->", "the diagram text is HTML-encoded inside the page");
+		html.Should().NotContain("");
+		html.Should().Contain($"");
+		html.Should().Contain("mermaid.initialize(");
+	}
+}
diff --git a/ILSpy.Tests/Dependencies/DependencyUiTests.cs b/ILSpy.Tests/Dependencies/DependencyUiTests.cs
new file mode 100644
index 00000000000..c80e0a25445
--- /dev/null
+++ b/ILSpy.Tests/Dependencies/DependencyUiTests.cs
@@ -0,0 +1,236 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+
+using Avalonia;
+using Avalonia.Headless;
+using Avalonia.Headless.NUnit;
+using Avalonia.Input;
+using Avalonia.VisualTree;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpyX.Analyzers;
+using ICSharpCode.ILSpyX.TreeView;
+
+using ICSharpCode.ILSpy.Analyzers;
+using ICSharpCode.ILSpy.Analyzers.TreeNodes;
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Dependencies;
+using ICSharpCode.ILSpy.Docking;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.ViewModels;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Dependencies;
+
+[TestFixture]
+public class DependencyUiTests
+{
+	static async Task<(MainWindowViewModel Vm, string LibraryName, string ConsumerName)> OpenPairAsync(Avalonia.Controls.Window? _ = null)
+	{
+		var (_, vm) = await TestHarness.BootAsync();
+		var (libraryPath, consumerPath, libraryName, consumerName) = DependencyFixtures.EmitPair("Ui");
+		await vm.OpenAssemblyAsync(libraryPath);
+		await vm.OpenAssemblyAsync(consumerPath);
+		return (vm, libraryName, consumerName);
+	}
+
+	static TextViewContext Select(SharpTreeNode node) => new() { SelectedTreeNodes = [node] };
+
+	[AvaloniaTest]
+	public async Task Assembly_Node_Has_A_Referenced_By_Folder_Listing_Its_Consumers()
+	{
+		var (vm, libraryName, consumerName) = await OpenPairAsync();
+		var library = vm.AssemblyTreeModel.FindNode(libraryName);
+
+		var folder = library.GetChild();
+		folder.Text.Should().Be(Resources.ReferencedBy);
+		var child = folder.GetChild();
+		child.Assembly.ShortName.Should().Be(consumerName);
+		child.IsVersionMismatch.Should().BeFalse();
+		((string)child.Text).Should().StartWith(consumerName);
+
+		// The consumer itself has no consumers: its folder is empty.
+		var consumer = vm.AssemblyTreeModel.FindNode(consumerName);
+		var consumerFolder = consumer.GetChild();
+		consumerFolder.EnsureLazyChildren();
+		consumerFolder.Children.Should().BeEmpty();
+	}
+
+	[AvaloniaTest]
+	public async Task Analyze_Accepts_An_Assembly_And_Shows_The_Module_Analyzers()
+	{
+		var (vm, libraryName, _) = await OpenPairAsync();
+		var library = vm.AssemblyTreeModel.FindNode(libraryName);
+		var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.Analyze));
+
+		entry.IsVisible(Select(library)).Should().BeTrue();
+		entry.IsEnabled(Select(library)).Should().BeTrue();
+		entry.Execute(Select(library));
+
+		var analyzerVm = AppComposition.Current.GetExport();
+		var moduleRow = analyzerVm.Root.Children.OfType().Single();
+		moduleRow.Module.MetadataFile.Should().BeSameAs(library.LoadedAssembly.GetMetadataFileOrNull());
+		moduleRow.EnsureLazyChildren();
+		moduleRow.Children.OfType().Select(r => r.AnalyzerHeader)
+			.Should().Contain([ModuleAnalyzerHeaders.ReferencedBy, ModuleAnalyzerHeaders.DependentCode]);
+
+		entry.Execute(Select(library));
+		analyzerVm.Root.Children.OfType().Should().ContainSingle("re-analyzing reuses the row");
+	}
+
+	[AvaloniaTest]
+	public async Task Referenced_By_Context_Entry_Lists_The_Consumer_In_The_Analyzer_Pane()
+	{
+		var (vm, libraryName, consumerName) = await OpenPairAsync();
+		var library = vm.AssemblyTreeModel.FindNode(libraryName);
+		var registry = AppComposition.Current.GetExport();
+		registry.Entries.Single(e => e.Metadata.Header == nameof(Resources.ReferencedBy)).Metadata.Category
+			.Should().Be("Navigation");
+		var entry = registry.GetEntry(nameof(Resources.ReferencedBy));
+
+		entry.IsVisible(Select(library)).Should().BeTrue();
+		entry.Execute(Select(library));
+
+		var analyzerVm = AppComposition.Current.GetExport();
+		var row = (AnalyzerSearchTreeNode)analyzerVm.SelectedItems.Single();
+		row.AnalyzerHeader.Should().Be(ModuleAnalyzerHeaders.ReferencedBy);
+		await Waiters.WaitForAsync(() => !row.IsLoading && row.Children.OfType().Any(),
+			description: "Referenced By results");
+		row.Children.OfType().Single().Module.AssemblyName.Should().Be(consumerName);
+	}
+
+	[AvaloniaTest]
+	public async Task Dependent_Code_Context_Entry_Lists_The_Using_Members()
+	{
+		var (vm, libraryName, consumerName) = await OpenPairAsync();
+		var library = vm.AssemblyTreeModel.FindNode(libraryName);
+		var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.DependentCode));
+
+		entry.IsVisible(Select(library)).Should().BeTrue();
+		entry.Execute(Select(library));
+
+		var analyzerVm = AppComposition.Current.GetExport();
+		var row = (AnalyzerSearchTreeNode)analyzerVm.SelectedItems.Single();
+		row.AnalyzerHeader.Should().Be(ModuleAnalyzerHeaders.DependentCode);
+		await Waiters.WaitForAsync(() => !row.IsLoading && row.Children.OfType().Any(),
+			description: "Dependent Code results");
+		var members = row.Children.OfType().Select(n => n.Member?.FullName).ToList();
+		members.Should().Contain($"{consumerName}.{DependencyFixtures.BodyUserType}.{DependencyFixtures.BodyUserMethod}");
+		members.Should().NotContain($"{consumerName}.{DependencyFixtures.IndependentType}.{DependencyFixtures.IndependentMethod}");
+	}
+
+	[AvaloniaTest]
+	public async Task Dependency_Diagram_Opens_A_Tab_And_A_Node_Click_Selects_The_Assembly()
+	{
+		var (window, _) = await TestHarness.BootAsync();
+		var vm = (MainWindowViewModel)window.DataContext!;
+		var (libraryPath, consumerPath, libraryName, consumerName) = DependencyFixtures.EmitPair("Ui");
+		await vm.OpenAssemblyAsync(libraryPath);
+		await vm.OpenAssemblyAsync(consumerPath);
+		var consumer = vm.AssemblyTreeModel.SelectNode(consumerName);
+
+		var registry = AppComposition.Current.GetExport();
+		registry.Commands.Single(c => c.Metadata.Header == nameof(Resources.AssemblyDependencyDiagram)).Metadata.ParentMenuID
+			.Should().Be(nameof(Resources._Navigate));
+		registry.GetCommand(nameof(Resources.AssemblyDependencyDiagram)).Execute(null);
+
+		var dockWorkspace = AppComposition.Current.GetExport();
+		ContentTabPage? tab = null;
+		await Waiters.WaitForAsync(() => {
+			tab = dockWorkspace.Documents?.VisibleDockables?.OfType()
+				.FirstOrDefault(t => t.Content is DependencyGraphViewModel);
+			return tab != null;
+		}, description: "dependency diagram tab");
+		var model = (DependencyGraphViewModel)tab!.Content!;
+		model.Graph.Nodes[0].Name.Should().Be(consumerName);
+		var libraryIndex = model.Graph.Nodes.Single(n => n.Name == libraryName).Index;
+
+		var view = await window.WaitForComponent();
+		await Waiters.WaitForAsync(() => view.GraphControl.Bounds.Width > 0 && view.GraphControl.Layout != null,
+			description: "graph control laid out");
+		window.Capture("dependency-diagram");
+
+		var center = view.GraphControl.GetNodeCenter(libraryIndex);
+		view.GraphControl.HitTestNode(center).Should().Be(libraryIndex);
+		var point = view.GraphControl.TranslatePoint(center, window)!.Value;
+		window.MouseDown(point, MouseButton.Left);
+		window.MouseUp(point, MouseButton.Left);
+
+		await Waiters.WaitForAsync(() => vm.AssemblyTreeModel.SelectedItem is AssemblyTreeNode { LoadedAssembly.ShortName: var n } && n == libraryName,
+			description: "library selected in the tree after clicking its node");
+		ReferenceEquals(consumer, vm.AssemblyTreeModel.SelectedItem).Should().BeFalse();
+	}
+
+	[AvaloniaTest]
+	public async Task Mermaid_Export_Writes_An_Html_Page_With_The_Graph()
+	{
+		var (vm, libraryName, consumerName) = await OpenPairAsync();
+		vm.AssemblyTreeModel.SelectNode(consumerName);
+		var path = Path.Combine(DependencyFixtures.NewDirectory(), "deps.html");
+
+		AppComposition.Current.GetExport()
+			.GetCommand(nameof(Resources.ExportDependencyDiagramMermaid)).Execute(path);
+
+		var dockWorkspace = AppComposition.Current.GetExport();
+		await Waiters.WaitForAsync(() => dockWorkspace.Documents?.VisibleDockables?.OfType()
+			.Any(t => t.Content is DecompilerTabPageModel { Title: var title } && title == Resources.ExportDependencyDiagramMermaid) == true,
+			description: "export report tab offering to open the page");
+		File.Exists(path).Should().BeTrue();
+		var html = await File.ReadAllTextAsync(path);
+		html.Should().Contain("graph LR");
+		html.Should().Contain(consumerName);
+		html.Should().Contain(libraryName);
+	}
+
+	[AvaloniaTest]
+	public async Task Unresolved_References_Report_Lists_The_Missing_Reference_With_Its_Log()
+	{
+		var (_, vm) = await TestHarness.BootAsync();
+		var (consumerPath, missingName) = DependencyFixtures.EmitConsumerWithMissingReference("Ui");
+		var orphan = await vm.OpenAssemblyAsync(consumerPath);
+		vm.AssemblyTreeModel.SelectNode(orphan.ShortName);
+
+		var registry = AppComposition.Current.GetExport();
+		registry.Commands.Single(c => c.Metadata.Header == nameof(Resources.UnresolvedReferencesReport)).Metadata.ParentMenuID
+			.Should().Be(nameof(Resources._Navigate));
+		registry.GetCommand(nameof(Resources.UnresolvedReferencesReport)).Execute(null);
+
+		var dockWorkspace = AppComposition.Current.GetExport();
+		string? text = null;
+		await Waiters.WaitForAsync(() => {
+			var content = dockWorkspace.Documents?.VisibleDockables?.OfType()
+				.Select(t => t.Content).OfType()
+				.FirstOrDefault(c => c.Title == Resources.UnresolvedReferencesReport);
+			text = content?.Text;
+			return text?.Contains(missingName, StringComparison.Ordinal) == true;
+		}, description: "unresolved references report text");
+		text.Should().Contain(orphan.ShortName);
+		text.Should().Contain("Could not find reference");
+	}
+}
diff --git a/ILSpy.Tests/Dependencies/LayeredLayoutTests.cs b/ILSpy.Tests/Dependencies/LayeredLayoutTests.cs
new file mode 100644
index 00000000000..13b99bfbcf9
--- /dev/null
+++ b/ILSpy.Tests/Dependencies/LayeredLayoutTests.cs
@@ -0,0 +1,153 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Linq;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpyX.Dependencies;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Dependencies;
+
+[TestFixture]
+public class LayeredLayoutTests
+{
+	[Test]
+	public void Longest_Path_Layering_Places_Each_Node_After_Its_Furthest_Predecessor()
+	{
+		// 0 -> 1 -> 2 plus the shortcut 0 -> 2: node 2 must land in layer 2, not 1.
+		var result = LayeredLayout.Compute(3, [(0, 1), (1, 2), (0, 2)]);
+
+		result.Layers.Should().Equal(0, 1, 2);
+		result.LayerCount.Should().Be(3);
+	}
+
+	[Test]
+	public void Every_Acyclic_Edge_Points_To_A_Later_Layer_And_Long_Edges_Are_Routed_Through_Each_Layer()
+	{
+		var edges = new (int, int)[] { (0, 1), (1, 2), (2, 3), (0, 3), (4, 3) };
+		var result = LayeredLayout.Compute(5, edges);
+
+		foreach (var edge in result.Edges)
+		{
+			edge.IsReversed.Should().BeFalse();
+			result.Layers[edge.To].Should().BeGreaterThan(result.Layers[edge.From]);
+			var source = result.Nodes[edge.From];
+			var target = result.Nodes[edge.To];
+			edge.Points[0].Should().Be(new LayoutPoint(source.Right, source.CenterY), "an edge leaves the right side of its source");
+			edge.Points[^1].Should().Be(new LayoutPoint(target.X, target.CenterY), "an edge enters the left side of its target");
+			int span = result.Layers[edge.To] - result.Layers[edge.From];
+			edge.Points.Count.Should().Be(2 + 2 * (span - 1), "each skipped layer adds an entry and an exit point");
+		}
+	}
+
+	[Test]
+	public void Cycles_Are_Broken_By_Reversing_A_Back_Edge()
+	{
+		var result = LayeredLayout.Compute(3, [(0, 1), (1, 2), (2, 0)]);
+
+		result.Edges.Should().HaveCount(3);
+		result.Edges.Count(e => e.IsReversed).Should().Be(1);
+		var back = result.Edges.Single(e => e.IsReversed);
+		back.From.Should().Be(2);
+		back.To.Should().Be(0);
+		result.Layers[back.From].Should().BeGreaterThan(result.Layers[back.To]);
+		// The polyline still runs from the edge's source to its target.
+		var source = result.Nodes[back.From];
+		var target = result.Nodes[back.To];
+		back.Points[0].Should().Be(new LayoutPoint(source.X, source.CenterY));
+		back.Points[^1].Should().Be(new LayoutPoint(target.Right, target.CenterY));
+	}
+
+	[Test]
+	public void Self_Loops_And_Duplicate_Edges_Are_Dropped()
+	{
+		var result = LayeredLayout.Compute(2, [(0, 0), (0, 1), (0, 1)]);
+
+		result.Edges.Should().ContainSingle().Which.Should().Match(e => e.From == 0 && e.To == 1);
+	}
+
+	[Test]
+	public void Barycenter_Ordering_Removes_An_Avoidable_Crossing()
+	{
+		// Index order puts 2 above 3 in the second layer, so 0->3 and 1->2 would cross.
+		var result = LayeredLayout.Compute(4, [(0, 3), (1, 2)]);
+
+		result.CrossingCount.Should().Be(0);
+		result.Nodes[3].Y.Should().BeLessThan(result.Nodes[2].Y, "the target of the upper source moves up");
+	}
+
+	[Test]
+	public void Nodes_Do_Not_Overlap_And_Layers_Advance_Left_To_Right()
+	{
+		var edges = Enumerable.Range(1, 8).Select(i => (0, i)).Concat(Enumerable.Range(1, 7).Select(i => (i, 9))).ToArray();
+		var widths = Enumerable.Range(0, 10).Select(i => 60.0 + 10 * i).ToArray();
+		var result = LayeredLayout.Compute(10, edges, widths);
+
+		for (int i = 0; i < 10; i++)
+		{
+			result.Nodes[i].Width.Should().Be(widths[i]);
+			for (int j = i + 1; j < 10; j++)
+			{
+				var a = result.Nodes[i];
+				var b = result.Nodes[j];
+				bool disjoint = a.Right <= b.X || b.Right <= a.X || a.Bottom <= b.Y || b.Bottom <= a.Y;
+				disjoint.Should().BeTrue($"nodes {i} and {j} must not overlap");
+				if (result.Layers[i] < result.Layers[j])
+					a.Right.Should().BeLessThan(b.X);
+			}
+		}
+		result.Nodes.Max(n => n.Right).Should().BeLessThanOrEqualTo(result.Width);
+		result.Nodes.Max(n => n.Bottom).Should().BeLessThanOrEqualTo(result.Height);
+	}
+
+	[Test]
+	public void Layout_Is_Deterministic()
+	{
+		var edges = new (int, int)[] { (0, 4), (1, 3), (2, 5), (3, 6), (4, 6), (5, 3), (6, 1), (0, 6), (2, 4) };
+		var first = LayeredLayout.Compute(7, edges);
+		var second = LayeredLayout.Compute(7, edges);
+
+		second.Nodes.Should().Equal(first.Nodes);
+		second.Layers.Should().Equal(first.Layers);
+		second.Edges.Select(e => string.Join(";", e.Points)).Should().Equal(first.Edges.Select(e => string.Join(";", e.Points)));
+	}
+
+	[Test]
+	public void HitTest_Finds_The_Node_Under_A_Point()
+	{
+		var result = LayeredLayout.Compute(2, [(0, 1)]);
+		var target = result.Nodes[1];
+
+		result.HitTest(target.X + 1, target.CenterY).Should().Be(1);
+		result.HitTest(-5, -5).Should().Be(-1);
+	}
+
+	[Test]
+	public void Empty_Graph_Has_No_Extent()
+	{
+		var result = LayeredLayout.Compute(0, Array.Empty<(int, int)>());
+
+		result.Nodes.Should().BeEmpty();
+		result.Width.Should().Be(0);
+		result.Height.Should().Be(0);
+	}
+}
diff --git a/ILSpy.Tests/Dependencies/ModuleAnalyzersTests.cs b/ILSpy.Tests/Dependencies/ModuleAnalyzersTests.cs
new file mode 100644
index 00000000000..4b8c7d51b34
--- /dev/null
+++ b/ILSpy.Tests/Dependencies/ModuleAnalyzersTests.cs
@@ -0,0 +1,112 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpyX;
+using ICSharpCode.ILSpyX.Analyzers;
+using ICSharpCode.ILSpyX.Analyzers.Builtin;
+
+using ICSharpCode.ILSpy.Languages;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Dependencies;
+
+[TestFixture]
+public class ModuleAnalyzersTests
+{
+	AssemblyList list = null!;
+	LoadedAssembly library = null!;
+	LoadedAssembly consumer = null!;
+	string consumerName = null!;
+
+	[OneTimeSetUp]
+	public async Task Setup()
+	{
+		var (libraryPath, consumerPath, _, name) = DependencyFixtures.EmitPair("Analyze");
+		consumerName = name;
+		list = new AssemblyList();
+		library = list.OpenAssembly(libraryPath);
+		consumer = list.OpenAssembly(consumerPath);
+		await library.GetLoadResultAsync();
+		await consumer.GetLoadResultAsync();
+		// An assembly that has nothing to do with the library must not contribute results.
+		await list.OpenAssembly(FixtureAssembly.Emit(DependencyFixtures.UniqueName("Bystander"))).GetLoadResultAsync();
+	}
+
+	AnalyzerContext CreateContext(CancellationToken ct = default)
+		=> new() { AssemblyList = list, Language = new CSharpLanguage(), CancellationToken = ct };
+
+	IModule LibraryModule()
+		=> new DecompilerTypeSystem(library.GetMetadataFileOrNull()!, library.GetAssemblyResolver()).MainModule;
+
+	[Test]
+	public void Module_Analyzers_Show_Only_For_Assemblies()
+	{
+		var module = LibraryModule();
+		var type = module.TopLevelTypeDefinitions.First(t => t.Name == "Api");
+
+		new ModuleDependentCodeAnalyzer().Show(module).Should().BeTrue();
+		new ModuleDependentCodeAnalyzer().Show(type).Should().BeFalse();
+		new ModuleDependentCodeAnalyzer().Show(null).Should().BeFalse();
+		new ModuleReferencedByAnalyzer().Show(module).Should().BeTrue();
+		new ModuleReferencedByAnalyzer().Show(type).Should().BeFalse();
+	}
+
+	[Test]
+	public void Dependent_Code_Lists_Body_Signature_And_Base_Type_Users_Only()
+	{
+		var results = new ModuleDependentCodeAnalyzer().Analyze(LibraryModule(), CreateContext()).ToList();
+
+		var names = results.OfType().Select(e => e.FullName).ToList();
+		names.Should().Contain($"{consumerName}.{DependencyFixtures.BodyUserType}.{DependencyFixtures.BodyUserMethod}");
+		names.Should().Contain($"{consumerName}.{DependencyFixtures.SignatureUserType}.{DependencyFixtures.SignatureUserMethod}");
+		names.Should().Contain($"{consumerName}.{DependencyFixtures.DerivedType}");
+		names.Should().NotContain(n => n.Contains(DependencyFixtures.IndependentType, StringComparison.Ordinal));
+		results.OfType().Should().OnlyContain(e => e.ParentModule!.MetadataFile == consumer.GetMetadataFileOrNull(),
+			"only assemblies that reference the library contribute");
+		names.Should().OnlyHaveUniqueItems();
+	}
+
+	[Test]
+	public void Dependent_Code_Honours_Cancellation()
+	{
+		using var cts = new CancellationTokenSource();
+		cts.Cancel();
+
+		var act = () => new ModuleDependentCodeAnalyzer().Analyze(LibraryModule(), CreateContext(cts.Token)).ToList();
+
+		act.Should().Throw();
+	}
+
+	[Test]
+	public void Referenced_By_Lists_The_Consumer_Module()
+	{
+		var results = new ModuleReferencedByAnalyzer().Analyze(LibraryModule(), CreateContext()).ToList();
+
+		results.Should().ContainSingle().Which.Should().BeAssignableTo()
+			.Which.MetadataFile.Should().BeSameAs(consumer.GetMetadataFileOrNull());
+	}
+}
diff --git a/ILSpy.Tests/Dependencies/ReferencedByFinderTests.cs b/ILSpy.Tests/Dependencies/ReferencedByFinderTests.cs
new file mode 100644
index 00000000000..9d124e19bdd
--- /dev/null
+++ b/ILSpy.Tests/Dependencies/ReferencedByFinderTests.cs
@@ -0,0 +1,98 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.Metadata;
+using ICSharpCode.ILSpyX;
+using ICSharpCode.ILSpyX.Dependencies;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Dependencies;
+
+[TestFixture]
+public class ReferencedByFinderTests
+{
+	static async Task OpenAsync(AssemblyList list, string path)
+	{
+		var loaded = list.OpenAssembly(path);
+		await loaded.GetLoadResultAsync();
+		return loaded;
+	}
+
+	[Test]
+	public async Task Finds_The_Consumer_Of_A_Library_And_Nothing_Else()
+	{
+		var (libraryPath, consumerPath, _, _) = DependencyFixtures.EmitPair("RefBy");
+		var other = FixtureAssembly.Emit(DependencyFixtures.UniqueName("Unrelated"));
+		var list = new AssemblyList();
+		var library = await OpenAsync(list, libraryPath);
+		var consumer = await OpenAsync(list, consumerPath);
+		await OpenAsync(list, other);
+
+		var result = ReferencedByFinder.FindReferencingAssemblies(list.GetAssemblies(), library.GetMetadataFileOrNull()!);
+
+		result.Should().ContainSingle();
+		result[0].Assembly.Should().BeSameAs(consumer);
+		result[0].Reference.Name.Should().Be(library.ShortName);
+		result[0].IsVersionMismatch.Should().BeFalse();
+	}
+
+	[Test]
+	public async Task A_Different_Referenced_Version_Still_Matches_And_Is_Reported()
+	{
+		var dir = DependencyFixtures.NewDirectory();
+		var libraryName = DependencyFixtures.UniqueName("RefByLib");
+		var libraryPath = DependencyFixtures.EmitLibrary(dir, libraryName, new Version(1, 0, 0, 0));
+		var consumerPath = DependencyFixtures.EmitConsumer(dir, DependencyFixtures.UniqueName("RefByApp"), libraryPath);
+		DependencyFixtures.EmitLibrary(dir, libraryName, new Version(3, 1, 0, 0));
+		var list = new AssemblyList();
+		var library = await OpenAsync(list, libraryPath);
+		await OpenAsync(list, consumerPath);
+
+		var result = ReferencedByFinder.FindReferencingAssemblies(list.GetAssemblies(), library.GetMetadataFileOrNull()!);
+
+		result.Should().ContainSingle().Which.IsVersionMismatch.Should().BeTrue();
+	}
+
+	[Test]
+	public void Matching_Requires_The_Same_Public_Key_Token()
+	{
+		// System.Runtime is strong-named; a reference with the right name but no token is a different assembly.
+		var runtimePath = Path.Combine(Path.GetDirectoryName(typeof(object).Assembly.Location)!, "System.Runtime.dll");
+		using var runtime = new PEFile(runtimePath);
+		string token = AssemblyReferenceMatcher.GetPublicKeyToken(runtime.Metadata)!;
+
+		token.Should().NotBe(AssemblyReferenceMatcher.NullToken);
+		AssemblyReferenceMatcher.Matches(
+			AssemblyNameReference.Parse($"System.Runtime, Version=1.0.0.0, Culture=neutral, PublicKeyToken={token}"), runtime)
+			.Should().BeTrue("name and token match; the version is not part of the identity");
+		AssemblyReferenceMatcher.Matches(
+			AssemblyNameReference.Parse("System.Runtime, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"), runtime)
+			.Should().BeFalse();
+		AssemblyReferenceMatcher.Matches(
+			AssemblyNameReference.Parse($"System.Runtime2, Version=1.0.0.0, Culture=neutral, PublicKeyToken={token}"), runtime)
+			.Should().BeFalse();
+	}
+}
diff --git a/ILSpy.Tests/Dependencies/UnresolvedReferencesReportTests.cs b/ILSpy.Tests/Dependencies/UnresolvedReferencesReportTests.cs
new file mode 100644
index 00000000000..9246068a5c2
--- /dev/null
+++ b/ILSpy.Tests/Dependencies/UnresolvedReferencesReportTests.cs
@@ -0,0 +1,67 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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 AwesomeAssertions;
+
+using ICSharpCode.Decompiler.Metadata;
+using ICSharpCode.ILSpyX;
+using ICSharpCode.ILSpyX.Dependencies;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Dependencies;
+
+[TestFixture]
+public class UnresolvedReferencesReportTests
+{
+	[Test]
+	public async Task Missing_Reference_Is_Reported_Under_Its_Referencing_Assembly_With_The_Probe_Log()
+	{
+		var (consumerPath, missingName) = DependencyFixtures.EmitConsumerWithMissingReference("Report");
+		var (libraryPath, resolvableConsumerPath, libraryName, _) = DependencyFixtures.EmitPair("Report");
+		var list = new AssemblyList();
+		var orphan = list.OpenAssembly(consumerPath);
+		var healthy = list.OpenAssembly(resolvableConsumerPath);
+		await orphan.GetLoadResultAsync();
+		await healthy.GetLoadResultAsync();
+
+		var groups = UnresolvedReferencesReport.Collect([orphan, healthy]);
+
+		var group = groups.Single(g => g.Assembly == orphan);
+		var missing = group.References.Single(r => r.Reference.Name == missingName);
+		missing.Messages.Should().Contain(m => m.Kind == MessageKind.Error && m.Message.Contains(missingName),
+			"the resolver's probe log explains why the reference failed");
+		groups.SelectMany(g => g.References).Should().NotContain(r => r.Reference.Name == libraryName,
+			"a reference that resolves is not reported");
+	}
+
+	[Test]
+	public async Task Fully_Resolved_Assemblies_Produce_No_Group()
+	{
+		var (libraryPath, _, _, _) = DependencyFixtures.EmitPair("Report");
+		var list = new AssemblyList();
+		var library = list.OpenAssembly(libraryPath);
+		await library.GetLoadResultAsync();
+
+		// The fixture library only references the core library, which always resolves.
+		UnresolvedReferencesReport.Collect([library]).Should().BeEmpty();
+	}
+}
diff --git a/ILSpy.Tests/Docking/DockWorkspaceTests.cs b/ILSpy.Tests/Docking/DockWorkspaceTests.cs
index c97c0e14d3e..16a57687374 100644
--- a/ILSpy.Tests/Docking/DockWorkspaceTests.cs
+++ b/ILSpy.Tests/Docking/DockWorkspaceTests.cs
@@ -43,11 +43,11 @@ public void DockWorkspace_resolves_and_exposes_root_layout_with_tool_panes()
 		workspace.Layout.Should().NotBeNull("ILSpyDockFactory.CreateLayout() wires the root dock in the ctor.");
 		workspace.Factory.Should().NotBeNull();
 #if DEBUG
-		workspace.ToolPaneMenuItems.Should().HaveCount(5,
-			"AssemblyTree, Search, Analyzers, Bookmarks, and the Debug Steps pane (Debug-only) are wired at this point.");
+		workspace.ToolPaneMenuItems.Should().HaveCount(7,
+			"AssemblyTree, Search, Analyzers, Bookmarks, Type Hierarchy, File Structure, and the Debug Steps pane (Debug-only) are wired at this point.");
 #else
-		workspace.ToolPaneMenuItems.Should().HaveCount(4,
-			"AssemblyTree, Search, Analyzers, and Bookmarks are the tool panes wired at this point.");
+		workspace.ToolPaneMenuItems.Should().HaveCount(6,
+			"AssemblyTree, Search, Analyzers, Bookmarks, Type Hierarchy, and File Structure are the tool panes wired at this point.");
 #endif
 	}
 }
diff --git a/ILSpy.Tests/Editor/CaretUsageHighlightTests.cs b/ILSpy.Tests/Editor/CaretUsageHighlightTests.cs
new file mode 100644
index 00000000000..de0f3748f7a
--- /dev/null
+++ b/ILSpy.Tests/Editor/CaretUsageHighlightTests.cs
@@ -0,0 +1,156 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+
+using Avalonia.Headless.NUnit;
+using Avalonia.VisualTree;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Options;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.TextView;
+
+/// 
+/// Highlight usages at caret (ReSharper-style): while the caret rests on a symbol, every
+/// occurrence of that symbol in the document is marked. Independent of the click highlight
+/// (), which keeps its own marks.
+/// 
+[TestFixture]
+public class CaretUsageHighlightTests
+{
+	static async Task<(DecompilerTextView View, DecompilerTabPageModel Tab, DisplaySettings Settings)> SetupAsync(bool enabled = true)
+	{
+		var (window, vm) = await TestHarness.BootAsync();
+		var settings = AppComposition.Current.GetExport().DisplaySettings;
+		settings.HighlightUsagesAtCaret = enabled;
+		await vm.OpenAssemblyAsync(typeof(MemberHighlightSample).Assembly.Location);
+		var typeNode = vm.AssemblyTreeModel.FindNode(
+			"ILSpy.Tests",
+			"ICSharpCode.ILSpy.Tests.TextView",
+			"ICSharpCode.ILSpy.Tests.TextView.MemberHighlightSample");
+		vm.AssemblyTreeModel.SelectNode(typeNode);
+		var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
+		var view = window.GetVisualDescendants().OfType().First();
+		return (view, tab, settings);
+	}
+
+	static List MemberSegments(DecompilerTabPageModel tab, string name)
+		=> tab.References!
+			.Where(r => r.Kind == ReferenceMode.Link && r.Reference is IMember m && m.Name == name)
+			.ToList();
+
+	[Test]
+	public void Setting_Defaults_To_On_And_Round_Trips()
+	{
+		new DisplaySettings().HighlightUsagesAtCaret.Should().BeTrue();
+
+		var loaded = new DisplaySettings();
+		loaded.LoadFromXml(new XElement("DisplaySettings"));
+		loaded.HighlightUsagesAtCaret.Should().BeTrue("a missing attribute keeps the default");
+
+		var off = new DisplaySettings { HighlightUsagesAtCaret = false };
+		var reloaded = new DisplaySettings();
+		reloaded.LoadFromXml(off.SaveToXml());
+		reloaded.HighlightUsagesAtCaret.Should().BeFalse();
+	}
+
+	[AvaloniaTest]
+	public async Task Caret_On_A_Member_Use_Marks_Every_Occurrence()
+	{
+		var (view, tab, _) = await SetupAsync();
+		var fieldSegments = MemberSegments(tab, nameof(MemberHighlightSample.Field));
+		var use = fieldSegments.First(r => !r.IsDefinition);
+
+		view.Editor.TextArea.Caret.Offset = use.StartOffset + 1;
+
+		view.CaretUsageMarks.Select(m => m.StartOffset).Should().BeEquivalentTo(
+			fieldSegments.Select(s => s.StartOffset),
+			"the definition and every use of the field under the caret are marked");
+		view.LocalReferenceMarks.Should().BeEmpty("the caret highlight must not touch the click-highlight marks");
+	}
+
+	[AvaloniaTest]
+	public async Task Caret_At_The_End_Of_An_Identifier_Still_Counts_As_On_It()
+	{
+		var (view, tab, _) = await SetupAsync();
+		var fieldSegments = MemberSegments(tab, nameof(MemberHighlightSample.Field));
+		var use = fieldSegments.First(r => !r.IsDefinition && r.Length > 1);
+
+		view.Editor.TextArea.Caret.Offset = use.EndOffset;
+
+		view.CaretUsageMarks.Should().NotBeEmpty();
+	}
+
+	[AvaloniaTest]
+	public async Task Caret_On_A_Parameter_Marks_Its_Local_Occurrences()
+	{
+		var (view, tab, _) = await SetupAsync();
+		var itemSegments = tab.References!
+			.Where(r => r.Kind == ReferenceMode.LocalHighlight && tab.Text.Substring(r.StartOffset, r.Length) == "item")
+			.ToList();
+		itemSegments.Should().HaveCountGreaterThanOrEqualTo(2, "the parameter is declared and used once");
+
+		view.Editor.TextArea.Caret.Offset = itemSegments[0].StartOffset;
+
+		view.CaretUsageMarks.Select(m => m.StartOffset).Should().BeEquivalentTo(
+			itemSegments.Select(s => s.StartOffset));
+	}
+
+	[AvaloniaTest]
+	public async Task Moving_The_Caret_Off_A_Symbol_Clears_The_Marks()
+	{
+		var (view, tab, _) = await SetupAsync();
+		var use = MemberSegments(tab, nameof(MemberHighlightSample.Field)).First(r => !r.IsDefinition);
+		view.Editor.TextArea.Caret.Offset = use.StartOffset;
+		view.CaretUsageMarks.Should().NotBeEmpty();
+
+		// Offset 0 is the start of the leading comment/using block, which carries no reference.
+		tab.References!.FindSegmentsContaining(0).Should().BeEmpty();
+		view.Editor.TextArea.Caret.Offset = 0;
+
+		view.CaretUsageMarks.Should().BeEmpty();
+	}
+
+	[AvaloniaTest]
+	public async Task Disabled_Setting_Marks_Nothing_And_Turning_It_Off_Clears_Marks()
+	{
+		var (view, tab, settings) = await SetupAsync(enabled: false);
+		var use = MemberSegments(tab, nameof(MemberHighlightSample.Field)).First(r => !r.IsDefinition);
+
+		view.Editor.TextArea.Caret.Offset = use.StartOffset;
+		view.CaretUsageMarks.Should().BeEmpty("the option is off");
+
+		settings.HighlightUsagesAtCaret = true;
+		view.CaretUsageMarks.Should().NotBeEmpty("turning the option on highlights the symbol already under the caret");
+
+		settings.HighlightUsagesAtCaret = false;
+		view.CaretUsageMarks.Should().BeEmpty("turning the option off clears the marks");
+	}
+}
diff --git a/ILSpy.Tests/MainWindow/MainMenuTests.cs b/ILSpy.Tests/MainWindow/MainMenuTests.cs
index b770a8e9fd8..bca65bb61e9 100644
--- a/ILSpy.Tests/MainWindow/MainMenuTests.cs
+++ b/ILSpy.Tests/MainWindow/MainMenuTests.cs
@@ -36,7 +36,7 @@
 
 namespace ICSharpCode.ILSpy.Tests;
 
-// MainMenu's top-level structure (File / View / Window with mnemonic underscores) is the
+// MainMenu's top-level structure (File / View / Navigate / Window with mnemonic underscores) is the
 // scaffolding every later commit hangs items onto via MEF. If a future commit accidentally
 // drops one of these top-levels or shuffles the order, the [ExportMainMenuCommand] entries
 // that target them by header would silently land in the wrong menu.
@@ -44,7 +44,7 @@ namespace ICSharpCode.ILSpy.Tests;
 public class MainMenuTests
 {
 	[AvaloniaTest]
-	public void MainMenu_top_level_items_are_File_View_Window_in_order()
+	public void MainMenu_top_level_items_are_File_View_Navigate_Window_in_order()
 	{
 		var window = AppComposition.Current.GetExport();
 		window.Show();
@@ -58,7 +58,7 @@ public void MainMenu_top_level_items_are_File_View_Window_in_order()
 			// PromoteHelpToMacAppMenu relocates the Help items into the application menu
 			// (macOS convention: About lives under the bold app-named menu), so _Help is
 			// not a window-menu top-level there.
-			headers.Should().Equal("_File", "_View", "_Window");
+			headers.Should().Equal("_File", "_View", "_Navigate", "_Window");
 
 			var appMenu = NativeMenu.GetMenu(Application.Current!);
 			appMenu.Should().NotBeNull("App.axaml declares the NativeMenu the Help items move into");
@@ -67,7 +67,7 @@ public void MainMenu_top_level_items_are_File_View_Window_in_order()
 		}
 		else
 		{
-			headers.Should().Equal("_File", "_View", "_Window", "_Help");
+			headers.Should().Equal("_File", "_View", "_Navigate", "_Window", "_Help");
 		}
 	}
 
@@ -156,7 +156,7 @@ public void Closing_An_Earlier_Window_Leaves_A_Later_Window_Help_Items_In_Place(
 
 	// The app menu is declared on Application and outlives every test, so a test that promotes
 	// placeholder items into it has to take them back out; otherwise a later test reading it
-	// (see MainMenu_top_level_items_are_File_View_Window_in_order) sees this test's leftovers.
+	// (see MainMenu_top_level_items_are_File_View_Navigate_Window_in_order) sees this test's leftovers.
 	static void RestoreAppMenu(NativeMenu appMenu, List promoted)
 	{
 		foreach (var item in promoted)
diff --git a/ILSpy.Tests/MainWindow/ToolPaneRegistryTests.cs b/ILSpy.Tests/MainWindow/ToolPaneRegistryTests.cs
index 162215f5e70..0c702b14d14 100644
--- a/ILSpy.Tests/MainWindow/ToolPaneRegistryTests.cs
+++ b/ILSpy.Tests/MainWindow/ToolPaneRegistryTests.cs
@@ -29,7 +29,9 @@
 using ICSharpCode.ILSpy.AssemblyTree;
 using ICSharpCode.ILSpy.Commands;
 using ICSharpCode.ILSpy.Docking;
+using ICSharpCode.ILSpy.FileStructure;
 using ICSharpCode.ILSpy.Search;
+using ICSharpCode.ILSpy.TypeHierarchy;
 
 using NUnit.Framework;
 
@@ -87,6 +89,20 @@ public void DockFactory_Builds_Layout_Zones_Driven_By_The_Registry()
 		allDockables.OfType().Should().ContainSingle();
 		allDockables.OfType().Should().BeEmpty("Search is hidden until invoked");
 		allDockables.OfType().Should().BeEmpty("Analyzer is hidden until invoked");
+		allDockables.OfType().Should().BeEmpty("Type Hierarchy is hidden until invoked");
+		allDockables.OfType().Should().BeEmpty("File Structure is hidden until invoked");
+	}
+
+	[AvaloniaTest]
+	public void Registry_Lists_The_Structure_Panes_In_Their_Dock_Zones()
+	{
+		var registry = AppComposition.Current.GetExport();
+		var byType = registry.Panes.ToDictionary(p => p.Pane.GetType(), p => p.Metadata);
+
+		byType[typeof(TypeHierarchyViewModel)].Alignment.Should().Be(ToolPaneAlignment.Bottom);
+		byType[typeof(TypeHierarchyViewModel)].IsVisibleByDefault.Should().BeFalse();
+		byType[typeof(FileStructureViewModel)].Alignment.Should().Be(ToolPaneAlignment.Right);
+		byType[typeof(FileStructureViewModel)].IsVisibleByDefault.Should().BeFalse();
 	}
 
 	static System.Collections.Generic.IEnumerable FlattenDockables(Dock.Model.Core.IDockable root)
diff --git a/ILSpy.Tests/Navigation/FileStructureTests.cs b/ILSpy.Tests/Navigation/FileStructureTests.cs
new file mode 100644
index 00000000000..fc191eebfe1
--- /dev/null
+++ b/ILSpy.Tests/Navigation/FileStructureTests.cs
@@ -0,0 +1,171 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+
+using Avalonia.Controls;
+using Avalonia.Headless.NUnit;
+using Avalonia.Input;
+using Avalonia.Threading;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.FileStructure;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.ViewModels;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+[TestFixture]
+public class FileStructureTests
+{
+	internal static async Task<(MainWindowViewModel Vm, DecompilerTabPageModel Tab)> DecompileFixtureTypeAsync(string typeName)
+	{
+		var (_, vm) = await TestHarness.BootAsync();
+		await HierarchyFixture.OpenAsync(vm);
+		vm.AssemblyTreeModel.SelectNode(HierarchyFixture.FindType(vm, typeName));
+		var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
+		tab.Text.Should().Contain($"class {typeName}");
+		return (vm, tab);
+	}
+
+	static FileStructureViewModel ShowPane()
+	{
+		AppComposition.Current.GetExport()
+			.GetCommand(nameof(Resources.FileStructure)).Execute(null);
+		return AppComposition.Current.GetExport();
+	}
+
+	[AvaloniaTest]
+	public async Task Pane_Lists_Types_And_Members_Of_The_Active_Document_In_Document_Order()
+	{
+		// Arrange
+		var (vm, tab) = await DecompileFixtureTypeAsync("Circle");
+
+		// Act
+		var pane = ShowPane();
+
+		// Assert -- the pane is visible and lists the type first, then its members, each
+		// pointing at the place its name is written.
+		vm.DockWorkspace.ToolPaneMenuItems.Single(i => i.Title == Resources.FileStructure)
+			.IsPaneVisible.Should().BeTrue();
+		var entries = pane.Entries.ToList();
+		entries.Select(e => e.Name).Should().ContainInOrder("Circle", "radius", "Area", "Diameter");
+		entries.Select(e => e.Offset).Should().BeInAscendingOrder();
+		entries.Should().OnlyContain(e => e.Icon != null);
+		foreach (var entry in entries)
+			tab.Text.AsSpan(entry.Offset).StartsWith(entry.Name.AsSpan(), StringComparison.Ordinal)
+				.Should().BeTrue($"'{entry.Name}' must point at its definition");
+		var type = entries.Single(e => e.Name == "Circle");
+		type.Entity.Should().BeAssignableTo();
+		entries.Where(e => e != type).Should().OnlyContain(e => e.Depth > type.Depth);
+	}
+
+	[AvaloniaTest]
+	public async Task Activating_An_Entry_Moves_The_Caret_To_Its_Definition()
+	{
+		var (_, tab) = await DecompileFixtureTypeAsync("Circle");
+		var pane = ShowPane();
+		var diameter = pane.Entries.Single(e => e.Name == "Diameter");
+
+		// Act
+		pane.Activate(diameter).Should().BeTrue();
+
+		// Assert
+		tab.CaptureViewState.Should().NotBeNull("the text view must be attached to the active tab");
+		tab.CaptureViewState!().CaretOffset.Should().Be(diameter.Offset);
+	}
+
+	[AvaloniaTest]
+	public async Task Pane_View_Lists_The_Entries_And_Enter_Moves_The_Caret()
+	{
+		var (_, tab) = await DecompileFixtureTypeAsync("Circle");
+		var pane = ShowPane();
+		var window = AppComposition.Current.GetExport();
+		var view = await window.WaitForComponent();
+		var list = view.FindControl("EntryList")!;
+		list.ItemsSource.Should().BeSameAs(pane.Entries);
+		var area = pane.Entries.Single(e => e.Name == "Area");
+
+		// Act -- select Area and press Enter in the list.
+		pane.SelectedEntry = area;
+		Dispatcher.UIThread.RunJobs();
+		list.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Enter, Source = list });
+
+		// Assert
+		tab.CaptureViewState!().CaretOffset.Should().Be(area.Offset);
+	}
+
+	[AvaloniaTest]
+	public async Task Pane_Follows_The_Active_Document_Content()
+	{
+		var (vm, _) = await DecompileFixtureTypeAsync("Circle");
+		var pane = ShowPane();
+		pane.Entries.Should().Contain(e => e.Name == "Circle");
+
+		// Act -- navigate the same tab to another type.
+		vm.AssemblyTreeModel.SelectNode(HierarchyFixture.FindType(vm, "Square"));
+		await vm.DockWorkspace.WaitForDecompiledTextAsync();
+
+		// Assert
+		await Waiters.WaitForAsync(() => pane.Entries.Any(e => e.Name == "Square"),
+			description: "the file structure to show the newly decompiled type");
+		pane.Entries.Should().NotContain(e => e.Name == "Circle");
+	}
+
+	[AvaloniaTest]
+	public async Task IL_Output_Lists_Definitions_With_Their_Metadata_Names()
+	{
+		// The IL view writes unresolved metadata references; the outline still names them.
+		var (_, vm) = await TestHarness.BootAsync();
+		await HierarchyFixture.OpenAsync(vm);
+		var language = AppComposition.Current.GetExport();
+		language.CurrentLanguage = language.Languages.Single(l => l.Name == "IL");
+		try
+		{
+			vm.AssemblyTreeModel.SelectNode(HierarchyFixture.FindType(vm, "Circle"));
+			var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
+			tab.Text.Should().Contain(".class");
+
+			var pane = ShowPane();
+
+			pane.Entries.Select(e => e.Name).Should().ContainInOrder("Circle", "radius", "Area", "Diameter");
+			pane.Entries.Should().OnlyContain(e => e.Icon != null);
+		}
+		finally
+		{
+			language.CurrentLanguage = language.Languages.Single(l => l.Name == "C#");
+		}
+	}
+
+	[Test]
+	public void Gesture_Is_Ctrl_Alt_F()
+	{
+		var gesture = KeyGesture.Parse(FileStructureCommand.Gesture);
+		gesture.Key.Should().Be(Key.F);
+		gesture.KeyModifiers.Should().Be(KeyModifiers.Control | KeyModifiers.Alt);
+	}
+}
diff --git a/ILSpy.Tests/Navigation/FindUsagesForRenameTests.cs b/ILSpy.Tests/Navigation/FindUsagesForRenameTests.cs
new file mode 100644
index 00000000000..9862609a652
--- /dev/null
+++ b/ILSpy.Tests/Navigation/FindUsagesForRenameTests.cs
@@ -0,0 +1,177 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Avalonia.Headless.NUnit;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Navigation;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.ViewModels;
+using ICSharpCode.ILSpy.Views;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Navigation;
+
+/// Interface member whose rename must reach the implementations and every call site.
+public interface IRenameTarget
+{
+	int Compute(int value);
+}
+
+/// Implements  with a virtual member that is overridden below.
+public class RenameTargetBase : IRenameTarget
+{
+	public virtual int Compute(int value) => value;
+}
+
+/// Overrides the virtual implementation.
+public class RenameTargetDerived : RenameTargetBase
+{
+	public override int Compute(int value) => value + 1;
+}
+
+/// Calls the member through the interface and through the derived class.
+public static class RenameTargetCaller
+{
+	public static int ViaInterface(IRenameTarget target) => target.Compute(1);
+
+	public static int ViaDerived() => new RenameTargetDerived().Compute(2);
+
+	public static int Unrelated() => 3;
+}
+
+/// 
+/// Read-only "Find Usages for Rename": for a symbol, everything a rename would have to touch --
+/// its declaration, related declarations (implementations / overrides / base members, type
+/// constructors) and every usage across the loaded assemblies -- without editing anything.
+/// 
+[TestFixture]
+public class FindUsagesForRenameTests
+{
+	static async Task<(MainWindow Window, MainWindowViewModel Vm, ITypeDefinition Base)> SetupAsync()
+	{
+		var (window, vm) = await TestHarness.BootAsync();
+		await vm.OpenAssemblyAsync(typeof(RenameTargetBase).Assembly.Location);
+		var typeNode = vm.AssemblyTreeModel.FindNode(
+			"ILSpy.Tests",
+			"ICSharpCode.ILSpy.Tests.Navigation",
+			"ICSharpCode.ILSpy.Tests.Navigation.RenameTargetBase");
+		return (window, vm, typeNode.TypeDefinition);
+	}
+
+	static string Names(System.Collections.Generic.IEnumerable entities)
+		=> string.Join(", ", entities.Select(e => e.DeclaringTypeDefinition?.Name + "." + e.Name));
+
+	[AvaloniaTest]
+	public async Task Virtual_Member_Collects_Base_Interface_Override_And_All_Call_Sites()
+	{
+		var (_, vm, baseType) = await SetupAsync();
+		var compute = baseType.Methods.Single(m => m.Name == nameof(RenameTargetBase.Compute));
+
+		var result = await RenameUsageFinder.FindAsync(compute, vm.AssemblyTreeModel.AssemblyList!,
+			vm.DockWorkspace.ActiveDecompilerTab?.Language ?? AppComposition.Current.GetExport().CurrentLanguage,
+			CancellationToken.None);
+
+		Names(result.Declarations).Should().Be("RenameTargetBase.Compute");
+		Names(result.RelatedDeclarations).Should().Contain("IRenameTarget.Compute")
+			.And.Contain("RenameTargetDerived.Compute");
+		var usageNames = result.Usages.Select(u => u.DeclaringTypeDefinition?.Name + "." + u.Name).ToList();
+		usageNames.Should().Contain("RenameTargetCaller.ViaInterface", "the interface call site must change too");
+		usageNames.Should().Contain("RenameTargetCaller.ViaDerived", "the call through the override must change too");
+		usageNames.Should().NotContain("RenameTargetCaller.Unrelated");
+	}
+
+	[AvaloniaTest]
+	public async Task Type_Collects_Its_Constructors_And_Usages()
+	{
+		var (_, vm, baseType) = await SetupAsync();
+		var derived = baseType.ParentModule!.TopLevelTypeDefinitions.Single(t => t.Name == nameof(RenameTargetDerived));
+
+		var result = await RenameUsageFinder.FindAsync(derived, vm.AssemblyTreeModel.AssemblyList!,
+			AppComposition.Current.GetExport().CurrentLanguage, CancellationToken.None);
+
+		result.Declarations.Should().ContainSingle().Which.Name.Should().Be(nameof(RenameTargetDerived));
+		result.RelatedDeclarations.OfType().Should().Contain(m => m.IsConstructor,
+			"a type's constructors carry its name");
+		result.Usages.Select(u => u.Name).Should().Contain(nameof(RenameTargetCaller.ViaDerived));
+	}
+
+	[AvaloniaTest]
+	public async Task Command_Opens_A_Navigable_Report_For_The_Selected_Member()
+	{
+		var (_, vm, _) = await SetupAsync();
+		var typeNode = vm.AssemblyTreeModel.FindNode(
+			"ILSpy.Tests",
+			"ICSharpCode.ILSpy.Tests.Navigation",
+			"ICSharpCode.ILSpy.Tests.Navigation.RenameTargetBase");
+		typeNode.EnsureLazyChildren();
+		var methodNode = typeNode.Children.OfType().Single(m => m.MethodDefinition.Name == nameof(RenameTargetBase.Compute));
+		vm.AssemblyTreeModel.SelectNode(methodNode);
+		await vm.DockWorkspace.WaitForDecompiledTextAsync();
+
+		var command = AppComposition.Current.GetExport().GetCommand(nameof(Resources.FindUsagesForRename));
+		command.CanExecute(null).Should().BeTrue();
+		command.Execute(null);
+
+		DecompilerTabPageModel? report = null;
+		await Waiters.WaitForAsync(() => {
+			report = vm.DockWorkspace.Documents!.VisibleDockables!.OfType()
+				.Select(t => t.Content).OfType()
+				.FirstOrDefault(t => t.Title?.StartsWith(Resources.FindUsagesForRename, StringComparison.Ordinal) == true
+					&& !t.IsDecompiling && t.Text.Contains("ViaInterface"));
+			return report != null;
+		}, null, "the Find Usages for Rename report tab");
+
+		report!.Text.Should().Contain("ILSpy.Tests", "results are grouped by assembly");
+		report.Text.Should().Contain("RenameTargetCaller", "and by declaring type");
+		var link = report.References!.FirstOrDefault(r => r.Reference is IMethod { Name: nameof(RenameTargetCaller.ViaInterface) });
+		link.Should().NotBeNull("every row links to its entity");
+
+		report.RaiseNavigateRequested(link!);
+		await vm.DockWorkspace.WaitForDecompiledTextAsync();
+		var selected = ((object?)vm.AssemblyTreeModel.SelectedItem).Should().BeOfType().Subject;
+		selected.MethodDefinition.Name.Should().Be(nameof(RenameTargetCaller.ViaInterface));
+	}
+
+	[AvaloniaTest]
+	public async Task Context_Menu_Entry_Is_In_The_Navigation_Category_And_Visible_For_Code_References()
+	{
+		var (_, _, baseType) = await SetupAsync();
+		var registry = AppComposition.Current.GetExport();
+		var export = registry.Entries.Single(e => e.Metadata.Header == nameof(Resources.FindUsagesForRename));
+		export.Metadata.Category.Should().Be("Navigation");
+
+		var compute = baseType.Methods.Single(m => m.Name == nameof(RenameTargetBase.Compute));
+		var segment = new ReferenceSegment { Reference = compute, StartOffset = 0, Length = 1 };
+		export.Value.IsVisible(new TextViewContext { Reference = segment }).Should().BeTrue();
+		export.Value.IsVisible(new TextViewContext()).Should().BeFalse();
+	}
+}
diff --git a/ILSpy.Tests/Navigation/GoToCommandTests.cs b/ILSpy.Tests/Navigation/GoToCommandTests.cs
new file mode 100644
index 00000000000..32f3ece796f
--- /dev/null
+++ b/ILSpy.Tests/Navigation/GoToCommandTests.cs
@@ -0,0 +1,207 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Headless.NUnit;
+using Avalonia.Input;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpy.Properties;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Navigation;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.ViewModels;
+using ICSharpCode.ILSpy.Views;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+[TestFixture]
+public class GoToCommandTests
+{
+	// Decompiles the fixture's Circle type and puts the caret (with keyboard focus) on the
+	// "ShapeBase" reference in its base-type list.
+	static async Task<(MainWindow Window, MainWindowViewModel ViewModel, DecompilerTextView View)> CaretOnBaseTypeReferenceAsync()
+	{
+		var (window, vm) = await TestHarness.BootAsync();
+		await GoToFixture.OpenAsync(vm);
+		vm.AssemblyTreeModel.SelectNode(GoToFixture.TypeNode(vm, "Circle"));
+		var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
+		var view = window.GetVisualDescendants().OfType().First(v => v.DataContext == tab);
+		AvaloniaHeadlessPlatform.ForceRenderTimerTick();
+		Dispatcher.UIThread.RunJobs();
+
+		var segment = tab.References!.First(r => !r.IsDefinition && r.Reference is ITypeDefinition { Name: "ShapeBase" });
+		view.Editor.TextArea.Focus();
+		view.Editor.TextArea.Caret.Offset = segment.StartOffset + 1;
+		Dispatcher.UIThread.RunJobs();
+		return (window, vm, view);
+	}
+
+	static NativeMenuItem NavigateMenu(MainWindow window)
+	{
+		var menu = NativeMenu.GetMenu(window) ?? throw new InvalidOperationException("main menu not attached");
+		return menu.Items.OfType().Single(i => i.Header == Resources._Navigate);
+	}
+
+	[AvaloniaTest]
+	public async Task Navigate_Menu_Lists_The_Go_To_Commands_With_Their_Shortcuts()
+	{
+		var (window, _) = await TestHarness.BootAsync();
+		var items = NavigateMenu(window).Menu!.Items.OfType()
+			.Where(i => i is not NativeMenuItemSeparator)
+			.ToDictionary(i => i.Header!, i => i.Gesture);
+
+		items[Resources.NavigateTo].Should().Be(KeyGesture.Parse(OperatingSystem.IsMacOS() ? "Cmd+Shift+G" : "Ctrl+Shift+G"));
+		items[Resources.GoToDeclaration].Should().Be(KeyGesture.Parse("F12"));
+		items[Resources.GoToImplementation].Should().Be(KeyGesture.Parse(OperatingSystem.IsMacOS() ? "Cmd+F12" : "Ctrl+F12"));
+		items[Resources.GoToBaseSymbols].Should().Be(KeyGesture.Parse("Alt+Home"));
+		items[Resources.GoToDerivedSymbols].Should().Be(KeyGesture.Parse("Alt+End"));
+		items[Resources.LocateInAssemblyExplorer].Should().Be(KeyGesture.Parse("Shift+Alt+L"));
+		items[Resources.Analyze].Should().Be(KeyGesture.Parse("Shift+F12"));
+	}
+
+	[AvaloniaTest]
+	public async Task Main_Menu_Go_To_Declaration_Acts_On_The_Symbol_Under_The_Caret()
+	{
+		var (_, vm, _) = await CaretOnBaseTypeReferenceAsync();
+		var command = AppComposition.Current.GetExport().GetCommand(nameof(Resources.GoToDeclaration));
+		var navigator = AppComposition.Current.GetExport();
+
+		command.CanExecute(null).Should().BeTrue();
+		command.Execute(null);
+		await navigator.LastOperation;
+		Dispatcher.UIThread.RunJobs();
+
+		var selected = (vm.AssemblyTreeModel.SelectedItem as IMemberTreeNode)?.Member;
+		selected.Should().NotBeNull();
+		GoToFixture.Describe(selected!).Should().Be("ShapeBase");
+	}
+
+	[AvaloniaTest]
+	public async Task Main_Menu_Go_To_Derived_Acts_On_The_Selected_Tree_Node_When_The_Tree_Has_Focus()
+	{
+		var (_, vm) = await TestHarness.BootAsync();
+		await GoToFixture.OpenAsync(vm);
+		var command = AppComposition.Current.GetExport().GetCommand(nameof(Resources.GoToDerivedSymbols));
+		var navigator = AppComposition.Current.GetExport();
+		var shape = GoToFixture.TypeNode(vm, "IShape");
+		vm.AssemblyTreeModel.SelectNode(shape);
+		await vm.DockWorkspace.WaitForDecompiledTextAsync();
+		ActiveNavigationContext.ClearLastFocusedTextView();
+
+		command.Execute(null);
+		await navigator.LastOperation;
+		Dispatcher.UIThread.RunJobs();
+
+		GoToFixture.Describe(((IMemberTreeNode)vm.AssemblyTreeModel.SelectedItem!).Member!).Should().Be("ShapeBase");
+	}
+
+	[AvaloniaTest]
+	public async Task Caret_Context_Resolves_The_Reference_At_The_Caret()
+	{
+		var (_, _, view) = await CaretOnBaseTypeReferenceAsync();
+		var context = ActiveNavigationContext.ForTextViewCaret(view);
+		context.TextView.Should().BeSameAs(view);
+		(context.Reference?.Reference as ITypeDefinition)?.Name.Should().Be("ShapeBase");
+	}
+
+	[AvaloniaTest]
+	public async Task Navigate_To_Lists_Visible_Navigation_Entries_And_Runs_The_Chosen_One()
+	{
+		var (_, vm, _) = await CaretOnBaseTypeReferenceAsync();
+		var command = AppComposition.Current.GetExport().GetCommand(nameof(Resources.NavigateTo));
+		var navigator = AppComposition.Current.GetExport();
+
+		command.Execute(null);
+		Dispatcher.UIThread.RunJobs();
+
+		var chooser = navigator.ActiveChooser;
+		chooser.Should().NotBeNull("Navigate To opens the action chooser");
+		var texts = chooser!.VisibleChoices.Select(c => c.Text).ToList();
+		texts.Should().Contain(new[] {
+			Resources.GoToDeclaration, Resources.GoToImplementation, Resources.GoToDerivedSymbols,
+			Resources.GoToBaseSymbols, Resources.LocateInAssemblyExplorer, Resources.Decompile });
+		texts.Should().NotContain(Resources.Analyze, "only Navigation-category entries are offered");
+
+		chooser.Filter = Resources.GoToDeclaration;
+		chooser.Accept();
+		await navigator.LastOperation;
+		Dispatcher.UIThread.RunJobs();
+
+		GoToFixture.Describe(((IMemberTreeNode)vm.AssemblyTreeModel.SelectedItem!).Member!).Should().Be("ShapeBase");
+	}
+
+	[AvaloniaTest]
+	public async Task Locate_In_Assembly_Explorer_Selects_The_Caret_Symbol_Node()
+	{
+		var (_, vm, view) = await CaretOnBaseTypeReferenceAsync();
+		var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.LocateInAssemblyExplorer));
+		var context = ActiveNavigationContext.ForTextViewCaret(view);
+
+		entry.IsVisible(context).Should().BeTrue();
+		entry.Execute(context);
+		Dispatcher.UIThread.RunJobs();
+
+		GoToFixture.Describe(((IMemberTreeNode)vm.AssemblyTreeModel.SelectedItem!).Member!).Should().Be("ShapeBase");
+	}
+
+	[AvaloniaTest]
+	public async Task Locate_In_Assembly_Explorer_Falls_Back_To_The_Document_Node()
+	{
+		var (_, vm, view) = await CaretOnBaseTypeReferenceAsync();
+		var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.LocateInAssemblyExplorer));
+		var circle = GoToFixture.TypeNode(vm, "Circle");
+		// The document's node is no longer selected (e.g. the user browsed the tree while keeping the tab).
+		vm.AssemblyTreeModel.SelectedItems.Clear();
+		var context = new TextViewContext { TextView = view };
+
+		entry.IsVisible(context).Should().BeTrue();
+		entry.Execute(context);
+		Dispatcher.UIThread.RunJobs();
+
+		ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, circle).Should().BeTrue();
+	}
+
+	[AvaloniaTest]
+	public async Task Find_Usages_Menu_Command_Analyzes_The_Caret_Symbol()
+	{
+		var (_, _, _) = await CaretOnBaseTypeReferenceAsync();
+		var command = AppComposition.Current.GetExport().GetCommand(nameof(Resources.Analyze));
+		var analyzer = AppComposition.Current.GetExport();
+
+		command.Execute(null);
+		Dispatcher.UIThread.RunJobs();
+
+		analyzer.Root.Children.OfType()
+			.Should().Contain(n => n.Member != null && n.Member.Name == "ShapeBase");
+	}
+}
diff --git a/ILSpy.Tests/Navigation/GoToFileMemberTests.cs b/ILSpy.Tests/Navigation/GoToFileMemberTests.cs
new file mode 100644
index 00000000000..4fa6d626e82
--- /dev/null
+++ b/ILSpy.Tests/Navigation/GoToFileMemberTests.cs
@@ -0,0 +1,138 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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 Avalonia.Input;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.FileStructure;
+using ICSharpCode.ILSpy.Properties;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+[TestFixture]
+public class GoToFileMemberTests
+{
+	[AvaloniaTest]
+	public async Task Typing_Filters_The_Members_Of_The_Active_Document()
+	{
+		var (_, tab) = await FileStructureTests.DecompileFixtureTypeAsync("Circle");
+		var model = new GoToFileMemberViewModel(tab);
+		model.Items.Select(e => e.Name).Should().Contain(["Circle", "radius", "Area", "Diameter"]);
+
+		// Act -- case-insensitive substring filter.
+		model.FilterText = "DIA";
+
+		// Assert -- only Diameter remains and is preselected so Enter accepts it.
+		model.Items.Select(e => e.Name).Should().Equal("Diameter");
+		model.SelectedItem.Should().BeSameAs(model.Items[0]);
+
+		model.FilterText = "zzz";
+		model.Items.Should().BeEmpty();
+		model.SelectedItem.Should().BeNull();
+		model.Accept().Should().BeFalse("nothing is selected");
+	}
+
+	[AvaloniaTest]
+	public async Task Accept_Moves_The_Caret_To_The_Member_And_Closes_The_Popup()
+	{
+		var (_, tab) = await FileStructureTests.DecompileFixtureTypeAsync("Circle");
+		var model = new GoToFileMemberViewModel(tab);
+		bool closed = false;
+		model.CloseRequested += (_, _) => closed = true;
+		model.FilterText = "area";
+		var area = model.SelectedItem!;
+
+		// Act
+		model.Accept().Should().BeTrue();
+
+		// Assert
+		closed.Should().BeTrue();
+		tab.CaptureViewState!().CaretOffset.Should().Be(area.Offset);
+	}
+
+	[AvaloniaTest]
+	public async Task Command_Opens_The_Popup_For_The_Active_Document()
+	{
+		var (vm, tab) = await FileStructureTests.DecompileFixtureTypeAsync("Circle");
+		var command = (GoToFileMemberCommand)AppComposition.Current.GetExport()
+			.GetCommand(nameof(Resources.GoToFileMember));
+
+		// Act
+		command.CanExecute(null).Should().BeTrue();
+		command.Execute(null);
+
+		// Assert -- the popup is open, bound to the active document, and listing its members.
+		command.OpenFlyout.Should().NotBeNull();
+		command.OpenFlyout!.IsOpen.Should().BeTrue();
+		command.OpenModel!.Document.Should().BeSameAs(tab);
+		command.OpenModel.Items.Should().Contain(e => e.Name == "Diameter");
+
+		// Accepting closes it.
+		command.OpenModel.FilterText = "diameter";
+		command.OpenModel.Accept().Should().BeTrue();
+		await Waiters.WaitForAsync(() => command.OpenFlyout == null, description: "the popup to close");
+	}
+
+	[Test]
+	public void Gesture_Is_Alt_Backslash()
+	{
+		// The backslash key of US and most European layouts reports as OemPipe (VK_OEM_5).
+		var gesture = KeyGesture.Parse(GoToFileMemberCommand.Gesture);
+		gesture.Key.Should().Be(Key.OemPipe);
+		gesture.KeyModifiers.Should().Be(KeyModifiers.Alt);
+	}
+
+	[AvaloniaTest]
+	public void Navigation_Gestures_Do_Not_Collide_With_Other_Commands()
+	{
+		var mine = new[] { TypeHierarchyGesture, FileStructureCommand.Gesture, GoToFileMemberCommand.Gesture }
+			.Select(KeyGesture.Parse).ToArray();
+		var mainMenu = AppComposition.Current.GetExport().Commands
+			.Select(c => (Header: c.Metadata.Header, Text: c.Metadata.InputGestureText));
+		var contextMenu = AppComposition.Current.GetExport().Entries
+			.Select(e => (Header: e.Metadata.Header, Text: e.Metadata.InputGestureText));
+		var owners = new[] { nameof(Resources.TypeHierarchy), nameof(Resources.FileStructure), nameof(Resources.GoToFileMember) };
+
+		foreach (var (header, text) in mainMenu.Concat(contextMenu))
+		{
+			if (string.IsNullOrEmpty(text) || owners.Contains(header))
+				continue;
+			KeyGesture other;
+			try
+			{
+				other = KeyGesture.Parse(text);
+			}
+			catch (System.Exception)
+			{
+				continue; // descriptive text such as "MMB", not a key gesture
+			}
+			mine.Should().NotContain(other, $"'{header}' already uses {text}");
+		}
+	}
+
+	static string TypeHierarchyGesture => TypeHierarchy.TypeHierarchyCommand.Gesture;
+}
diff --git a/ILSpy.Tests/Navigation/GoToFixture.cs b/ILSpy.Tests/Navigation/GoToFixture.cs
new file mode 100644
index 00000000000..445874768f8
--- /dev/null
+++ b/ILSpy.Tests/Navigation/GoToFixture.cs
@@ -0,0 +1,123 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Reflection.Emit;
+using System.Threading.Tasks;
+
+using ICSharpCode.Decompiler.TypeSystem;
+
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.ViewModels;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+/// 
+/// Emits a small inheritance hierarchy for the go-to navigation tests:
+/// 
+/// interface IShape { double Area(); }
+/// abstract class ShapeBase : IShape { public abstract double Area(); }
+/// class Circle : ShapeBase { public override double Area(); }
+/// class Square : ShapeBase { public override double Area(); }
+/// class Lonely { public void Solo(); }
+/// 
+/// 
+public static class GoToFixture
+{
+	public const string Name = "GoToFixture";
+
+	public static string Emit()
+	{
+		var ab = new PersistedAssemblyBuilder(new AssemblyName(Name), typeof(object).Assembly);
+		var module = ab.DefineDynamicModule(Name);
+
+		var shape = module.DefineType($"{Name}.IShape",
+			TypeAttributes.Public | TypeAttributes.Interface | TypeAttributes.Abstract);
+		shape.DefineMethod("Area",
+			MethodAttributes.Public | MethodAttributes.Abstract | MethodAttributes.Virtual
+			| MethodAttributes.HideBySig | MethodAttributes.NewSlot,
+			typeof(double), Type.EmptyTypes);
+		shape.CreateType();
+
+		var shapeBase = module.DefineType($"{Name}.ShapeBase",
+			TypeAttributes.Public | TypeAttributes.Abstract | TypeAttributes.Class, typeof(object));
+		shapeBase.AddInterfaceImplementation(shape);
+		shapeBase.DefineMethod("Area",
+			MethodAttributes.Public | MethodAttributes.Abstract | MethodAttributes.Virtual
+			| MethodAttributes.HideBySig | MethodAttributes.NewSlot,
+			typeof(double), Type.EmptyTypes);
+		shapeBase.DefineDefaultConstructor(MethodAttributes.Family);
+		shapeBase.CreateType();
+
+		DefineShape(module, shapeBase, "Circle", 3.14);
+		DefineShape(module, shapeBase, "Square", 4.0);
+
+		var lonely = module.DefineType($"{Name}.Lonely", TypeAttributes.Public | TypeAttributes.Class);
+		var solo = lonely.DefineMethod("Solo", MethodAttributes.Public | MethodAttributes.HideBySig,
+			typeof(void), Type.EmptyTypes);
+		solo.GetILGenerator().Emit(OpCodes.Ret);
+		lonely.CreateType();
+
+		var dir = Path.Combine(Path.GetTempPath(), $"ILSpyGoToFixture_{Guid.NewGuid():N}");
+		Directory.CreateDirectory(dir);
+		var path = Path.Combine(dir, $"{Name}.dll");
+		ab.Save(path);
+		return path;
+	}
+
+	static void DefineShape(ModuleBuilder module, TypeBuilder shapeBase, string name, double area)
+	{
+		var type = module.DefineType($"{Name}.{name}", TypeAttributes.Public | TypeAttributes.Class, shapeBase);
+		var method = type.DefineMethod("Area",
+			MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.HideBySig,
+			typeof(double), Type.EmptyTypes);
+		var il = method.GetILGenerator();
+		il.Emit(OpCodes.Ldc_R8, area);
+		il.Emit(OpCodes.Ret);
+		type.DefineDefaultConstructor(MethodAttributes.Public);
+		type.CreateType();
+	}
+
+	public static async Task OpenAsync(MainWindowViewModel vm)
+	{
+		await vm.OpenAssemblyAsync(Emit());
+	}
+
+	public static TypeTreeNode TypeNode(MainWindowViewModel vm, string typeName)
+		=> vm.AssemblyTreeModel.FindNode(Name, Name, $"{Name}.{typeName}");
+
+	public static MethodTreeNode MethodNode(MainWindowViewModel vm, string typeName, string methodName)
+	{
+		var typeNode = TypeNode(vm, typeName);
+		typeNode.IsExpanded = true;
+		return typeNode.Children.OfType().Single(m => m.MethodDefinition.Name == methodName);
+	}
+
+	public static ITypeDefinition TypeDef(MainWindowViewModel vm, string typeName)
+		=> TypeNode(vm, typeName).TypeDefinition;
+
+	public static IMethod Method(MainWindowViewModel vm, string typeName, string methodName)
+		=> MethodNode(vm, typeName, methodName).MethodDefinition;
+
+	/// "Type.Member" or "Type" for a navigation target, for order-insensitive assertions.
+	public static string Describe(IEntity entity)
+		=> entity is ITypeDefinition td ? td.Name : $"{entity.DeclaringTypeDefinition?.Name}.{entity.Name}";
+}
diff --git a/ILSpy.Tests/Navigation/GoToLineTests.cs b/ILSpy.Tests/Navigation/GoToLineTests.cs
new file mode 100644
index 00000000000..cbf32e05776
--- /dev/null
+++ b/ILSpy.Tests/Navigation/GoToLineTests.cs
@@ -0,0 +1,145 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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;
+using Avalonia.Headless.NUnit;
+using Avalonia.Input;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Navigation;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.Tests.TextView;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.Views;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Navigation;
+
+/// 
+/// Go to Line (dotPeek Ctrl+G in the code view): a small prompt in the active decompiler view
+/// that moves the caret to "line" or "line:column".
+/// 
+[TestFixture]
+public class GoToLineTests
+{
+	static async Task<(MainWindow Window, DecompilerTextView View)> SetupAsync()
+	{
+		var (window, vm) = await TestHarness.BootAsync();
+		await vm.OpenAssemblyAsync(typeof(MemberHighlightSample).Assembly.Location);
+		var typeNode = vm.AssemblyTreeModel.FindNode(
+			"ILSpy.Tests",
+			"ICSharpCode.ILSpy.Tests.TextView",
+			"ICSharpCode.ILSpy.Tests.TextView.MemberHighlightSample");
+		vm.AssemblyTreeModel.SelectNode(typeNode);
+		await vm.DockWorkspace.WaitForDecompiledTextAsync();
+		var view = window.GetVisualDescendants().OfType().First();
+		return (window, view);
+	}
+
+	[TestCase("12", 12, null)]
+	[TestCase(" 7 ", 7, null)]
+	[TestCase("12:5", 12, 5)]
+	[TestCase("3,9", 3, 9)]
+	public void Parses_Line_And_Optional_Column(string input, int line, int? column)
+	{
+		GoToLineInput.TryParse(input, out var parsedLine, out var parsedColumn).Should().BeTrue();
+		parsedLine.Should().Be(line);
+		parsedColumn.Should().Be(column);
+	}
+
+	[TestCase("")]
+	[TestCase("abc")]
+	[TestCase("0")]
+	[TestCase("-3")]
+	[TestCase("4:0")]
+	[TestCase("4:x")]
+	public void Rejects_Invalid_Input(string input)
+	{
+		GoToLineInput.TryParse(input, out _, out _).Should().BeFalse();
+	}
+
+	[AvaloniaTest]
+	public async Task GoToLine_Moves_The_Caret_To_The_Line_And_Column()
+	{
+		var (_, view) = await SetupAsync();
+		view.Editor.Document.LineCount.Should().BeGreaterThan(5);
+
+		var target = view.Editor.Document.Lines.Skip(1).First(l => l.Length >= 5).LineNumber;
+
+		view.GoToLine(target, 3).Should().BeTrue();
+
+		view.Editor.TextArea.Caret.Line.Should().Be(target);
+		view.Editor.TextArea.Caret.Column.Should().Be(3);
+	}
+
+	[AvaloniaTest]
+	public async Task GoToLine_Clamps_Out_Of_Range_Positions()
+	{
+		var (_, view) = await SetupAsync();
+		int lineCount = view.Editor.Document.LineCount;
+
+		view.GoToLine(lineCount + 100, 1000).Should().BeTrue();
+
+		view.Editor.TextArea.Caret.Line.Should().Be(lineCount);
+		var lastLine = view.Editor.Document.GetLineByNumber(lineCount);
+		view.Editor.TextArea.Caret.Column.Should().Be(lastLine.Length + 1, "the column is clamped to the end of the line");
+	}
+
+	[AvaloniaTest]
+	public async Task Ctrl_G_In_The_Editor_Opens_The_Prompt_And_Enter_Jumps()
+	{
+		var (window, view) = await SetupAsync();
+		view.Editor.TextArea.Focus();
+		Dispatcher.UIThread.RunJobs();
+
+		window.KeyPress(Key.G, RawInputModifiers.Control, PhysicalKey.G, null);
+		Dispatcher.UIThread.RunJobs();
+
+		var prompt = window.OwnedWindows.OfType().Should().ContainSingle(
+			"Ctrl+G inside the code view opens the Go to Line prompt").Subject;
+		prompt.InputText = "5";
+		prompt.Accept();
+		Dispatcher.UIThread.RunJobs();
+
+		view.Editor.TextArea.Caret.Line.Should().Be(5);
+		window.OwnedWindows.OfType().Should().BeEmpty("accepting closes the prompt");
+	}
+
+	[AvaloniaTest]
+	public async Task Menu_Command_Opens_The_Prompt_For_The_Active_Code_View()
+	{
+		var (window, _) = await SetupAsync();
+
+		var command = AppComposition.Current.GetExport().GetCommand(nameof(Resources.GoToLine));
+		command.CanExecute(null).Should().BeTrue("a decompiled document is showing");
+		command.Execute(null);
+
+		var prompt = window.OwnedWindows.OfType().Should().ContainSingle().Subject;
+		prompt.Close();
+	}
+}
diff --git a/ILSpy.Tests/Navigation/GoToSymbolTests.cs b/ILSpy.Tests/Navigation/GoToSymbolTests.cs
new file mode 100644
index 00000000000..1a5b7a2a24d
--- /dev/null
+++ b/ILSpy.Tests/Navigation/GoToSymbolTests.cs
@@ -0,0 +1,228 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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;
+using System.Threading.Tasks;
+
+using Avalonia.Headless.NUnit;
+using Avalonia.Input;
+using Avalonia.Threading;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpy.Properties;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Navigation;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.ViewModels;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+[TestFixture]
+public class GoToSymbolTests
+{
+	static async Task BootWithFixtureAsync()
+	{
+		var (_, vm) = await TestHarness.BootAsync();
+		await GoToFixture.OpenAsync(vm);
+		return vm;
+	}
+
+	static string[] Find(MainWindowViewModel vm, GoToKind kind, IEntity entity)
+		=> SymbolHierarchy.Find(kind, entity, vm.AssemblyTreeModel.AssemblyList!, CancellationToken.None)
+			.Select(GoToFixture.Describe).OrderBy(s => s).ToArray();
+
+	[AvaloniaTest]
+	public async Task Implementations_Of_An_Interface_Method_Are_The_Concrete_Overrides()
+	{
+		var vm = await BootWithFixtureAsync();
+		Find(vm, GoToKind.Implementation, GoToFixture.Method(vm, "IShape", "Area"))
+			.Should().Equal("Circle.Area", "Square.Area");
+	}
+
+	[AvaloniaTest]
+	public async Task Derived_Symbols_Of_An_Interface_Method_Are_Its_Direct_Implementations()
+	{
+		var vm = await BootWithFixtureAsync();
+		Find(vm, GoToKind.DerivedSymbols, GoToFixture.Method(vm, "IShape", "Area"))
+			.Should().Equal("ShapeBase.Area");
+		Find(vm, GoToKind.DerivedSymbols, GoToFixture.Method(vm, "ShapeBase", "Area"))
+			.Should().Equal("Circle.Area", "Square.Area");
+	}
+
+	[AvaloniaTest]
+	public async Task Base_Symbols_Of_An_Override_Are_The_Overridden_And_Implemented_Members()
+	{
+		var vm = await BootWithFixtureAsync();
+		Find(vm, GoToKind.BaseSymbols, GoToFixture.Method(vm, "Circle", "Area"))
+			.Should().Equal("IShape.Area", "ShapeBase.Area");
+	}
+
+	[AvaloniaTest]
+	public async Task Type_Hierarchy_Is_Resolved_Across_The_Assembly_List()
+	{
+		var vm = await BootWithFixtureAsync();
+		Find(vm, GoToKind.Implementation, GoToFixture.TypeDef(vm, "IShape")).Should().Equal("Circle", "Square");
+		Find(vm, GoToKind.DerivedSymbols, GoToFixture.TypeDef(vm, "IShape")).Should().Equal("ShapeBase");
+		Find(vm, GoToKind.DerivedSymbols, GoToFixture.TypeDef(vm, "ShapeBase")).Should().Equal("Circle", "Square");
+		Find(vm, GoToKind.BaseSymbols, GoToFixture.TypeDef(vm, "Circle")).Should().Equal("ShapeBase");
+		Find(vm, GoToKind.BaseSymbols, GoToFixture.TypeDef(vm, "ShapeBase")).Should().Equal("IShape", "Object");
+	}
+
+	[AvaloniaTest]
+	public async Task Hierarchy_Entries_Are_Hidden_For_Symbols_That_Cannot_Have_Inheritors()
+	{
+		var vm = await BootWithFixtureAsync();
+		var registry = AppComposition.Current.GetExport();
+		var solo = GoToFixture.MethodNode(vm, "Lonely", "Solo");
+		var context = new TextViewContext { SelectedTreeNodes = [solo] };
+
+		registry.GetEntry(nameof(Resources.GoToImplementation)).IsVisible(context).Should().BeFalse();
+		registry.GetEntry(nameof(Resources.GoToDerivedSymbols)).IsVisible(context).Should().BeFalse();
+		registry.GetEntry(nameof(Resources.GoToBaseSymbols)).IsVisible(context).Should().BeFalse(
+			"a non-virtual method declared on a class with only System.Object as base has no base symbol");
+		registry.GetEntry(nameof(Resources.GoToDeclaration)).IsVisible(context).Should().BeTrue();
+	}
+
+	[AvaloniaTest]
+	public async Task Navigation_Entries_Share_The_Navigation_Category()
+	{
+		await TestHarness.BootAsync();
+		var registry = AppComposition.Current.GetExport();
+		foreach (var header in new[] {
+			nameof(Resources.GoToDeclaration), nameof(Resources.GoToImplementation),
+			nameof(Resources.GoToBaseSymbols), nameof(Resources.GoToDerivedSymbols),
+			nameof(Resources.LocateInAssemblyExplorer) })
+		{
+			registry.Entries.Single(e => e.Metadata.Header == header).Metadata.Category
+				.Should().Be("Navigation", $"{header} must surface in the Navigate To popup");
+		}
+	}
+
+	[AvaloniaTest]
+	public async Task Go_To_Base_On_A_Tree_Node_With_One_Target_Navigates_Directly()
+	{
+		var vm = await BootWithFixtureAsync();
+		var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.GoToBaseSymbols));
+		var navigator = AppComposition.Current.GetExport();
+		var circle = GoToFixture.TypeNode(vm, "Circle");
+		vm.AssemblyTreeModel.SelectNode(circle);
+		var context = new TextViewContext { SelectedTreeNodes = [circle] };
+
+		entry.IsVisible(context).Should().BeTrue();
+		entry.Execute(context);
+		await navigator.LastOperation;
+		Dispatcher.UIThread.RunJobs();
+
+		var selected = (vm.AssemblyTreeModel.SelectedItem as IMemberTreeNode)?.Member;
+		selected.Should().NotBeNull("a single base type is navigated to without a chooser");
+		GoToFixture.Describe(selected!).Should().Be("ShapeBase");
+		navigator.ActiveChooser.Should().BeNull("one target needs no chooser");
+	}
+
+	[AvaloniaTest]
+	public async Task Go_To_Implementation_On_A_Code_Reference_With_Several_Targets_Opens_A_Searchable_Chooser()
+	{
+		var vm = await BootWithFixtureAsync();
+		var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.GoToImplementation));
+		var navigator = AppComposition.Current.GetExport();
+		var area = GoToFixture.Method(vm, "IShape", "Area");
+		var context = new TextViewContext { Reference = new ReferenceSegment { Reference = area } };
+
+		entry.IsVisible(context).Should().BeTrue();
+		entry.Execute(context);
+		await navigator.LastOperation;
+		Dispatcher.UIThread.RunJobs();
+
+		var chooser = navigator.ActiveChooser;
+		chooser.Should().NotBeNull("two implementations need a chooser");
+		chooser!.IsOpen.Should().BeTrue();
+		chooser.VisibleChoices.Should().HaveCount(2);
+
+		chooser.Filter = "Square";
+		chooser.VisibleChoices.Should().ContainSingle().Which.Text.Should().Contain("Square");
+
+		chooser.FilterBox.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Enter });
+		Dispatcher.UIThread.RunJobs();
+
+		chooser.IsOpen.Should().BeFalse("accepting a choice closes the chooser");
+		var selected = (vm.AssemblyTreeModel.SelectedItem as IMemberTreeNode)?.Member;
+		selected.Should().NotBeNull();
+		GoToFixture.Describe(selected!).Should().Be("Square.Area");
+	}
+
+	[AvaloniaTest]
+	public async Task Escape_Closes_The_Chooser_Without_Navigating()
+	{
+		var vm = await BootWithFixtureAsync();
+		var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.GoToDerivedSymbols));
+		var navigator = AppComposition.Current.GetExport();
+		var shapeBase = GoToFixture.TypeNode(vm, "ShapeBase");
+		vm.AssemblyTreeModel.SelectNode(shapeBase);
+		entry.Execute(new TextViewContext { SelectedTreeNodes = [shapeBase] });
+		await navigator.LastOperation;
+		Dispatcher.UIThread.RunJobs();
+
+		var chooser = navigator.ActiveChooser;
+		chooser.Should().NotBeNull();
+		chooser!.FilterBox.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Escape });
+		Dispatcher.UIThread.RunJobs();
+
+		chooser.IsOpen.Should().BeFalse();
+		ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, shapeBase).Should().BeTrue();
+	}
+
+	[AvaloniaTest]
+	public async Task No_Targets_Shows_A_Notice_Instead_Of_Navigating()
+	{
+		var vm = await BootWithFixtureAsync();
+		var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.GoToDerivedSymbols));
+		var navigator = AppComposition.Current.GetExport();
+		var circle = GoToFixture.TypeNode(vm, "Circle");
+		vm.AssemblyTreeModel.SelectNode(circle);
+		entry.Execute(new TextViewContext { SelectedTreeNodes = [circle] });
+		await navigator.LastOperation;
+		Dispatcher.UIThread.RunJobs();
+
+		navigator.ActiveChooser.Should().BeNull();
+		navigator.LastNotice.Should().Be("No derived symbols found");
+		ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, circle).Should().BeTrue();
+	}
+
+	[AvaloniaTest]
+	public async Task Go_To_Declaration_On_A_Code_Reference_Navigates_To_The_Definition()
+	{
+		var vm = await BootWithFixtureAsync();
+		var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.GoToDeclaration));
+		var navigator = AppComposition.Current.GetExport();
+		vm.AssemblyTreeModel.SelectNode(GoToFixture.TypeNode(vm, "Lonely"));
+		var square = GoToFixture.TypeDef(vm, "Square");
+
+		entry.Execute(new TextViewContext { Reference = new ReferenceSegment { Reference = square } });
+		await navigator.LastOperation;
+		Dispatcher.UIThread.RunJobs();
+
+		GoToFixture.Describe(((IMemberTreeNode)vm.AssemblyTreeModel.SelectedItem!).Member!).Should().Be("Square");
+	}
+}
diff --git a/ILSpy.Tests/Navigation/HierarchyFixture.cs b/ILSpy.Tests/Navigation/HierarchyFixture.cs
new file mode 100644
index 00000000000..de640900be7
--- /dev/null
+++ b/ILSpy.Tests/Navigation/HierarchyFixture.cs
@@ -0,0 +1,126 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.IO;
+using System.Reflection;
+using System.Reflection.Emit;
+using System.Threading.Tasks;
+
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.ViewModels;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+/// 
+/// Emits a small assembly with a known inheritance graph for the type-hierarchy and
+/// file-structure tests:
+/// 
+/// interface IShape { double Area(); }
+/// abstract class ShapeBase : IShape { abstract double Area(); }
+/// class Circle : ShapeBase { double radius; double Area(); double Diameter(); }
+/// class BigCircle : Circle { }
+/// class Square : ShapeBase { double Area(); }
+/// 
+/// All types live in the  namespace of an assembly with the same name.
+/// 
+static class HierarchyFixture
+{
+	public const string Namespace = "Shapes";
+
+	public static string Emit()
+	{
+		var ab = new PersistedAssemblyBuilder(new AssemblyName(Namespace), typeof(object).Assembly);
+		var module = ab.DefineDynamicModule(Namespace);
+		var objectCtor = typeof(object).GetConstructor(Type.EmptyTypes)!;
+
+		var shape = module.DefineType($"{Namespace}.IShape",
+			TypeAttributes.Public | TypeAttributes.Interface | TypeAttributes.Abstract);
+		shape.DefineMethod("Area",
+			MethodAttributes.Public | MethodAttributes.Abstract | MethodAttributes.Virtual
+			| MethodAttributes.HideBySig | MethodAttributes.NewSlot,
+			typeof(double), Type.EmptyTypes);
+		shape.CreateType();
+
+		var shapeBase = module.DefineType($"{Namespace}.ShapeBase",
+			TypeAttributes.Public | TypeAttributes.Class | TypeAttributes.Abstract, typeof(object), [shape]);
+		shapeBase.DefineMethod("Area",
+			MethodAttributes.Public | MethodAttributes.Abstract | MethodAttributes.Virtual
+			| MethodAttributes.HideBySig | MethodAttributes.NewSlot,
+			typeof(double), Type.EmptyTypes);
+		var shapeBaseCtor = DefineCtor(shapeBase, objectCtor, MethodAttributes.Family);
+		shapeBase.CreateType();
+
+		var circle = module.DefineType($"{Namespace}.Circle", TypeAttributes.Public | TypeAttributes.Class, shapeBase);
+		var radius = circle.DefineField("radius", typeof(double), FieldAttributes.Private);
+		DefineAreaOverride(circle, 3.0);
+		var diameter = circle.DefineMethod("Diameter",
+			MethodAttributes.Public | MethodAttributes.HideBySig, typeof(double), Type.EmptyTypes);
+		var il = diameter.GetILGenerator();
+		il.Emit(OpCodes.Ldarg_0);
+		il.Emit(OpCodes.Ldfld, radius);
+		il.Emit(OpCodes.Ldc_R8, 2.0);
+		il.Emit(OpCodes.Mul);
+		il.Emit(OpCodes.Ret);
+		var circleCtor = DefineCtor(circle, shapeBaseCtor, MethodAttributes.Public);
+		circle.CreateType();
+
+		var bigCircle = module.DefineType($"{Namespace}.BigCircle", TypeAttributes.Public | TypeAttributes.Class, circle);
+		DefineCtor(bigCircle, circleCtor, MethodAttributes.Public);
+		bigCircle.CreateType();
+
+		var square = module.DefineType($"{Namespace}.Square", TypeAttributes.Public | TypeAttributes.Class, shapeBase);
+		DefineAreaOverride(square, 4.0);
+		DefineCtor(square, shapeBaseCtor, MethodAttributes.Public);
+		square.CreateType();
+
+		var dir = Path.Combine(Path.GetTempPath(), $"ILSpyHierarchyFixture_{Guid.NewGuid():N}");
+		Directory.CreateDirectory(dir);
+		var path = Path.Combine(dir, $"{Namespace}.dll");
+		ab.Save(path);
+		return path;
+	}
+
+	static ConstructorInfo DefineCtor(TypeBuilder type, ConstructorInfo baseCtor, MethodAttributes access)
+	{
+		var ctor = type.DefineConstructor(access | MethodAttributes.HideBySig | MethodAttributes.SpecialName
+			| MethodAttributes.RTSpecialName, CallingConventions.Standard, Type.EmptyTypes);
+		var il = ctor.GetILGenerator();
+		il.Emit(OpCodes.Ldarg_0);
+		il.Emit(OpCodes.Call, baseCtor);
+		il.Emit(OpCodes.Ret);
+		return ctor;
+	}
+
+	static void DefineAreaOverride(TypeBuilder type, double value)
+	{
+		var area = type.DefineMethod("Area",
+			MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.HideBySig,
+			typeof(double), Type.EmptyTypes);
+		var il = area.GetILGenerator();
+		il.Emit(OpCodes.Ldc_R8, value);
+		il.Emit(OpCodes.Ret);
+	}
+
+	/// Opens the fixture through the production Open command.
+	public static Task OpenAsync(MainWindowViewModel vm) => vm.OpenAssemblyAsync(Emit());
+
+	/// The assembly-tree node of the fixture type named .
+	public static TypeTreeNode FindType(MainWindowViewModel vm, string name)
+		=> vm.AssemblyTreeModel.FindNode(Namespace, Namespace, $"{Namespace}.{name}");
+}
diff --git a/ILSpy.Tests/Navigation/RecentFilesTests.cs b/ILSpy.Tests/Navigation/RecentFilesTests.cs
new file mode 100644
index 00000000000..f1d99885cc7
--- /dev/null
+++ b/ILSpy.Tests/Navigation/RecentFilesTests.cs
@@ -0,0 +1,138 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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 Avalonia.Input;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.AssemblyTree;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Navigation;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpyX.TreeView;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Navigation;
+
+/// 
+/// Recent Files (dotPeek "Recent Files"): every tree node the user navigates to lands at the top
+/// of a distinct, most-recent-first list, and the popup over that list navigates back to the
+/// chosen node.
+/// 
+[TestFixture]
+public class RecentFilesTests
+{
+	// Namespace nodes decompile to a single comment line, the cheapest navigation targets.
+	static (NamespaceTreeNode A, NamespaceTreeNode B, NamespaceTreeNode C) CheapNodes(AssemblyTreeModel atm)
+		=> (atm.FindNode(TreeNavigation.CoreLibName, "System.Runtime.Versioning"),
+			atm.FindNode(TreeNavigation.CoreLibName, "System.Text"),
+			atm.FindNode(TreeNavigation.CoreLibName, "System.Globalization"));
+
+	[AvaloniaTest]
+	public async Task Navigated_Nodes_Are_Listed_Most_Recent_First_Without_Duplicates()
+	{
+		var (_, vm) = await TestHarness.BootAsync();
+		var dock = vm.DockWorkspace;
+		var (a, b, c) = CheapNodes(vm.AssemblyTreeModel);
+
+		foreach (var node in new[] { a, b, c, a })
+		{
+			vm.AssemblyTreeModel.SelectNode(node);
+			await dock.WaitForDecompiledTextAsync();
+		}
+
+		var files = dock.RecentNavigation.Files.Select(f => f.Node).ToList();
+		files.Take(3).Should().Equal(new SharpTreeNode[] { a, c, b },
+			"re-visiting A must move it to the top instead of adding a second entry");
+		files.Count(n => ReferenceEquals(n, a)).Should().Be(1);
+	}
+
+	[AvaloniaTest]
+	public async Task Popup_Filters_The_List_And_Accepting_An_Entry_Navigates_To_It()
+	{
+		var (window, vm) = await TestHarness.BootAsync();
+		var dock = vm.DockWorkspace;
+		var (a, b, _) = CheapNodes(vm.AssemblyTreeModel);
+		vm.AssemblyTreeModel.SelectNode(a);
+		await dock.WaitForDecompiledTextAsync();
+		vm.AssemblyTreeModel.SelectNode(b);
+		await dock.WaitForDecompiledTextAsync();
+
+		var command = AppComposition.Current.GetExport().GetCommand(nameof(Resources.RecentFiles));
+		command.Execute(null);
+
+		var popup = window.OwnedWindows.OfType().Should().ContainSingle(
+			"Recent Files must open a quick-pick popup owned by the main window").Subject;
+		popup.Model.FilteredItems.Should().NotBeEmpty();
+		var first = popup.Model.FilteredItems[0].Payload.Should().BeOfType().Subject;
+		ReferenceEquals(first.Node, b).Should().BeTrue("the most recent entry comes first");
+
+		popup.Model.Filter = "Versioning";
+		popup.Model.FilteredItems.Should().ContainSingle("the filter must narrow the list to matching entries")
+			.Which.Text.Should().Contain("System.Runtime.Versioning");
+
+		popup.Model.Accept();
+		await dock.WaitForDecompiledTextAsync();
+
+		ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, a).Should().BeTrue("accepting an entry navigates to its node");
+		window.OwnedWindows.OfType().Should().BeEmpty("accepting closes the popup");
+	}
+
+	[AvaloniaTest]
+	public void Model_Filter_Matches_Text_And_Detail_Case_Insensitively()
+	{
+		var model = new QuickPickModel(new[] {
+			new QuickPickItem("Alpha", "first.dll", null, 1),
+			new QuickPickItem("Beta", "second.dll", null, 2),
+			new QuickPickItem("Gamma", "SECOND.dll", null, 3),
+		}, _ => { });
+
+		model.FilteredItems.Should().HaveCount(3);
+		model.SelectedItem.Should().BeSameAs(model.FilteredItems[0], "the first entry is preselected");
+
+		model.Filter = "second";
+		model.FilteredItems.Select(i => i.Text).Should().Equal("Beta", "Gamma");
+		model.SelectedItem!.Text.Should().Be("Beta", "the selection follows the filtered list");
+
+		model.Filter = "zzz";
+		model.FilteredItems.Should().BeEmpty();
+		model.SelectedItem.Should().BeNull();
+	}
+
+	[AvaloniaTest]
+	public async Task Navigation_Shortcuts_Are_Bound_On_The_Main_Window()
+	{
+		var (window, _) = await TestHarness.BootAsync();
+
+		bool Bound(Key key, KeyModifiers modifiers)
+			=> window.KeyBindings.Any(b => b.Gesture is { } g && g.Key == key && g.KeyModifiers == modifiers);
+
+		Bound(Key.OemComma, KeyModifiers.Control).Should().BeTrue("Recent Files is Ctrl+Comma");
+		Bound(Key.OemComma, KeyModifiers.Control | KeyModifiers.Shift).Should().BeTrue("Recent Locations is Ctrl+Shift+Comma");
+		Bound(Key.F2, KeyModifiers.None).Should().BeTrue("Find Usages for Rename is F2");
+		Bound(Key.G, KeyModifiers.Control).Should().BeFalse(
+			"Ctrl+G is Go to Line only inside the code view; metadata grids use it for Go to token");
+	}
+}
diff --git a/ILSpy.Tests/Navigation/RecentLocationsTests.cs b/ILSpy.Tests/Navigation/RecentLocationsTests.cs
new file mode 100644
index 00000000000..30270ebf12e
--- /dev/null
+++ b/ILSpy.Tests/Navigation/RecentLocationsTests.cs
@@ -0,0 +1,127 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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 ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.AssemblyTree;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Navigation;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpyX.TreeView;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Navigation;
+
+/// 
+/// Recent Locations (dotPeek "Recent Locations"): the caret position the user leaves behind on
+/// each navigation is remembered together with a one-line preview of the code there, and
+/// choosing a location re-opens its node with the caret restored.
+/// 
+[TestFixture]
+public class RecentLocationsTests
+{
+	static (NamespaceTreeNode A, NamespaceTreeNode B) CheapNodes(AssemblyTreeModel atm)
+		=> (atm.FindNode(TreeNavigation.CoreLibName, "System.Runtime.Versioning"),
+			atm.FindNode(TreeNavigation.CoreLibName, "System.Text"));
+
+	[AvaloniaTest]
+	public async Task Leaving_A_Node_Records_Its_Caret_Location_With_A_Code_Preview()
+	{
+		var (_, vm) = await TestHarness.BootAsync();
+		var dock = vm.DockWorkspace;
+		var (a, b) = CheapNodes(vm.AssemblyTreeModel);
+
+		vm.AssemblyTreeModel.SelectNode(a);
+		var tab = await dock.WaitForDecompiledTextAsync();
+		var text = tab.Text;
+		// Headless has no laid-out editor; report the caret the user "left" on node A.
+		tab.CaptureViewState = () => new DecompilerTextViewState(4, 0, 0, null);
+
+		vm.AssemblyTreeModel.SelectNode(b);
+		await dock.WaitForDecompiledTextAsync();
+
+		var location = dock.RecentNavigation.Locations.Should().NotBeEmpty().And.Subject.First();
+		ReferenceEquals(location.Node, a).Should().BeTrue();
+		location.State.CaretOffset.Should().Be(4);
+		location.Line.Should().Be(1);
+		location.Column.Should().Be(5);
+		location.Preview.Should().Be(text.Split('\n')[0].Trim(),
+			"the preview is the trimmed text of the caret line");
+	}
+
+	[AvaloniaTest]
+	public async Task Navigating_To_A_Location_Reopens_The_Node_And_Restores_The_Caret()
+	{
+		var (_, vm) = await TestHarness.BootAsync();
+		var dock = vm.DockWorkspace;
+		var (a, b) = CheapNodes(vm.AssemblyTreeModel);
+
+		vm.AssemblyTreeModel.SelectNode(a);
+		var tab = await dock.WaitForDecompiledTextAsync();
+		tab.CaptureViewState = () => new DecompilerTextViewState(6, 12.5, 0, null);
+		vm.AssemblyTreeModel.SelectNode(b);
+		await dock.WaitForDecompiledTextAsync();
+
+		var location = dock.RecentNavigation.Locations.First(l => ReferenceEquals(l.Node, a));
+		dock.NavigateToRecentLocation(location);
+
+		ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, a).Should().BeTrue();
+		var target = dock.ActiveDecompilerTab!;
+		target.PendingViewState.Should().NotBeNull("the location's view state is handed to the editor");
+		target.PendingViewState!.Value.CaretOffset.Should().Be(6);
+		target.PendingViewState!.Value.VerticalOffset.Should().Be(12.5);
+
+		await dock.WaitForDecompiledTextAsync();
+		target.PendingViewState.Should().BeNull("the editor consumes the restored state");
+	}
+
+	[AvaloniaTest]
+	public async Task Popup_Lists_Locations_With_Their_Preview_And_Includes_The_Current_One()
+	{
+		var (window, vm) = await TestHarness.BootAsync();
+		var dock = vm.DockWorkspace;
+		var (a, b) = CheapNodes(vm.AssemblyTreeModel);
+
+		vm.AssemblyTreeModel.SelectNode(a);
+		var tabA = await dock.WaitForDecompiledTextAsync();
+		tabA.CaptureViewState = () => new DecompilerTextViewState(0, 0, 0, null);
+		vm.AssemblyTreeModel.SelectNode(b);
+		var tabB = await dock.WaitForDecompiledTextAsync();
+		tabB.CaptureViewState = () => new DecompilerTextViewState(0, 0, 0, null);
+
+		var command = AppComposition.Current.GetExport().GetCommand(nameof(Resources.RecentLocations));
+		command.Execute(null);
+
+		var popup = window.OwnedWindows.OfType().Should().ContainSingle().Subject;
+		var locations = popup.Model.FilteredItems.Select(i => i.Payload).OfType().ToList();
+		locations.Select(l => l.Node).Should().ContainInOrder(new SharpTreeNode[] { b, a },
+			"the current location is listed first, then the one left behind");
+		popup.Model.FilteredItems[0].Detail.Should().Contain(tabB.Text.Split('\n')[0].Trim(),
+			"each entry shows the code at its position");
+		popup.Close();
+	}
+}
diff --git a/ILSpy.Tests/Navigation/TypeHierarchyTests.cs b/ILSpy.Tests/Navigation/TypeHierarchyTests.cs
new file mode 100644
index 00000000000..9ff35ecdb8d
--- /dev/null
+++ b/ILSpy.Tests/Navigation/TypeHierarchyTests.cs
@@ -0,0 +1,238 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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.Controls;
+using Avalonia.Headless.NUnit;
+using Avalonia.Input;
+using Avalonia.Threading;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.TypeHierarchy;
+using ICSharpCode.ILSpy.ViewModels;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+[TestFixture]
+public class TypeHierarchyTests
+{
+	static async Task<(MainWindowViewModel Vm, TypeHierarchyViewModel Pane)> ShowForSelectedAsync(string typeName)
+	{
+		var (_, vm) = await TestHarness.BootAsync();
+		await HierarchyFixture.OpenAsync(vm);
+		vm.AssemblyTreeModel.SelectNode(HierarchyFixture.FindType(vm, typeName));
+		AppComposition.Current.GetExport()
+			.GetCommand(nameof(Resources.TypeHierarchy)).Execute(null);
+		var pane = AppComposition.Current.GetExport();
+		pane.Target.Should().NotBeNull("the command must target the selected type");
+		await pane.Target!.LoadChildrenAsync();
+		return (vm, pane);
+	}
+
+	static TypeHierarchyNode Group(TypeHierarchyNode target, TypeHierarchyNodeKind kind)
+		=> target.Children.Single(c => c.Kind == kind);
+
+	static string[] TypeNames(TypeHierarchyNode group)
+		=> group.Children.Select(c => c.Type!.FullName).ToArray();
+
+	[AvaloniaTest]
+	public async Task Command_Shows_Pane_With_Base_Types_And_Derived_Types_Of_Selected_Type()
+	{
+		// Arrange + Act -- select ShapeBase in the tree and run Navigate > Type Hierarchy.
+		var (vm, pane) = await ShowForSelectedAsync("ShapeBase");
+
+		// Assert -- the pane is docked, targets ShapeBase, lists its direct supertypes (the base
+		// class and the implemented interface) and, once the background search finishes, its
+		// direct subclasses.
+		vm.DockWorkspace.ToolPaneMenuItems.Single(i => i.Title == Resources.TypeHierarchy)
+			.IsPaneVisible.Should().BeTrue();
+		pane.Target!.Type!.FullName.Should().Be("Shapes.ShapeBase");
+		pane.Roots.Should().ContainSingle().Which.Should().BeSameAs(pane.Target);
+
+		var bases = Group(pane.Target, TypeHierarchyNodeKind.BaseTypesGroup);
+		TypeNames(bases).Should().BeEquivalentTo("System.Object", "Shapes.IShape");
+
+		var derived = Group(pane.Target, TypeHierarchyNodeKind.DerivedTypesGroup);
+		await derived.LoadChildrenAsync();
+		TypeNames(derived).Should().BeEquivalentTo("Shapes.Circle", "Shapes.Square");
+		derived.Children.Should().OnlyContain(c => c.Kind == TypeHierarchyNodeKind.DerivedType && c.Icon != null);
+	}
+
+	[AvaloniaTest]
+	public async Task Derived_Nodes_Expand_To_Their_Own_Subtypes_And_Base_Nodes_To_Their_Supertypes()
+	{
+		var (_, pane) = await ShowForSelectedAsync("ShapeBase");
+
+		// Act -- drill one level further both ways.
+		var derived = Group(pane.Target!, TypeHierarchyNodeKind.DerivedTypesGroup);
+		await derived.LoadChildrenAsync();
+		var circle = derived.Children.Single(c => c.Type!.Name == "Circle");
+		await circle.LoadChildrenAsync();
+
+		var bases = Group(pane.Target!, TypeHierarchyNodeKind.BaseTypesGroup);
+		var iShape = bases.Children.Single(c => c.Type!.Name == "IShape");
+		await iShape.LoadChildrenAsync();
+
+		// Assert -- Circle's subtypes are listed below it; IShape has no supertypes.
+		TypeNames(circle).Should().Equal("Shapes.BigCircle");
+		iShape.Children.Should().BeEmpty();
+	}
+
+	[AvaloniaTest]
+	public async Task Interface_Target_Lists_Its_Implementations()
+	{
+		var (_, pane) = await ShowForSelectedAsync("IShape");
+
+		var derived = Group(pane.Target!, TypeHierarchyNodeKind.DerivedTypesGroup);
+		await derived.LoadChildrenAsync();
+
+		TypeNames(derived).Should().Equal("Shapes.ShapeBase");
+		Group(pane.Target!, TypeHierarchyNodeKind.BaseTypesGroup).Children.Should().BeEmpty();
+	}
+
+	[AvaloniaTest]
+	public async Task Member_Selection_Targets_The_Declaring_Type()
+	{
+		// Arrange -- select a method of Circle rather than the type itself.
+		var (_, vm) = await TestHarness.BootAsync();
+		await HierarchyFixture.OpenAsync(vm);
+		var circleNode = HierarchyFixture.FindType(vm, "Circle");
+		var method = circleNode.GetChild(m => m.MethodDefinition.Name == "Diameter");
+		vm.AssemblyTreeModel.SelectNode(method);
+
+		// Act
+		AppComposition.Current.GetExport()
+			.GetCommand(nameof(Resources.TypeHierarchy)).Execute(null);
+
+		// Assert
+		var pane = AppComposition.Current.GetExport();
+		pane.Target!.Type!.FullName.Should().Be("Shapes.Circle");
+	}
+
+	[AvaloniaTest]
+	public async Task Activating_A_Node_Navigates_The_Assembly_Tree_And_Records_History()
+	{
+		var (vm, pane) = await ShowForSelectedAsync("ShapeBase");
+		var derived = Group(pane.Target!, TypeHierarchyNodeKind.DerivedTypesGroup);
+		await derived.LoadChildrenAsync();
+		var square = derived.Children.Single(c => c.Type!.Name == "Square");
+		// NavigationHistory merges selections within 0.5s into one entry.
+		await Task.Delay(600);
+
+		// Act
+		pane.Activate(square).Should().BeTrue();
+
+		// Assert -- the tree selection moves to Square through the normal navigation path, so
+		// Back returns to the previous selection.
+		var selected = vm.AssemblyTreeModel.SelectedItem as TypeTreeNode;
+		Assert.That(selected, Is.Not.Null, "the selection must be a type node");
+		selected!.TypeDefinition.FullName.Should().Be("Shapes.Square");
+		vm.DockWorkspace.NavigateBackCommand.CanExecute(null).Should().BeTrue();
+	}
+
+	[AvaloniaTest]
+	public async Task Pane_View_Renders_The_Hierarchy_And_Enter_Navigates_To_The_Selected_Type()
+	{
+		var (vm, pane) = await ShowForSelectedAsync("ShapeBase");
+		var derived = Group(pane.Target!, TypeHierarchyNodeKind.DerivedTypesGroup);
+		await derived.LoadChildrenAsync();
+		var circle = derived.Children.Single(c => c.Type!.Name == "Circle");
+		var window = AppComposition.Current.GetExport();
+		var view = await window.WaitForComponent();
+		var tree = view.FindControl("HierarchyTree")!;
+		tree.ItemsSource.Should().BeSameAs(pane.Roots);
+		await Task.Delay(600);
+
+		// Act -- select Circle and press Enter in the tree.
+		pane.SelectedNode = circle;
+		Dispatcher.UIThread.RunJobs();
+		tree.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Enter, Source = tree });
+
+		// Assert
+		var selected = vm.AssemblyTreeModel.SelectedItem as TypeTreeNode;
+		Assert.That(selected, Is.Not.Null, "the selection must be a type node");
+		selected!.TypeDefinition.FullName.Should().Be("Shapes.Circle");
+	}
+
+	[AvaloniaTest]
+	public async Task Retargeting_Cancels_The_Previous_Derived_Type_Search()
+	{
+		var (_, vm) = await TestHarness.BootAsync();
+		await HierarchyFixture.OpenAsync(vm);
+		var pane = AppComposition.Current.GetExport();
+		var first = HierarchyFixture.FindType(vm, "ShapeBase").TypeDefinition;
+		var second = HierarchyFixture.FindType(vm, "Circle").TypeDefinition;
+
+		// Act -- start a search, then retarget before it can be observed.
+		pane.ShowHierarchy(first);
+		var firstToken = pane.SearchCancellationToken;
+		pane.ShowHierarchy(second);
+
+		// Assert
+		firstToken.IsCancellationRequested.Should().BeTrue();
+		pane.SearchCancellationToken.IsCancellationRequested.Should().BeFalse();
+		pane.Target!.Type!.FullName.Should().Be("Shapes.Circle");
+	}
+
+	[AvaloniaTest]
+	public async Task Code_Reference_Resolves_To_A_Type_For_The_Context_Menu_Entry()
+	{
+		var (_, vm) = await TestHarness.BootAsync();
+		await HierarchyFixture.OpenAsync(vm);
+		var square = HierarchyFixture.FindType(vm, "Square").TypeDefinition;
+		var area = square.Methods.Single(m => m.Name == "Area");
+		var entry = AppComposition.Current.GetExport().Entries
+			.Single(e => e.Metadata.Header == nameof(Resources.TypeHierarchy));
+
+		// The Navigation category is what lists the entry in the Navigate To popup.
+		entry.Metadata.Category.Should().Be("Navigation");
+
+		var memberContext = new TextViewContext { Reference = new ReferenceSegment { Reference = area } };
+		entry.Value.IsVisible(memberContext).Should().BeTrue();
+		TypeHierarchyViewModel.ResolveType(area)!.FullName.Should().Be("Shapes.Square");
+		TypeHierarchyViewModel.ResolveType((IType)square)!.FullName.Should().Be("Shapes.Square");
+
+		var localContext = new TextViewContext { Reference = new ReferenceSegment { Reference = "local" } };
+		entry.Value.IsVisible(localContext).Should().BeFalse();
+
+		// Act
+		entry.Value.Execute(memberContext);
+
+		// Assert
+		AppComposition.Current.GetExport().Target!.Type!.FullName.Should().Be("Shapes.Square");
+	}
+
+	[Test]
+	public void Gesture_Is_Ctrl_Alt_H()
+	{
+		var gesture = KeyGesture.Parse(TypeHierarchyCommand.Gesture);
+		gesture.Key.Should().Be(Key.H);
+		gesture.KeyModifiers.Should().Be(KeyModifiers.Control | KeyModifiers.Alt);
+	}
+}
diff --git a/ILSpy/Analyzers/AnalyzeContextMenuEntry.cs b/ILSpy/Analyzers/AnalyzeContextMenuEntry.cs
index 49861418d40..c01e31e3065 100644
--- a/ILSpy/Analyzers/AnalyzeContextMenuEntry.cs
+++ b/ILSpy/Analyzers/AnalyzeContextMenuEntry.cs
@@ -69,8 +69,9 @@ public static bool IsVisibleForContext(TextViewContext context)
 			if (context.SelectedTreeNodes is { Length: > 0 } nodes)
 			{
 				// Top-level analyzer rows are already analysed (Remove is the entry for those);
-				// result rows underneath promote their entity to a new top-level row.
-				return nodes.All(n => n is IMemberTreeNode
+				// result rows underneath promote their entity to a new top-level row. Assembly
+				// nodes analyse the whole module.
+				return nodes.All(n => (n is IMemberTreeNode || n is AssemblyTreeNode)
 					&& n is not AnalyzerEntityTreeNode { Parent.IsRoot: true });
 			}
 			// Right-clicking a resolved symbol in the decompiled code: the reference carries the entity.
@@ -80,7 +81,7 @@ public static bool IsVisibleForContext(TextViewContext context)
 		public static bool IsEnabledForContext(TextViewContext context)
 		{
 			if (context.SelectedTreeNodes is { Length: > 0 } nodes)
-				return nodes.OfType().All(n => IsAnalysable(n.Member));
+				return nodes.All(n => SymbolOf(n) is { } symbol && IsAnalysable(symbol));
 			return context.Reference?.Reference is IEntity entity && IsAnalysable(entity);
 		}
 
@@ -100,28 +101,44 @@ public static bool Analyze(TextViewContext context, AnalyzerTreeViewModel analyz
 			return false;
 		}
 
-		// The analysable entities for this invocation: a tree-node selection (assembly/analyzer tree),
+		// The analysable symbols for this invocation: a tree-node selection (assembly/analyzer tree),
 		// or the single resolved entity under a right-clicked code reference (decompiler text view).
-		static System.Collections.Generic.List MembersToAnalyse(TextViewContext context)
+		static System.Collections.Generic.List MembersToAnalyse(TextViewContext context)
 		{
 			if (context.SelectedTreeNodes is { Length: > 0 } nodes)
 			{
-				return nodes.OfType()
-					.Select(n => n.Member)
+				return nodes.Select(SymbolOf)
 					.Where(IsAnalysable)
 					.Select(m => m!)
 					.ToList();
 			}
 			if (context.Reference?.Reference is IEntity entity && IsAnalysable(entity))
-				return new System.Collections.Generic.List { entity };
-			return new System.Collections.Generic.List();
+				return new System.Collections.Generic.List { entity };
+			return new System.Collections.Generic.List();
 		}
 
+		/// 
+		/// The symbol a selected node stands for: the member of a member row, the module of an
+		/// assembly node or of a module row in the analyzer pane.
+		/// 
+		internal static ISymbol? SymbolOf(ICSharpCode.ILSpyX.TreeView.SharpTreeNode node) => node switch {
+			ICSharpCode.ILSpy.Analyzers.TreeNodes.AnalyzedModuleTreeNode moduleNode => moduleNode.Module,
+			IMemberTreeNode memberNode => memberNode.Member,
+			AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true } assemblyNode
+				=> assemblyNode.LoadedAssembly.GetTypeSystemOrNull()?.MainModule,
+			_ => null,
+		};
+
 		/// 
 		/// Const fields are textual literals at every use-site rather than entities the
-		/// analyser can match against — exclude them so the entry stays disabled.
+		/// analyser can match against, so they are excluded and the entry stays disabled. Modules
+		/// are analysable when they carry metadata.
 		/// 
-		static bool IsAnalysable(IEntity? entity) => entity is not null and not IField { IsConst: true };
+		static bool IsAnalysable(ISymbol? symbol) => symbol switch {
+			IModule module => module.MetadataFile != null,
+			IEntity entity => entity is not IField { IsConst: true },
+			_ => false,
+		};
 	}
 
 	[Export]
diff --git a/ILSpy/Analyzers/AnalyzerTreeViewModel.cs b/ILSpy/Analyzers/AnalyzerTreeViewModel.cs
index cc796902bcc..05745b8f98a 100644
--- a/ILSpy/Analyzers/AnalyzerTreeViewModel.cs
+++ b/ILSpy/Analyzers/AnalyzerTreeViewModel.cs
@@ -84,6 +84,41 @@ public AnalyzerEntityTreeNode Analyze(IEntity entity)
 			return node;
 		}
 
+		/// 
+		/// Adds a whole module (assembly) to the analyzer pane, reusing the existing row for the same
+		/// loaded file, and selects it. Its children are the analyzers that apply to modules
+		/// ("Referenced By", "Dependent Code", ...).
+		/// 
+		public AnalyzerEntityTreeNode Analyze(IModule module)
+		{
+			ArgumentNullException.ThrowIfNull(module);
+			var existing = Root.Children
+				.OfType()
+				.FirstOrDefault(n => n.Module.MetadataFile != null
+					&& ReferenceEquals(n.Module.MetadataFile, module.MetadataFile));
+			if (existing != null)
+			{
+				SyncSelection(existing);
+				return existing;
+			}
+			var node = new AnalyzedModuleTreeNode(module, source: null);
+			Root.Children.Add(node);
+			node.IsExpanded = true;
+			SyncSelection(node);
+			return node;
+		}
+
+		/// Dispatches to  or .
+		public AnalyzerEntityTreeNode Analyze(ISymbol symbol)
+		{
+			return symbol switch {
+				IEntity entity => Analyze(entity),
+				IModule module => Analyze(module),
+				_ => throw new ArgumentOutOfRangeException(nameof(symbol),
+					$"Symbol {symbol?.GetType().FullName} is not supported by the analyzer pane.")
+			};
+		}
+
 		static bool IsSameEntity(IEntity? a, IEntity b)
 		{
 			// Entities reaching the pane come from different type systems (the assembly tree's,
diff --git a/ILSpy/Dependencies/DependencyCommands.cs b/ILSpy/Dependencies/DependencyCommands.cs
new file mode 100644
index 00000000000..07d4f031d9a
--- /dev/null
+++ b/ILSpy/Dependencies/DependencyCommands.cs
@@ -0,0 +1,196 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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.Composition;
+using System.Linq;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpyX.Analyzers;
+using ICSharpCode.ILSpyX.TreeView;
+
+using ICSharpCode.ILSpy.Analyzers;
+using ICSharpCode.ILSpy.AssemblyTree;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Docking;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TreeNodes;
+
+namespace ICSharpCode.ILSpy.Dependencies
+{
+	/// Shared plumbing of the dependency context-menu entries.
+	static class DependencyMenuHelpers
+	{
+		/// Category shared by the entries that also appear in the Navigate To popup.
+		public const string NavigationCategory = "Navigation";
+
+		/// 
+		/// The module a single selected node stands for: an assembly node's own module, or the
+		/// assembly an assembly-reference node resolves to. Null when there is none.
+		/// 
+		public static IModule? GetModule(TextViewContext context)
+		{
+			if (context.SelectedTreeNodes is not [var node])
+				return null;
+			switch (node)
+			{
+				case AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true } assemblyNode:
+					return assemblyNode.LoadedAssembly.GetTypeSystemOrNull()?.MainModule;
+				case AssemblyReferenceTreeNode referenceNode:
+					var owner = referenceNode.AncestorsAndSelf().OfType().FirstOrDefault();
+					if (owner == null)
+						return null;
+					var resolved = owner.LoadedAssembly.GetAssemblyResolver().Resolve(referenceNode.AssemblyReference);
+					if (resolved == null)
+						return null;
+					var loaded = owner.LoadedAssembly.AssemblyList.FindAssembly(resolved.FileName);
+					return loaded?.GetTypeSystemOrNull()?.MainModule;
+				default:
+					return null;
+			}
+		}
+
+		public static bool IsModuleSelection(TextViewContext context)
+			=> context.SelectedTreeNodes is [AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true }]
+				or [AssemblyReferenceTreeNode];
+
+		/// 
+		/// Adds  to the analyzer pane, expands the analyzer row with the
+		/// given header, selects it and brings the pane to the front.
+		/// 
+		public static AnalyzerSearchTreeNode? AnalyzeWith(IModule module, string analyzerHeader,
+			AnalyzerTreeViewModel analyzerTreeViewModel, DockWorkspace dockWorkspace)
+		{
+			var moduleNode = analyzerTreeViewModel.Analyze(module);
+			moduleNode.EnsureLazyChildren();
+			var row = moduleNode.Children.OfType()
+				.FirstOrDefault(r => r.AnalyzerHeader == analyzerHeader);
+			if (row != null)
+			{
+				row.IsExpanded = true;
+				analyzerTreeViewModel.SelectedItems.Clear();
+				analyzerTreeViewModel.SelectedItems.Add(row);
+			}
+			dockWorkspace.ShowToolPane(AnalyzerTreeViewModel.PaneContentId);
+			return row;
+		}
+	}
+
+	/// Right-click an assembly (or an assembly reference) -> "Referenced By" in the analyzer pane.
+	[ExportContextMenuEntry(Header = nameof(Resources.ReferencedBy), Category = DependencyMenuHelpers.NavigationCategory, Order = 600)]
+	[Shared]
+	[method: ImportingConstructor]
+	public sealed class ReferencedByContextMenuEntry(AnalyzerTreeViewModel analyzerTreeViewModel, DockWorkspace dockWorkspace) : IContextMenuEntry
+	{
+		public bool IsVisible(TextViewContext context) => DependencyMenuHelpers.IsModuleSelection(context);
+
+		public bool IsEnabled(TextViewContext context) => true;
+
+		public void Execute(TextViewContext context)
+		{
+			if (DependencyMenuHelpers.GetModule(context) is { } module)
+				DependencyMenuHelpers.AnalyzeWith(module, ModuleAnalyzerHeaders.ReferencedBy, analyzerTreeViewModel, dockWorkspace);
+		}
+	}
+
+	/// Right-click an assembly (or an assembly reference) -> "Dependent Code" in the analyzer pane.
+	[ExportContextMenuEntry(Header = nameof(Resources.DependentCode), Category = DependencyMenuHelpers.NavigationCategory, Order = 610)]
+	[Shared]
+	[method: ImportingConstructor]
+	public sealed class DependentCodeContextMenuEntry(AnalyzerTreeViewModel analyzerTreeViewModel, DockWorkspace dockWorkspace) : IContextMenuEntry
+	{
+		public bool IsVisible(TextViewContext context) => DependencyMenuHelpers.IsModuleSelection(context);
+
+		public bool IsEnabled(TextViewContext context) => true;
+
+		public void Execute(TextViewContext context)
+		{
+			if (DependencyMenuHelpers.GetModule(context) is { } module)
+				DependencyMenuHelpers.AnalyzeWith(module, ModuleAnalyzerHeaders.DependentCode, analyzerTreeViewModel, dockWorkspace);
+		}
+	}
+
+	/// Right-click one or more assemblies -> "Show Assembly Dependency Diagram".
+	[ExportContextMenuEntry(Header = nameof(Resources.AssemblyDependencyDiagram), Category = DependencyMenuHelpers.NavigationCategory, Order = 620)]
+	[Shared]
+	public sealed class AssemblyDependencyDiagramContextMenuEntry : IContextMenuEntry
+	{
+		public bool IsVisible(TextViewContext context)
+			=> context.SelectedTreeNodes is { Length: > 0 } nodes
+				&& nodes.All(n => n is AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true });
+
+		public bool IsEnabled(TextViewContext context) => true;
+
+		public void Execute(TextViewContext context)
+		{
+			var roots = DependencyDiagramActions.GetTargetAssemblies(context.SelectedTreeNodes, list: null);
+			DependencyDiagramActions.ShowDiagramAsync(roots).HandleExceptions();
+		}
+	}
+
+	/// Navigate -> "Show Assembly Dependency Diagram" for the selected assemblies, or the whole list.
+	[ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.AssemblyDependencyDiagram), MenuCategory = "Dependencies", MenuOrder = 600)]
+	[Shared]
+	[method: ImportingConstructor]
+	public sealed class AssemblyDependencyDiagramCommand(AssemblyTreeModel assemblyTreeModel) : SimpleCommand
+	{
+		public override bool CanExecute(object? parameter) => (assemblyTreeModel.AssemblyList?.Count ?? 0) > 0;
+
+		public override void Execute(object? parameter)
+		{
+			var roots = DependencyDiagramActions.GetTargetAssemblies(assemblyTreeModel.SelectedItems, assemblyTreeModel.AssemblyList);
+			DependencyDiagramActions.ShowDiagramAsync(roots).HandleExceptions();
+		}
+	}
+
+	/// 
+	/// Navigate -> "Export Dependency Diagram (Mermaid HTML)..." for the selected assemblies, or the
+	/// whole list. A string parameter is used as the output path instead of asking for one.
+	/// 
+	[ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.ExportDependencyDiagramMermaid), MenuCategory = "Dependencies", MenuOrder = 610)]
+	[Shared]
+	[method: ImportingConstructor]
+	public sealed class ExportDependencyDiagramMermaidCommand(AssemblyTreeModel assemblyTreeModel) : SimpleCommand
+	{
+		public override bool CanExecute(object? parameter) => (assemblyTreeModel.AssemblyList?.Count ?? 0) > 0;
+
+		public override void Execute(object? parameter)
+		{
+			var roots = DependencyDiagramActions.GetTargetAssemblies(assemblyTreeModel.SelectedItems, assemblyTreeModel.AssemblyList);
+			DependencyDiagramActions.ExportMermaidAsync(roots, parameter as string).HandleExceptions();
+		}
+	}
+
+	/// 
+	/// Navigate -> "Unresolved References Report" for the selected assembly nodes, or the whole list
+	/// when no assembly node is selected.
+	/// 
+	[ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.UnresolvedReferencesReport), MenuCategory = "Dependencies", MenuOrder = 620)]
+	[Shared]
+	[method: ImportingConstructor]
+	public sealed class UnresolvedReferencesReportCommand(AssemblyTreeModel assemblyTreeModel) : SimpleCommand
+	{
+		public override bool CanExecute(object? parameter) => (assemblyTreeModel.AssemblyList?.Count ?? 0) > 0;
+
+		public override void Execute(object? parameter)
+		{
+			var assemblies = DependencyDiagramActions.GetTargetAssemblies(assemblyTreeModel.SelectedItems, assemblyTreeModel.AssemblyList,
+				includeOwnersOfSelection: false);
+			DependencyDiagramActions.ShowUnresolvedReferencesReportAsync(assemblies).HandleExceptions();
+		}
+	}
+}
diff --git a/ILSpy/Dependencies/DependencyDiagramActions.cs b/ILSpy/Dependencies/DependencyDiagramActions.cs
new file mode 100644
index 00000000000..952e3c32b19
--- /dev/null
+++ b/ILSpy/Dependencies/DependencyDiagramActions.cs
@@ -0,0 +1,178 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+using ICSharpCode.Decompiler;
+using ICSharpCode.Decompiler.Metadata;
+using ICSharpCode.ILSpyX;
+using ICSharpCode.ILSpyX.Dependencies;
+using ICSharpCode.ILSpyX.TreeView;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Docking;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.Util;
+
+namespace ICSharpCode.ILSpy.Dependencies
+{
+	/// 
+	/// The actions behind the dependency menu entries and commands: open the in-app dependency
+	/// diagram, export it as Mermaid HTML, and show the unresolved-references report. The heavy
+	/// part of each (resolving references) runs off the UI thread.
+	/// 
+	public static class DependencyDiagramActions
+	{
+		/// 
+		/// The assemblies an action applies to: the selected assembly nodes (with
+		/// , also the assemblies owning any other selected
+		/// node), or every successfully loaded assembly of  when that yields none.
+		/// 
+		public static IReadOnlyList GetTargetAssemblies(IEnumerable? selection, AssemblyList? list,
+			bool includeOwnersOfSelection = true)
+		{
+			var selected = (selection ?? Enumerable.Empty())
+				.Select(n => includeOwnersOfSelection
+					? n.AncestorsAndSelf().OfType().FirstOrDefault()
+					: n as AssemblyTreeNode)
+				.Where(n => n is { LoadedAssembly.IsLoadedAsValidAssembly: true })
+				.Select(n => n!.LoadedAssembly)
+				.Distinct()
+				.ToList();
+			if (selected.Count > 0)
+				return selected;
+			return list?.GetAssemblies().Where(a => a.IsLoadedAsValidAssembly).ToList()
+				?? (IReadOnlyList)Array.Empty();
+		}
+
+		/// Title of a diagram/report over .
+		public static string DescribeRoots(IReadOnlyList roots)
+		{
+			if (roots.Count == 1)
+				return roots[0].ShortName;
+			var list = roots.FirstOrDefault()?.AssemblyList;
+			return list != null && roots.Count == list.GetAssemblies().Count(a => a.IsLoadedAsValidAssembly)
+				? list.ListName
+				: $"{roots.Count} assemblies";
+		}
+
+		/// 
+		/// Builds the transitive dependency graph of  off the UI thread and
+		/// opens it in a new document tab. Returns the tab content, or null when there is nothing to show.
+		/// 
+		public static async Task ShowDiagramAsync(IReadOnlyList roots)
+		{
+			ArgumentNullException.ThrowIfNull(roots);
+			var dockWorkspace = AppComposition.TryGetExport();
+			if (roots.Count == 0 || dockWorkspace == null)
+				return null;
+			var graph = await Task.Run(() => AssemblyDependencyGraph.Build(roots, transitive: true)).ConfigureAwait(true);
+			var content = new DependencyGraphViewModel(graph, roots, $"Dependencies: {DescribeRoots(roots)}");
+			content.Tab = dockWorkspace.OpenNewTab(content);
+			return content;
+		}
+
+		/// 
+		/// Writes the dependency graph of  as a Mermaid HTML page to
+		///  (asking for a file when it is null), then shows a short report
+		/// tab offering to open the page. Returns the written path, or null when cancelled.
+		/// 
+		public static async Task ExportMermaidAsync(IReadOnlyList roots, string? path)
+		{
+			ArgumentNullException.ThrowIfNull(roots);
+			if (roots.Count == 0)
+				return null;
+			string title = $"Assembly dependencies: {DescribeRoots(roots)}";
+			if (path == null)
+			{
+				path = await FilePickers.SaveAsync("HTML files|*.html", DescribeRoots(roots) + ".dependencies.html").ConfigureAwait(true);
+				if (string.IsNullOrEmpty(path))
+					return null;
+			}
+			string target = path;
+			await Task.Run(() => {
+				var graph = AssemblyDependencyGraph.Build(roots, transitive: true);
+				// Written next to the target and moved into place, so the page never appears half-written.
+				string temp = target + ".tmp";
+				File.WriteAllText(temp, DependencyGraphMermaidWriter.ToHtml(graph, title), new UTF8Encoding(false));
+				File.Move(temp, target, overwrite: true);
+			}).ConfigureAwait(true);
+
+			var dockWorkspace = AppComposition.TryGetExport();
+			if (dockWorkspace != null)
+			{
+				var output = new AvaloniaEditTextOutput { Title = Resources.ExportDependencyDiagramMermaid };
+				output.WriteLine("Dependency diagram written to:");
+				output.WriteLine(target);
+				output.WriteLine();
+				output.AddButton(null, "Open in Browser", (_, _) => ShellHelper.OpenWithDefaultApplication(target));
+				output.WriteLine();
+				output.AddRevealFileButton(target);
+				dockWorkspace.ShowTextInNewTab(Resources.ExportDependencyDiagramMermaid, output);
+			}
+			return target;
+		}
+
+		/// Opens the unresolved-references report for  in a new tab.
+		public static Task ShowUnresolvedReferencesReportAsync(IReadOnlyList assemblies)
+		{
+			ArgumentNullException.ThrowIfNull(assemblies);
+			var dockWorkspace = AppComposition.TryGetExport();
+			if (dockWorkspace == null)
+				return Task.CompletedTask;
+			return dockWorkspace.RunInNewTabAsync(Resources.UnresolvedReferencesReport,
+				token => Task.Run(() => CreateUnresolvedReferencesReport(assemblies, token), token));
+		}
+
+		/// Renders the unresolved-references report, grouped by referencing assembly.
+		internal static AvaloniaEditTextOutput CreateUnresolvedReferencesReport(IReadOnlyList assemblies, CancellationToken token)
+		{
+			var groups = UnresolvedReferencesReport.Collect(assemblies, token);
+			var output = new AvaloniaEditTextOutput { Title = Resources.UnresolvedReferencesReport };
+			int total = groups.Sum(g => g.References.Count);
+			output.WriteLine($"{total} unresolved assembly reference(s) in {groups.Count} of {assemblies.Count} assemblies.");
+			output.WriteLine();
+			foreach (var group in groups)
+			{
+				output.WriteLine($"{group.Assembly.ShortName} ({group.Assembly.FileName})");
+				output.Indent();
+				foreach (var unresolved in group.References)
+				{
+					output.WriteLine(unresolved.Reference.FullName);
+					output.Indent();
+					var log = new UnresolvedAssemblyNameReference(unresolved.Reference.FullName);
+					log.Messages.AddRange(unresolved.Messages.Select(m => (m.Kind, m.Message)));
+					AssemblyReferenceTreeNode.PrintAssemblyLoadLogMessages(output, log);
+					output.Unindent();
+				}
+				output.Unindent();
+				output.WriteLine();
+			}
+			return output;
+		}
+	}
+}
diff --git a/ILSpy/Dependencies/DependencyGraphControl.cs b/ILSpy/Dependencies/DependencyGraphControl.cs
new file mode 100644
index 00000000000..53a1acbe85f
--- /dev/null
+++ b/ILSpy/Dependencies/DependencyGraphControl.cs
@@ -0,0 +1,330 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Styling;
+
+using ICSharpCode.ILSpyX.Dependencies;
+
+namespace ICSharpCode.ILSpy.Dependencies
+{
+	/// 
+	/// Draws an  laid out by :
+	/// boxes for assemblies (bold border for roots, dashed red for unresolved references) and
+	/// curved arrows for references (orange when satisfied by a different version). The mouse
+	/// wheel zooms around the pointer, dragging pans, a click raises  and
+	/// a double-click raises .
+	/// 
+	public sealed class DependencyGraphControl : Control
+	{
+		const double FontSize = 12;
+		const double HorizontalPadding = 10;
+		const double MinScale = 0.05;
+		const double MaxScale = 4;
+		const double DragThreshold = 4;
+
+		static readonly Typeface NormalTypeface = new(FontFamily.Default);
+		static readonly Typeface BoldTypeface = new(FontFamily.Default, FontStyle.Normal, FontWeight.Bold);
+
+		AssemblyDependencyGraph? graph;
+		LayeredLayoutResult? layout;
+		double scale = 1;
+		Vector offset;
+		bool fitPending = true;
+		Point? pressPoint;
+		Vector pressOffset;
+		bool dragging;
+
+		public DependencyGraphControl()
+		{
+			ClipToBounds = true;
+			Focusable = true;
+		}
+
+		/// Raised with the node index when a node is clicked.
+		public event Action? NodeClicked;
+
+		/// Raised with the node index when a node is double-clicked.
+		public event Action? NodeDoubleClicked;
+
+		public AssemblyDependencyGraph? Graph {
+			get => graph;
+			set {
+				graph = value;
+				layout = null;
+				fitPending = true;
+				InvalidateVisual();
+			}
+		}
+
+		/// Index of the highlighted node, or -1.
+		public int SelectedIndex { get; set; } = -1;
+
+		/// The current layout (computed on first use).
+		public LayeredLayoutResult? Layout => EnsureLayout();
+
+		public double Scale => scale;
+
+		LayeredLayoutResult? EnsureLayout()
+		{
+			if (layout != null || graph == null)
+				return layout;
+			var widths = new double[graph.Nodes.Count];
+			foreach (var node in graph.Nodes)
+			{
+				var text = CreateLabel(node, Brushes.Black);
+				widths[node.Index] = Math.Ceiling(text.Width) + 2 * HorizontalPadding;
+			}
+			layout = LayeredLayout.Compute(graph.Nodes.Count, graph.EdgePairs, widths,
+				new LayeredLayoutOptions { NodeHeight = 28, LayerSpacing = 90, NodeSpacing = 14 });
+			return layout;
+		}
+
+		static string LabelOf(AssemblyDependencyNode node)
+			=> node.Version != null ? $"{node.Name} {node.Version}" : node.Name;
+
+		static FormattedText CreateLabel(AssemblyDependencyNode node, IBrush brush)
+			=> new(LabelOf(node), CultureInfo.CurrentUICulture, FlowDirection.LeftToRight,
+				node.IsRoot ? BoldTypeface : NormalTypeface, FontSize, brush);
+
+		/// Scales and centers the drawing so the whole graph fits the control.
+		public void ZoomToFit()
+		{
+			var l = EnsureLayout();
+			if (l == null || Bounds.Width <= 0 || Bounds.Height <= 0 || l.Width <= 0 || l.Height <= 0)
+				return;
+			scale = Math.Clamp(Math.Min(Bounds.Width / l.Width, Bounds.Height / l.Height), MinScale, 1);
+			offset = new Vector((Bounds.Width - l.Width * scale) / 2, (Bounds.Height - l.Height * scale) / 2);
+			fitPending = false;
+			InvalidateVisual();
+		}
+
+		/// Converts a point in control coordinates to layout coordinates.
+		public Point ToLayout(Point controlPoint) => new((controlPoint.X - offset.X) / scale, (controlPoint.Y - offset.Y) / scale);
+
+		/// Center of a node in control coordinates.
+		public Point GetNodeCenter(int index)
+		{
+			var l = EnsureLayout() ?? throw new InvalidOperationException("No graph.");
+			var r = l.Nodes[index];
+			return new Point((r.X + r.Width / 2) * scale + offset.X, r.CenterY * scale + offset.Y);
+		}
+
+		/// Index of the node under a point in control coordinates, or -1.
+		public int HitTestNode(Point controlPoint)
+		{
+			var l = EnsureLayout();
+			if (l == null)
+				return -1;
+			var p = ToLayout(controlPoint);
+			return l.HitTest(p.X, p.Y);
+		}
+
+		protected override Size ArrangeOverride(Size finalSize)
+		{
+			var result = base.ArrangeOverride(finalSize);
+			if (fitPending && finalSize.Width > 0 && finalSize.Height > 0)
+			{
+				// Bounds is updated after ArrangeOverride returns, so fit against the final size.
+				var l = EnsureLayout();
+				if (l != null && l.Width > 0 && l.Height > 0)
+				{
+					scale = Math.Clamp(Math.Min(finalSize.Width / l.Width, finalSize.Height / l.Height), MinScale, 1);
+					offset = new Vector((finalSize.Width - l.Width * scale) / 2, (finalSize.Height - l.Height * scale) / 2);
+					fitPending = false;
+				}
+			}
+			return result;
+		}
+
+		public override void Render(DrawingContext context)
+		{
+			// A transparent background makes the whole area hit-testable for panning and zooming.
+			context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
+			var l = EnsureLayout();
+			if (l == null || graph == null)
+				return;
+
+			bool dark = ActualThemeVariant == ThemeVariant.Dark;
+			IBrush text = dark ? Brushes.Gainsboro : Brushes.Black;
+			IBrush nodeFill = dark ? new SolidColorBrush(Color.FromRgb(0x2d, 0x33, 0x3b)) : new SolidColorBrush(Color.FromRgb(0xee, 0xf3, 0xfa));
+			IBrush nodeStroke = dark ? new SolidColorBrush(Color.FromRgb(0x7a, 0x9c, 0xc6)) : new SolidColorBrush(Color.FromRgb(0x3c, 0x6e, 0xb4));
+			IBrush unresolvedFill = dark ? new SolidColorBrush(Color.FromRgb(0x4a, 0x24, 0x24)) : new SolidColorBrush(Color.FromRgb(0xfd, 0xe2, 0xe2));
+			IBrush unresolvedStroke = new SolidColorBrush(Color.FromRgb(0xc0, 0x39, 0x2b));
+			IBrush edgeBrush = dark ? new SolidColorBrush(Color.FromRgb(0x8a, 0x8a, 0x8a)) : new SolidColorBrush(Color.FromRgb(0x70, 0x70, 0x70));
+			IBrush mismatchBrush = new SolidColorBrush(Color.FromRgb(0xe6, 0x7e, 0x22));
+			IBrush selectedStroke = new SolidColorBrush(Color.FromRgb(0xf1, 0xc4, 0x0f));
+
+			using (context.PushTransform(Matrix.CreateScale(scale, scale) * Matrix.CreateTranslation(offset)))
+			{
+				for (int i = 0; i < l.Edges.Count; i++)
+				{
+					var edge = l.Edges[i];
+					bool mismatch = graph.Edges[i].IsVersionMismatch;
+					DrawEdge(context, edge.Points, new Pen(mismatch ? mismatchBrush : edgeBrush, mismatch ? 1.6 : 1.1));
+				}
+				foreach (var node in graph.Nodes)
+				{
+					var r = l.Nodes[node.Index];
+					var rect = new Rect(r.X, r.Y, r.Width, r.Height);
+					var fill = node.IsResolved ? nodeFill : unresolvedFill;
+					Pen pen;
+					if (node.Index == SelectedIndex)
+						pen = new Pen(selectedStroke, 3);
+					else if (!node.IsResolved)
+						pen = new Pen(unresolvedStroke, 1.5, new DashStyle(new double[] { 4, 3 }, 0));
+					else
+						pen = new Pen(nodeStroke, node.IsRoot ? 2.5 : 1.2);
+					context.DrawRectangle(fill, pen, rect, 4, 4);
+					var label = CreateLabel(node, node.IsResolved ? text : unresolvedStroke);
+					context.DrawText(label, new Point(r.X + HorizontalPadding, r.CenterY - label.Height / 2));
+				}
+			}
+		}
+
+		// Edges between layers are drawn as cubic curves with horizontal tangents, straight runs
+		// through skipped layers as lines, and the end gets an arrow head.
+		static void DrawEdge(DrawingContext context, IReadOnlyList points, Pen pen)
+		{
+			if (points.Count < 2)
+				return;
+			var geometry = new StreamGeometry();
+			using (var g = geometry.Open())
+			{
+				g.BeginFigure(ToPoint(points[0]), false);
+				for (int i = 1; i < points.Count; i++)
+				{
+					var a = ToPoint(points[i - 1]);
+					var b = ToPoint(points[i]);
+					if (Math.Abs(a.Y - b.Y) < 0.5)
+					{
+						g.LineTo(b);
+					}
+					else
+					{
+						double dx = (b.X - a.X) / 2;
+						g.CubicBezierTo(new Point(a.X + dx, a.Y), new Point(b.X - dx, b.Y), b);
+					}
+				}
+				g.EndFigure(false);
+			}
+			context.DrawGeometry(null, pen, geometry);
+
+			var tip = ToPoint(points[^1]);
+			double direction = Math.Sign(points[^1].X - points[^2].X);
+			if (direction == 0)
+				direction = 1;
+			const double length = 8, half = 4;
+			var head = new StreamGeometry();
+			using (var g = head.Open())
+			{
+				g.BeginFigure(tip, true);
+				g.LineTo(new Point(tip.X - direction * length, tip.Y - half));
+				g.LineTo(new Point(tip.X - direction * length, tip.Y + half));
+				g.EndFigure(true);
+			}
+			context.DrawGeometry(pen.Brush, null, head);
+		}
+
+		static Point ToPoint(LayoutPoint p) => new(p.X, p.Y);
+
+		protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
+		{
+			base.OnPointerWheelChanged(e);
+			var position = e.GetPosition(this);
+			double factor = Math.Pow(1.15, e.Delta.Y);
+			double newScale = Math.Clamp(scale * factor, MinScale, MaxScale);
+			// Keep the layout point under the pointer fixed while zooming.
+			var anchor = ToLayout(position);
+			scale = newScale;
+			offset = new Vector(position.X - anchor.X * scale, position.Y - anchor.Y * scale);
+			fitPending = false;
+			InvalidateVisual();
+			e.Handled = true;
+		}
+
+		protected override void OnPointerPressed(PointerPressedEventArgs e)
+		{
+			base.OnPointerPressed(e);
+			if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+				return;
+			var position = e.GetPosition(this);
+			if (e.ClickCount >= 2)
+			{
+				int index = HitTestNode(position);
+				pressPoint = null;
+				if (index >= 0)
+				{
+					NodeDoubleClicked?.Invoke(index);
+					e.Handled = true;
+				}
+				return;
+			}
+			pressPoint = position;
+			pressOffset = offset;
+			dragging = false;
+			e.Pointer.Capture(this);
+			e.Handled = true;
+		}
+
+		protected override void OnPointerMoved(PointerEventArgs e)
+		{
+			base.OnPointerMoved(e);
+			if (pressPoint is not { } start)
+				return;
+			var delta = e.GetPosition(this) - start;
+			if (!dragging && (Math.Abs(delta.X) > DragThreshold || Math.Abs(delta.Y) > DragThreshold))
+				dragging = true;
+			if (dragging)
+			{
+				offset = pressOffset + delta;
+				fitPending = false;
+				InvalidateVisual();
+			}
+		}
+
+		protected override void OnPointerReleased(PointerReleasedEventArgs e)
+		{
+			base.OnPointerReleased(e);
+			if (pressPoint is not { } start)
+				return;
+			pressPoint = null;
+			e.Pointer.Capture(null);
+			if (dragging)
+			{
+				dragging = false;
+				return;
+			}
+			int index = HitTestNode(start);
+			if (index >= 0)
+			{
+				SelectedIndex = index;
+				InvalidateVisual();
+				NodeClicked?.Invoke(index);
+			}
+			e.Handled = true;
+		}
+	}
+}
diff --git a/ILSpy/Dependencies/DependencyGraphView.cs b/ILSpy/Dependencies/DependencyGraphView.cs
new file mode 100644
index 00000000000..717dba88692
--- /dev/null
+++ b/ILSpy/Dependencies/DependencyGraphView.cs
@@ -0,0 +1,69 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+
+namespace ICSharpCode.ILSpy.Dependencies
+{
+	/// 
+	/// View for  (resolved by the view locator's
+	/// *ViewModel to *View convention): a toolbar with the graph summary, a
+	/// zoom-to-fit button and the Mermaid export, above a .
+	/// 
+	public sealed class DependencyGraphView : UserControl
+	{
+		readonly DependencyGraphControl graphControl = new();
+		readonly TextBlock summary = new() { VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(4, 0, 12, 0) };
+		DependencyGraphViewModel? model;
+
+		public DependencyGraphView()
+		{
+			var fit = new Button { Content = "Zoom to Fit", Margin = new Thickness(2) };
+			fit.Click += (_, _) => graphControl.ZoomToFit();
+			var export = new Button { Content = ICSharpCode.ILSpy.Properties.Resources.ExportDependencyDiagramMermaid, Margin = new Thickness(2) };
+			export.Click += (_, _) => {
+				if (model != null)
+					DependencyDiagramActions.ExportMermaidAsync(model.Roots, path: null).HandleExceptions();
+			};
+			var toolbar = new StackPanel {
+				Orientation = Orientation.Horizontal,
+				Margin = new Thickness(4),
+				Children = { summary, fit, export },
+			};
+			DockPanel.SetDock(toolbar, Avalonia.Controls.Dock.Top);
+			graphControl.NodeClicked += index => model?.SelectInTree(index);
+			graphControl.NodeDoubleClicked += index => model?.OpenInNewTab(index);
+			Content = new DockPanel { Children = { toolbar, graphControl } };
+		}
+
+		/// The drawing surface (exposed for UI tests).
+		public DependencyGraphControl GraphControl => graphControl;
+
+		protected override void OnDataContextChanged(EventArgs e)
+		{
+			base.OnDataContextChanged(e);
+			model = DataContext as DependencyGraphViewModel;
+			graphControl.Graph = model?.Graph;
+			summary.Text = model?.Summary ?? string.Empty;
+		}
+	}
+}
diff --git a/ILSpy/Dependencies/DependencyGraphViewModel.cs b/ILSpy/Dependencies/DependencyGraphViewModel.cs
new file mode 100644
index 00000000000..d699ac2ea8d
--- /dev/null
+++ b/ILSpy/Dependencies/DependencyGraphViewModel.cs
@@ -0,0 +1,115 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+using Avalonia.Threading;
+
+using ICSharpCode.ILSpyX;
+using ICSharpCode.ILSpyX.Dependencies;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.AssemblyTree;
+using ICSharpCode.ILSpy.Docking;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.ViewModels;
+
+namespace ICSharpCode.ILSpy.Dependencies
+{
+	/// 
+	/// Tab content for the assembly dependency diagram. Holds the graph (built once, off the UI
+	/// thread, before the tab opens) and the actions the view triggers: selecting a node's assembly
+	/// in the tree, opening it in its own tab, and exporting the diagram as Mermaid HTML.
+	/// 
+	public sealed class DependencyGraphViewModel : ContentPageModel
+	{
+		public DependencyGraphViewModel(AssemblyDependencyGraph graph, IReadOnlyList roots, string title)
+		{
+			Graph = graph ?? throw new ArgumentNullException(nameof(graph));
+			Roots = roots ?? throw new ArgumentNullException(nameof(roots));
+			Title = title ?? throw new ArgumentNullException(nameof(title));
+			// The diagram does not depend on the decompiler language.
+			SupportsLanguageSwitching = false;
+		}
+
+		public AssemblyDependencyGraph Graph { get; }
+
+		/// The assemblies the graph was built from.
+		public IReadOnlyList Roots { get; }
+
+		/// The tab hosting this content, used to bring the diagram back to the front.
+		public ContentTabPage? Tab { get; set; }
+
+		/// One-line summary shown above the diagram.
+		public string Summary {
+			get {
+				int unresolved = Graph.Nodes.Count(n => !n.IsResolved);
+				int mismatches = Graph.Edges.Count(e => e.IsVersionMismatch);
+				return $"{Graph.Nodes.Count} assemblies, {Graph.Edges.Count} references, {unresolved} unresolved, {mismatches} version mismatches";
+			}
+		}
+
+		/// The assembly-tree node of a graph node, or null when it is unresolved or not in the list.
+		public AssemblyTreeNode? FindTreeNode(int index)
+		{
+			if ((uint)index >= (uint)Graph.Nodes.Count)
+				return null;
+			var file = Graph.Nodes[index].MetadataFile;
+			if (file == null)
+				return null;
+			var model = AppComposition.TryGetExport();
+			return (model?.Root as AssemblyListTreeNode)?.FindAssemblyNode(file);
+		}
+
+		/// 
+		/// Selects the node's assembly in the assembly tree. Selecting a tree node shows it in the
+		/// preview tab, so the diagram tab is brought back to the front afterwards.
+		/// 
+		public bool SelectInTree(int index)
+		{
+			var node = FindTreeNode(index);
+			var model = AppComposition.TryGetExport();
+			if (node == null || model == null)
+				return false;
+			model.SelectedItem = node;
+			var tab = Tab;
+			var dockWorkspace = AppComposition.TryGetExport();
+			if (tab != null && dockWorkspace != null)
+			{
+				Dispatcher.UIThread.Post(() => {
+					if (dockWorkspace.Documents?.VisibleDockables?.Contains(tab) == true)
+						dockWorkspace.Factory.SetActiveDockable(tab);
+				}, DispatcherPriority.Background);
+			}
+			return true;
+		}
+
+		/// Opens the node's assembly in a new document tab.
+		public bool OpenInNewTab(int index)
+		{
+			var node = FindTreeNode(index);
+			var dockWorkspace = AppComposition.TryGetExport();
+			if (node == null || dockWorkspace == null)
+				return false;
+			dockWorkspace.OpenNodeInNewTab(node);
+			return true;
+		}
+	}
+}
diff --git a/ILSpy/Dependencies/ReferencedByTreeNodes.cs b/ILSpy/Dependencies/ReferencedByTreeNodes.cs
new file mode 100644
index 00000000000..a2bb1ff7b60
--- /dev/null
+++ b/ILSpy/Dependencies/ReferencedByTreeNodes.cs
@@ -0,0 +1,172 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Linq;
+
+using ICSharpCode.Decompiler;
+using ICSharpCode.Decompiler.IL;
+using ICSharpCode.Decompiler.Metadata;
+using ICSharpCode.ILSpyX;
+using ICSharpCode.ILSpyX.Dependencies;
+using ICSharpCode.ILSpyX.TreeView.PlatformAbstractions;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.AssemblyTree;
+using ICSharpCode.ILSpy.Languages;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TreeNodes;
+
+namespace ICSharpCode.ILSpy.Dependencies
+{
+	/// 
+	/// "Referenced By" folder under an assembly node: the reverse of the References folder. Lists
+	/// the assemblies of the current list that reference this assembly (matched by name and public
+	/// key token), each of which expands to its own referencing assemblies.
+	/// 
+	public sealed class ReferencedByFolderTreeNode : ILSpyTreeNode
+	{
+		readonly MetadataFile module;
+		readonly AssemblyTreeNode parentAssembly;
+
+		public ReferencedByFolderTreeNode(MetadataFile module, AssemblyTreeNode parentAssembly)
+		{
+			this.module = module ?? throw new ArgumentNullException(nameof(module));
+			this.parentAssembly = parentAssembly ?? throw new ArgumentNullException(nameof(parentAssembly));
+			LazyLoading = true;
+		}
+
+		public MetadataFile Module => module;
+
+		public override object Text => Resources.ReferencedBy;
+
+		public override object? NavigationText => $"{Text} ({module.Name})";
+
+		public override object Icon => Images.ReferenceFolder;
+
+		protected override void LoadChildren()
+		{
+			foreach (var child in ReferencingAssemblyTreeNode.CreateChildren(parentAssembly.LoadedAssembly.AssemblyList, module, this))
+				Children.Add(child);
+		}
+
+		public override void Decompile(Language language, ITextOutput output, DecompilationOptions options)
+		{
+			EnsureLazyChildren();
+			output.WriteLine($"Assemblies in the list that reference {module.FullName}:");
+			output.WriteLine();
+			if (Children.Count == 0)
+				output.WriteLine("(none)");
+			foreach (var node in Children.OfType())
+				node.WriteSummary(output);
+		}
+	}
+
+	/// 
+	/// One referencing assembly under a  (or, recursively,
+	/// under another referencing assembly). Activating it selects that assembly in the tree.
+	/// 
+	public sealed class ReferencingAssemblyTreeNode : ILSpyTreeNode
+	{
+		readonly ReferencingAssembly referencing;
+
+		ReferencingAssemblyTreeNode(ReferencingAssembly referencing)
+		{
+			this.referencing = referencing;
+			LazyLoading = true;
+		}
+
+		public LoadedAssembly Assembly => referencing.Assembly;
+
+		public AssemblyReference Reference => referencing.Reference;
+
+		public bool IsVersionMismatch => referencing.IsVersionMismatch;
+
+		public override object Text {
+			get {
+				var name = ILAmbience.EscapeName(referencing.Assembly.ShortName);
+				var version = AssemblyReferenceMatcher.GetAssemblyVersion(referencing.Module.Metadata);
+				var text = version != null ? $"{name} ({version})" : name;
+				if (referencing.IsVersionMismatch)
+					text += $" - references v{referencing.Reference.Version}";
+				return text;
+			}
+		}
+
+		public override object? NavigationText => $"{Text} ({Resources.ReferencedBy})";
+
+		public override object Icon => referencing.IsVersionMismatch ? Images.AssemblyWarning : Images.Assembly;
+
+		public override object? ToolTip => referencing.Assembly.FileName;
+
+		/// 
+		/// One node per assembly of  that references ,
+		/// skipping assemblies already on the path from  up to the folder so
+		/// that reference cycles do not recurse forever.
+		/// 
+		internal static System.Collections.Generic.IEnumerable CreateChildren(
+			AssemblyList list, MetadataFile target, ILSpyTreeNode parent)
+		{
+			var onPath = parent.AncestorsAndSelf()
+				.Select(n => n switch {
+					ReferencingAssemblyTreeNode r => r.referencing.Module,
+					ReferencedByFolderTreeNode f => f.Module,
+					_ => null,
+				})
+				.Where(m => m != null)
+				.ToHashSet();
+			foreach (var referencing in ReferencedByFinder.FindReferencingAssemblies(list.GetAssemblies(), target))
+			{
+				if (!onPath.Contains(referencing.Module))
+					yield return new ReferencingAssemblyTreeNode(referencing);
+			}
+		}
+
+		protected override void LoadChildren()
+		{
+			foreach (var child in CreateChildren(referencing.Assembly.AssemblyList, referencing.Module, this))
+				Children.Add(child);
+		}
+
+		public override void ActivateItem(IPlatformRoutedEventArgs e)
+		{
+			var model = AppComposition.TryGetExport();
+			if (model?.Root is not AssemblyListTreeNode listNode)
+				return;
+			var node = listNode.FindAssemblyNode(referencing.Assembly);
+			if (node == null)
+				return;
+			model.SelectedItem = node;
+			e.Handled = true;
+		}
+
+		internal void WriteSummary(ITextOutput output)
+		{
+			output.WriteLine($"{Text}");
+			output.Indent();
+			output.WriteLine("File: " + referencing.Assembly.FileName);
+			output.WriteLine("Reference: " + referencing.Reference.FullName);
+			output.Unindent();
+		}
+
+		public override void Decompile(Language language, ITextOutput output, DecompilationOptions options)
+		{
+			WriteSummary(output);
+		}
+	}
+}
diff --git a/ILSpy/Docking/DockWorkspace.RecentNavigation.cs b/ILSpy/Docking/DockWorkspace.RecentNavigation.cs
new file mode 100644
index 00000000000..a10124d232c
--- /dev/null
+++ b/ILSpy/Docking/DockWorkspace.RecentNavigation.cs
@@ -0,0 +1,120 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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 ICSharpCode.ILSpy.Navigation;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.ViewModels;
+
+namespace ICSharpCode.ILSpy.Docking
+{
+	/// 
+	/// Recent Files / Recent Locations bookkeeping and navigation. The lists are fed from the
+	/// navigation-history recorder, so they see exactly the navigations Back/Forward see.
+	/// 
+	public partial class DockWorkspace
+	{
+		/// Most-recently-used tree nodes and caret locations of this workspace.
+		public RecentNavigation RecentNavigation { get; } = new();
+
+		// Called while a history entry is recorded: the outgoing entry has just been stamped with the
+		// editor's view state, and its tab still holds the outgoing document text (the decompile of
+		// the incoming node lands asynchronously), so the location and its preview line match.
+		void RecordRecentNavigation(NavigationEntry? outgoing, NavigationEntry incoming)
+		{
+			if (outgoing is TreeNodeEntry { CaretOffset: { } caret } left
+				&& UnwrapDecompilerTab(left.Tab) is { IsStaticContent: false } tab)
+			{
+				var state = new DecompilerTextViewState(caret, left.VerticalOffset ?? 0, left.HorizontalOffset ?? 0, left.Foldings);
+				RecentNavigation.RecordLocation(RecentLocation.Create(left.Node, left.Tab, state, tab.Text));
+			}
+			if (incoming is TreeNodeEntry entered)
+				RecentNavigation.RecordFile(entered.Node);
+		}
+
+		/// 
+		/// Snapshot of where the caret is right now in the document of the current history entry, or
+		/// null when that document is not a decompiler view showing the entry's node.
+		/// 
+		public RecentLocation? CaptureCurrentLocation()
+		{
+			if (history.Current is not TreeNodeEntry current
+				|| UnwrapDecompilerTab(current.Tab) is not { IsStaticContent: false } tab
+				|| !ReferenceEquals(tab.CurrentNode, current.Node)
+				|| tab.CaptureViewState?.Invoke() is not { } state)
+			{
+				return null;
+			}
+			return RecentLocation.Create(current.Node, current.Tab, state, tab.Text);
+		}
+
+		/// Navigates to a recent file by selecting its node in the assembly tree.
+		public void NavigateToRecentFile(RecentFile file)
+		{
+			System.ArgumentNullException.ThrowIfNull(file);
+			assemblyTreeModel.SelectedItem = file.Node;
+		}
+
+		/// 
+		/// Navigates to a recent location: re-opens its node (in the frozen tab that recorded it when
+		/// that tab is still open and showing it, otherwise through the tree selection) and restores
+		/// the recorded caret and scroll position.
+		/// 
+		public void NavigateToRecentLocation(RecentLocation location)
+		{
+			System.ArgumentNullException.ThrowIfNull(location);
+			if (location.Tab is ContentTabPage recordedTab
+				&& !ReferenceEquals(recordedTab, factory.MainTab)
+				&& factory.Documents is { VisibleDockables: { } docs } documents
+				&& docs.Contains(recordedTab)
+				&& UnwrapDecompilerTab(recordedTab) is { } frozen
+				&& ReferenceEquals(frozen.CurrentNode, location.Node))
+			{
+				if (!ReferenceEquals(documents.ActiveDockable, recordedTab))
+					factory.SetActiveDockable(recordedTab);
+				RestoreViewState(frozen, location.State);
+				return;
+			}
+
+			if (ActiveDecompilerTab is { IsDecompiling: false } shown
+				&& ReferenceEquals(shown.CurrentNode, location.Node)
+				&& ReferenceEquals(assemblyTreeModel.SelectedItem, location.Node))
+			{
+				// Already on screen: no decompile will run to consume a pending state.
+				RestoreViewState(shown, location.State);
+				return;
+			}
+
+			assemblyTreeModel.SelectedItem = location.Node;
+			// The selection started an asynchronous decompile; the editor applies the pending state
+			// once the new text lands.
+			if (ActiveDecompilerTab is { } target && ReferenceEquals(target.CurrentNode, location.Node))
+				target.PendingViewState = location.State;
+		}
+
+		static void RestoreViewState(DecompilerTabPageModel tab, DecompilerTextViewState state)
+		{
+			if (tab.ApplyViewState is { } apply)
+				apply(state);
+			else
+				tab.PendingViewState = state;
+		}
+	}
+}
diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs
index 6587e91b922..d9e0891b654 100644
--- a/ILSpy/Docking/DockWorkspace.cs
+++ b/ILSpy/Docking/DockWorkspace.cs
@@ -126,6 +126,7 @@ public void PruneHistory(Predicate predicateOnNode)
 			ArgumentNullException.ThrowIfNull(predicateOnNode);
 			history.RemoveAll(entry =>
 				entry is TreeNodeEntry t && predicateOnNode(t.Node));
+			RecentNavigation.RemoveAll(predicateOnNode);
 		}
 
 		public IRootDock Layout { get; }
@@ -320,6 +321,7 @@ void PruneHistoryAfterAssemblyListChange(HashSet true);
+				RecentNavigation.Clear();
 			}
 			else
 			{
@@ -328,6 +330,9 @@ entry is Navigation.TreeNodeEntry t
 					&& t.Node.AncestorsAndSelf()
 						.OfType()
 						.Any(a => removed.Contains(a.LoadedAssembly)));
+				RecentNavigation.RemoveAll(node => node.AncestorsAndSelf()
+					.OfType()
+					.Any(a => removed.Contains(a.LoadedAssembly)));
 			}
 			NavigateBackCommand.NotifyCanExecuteChanged();
 			NavigateForwardCommand.NotifyCanExecuteChanged();
@@ -548,8 +553,10 @@ void RecordHistoryEntry(NavigationEntry entry)
 			// point the editor still shows the previous selection's content, so the values
 			// describe where the user was looking before this new entry takes over. The
 			// captured state is then restored if/when the user navigates Back/Forward to it.
+			var outgoing = history.Current;
 			CaptureCurrentViewState();
 			history.Record(entry);
+			RecordRecentNavigation(outgoing, entry);
 			NavigateBackCommand.NotifyCanExecuteChanged();
 			NavigateForwardCommand.NotifyCanExecuteChanged();
 		}
diff --git a/ILSpy/FileStructure/ActiveDocument.cs b/ILSpy/FileStructure/ActiveDocument.cs
new file mode 100644
index 00000000000..d3732323594
--- /dev/null
+++ b/ILSpy/FileStructure/ActiveDocument.cs
@@ -0,0 +1,77 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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 Avalonia;
+using Avalonia.Controls;
+using Avalonia.VisualTree;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Docking;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.Views;
+
+namespace ICSharpCode.ILSpy.FileStructure
+{
+	/// 
+	/// Locates the decompiler document that navigation commands act on, and the symbol under its
+	/// caret. Resolved through the composition host on every call, so the result always reflects
+	/// the current layout.
+	/// 
+	internal static class ActiveDocument
+	{
+		/// The decompiler document of the active document tab, or null when the active tab
+		/// shows something else (metadata table, options, ...).
+		public static DecompilerTabPageModel? Get()
+		{
+			var workspace = AppComposition.TryGetExport();
+			if (workspace == null)
+				return null;
+			if (workspace.ActiveContentTabPage?.Content is DecompilerTabPageModel { IsStaticContent: false } active)
+				return active;
+			return workspace.ActiveDecompilerTab;
+		}
+
+		/// The decompiler document whose editor holds the keyboard focus, or null.
+		public static DecompilerTabPageModel? GetFocused()
+		{
+			var window = AppComposition.TryGetExport();
+			if (window?.FocusManager?.GetFocusedElement() is not Visual focused)
+				return null;
+			var view = focused.GetSelfAndVisualAncestors().OfType().FirstOrDefault();
+			return view?.DataContext as DecompilerTabPageModel;
+		}
+
+		/// 
+		/// The navigable reference (type, member or metadata reference) at the caret of
+		/// , or null when the caret is not on one or no editor is attached.
+		/// 
+		public static object? GetReferenceAtCaret(DecompilerTabPageModel document)
+		{
+			if (document.CaptureViewState?.Invoke() is not { } state || document.References is not { } references)
+				return null;
+			int offset = state.CaretOffset;
+			// A caret right after the last character of a name still counts as being on it.
+			var segment = references.FindSegmentsContaining(offset)
+				.Concat(offset > 0 ? references.FindSegmentsContaining(offset - 1) : Enumerable.Empty())
+				.FirstOrDefault(s => s.Kind == ReferenceMode.Link && s.Reference != null);
+			return segment?.Reference;
+		}
+	}
+}
diff --git a/ILSpy/FileStructure/FileMemberEntry.cs b/ILSpy/FileStructure/FileMemberEntry.cs
new file mode 100644
index 00000000000..b4b27916124
--- /dev/null
+++ b/ILSpy/FileStructure/FileMemberEntry.cs
@@ -0,0 +1,213 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection.Metadata;
+
+using Avalonia;
+using Avalonia.Media;
+
+using ICSharpCode.Decompiler.Metadata;
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpyX;
+
+namespace ICSharpCode.ILSpy.FileStructure
+{
+	/// 
+	/// One type or member defined in a decompiled document, as listed by the File Structure pane
+	/// and the Go to File Member popup.
+	/// 
+	/// The name as written at the definition (a constructor shows its type's name).
+	/// Extra text that tells overloads apart (a method's parameter types), or empty.
+	/// The same icon the assembly tree shows for the entity.
+	/// Document offset of the definition's name; navigation puts the caret here.
+	/// Nesting level: 0 for a top-level type, one more per enclosing type.
+	/// The resolved entity, or null for an unresolved metadata definition (IL view).
+	public sealed record FileMemberEntry(string Name, string Detail, IImage? Icon, int Offset, int Depth, IEntity? Entity)
+	{
+		/// Name plus detail, the text a row shows.
+		public string DisplayText => Detail.Length == 0 ? Name : Name + Detail;
+
+		/// Left indentation of a row, proportional to .
+		public Thickness Indent => new(Depth * 16, 0, 0, 0);
+	}
+
+	/// 
+	/// Builds the member outline of a decompiled document from the definition references the
+	/// decompiler recorded while writing it, so the outline matches the text exactly and costs no
+	/// extra decompilation.
+	/// 
+	public static class FileMemberCollector
+	{
+		public static IReadOnlyList Collect(DecompilerTabPageModel? document)
+		{
+			if (document?.References is not { } references || string.IsNullOrEmpty(document.Text))
+				return Array.Empty();
+			string text = document.Text;
+			var entries = new List();
+			var seen = new HashSet<(object Module, int Token)>();
+			foreach (var segment in references)
+			{
+				if (!segment.IsDefinition || segment.Kind != ReferenceMode.Link || segment.EndOffset > text.Length)
+					continue;
+				var entry = segment.Reference switch {
+					IEntity entity => FromEntity(entity, segment, text),
+					EntityReference metadataReference => FromMetadata(metadataReference, segment, text),
+					_ => null,
+				};
+				if (entry == null)
+					continue;
+				if (Identity(segment.Reference!) is { } identity && !seen.Add(identity))
+					continue;
+				entries.Add(entry);
+			}
+			entries.Sort((a, b) => a.Offset.CompareTo(b.Offset));
+			int minDepth = entries.Count == 0 ? 0 : entries.Min(e => e.Depth);
+			return minDepth == 0 ? entries : entries.Select(e => e with { Depth = e.Depth - minDepth }).ToList();
+		}
+
+		static (object, int)? Identity(object reference) => reference switch {
+			IEntity { MetadataToken.IsNil: false } entity when entity.ParentModule?.MetadataFile is { } file
+				=> (file, System.Reflection.Metadata.Ecma335.MetadataTokens.GetToken(entity.MetadataToken)),
+			EntityReference r => (r.Module, System.Reflection.Metadata.Ecma335.MetadataTokens.GetToken(r.Handle)),
+			_ => null,
+		};
+
+		static FileMemberEntry? FromEntity(IEntity entity, ReferenceSegment segment, string text)
+		{
+			// Accessors are listed through their property or event.
+			if (entity is IMethod { AccessorOwner: not null })
+				return null;
+			var icon = GetIcon(entity);
+			if (icon == null)
+				return null;
+			string name = text.Substring(segment.StartOffset, segment.Length);
+			string detail = entity is IMethod method
+				? "(" + string.Join(", ", method.Parameters.Select(p => p.Type.Name)) + ")"
+				: string.Empty;
+			return new FileMemberEntry(name, detail, icon, segment.StartOffset, NestingDepth(entity), entity);
+		}
+
+		static IImage? GetIcon(IEntity entity) => entity switch {
+			ITypeDefinition type => TypeTreeNode.GetIcon(type),
+			IMethod method => MethodTreeNode.GetIcon(method),
+			IProperty property => PropertyTreeNode.GetIcon(property),
+			IField field => FieldTreeNode.GetIcon(field),
+			IEvent @event => EventTreeNode.GetIcon(@event),
+			_ => null,
+		};
+
+		// A type nested N levels deep sits at depth N; its members one level further in.
+		static int NestingDepth(IEntity entity)
+		{
+			int depth = entity is ITypeDefinition ? 0 : 1;
+			for (var declaring = entity.DeclaringTypeDefinition; declaring != null; declaring = declaring.DeclaringTypeDefinition)
+				depth++;
+			return depth;
+		}
+
+		// The IL view writes definitions as unresolved (module, handle) references whose text is
+		// the directive keyword (".method", ".class", ...); the name comes from the metadata.
+		static FileMemberEntry? FromMetadata(EntityReference reference, ReferenceSegment segment, string text)
+		{
+			if (reference.Protocol != "decompile" || reference.Handle.IsNil)
+				return null;
+			var metadataFile = ResolveMetadata(reference);
+			if (metadataFile == null)
+				return null;
+			var metadata = metadataFile.Metadata;
+			try
+			{
+				switch (reference.Handle.Kind)
+				{
+					case HandleKind.TypeDefinition:
+					{
+						var handle = (TypeDefinitionHandle)reference.Handle;
+						var definition = metadata.GetTypeDefinition(handle);
+						int depth = 0;
+						for (var declaring = definition.GetDeclaringType(); !declaring.IsNil; declaring = metadata.GetTypeDefinition(declaring).GetDeclaringType())
+							depth++;
+						return new FileMemberEntry(StripArity(metadata.GetString(definition.Name)), string.Empty,
+							Images.Class, segment.StartOffset, depth, null);
+					}
+					case HandleKind.MethodDefinition:
+					{
+						var definition = metadata.GetMethodDefinition((MethodDefinitionHandle)reference.Handle);
+						return new FileMemberEntry(metadata.GetString(definition.Name), string.Empty, Images.Method,
+							segment.StartOffset, MemberDepth(metadata, definition.GetDeclaringType()), null);
+					}
+					case HandleKind.FieldDefinition:
+					{
+						var definition = metadata.GetFieldDefinition((FieldDefinitionHandle)reference.Handle);
+						return new FileMemberEntry(metadata.GetString(definition.Name), string.Empty, Images.Field,
+							segment.StartOffset, MemberDepth(metadata, definition.GetDeclaringType()), null);
+					}
+					case HandleKind.PropertyDefinition:
+					{
+						var handle = (PropertyDefinitionHandle)reference.Handle;
+						var definition = metadata.GetPropertyDefinition(handle);
+						var declaring = metadata.GetMethodDefinition(definition.GetAccessors().Getter.IsNil
+							? definition.GetAccessors().Setter : definition.GetAccessors().Getter).GetDeclaringType();
+						return new FileMemberEntry(metadata.GetString(definition.Name), string.Empty, Images.Property,
+							segment.StartOffset, MemberDepth(metadata, declaring), null);
+					}
+					case HandleKind.EventDefinition:
+					{
+						var definition = metadata.GetEventDefinition((EventDefinitionHandle)reference.Handle);
+						var declaring = metadata.GetMethodDefinition(definition.GetAccessors().Adder).GetDeclaringType();
+						return new FileMemberEntry(metadata.GetString(definition.Name), string.Empty, Images.Event,
+							segment.StartOffset, MemberDepth(metadata, declaring), null);
+					}
+					default:
+						return null;
+				}
+			}
+			catch (BadImageFormatException)
+			{
+				return null;
+			}
+		}
+
+		static int MemberDepth(MetadataReader metadata, TypeDefinitionHandle declaringType)
+		{
+			int depth = 1;
+			for (var declaring = declaringType; !declaring.IsNil; declaring = metadata.GetTypeDefinition(declaring).GetDeclaringType())
+			{
+				if (!metadata.GetTypeDefinition(declaring).GetDeclaringType().IsNil)
+					depth++;
+			}
+			return depth;
+		}
+
+		static string StripArity(string name)
+		{
+			int tick = name.IndexOf('`');
+			return tick > 0 ? name.Substring(0, tick) : name;
+		}
+
+		static MetadataFile? ResolveMetadata(EntityReference reference)
+		{
+			var assemblyList = AppEnv.AppComposition.TryGetExport()?.AssemblyList;
+			return assemblyList == null ? null : reference.ResolveAssembly(assemblyList);
+		}
+	}
+}
diff --git a/ILSpy/FileStructure/FileStructureCommands.cs b/ILSpy/FileStructure/FileStructureCommands.cs
new file mode 100644
index 00000000000..a04615d9efd
--- /dev/null
+++ b/ILSpy/FileStructure/FileStructureCommands.cs
@@ -0,0 +1,95 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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.Composition;
+
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Docking;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.Views;
+
+namespace ICSharpCode.ILSpy.FileStructure
+{
+	/// Navigate > File Structure: shows the outline pane for the active document.
+	[ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.FileStructure),
+		MenuCategory = "Structure", MenuOrder = 110, InputGestureText = Gesture)]
+	[Shared]
+	[method: ImportingConstructor]
+	public sealed class FileStructureCommand(DockWorkspace dockWorkspace, FileStructureViewModel pane) : SimpleCommand
+	{
+		public const string Gesture = "Ctrl+Alt+F";
+
+		public override void Execute(object? parameter)
+		{
+			pane.Attach();
+			dockWorkspace.ShowToolPane(FileStructureViewModel.PaneContentId);
+		}
+	}
+
+	/// 
+	/// Navigate > Go to File Member: a searchable popup over the active document listing the
+	/// same members as the File Structure pane; Enter jumps to the selected one.
+	/// 
+	[ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.GoToFileMember),
+		MenuCategory = "Structure", MenuOrder = 120, InputGestureText = Gesture)]
+	[Shared]
+	public sealed class GoToFileMemberCommand : SimpleCommand
+	{
+		/// Alt+\ -- the backslash key of US and most European layouts reports as OemPipe.
+		public const string Gesture = "Alt+OemPipe";
+
+		/// The popup currently shown, or null.
+		internal Flyout? OpenFlyout { get; private set; }
+
+		/// The model of the popup currently shown, or null.
+		internal GoToFileMemberViewModel? OpenModel { get; private set; }
+
+		public override bool CanExecute(object? parameter) => ActiveDocument.Get() != null;
+
+		public override void Execute(object? parameter)
+		{
+			if (ActiveDocument.Get() is not { } document)
+				return;
+			if (AppComposition.TryGetExport() is not { } window)
+				return;
+			OpenFlyout?.Hide();
+
+			var model = new GoToFileMemberViewModel(document);
+			var flyout = new Flyout {
+				Content = new GoToFileMemberView { DataContext = model },
+				Placement = PlacementMode.Center,
+				ShowMode = FlyoutShowMode.Standard,
+			};
+			model.CloseRequested += (_, _) => flyout.Hide();
+			flyout.Closed += (_, _) => {
+				if (ReferenceEquals(OpenFlyout, flyout))
+				{
+					OpenFlyout = null;
+					OpenModel = null;
+				}
+			};
+			OpenFlyout = flyout;
+			OpenModel = model;
+			flyout.ShowAt(window);
+		}
+	}
+}
diff --git a/ILSpy/FileStructure/FileStructureView.axaml b/ILSpy/FileStructure/FileStructureView.axaml
new file mode 100644
index 00000000000..aed8901871c
--- /dev/null
+++ b/ILSpy/FileStructure/FileStructureView.axaml
@@ -0,0 +1,28 @@
+
+	
+		
+		
+			
+				
+					
+						
+						
+					
+				
+			
+		
+	
+
diff --git a/ILSpy/FileStructure/FileStructureView.axaml.cs b/ILSpy/FileStructure/FileStructureView.axaml.cs
new file mode 100644
index 00000000000..40a94ad63c5
--- /dev/null
+++ b/ILSpy/FileStructure/FileStructureView.axaml.cs
@@ -0,0 +1,62 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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 Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+
+namespace ICSharpCode.ILSpy.FileStructure
+{
+	/// 
+	/// Renders  as a flat, indented list. A click or Enter on a
+	/// row moves the editor caret to that member.
+	/// 
+	public partial class FileStructureView : UserControl
+	{
+		public FileStructureView()
+		{
+			InitializeComponent();
+			EntryList.Tapped += OnEntryTapped;
+			EntryList.AddHandler(KeyDownEvent, OnEntryKeyDown, RoutingStrategies.Tunnel);
+		}
+
+		protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+		{
+			base.OnAttachedToVisualTree(e);
+			(DataContext as FileStructureViewModel)?.Attach();
+		}
+
+		void OnEntryTapped(object? sender, TappedEventArgs e)
+		{
+			if (ActivateSelection())
+				e.Handled = true;
+		}
+
+		void OnEntryKeyDown(object? sender, KeyEventArgs e)
+		{
+			if (e.Key == Key.Enter && e.KeyModifiers == KeyModifiers.None && ActivateSelection())
+				e.Handled = true;
+		}
+
+		bool ActivateSelection()
+			=> DataContext is FileStructureViewModel model
+				&& EntryList.SelectedItem is FileMemberEntry entry
+				&& model.Activate(entry);
+	}
+}
diff --git a/ILSpy/FileStructure/FileStructureViewModel.cs b/ILSpy/FileStructure/FileStructureViewModel.cs
new file mode 100644
index 00000000000..498c499a2d7
--- /dev/null
+++ b/ILSpy/FileStructure/FileStructureViewModel.cs
@@ -0,0 +1,147 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Composition;
+
+using CommunityToolkit.Mvvm.ComponentModel;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Docking;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.ViewModels;
+
+namespace ICSharpCode.ILSpy.FileStructure
+{
+	/// 
+	/// The File Structure tool pane: the types and members of the active decompiler document in
+	/// document order. Follows the active document tab and refreshes whenever that document's text
+	/// is replaced (a new decompile, a language switch, ...). Activating a row moves the editor's
+	/// caret to the member's definition.
+	/// 
+	[Export]
+	[ExportToolPane(ContentId = PaneContentId, Alignment = ToolPaneAlignment.Right, Order = 0, IsVisibleByDefault = false)]
+	[Shared]
+	public sealed partial class FileStructureViewModel : ToolPaneModel
+	{
+		public const string PaneContentId = "FileStructure";
+
+		// The workspace is resolved lazily: it is built from the tool-pane registry, which builds
+		// this pane, so it cannot be a constructor dependency.
+		DockWorkspace? workspace;
+		ContentTabPage? trackedTab;
+
+		[ObservableProperty]
+		private DecompilerTabPageModel? document;
+
+		[ObservableProperty]
+		private FileMemberEntry? selectedEntry;
+
+		public FileStructureViewModel()
+		{
+			Id = PaneContentId;
+			Title = Resources.FileStructure;
+		}
+
+		/// The outline of .
+		public ObservableCollection Entries { get; } = new();
+
+		/// 
+		/// Starts following the active document (idempotent) and refreshes the outline. Called when
+		/// the pane is shown or its view attaches.
+		/// 
+		public void Attach()
+		{
+			if (workspace == null)
+			{
+				workspace = AppComposition.TryGetExport();
+				if (workspace != null)
+					workspace.PropertyChanged += OnWorkspacePropertyChanged;
+			}
+			TrackActiveDocument();
+		}
+
+		/// Moves the editor caret to 's definition.
+		public bool Activate(FileMemberEntry? entry)
+		{
+			if (entry == null || Document?.NavigateToOffset is not { } navigate)
+				return false;
+			SelectedEntry = entry;
+			navigate(entry.Offset);
+			return true;
+		}
+
+		void OnWorkspacePropertyChanged(object? sender, PropertyChangedEventArgs e)
+		{
+			if (e.PropertyName == nameof(DockWorkspace.ActiveContentTabPage))
+				TrackActiveDocument();
+		}
+
+		void OnTrackedTabPropertyChanged(object? sender, PropertyChangedEventArgs e)
+		{
+			// The reusable preview tab swaps its content between decompiler output and other pages.
+			if (e.PropertyName == nameof(ContentTabPage.Content))
+				TrackActiveDocument();
+		}
+
+		void TrackActiveDocument()
+		{
+			var tab = workspace?.ActiveContentTabPage;
+			if (!ReferenceEquals(tab, trackedTab))
+			{
+				if (trackedTab != null)
+					trackedTab.PropertyChanged -= OnTrackedTabPropertyChanged;
+				trackedTab = tab;
+				if (trackedTab != null)
+					trackedTab.PropertyChanged += OnTrackedTabPropertyChanged;
+			}
+			Document = ActiveDocument.Get();
+			Rebuild();
+		}
+
+		partial void OnDocumentChanged(DecompilerTabPageModel? oldValue, DecompilerTabPageModel? newValue)
+		{
+			if (oldValue != null)
+				oldValue.PropertyChanged -= OnDocumentPropertyChanged;
+			if (newValue != null)
+				newValue.PropertyChanged += OnDocumentPropertyChanged;
+		}
+
+		void OnDocumentPropertyChanged(object? sender, PropertyChangedEventArgs e)
+		{
+			// Text is assigned after the references it is described by, so it marks a complete update.
+			if (e.PropertyName == nameof(DecompilerTabPageModel.Text))
+				Rebuild();
+		}
+
+		void Rebuild()
+		{
+			Entries.Clear();
+			foreach (var entry in FileMemberCollector.Collect(Document))
+				Entries.Add(entry);
+			SelectedEntry = null;
+			OnPropertyChanged(nameof(HasNoEntries));
+		}
+
+		/// True when the outline has no rows.
+		public bool HasNoEntries => Entries.Count == 0;
+	}
+}
diff --git a/ILSpy/FileStructure/GoToFileMemberView.axaml b/ILSpy/FileStructure/GoToFileMemberView.axaml
new file mode 100644
index 00000000000..faa342485c0
--- /dev/null
+++ b/ILSpy/FileStructure/GoToFileMemberView.axaml
@@ -0,0 +1,28 @@
+
+	
+		
+		
+			
+				
+					
+						
+						
+					
+				
+			
+		
+	
+
diff --git a/ILSpy/FileStructure/GoToFileMemberView.axaml.cs b/ILSpy/FileStructure/GoToFileMemberView.axaml.cs
new file mode 100644
index 00000000000..3156deb8e5f
--- /dev/null
+++ b/ILSpy/FileStructure/GoToFileMemberView.axaml.cs
@@ -0,0 +1,85 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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 Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+
+namespace ICSharpCode.ILSpy.FileStructure
+{
+	/// 
+	/// Content of the Go to File Member popup: a filter box over the member list. The filter box
+	/// keeps the focus; Up/Down move the selection, Enter accepts, Escape cancels, and a click on
+	/// a row accepts it.
+	/// 
+	public partial class GoToFileMemberView : UserControl
+	{
+		public GoToFileMemberView()
+		{
+			InitializeComponent();
+			AddHandler(KeyDownEvent, OnKeyDownTunnel, RoutingStrategies.Tunnel);
+			ResultList.Tapped += OnResultTapped;
+		}
+
+		protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+		{
+			base.OnAttachedToVisualTree(e);
+			Dispatcher.UIThread.Post(() => FilterBox.Focus());
+		}
+
+		void OnKeyDownTunnel(object? sender, KeyEventArgs e)
+		{
+			if (DataContext is not GoToFileMemberViewModel model)
+				return;
+			switch (e.Key)
+			{
+				case Key.Down:
+					model.MoveSelection(1);
+					break;
+				case Key.Up:
+					model.MoveSelection(-1);
+					break;
+				case Key.PageDown:
+					model.MoveSelection(10);
+					break;
+				case Key.PageUp:
+					model.MoveSelection(-10);
+					break;
+				case Key.Enter:
+					model.Accept();
+					break;
+				case Key.Escape:
+					model.Cancel();
+					break;
+				default:
+					return;
+			}
+			if (model.SelectedItem != null)
+				ResultList.ScrollIntoView(model.SelectedItem);
+			e.Handled = true;
+		}
+
+		void OnResultTapped(object? sender, TappedEventArgs e)
+		{
+			if (DataContext is GoToFileMemberViewModel model && model.Accept())
+				e.Handled = true;
+		}
+	}
+}
diff --git a/ILSpy/FileStructure/GoToFileMemberViewModel.cs b/ILSpy/FileStructure/GoToFileMemberViewModel.cs
new file mode 100644
index 00000000000..eae9184a48f
--- /dev/null
+++ b/ILSpy/FileStructure/GoToFileMemberViewModel.cs
@@ -0,0 +1,96 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+using CommunityToolkit.Mvvm.ComponentModel;
+
+using ICSharpCode.ILSpy.TextView;
+
+namespace ICSharpCode.ILSpy.FileStructure
+{
+	/// 
+	/// The Go to File Member popup: the members of one document, narrowed by a case-insensitive
+	/// substring filter as the user types. The first match is preselected so Enter accepts it.
+	/// 
+	public sealed partial class GoToFileMemberViewModel : ObservableObject
+	{
+		readonly IReadOnlyList allEntries;
+
+		[ObservableProperty]
+		private string filterText = string.Empty;
+
+		[ObservableProperty]
+		private FileMemberEntry? selectedItem;
+
+		public GoToFileMemberViewModel(DecompilerTabPageModel document)
+		{
+			Document = document ?? throw new ArgumentNullException(nameof(document));
+			allEntries = FileMemberCollector.Collect(document);
+			ApplyFilter();
+		}
+
+		public DecompilerTabPageModel Document { get; }
+
+		/// The entries matching , in document order.
+		public ObservableCollection Items { get; } = new();
+
+		/// Raised when the popup should close: after a successful accept, or on cancel.
+		public event EventHandler? CloseRequested;
+
+		partial void OnFilterTextChanged(string value) => ApplyFilter();
+
+		void ApplyFilter()
+		{
+			Items.Clear();
+			foreach (var entry in allEntries)
+			{
+				if (FilterText.Length == 0 || entry.DisplayText.Contains(FilterText, StringComparison.OrdinalIgnoreCase))
+					Items.Add(entry);
+			}
+			SelectedItem = Items.Count > 0 ? Items[0] : null;
+		}
+
+		/// Moves the selection by  rows, clamped to the list.
+		public void MoveSelection(int delta)
+		{
+			if (Items.Count == 0)
+				return;
+			int index = SelectedItem == null ? -1 : Items.IndexOf(SelectedItem);
+			SelectedItem = Items[Math.Clamp(index + delta, 0, Items.Count - 1)];
+		}
+
+		/// 
+		/// Navigates the document to the selected member and asks the popup to close. Returns false
+		/// (and stays open) when nothing is selected or the document has no editor attached.
+		/// 
+		public bool Accept()
+		{
+			if (SelectedItem is not { } entry || Document.NavigateToOffset is not { } navigate)
+				return false;
+			navigate(entry.Offset);
+			CloseRequested?.Invoke(this, EventArgs.Empty);
+			return true;
+		}
+
+		/// Closes the popup without navigating.
+		public void Cancel() => CloseRequested?.Invoke(this, EventArgs.Empty);
+	}
+}
diff --git a/ILSpy/Navigation/ActiveNavigationContext.cs b/ILSpy/Navigation/ActiveNavigationContext.cs
new file mode 100644
index 00000000000..03be2e9cc59
--- /dev/null
+++ b/ILSpy/Navigation/ActiveNavigationContext.cs
@@ -0,0 +1,120 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Linq;
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.VisualTree;
+
+using ICSharpCode.ILSpy.AssemblyTree;
+using ICSharpCode.ILSpy.TextView;
+
+namespace ICSharpCode.ILSpy.Navigation
+{
+	/// 
+	/// Builds the  that keyboard and main-menu navigation commands act
+	/// on, mirroring what a right-click would produce: the symbol under the caret when a decompiler
+	/// text view was the last surface to hold keyboard focus, otherwise the assembly-tree selection.
+	/// 
+	public static class ActiveNavigationContext
+	{
+		static WeakReference? lastFocusedTextView;
+		static bool focusTrackingInstalled;
+
+		/// 
+		/// Starts recording which surface holds keyboard focus. Idempotent; focus changes inside
+		/// popups (menus, flyouts, the chooser) and in menu bars are ignored, so opening the main
+		/// menu does not make the commands lose the surface they were invoked for.
+		/// 
+		public static void EnsureFocusTracking()
+		{
+			if (focusTrackingInstalled)
+				return;
+			focusTrackingInstalled = true;
+			InputElement.GotFocusEvent.AddClassHandler((topLevel, e) => OnGotFocus(topLevel, e.Source), handledEventsToo: true);
+		}
+
+		static void OnGotFocus(TopLevel topLevel, object? source)
+		{
+			if (topLevel is PopupRoot || source is not Visual visual)
+				return;
+			if (visual is MenuItem || visual.FindAncestorOfType() != null || visual.FindAncestorOfType() != null)
+				return;
+			var textView = visual as DecompilerTextView ?? visual.FindAncestorOfType();
+			lastFocusedTextView = textView == null ? null : new WeakReference(textView);
+		}
+
+		/// Forgets the last focused text view, so the next context comes from the tree selection.
+		internal static void ClearLastFocusedTextView() => lastFocusedTextView = null;
+
+		/// The decompiler text view that last held keyboard focus, while it is still on screen.
+		public static DecompilerTextView? LastFocusedTextView {
+			get {
+				if (lastFocusedTextView == null || !lastFocusedTextView.TryGetTarget(out var view))
+					return null;
+				return TopLevel.GetTopLevel(view) != null && view.IsEffectivelyVisible ? view : null;
+			}
+		}
+
+		/// The context for the surface the user is working in; see the type summary.
+		public static TextViewContext Current(AssemblyTreeModel assemblyTreeModel)
+		{
+			ArgumentNullException.ThrowIfNull(assemblyTreeModel);
+			return LastFocusedTextView is { } view ? ForTextViewCaret(view) : ForTreeSelection(assemblyTreeModel);
+		}
+
+		/// The context for the reference under the caret of .
+		public static TextViewContext ForTextViewCaret(DecompilerTextView view)
+		{
+			ArgumentNullException.ThrowIfNull(view);
+			var offset = view.Editor.TextArea.Caret.Offset;
+			ReferenceSegment? segment = null;
+			if (view.DataContext is DecompilerTabPageModel { References: { } references })
+			{
+				// A caret placed just after an identifier still counts as being on it.
+				segment = references.FindSegmentsContaining(offset).FirstOrDefault(r => r.Reference != null)
+					?? (offset > 0 ? references.FindSegmentsContaining(offset - 1).FirstOrDefault(r => r.Reference != null) : null);
+			}
+			return new TextViewContext {
+				TextView = view,
+				Reference = segment,
+				TextLocation = offset,
+			};
+		}
+
+		/// The context for the current assembly-tree selection.
+		public static TextViewContext ForTreeSelection(AssemblyTreeModel assemblyTreeModel)
+		{
+			ArgumentNullException.ThrowIfNull(assemblyTreeModel);
+			return new TextViewContext {
+				SelectedTreeNodes = assemblyTreeModel.SelectedItems.ToArray(),
+			};
+		}
+
+		/// The control a popup for  is shown over, when the context names one.
+		public static Control? AnchorOf(TextViewContext context)
+		{
+			ArgumentNullException.ThrowIfNull(context);
+			return context.TextView ?? context.TreeGrid ?? context.ListBox ?? (Control?)context.DataGrid ?? context.OriginalSource as Control;
+		}
+	}
+}
diff --git a/ILSpy/Navigation/FindUsagesForRename.cs b/ILSpy/Navigation/FindUsagesForRename.cs
new file mode 100644
index 00000000000..29b11662c6c
--- /dev/null
+++ b/ILSpy/Navigation/FindUsagesForRename.cs
@@ -0,0 +1,391 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Collections.Generic;
+using System.Composition;
+using System.Linq;
+using System.Reflection.Metadata;
+using System.Threading;
+using System.Threading.Tasks;
+
+using ICSharpCode.Decompiler.Metadata;
+using ICSharpCode.Decompiler.Output;
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpyX;
+using ICSharpCode.ILSpyX.Abstractions;
+using ICSharpCode.ILSpyX.Analyzers;
+
+using ICSharpCode.ILSpy.Analyzers;
+using ICSharpCode.ILSpy.AssemblyTree;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Docking;
+using ICSharpCode.ILSpy.Languages;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.Util;
+
+namespace ICSharpCode.ILSpy.Navigation
+{
+	/// One entity a rename would touch, with how it relates to the renamed symbol: the
+	/// header of the analyzer that found it, "Declaration", or "Constructor".
+	public sealed record RenameUsage(IEntity Entity, string Relation);
+
+	/// 
+	/// Everything a rename of  would have to change: its declaration, related
+	/// declarations that share its name (overridden / implemented base members, overrides,
+	/// implementations, a type's constructors) and the entities whose code uses any of them.
+	/// 
+	public sealed class RenameUsageResult(IEntity symbol, IReadOnlyList declarations,
+		IReadOnlyList relatedDeclarations, IReadOnlyList usages)
+	{
+		public IEntity Symbol { get; } = symbol;
+		public IReadOnlyList DeclarationEntries { get; } = declarations;
+		public IReadOnlyList RelatedDeclarationEntries { get; } = relatedDeclarations;
+		public IReadOnlyList UsageEntries { get; } = usages;
+
+		public IEnumerable Declarations => DeclarationEntries.Select(u => u.Entity);
+		public IEnumerable RelatedDeclarations => RelatedDeclarationEntries.Select(u => u.Entity);
+		public IEnumerable Usages => UsageEntries.Select(u => u.Entity);
+	}
+
+	/// 
+	/// Read-only rename preview ("Find Usages for Rename"): collects the places a rename would touch
+	/// by running the registered analyzers. Related declarations come from the analyzers headed
+	/// "Overrides", "Implements", "Overridden By" and "Implemented By"; usages from "Used By",
+	/// "Read By", "Assigned By", "Address Taken By" and "Applied To", run over the symbol, its
+	/// related declarations and their accessors. Analyzers are matched by header, so plugin
+	/// analyzers that contribute the same relations take part as well.
+	/// 
+	public static class RenameUsageFinder
+	{
+		static readonly HashSet RelationHeaders = new(StringComparer.Ordinal) {
+			"Overrides", "Implements", "Overridden By", "Implemented By",
+		};
+
+		static readonly HashSet UsageHeaders = new(StringComparer.Ordinal) {
+			"Used By", "Read By", "Assigned By", "Address Taken By", "Applied To",
+		};
+
+		/// 
+		/// Runs the analysis on a background thread. The analyzer instances are created on the calling
+		/// thread from the composition's .
+		/// 
+		public static Task FindAsync(IEntity symbol, AssemblyList assemblyList, ILanguage language, CancellationToken cancellationToken)
+		{
+			ArgumentNullException.ThrowIfNull(symbol);
+			ArgumentNullException.ThrowIfNull(assemblyList);
+			ArgumentNullException.ThrowIfNull(language);
+			var analyzers = new List<(string Header, IAnalyzer Analyzer)>();
+			foreach (var factory in AppEnv.AppComposition.TryGetExport()?.Analyzers ?? [])
+			{
+				if (factory.Metadata?.Header is { } header && (RelationHeaders.Contains(header) || UsageHeaders.Contains(header)))
+					analyzers.Add((header, factory.CreateExport().Value));
+			}
+			return Task.Run(() => Find(symbol, assemblyList, language, analyzers, cancellationToken), cancellationToken);
+		}
+
+		internal static RenameUsageResult Find(IEntity symbol, AssemblyList assemblyList, ILanguage language,
+			IReadOnlyList<(string Header, IAnalyzer Analyzer)> analyzers, CancellationToken cancellationToken)
+		{
+			symbol = Normalize(symbol);
+			var context = new AnalyzerContext {
+				AssemblyList = assemblyList,
+				Language = language,
+				CancellationToken = cancellationToken,
+			};
+			var seenDeclarations = new HashSet { EntityKey.Of(symbol) };
+			var declarations = new List { new(symbol, "Declaration") };
+			var related = new List();
+
+			foreach (var (header, analyzer) in analyzers.Where(a => RelationHeaders.Contains(a.Header)))
+			{
+				foreach (var entity in Run(analyzer, symbol, context))
+				{
+					if (seenDeclarations.Add(EntityKey.Of(entity)))
+						related.Add(new RenameUsage(entity, header));
+				}
+			}
+			if (symbol is ITypeDefinition type)
+			{
+				foreach (var ctor in type.Methods.Where(m => m.IsConstructor))
+				{
+					if (seenDeclarations.Add(EntityKey.Of(ctor)))
+						related.Add(new RenameUsage(ctor, "Constructor"));
+				}
+			}
+
+			// Whose code refers to the symbol or to a declaration sharing its name. A type's
+			// constructors are only referenced together with the type, so they need no scan of their own.
+			var targets = new List { symbol };
+			if (symbol is not ITypeDefinition)
+				targets.AddRange(related.Select(r => r.Entity));
+			var seenUsages = new HashSet();
+			var usages = new List();
+			foreach (var target in targets.SelectMany(WithAccessors))
+			{
+				foreach (var (header, analyzer) in analyzers.Where(a => UsageHeaders.Contains(a.Header)))
+				{
+					foreach (var entity in Run(analyzer, target, context))
+					{
+						if (seenUsages.Add(EntityKey.Of(entity)))
+							usages.Add(new RenameUsage(entity, header));
+					}
+				}
+			}
+			return new RenameUsageResult(symbol, declarations, related, usages);
+		}
+
+		static List Run(IAnalyzer analyzer, IEntity target, AnalyzerContext context)
+		{
+			context.CancellationToken.ThrowIfCancellationRequested();
+			if (!analyzer.Show(target))
+				return new List();
+			return analyzer.Analyze(target, context).OfType().Select(Normalize).ToList();
+		}
+
+		static IEnumerable WithAccessors(IEntity entity)
+		{
+			yield return entity;
+			switch (entity)
+			{
+				case IProperty property:
+					if (property.Getter != null)
+						yield return property.Getter;
+					if (property.Setter != null)
+						yield return property.Setter;
+					break;
+				case IEvent ev:
+					if (ev.AddAccessor != null)
+						yield return ev.AddAccessor;
+					if (ev.RemoveAccessor != null)
+						yield return ev.RemoveAccessor;
+					if (ev.InvokeAccessor != null)
+						yield return ev.InvokeAccessor;
+					break;
+			}
+		}
+
+		/// Maps a specialized member to its definition; other entities are returned as is.
+		public static IEntity Normalize(IEntity entity) => entity is IMember member ? member.MemberDefinition : entity;
+
+		/// 
+		/// Resolves a code reference (a resolved member or type) to the entity a rename would target,
+		/// or null when it does not denote a renamable entity.
+		/// 
+		public static IEntity? ResolveEntity(object? reference) => reference switch {
+			IMember member => member.MemberDefinition,
+			IType type => type.GetDefinition(),
+			IEntity entity => entity,
+			_ => null,
+		};
+
+		// Entities from different analyzer type systems compare by module and token; generated
+		// entities without a token fall back to object identity.
+		readonly record struct EntityKey(MetadataFile? Module, EntityHandle Token, object? Identity)
+		{
+			public static EntityKey Of(IEntity entity)
+				=> entity.MetadataToken.IsNil || entity.ParentModule?.MetadataFile is null
+					? new EntityKey(null, default, entity)
+					: new EntityKey(entity.ParentModule.MetadataFile, entity.MetadataToken, null);
+		}
+	}
+
+	/// Renders a  as a text report whose rows link to their entities.
+	public static class RenameUsageReport
+	{
+		const ConversionFlags RowFlags = ConversionFlags.ShowParameterList | ConversionFlags.ShowTypeParameterList;
+
+		public static AvaloniaEditTextOutput Write(RenameUsageResult result, Language language, string title)
+		{
+			ArgumentNullException.ThrowIfNull(result);
+			ArgumentNullException.ThrowIfNull(language);
+			var output = new AvaloniaEditTextOutput { Title = title };
+			var symbolText = language.EntityToString(result.Symbol,
+				ConversionFlags.ShowDeclaringType | ConversionFlags.UseFullyQualifiedEntityNames | RowFlags);
+			var all = result.DeclarationEntries.Concat(result.RelatedDeclarationEntries).Concat(result.UsageEntries);
+			int assemblies = all.Select(u => AssemblyName(u.Entity)).Distinct().Count();
+			WriteLine(output, $"// {Resources.FindUsagesForRename}: {symbolText}");
+			WriteLine(output, $"// Read-only preview, nothing is renamed: {result.DeclarationEntries.Count} declaration(s), "
+				+ $"{result.RelatedDeclarationEntries.Count} related declaration(s), "
+				+ $"{result.UsageEntries.Count} usage(s) in {assemblies} assembly(ies).");
+			WriteSection(output, language, "Declaration", result.DeclarationEntries);
+			WriteSection(output, language, "Related declarations", result.RelatedDeclarationEntries);
+			WriteSection(output, language, "Usages", result.UsageEntries);
+			return output;
+		}
+
+		static void WriteSection(AvaloniaEditTextOutput output, Language language, string heading, IReadOnlyList entries)
+		{
+			output.WriteLine();
+			WriteLine(output, $"{heading} ({entries.Count})");
+			output.Indent();
+			foreach (var byAssembly in entries.GroupBy(e => AssemblyName(e.Entity)).OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase))
+			{
+				WriteLine(output, byAssembly.Key);
+				output.Indent();
+				foreach (var byType in byAssembly.GroupBy(e => GroupName(e.Entity)).OrderBy(g => g.Key, StringComparer.Ordinal))
+				{
+					WriteLine(output, byType.Key);
+					output.Indent();
+					foreach (var entry in byType.OrderBy(e => e.Entity.Name, StringComparer.Ordinal))
+					{
+						WriteEntity(output, language, entry.Entity);
+						output.Write("  // " + entry.Relation);
+						output.WriteLine();
+					}
+					output.Unindent();
+				}
+				output.Unindent();
+			}
+			output.Unindent();
+		}
+
+		internal static void WriteLine(AvaloniaEditTextOutput output, string text)
+		{
+			output.Write(text);
+			output.WriteLine();
+		}
+
+		static void WriteEntity(AvaloniaEditTextOutput output, Language language, IEntity entity)
+		{
+			var text = language.EntityToString(entity, RowFlags);
+			switch (entity)
+			{
+				case IMember member:
+					output.WriteReference(member, text);
+					break;
+				case ITypeDefinition type:
+					output.WriteReference(type, text);
+					break;
+				default:
+					output.Write(text);
+					break;
+			}
+		}
+
+		static string AssemblyName(IEntity entity)
+			=> entity.ParentModule?.AssemblyName ?? entity.ParentModule?.MetadataFile?.Name ?? "?";
+
+		// Members group under their declaring type; a type groups under its declaring type, or under
+		// its namespace when it is a top-level type.
+		static string GroupName(IEntity entity) => entity switch {
+			ITypeDefinition { DeclaringTypeDefinition: { } outer } => outer.FullName,
+			ITypeDefinition type => string.IsNullOrEmpty(type.Namespace) ? "" : type.Namespace,
+			_ => entity.DeclaringTypeDefinition?.FullName ?? string.Empty,
+		};
+	}
+
+	/// Opens the rename preview for an entity in a new, frozen report tab.
+	[Export]
+	[Shared]
+	[method: ImportingConstructor]
+	public sealed class FindUsagesForRenameService(DockWorkspace dockWorkspace, AssemblyTreeModel assemblyTreeModel, LanguageService languageService)
+	{
+		/// 
+		/// Runs the analysis off the UI thread inside a new tab (cancellable through the tab's wait
+		/// overlay) and shows the report there. Clicking a row navigates to it and highlights the
+		/// renamed symbol in the navigated-to code.
+		/// 
+		public async Task ShowAsync(IEntity entity)
+		{
+			ArgumentNullException.ThrowIfNull(entity);
+			if (assemblyTreeModel.AssemblyList is not { } assemblyList)
+				return;
+			var symbol = RenameUsageFinder.Normalize(entity);
+			var language = languageService.CurrentLanguage;
+			var title = $"{Resources.FindUsagesForRename}: {symbol.Name}";
+			var content = new DecompilerTabPageModel { Language = language, Title = title };
+			content.NavigateRequested += (_, e) =>
+				MessageBus.Send(this, new NavigateToReferenceEventArgs(e.Reference, symbol, e.InNewTabPage));
+			dockWorkspace.OpenNewTab(content);
+			try
+			{
+				var output = await content.RunWithCancellation(async token => {
+					var result = await RenameUsageFinder.FindAsync(symbol, assemblyList, language, token).ConfigureAwait(false);
+					return RenameUsageReport.Write(result, language, title);
+				}, title).ConfigureAwait(true);
+				content.ShowText(output);
+			}
+			catch (OperationCanceledException)
+			{
+				var cancelled = new AvaloniaEditTextOutput { Title = title };
+				RenameUsageReport.WriteLine(cancelled, Resources.OperationWasCancelled);
+				content.ShowText(cancelled);
+			}
+		}
+
+		/// The entity a rename invoked from  targets, or null.
+		public static IEntity? GetTarget(TextViewContext context)
+		{
+			ArgumentNullException.ThrowIfNull(context);
+			if (context.SelectedTreeNodes is { Length: > 0 } nodes)
+				return nodes.Length == 1 && nodes[0] is IMemberTreeNode { Member: { } member } ? member : null;
+			if (context.Reference is { Kind: not ReferenceMode.LocalHighlight and not ReferenceMode.HoverOnly } segment)
+				return RenameUsageFinder.ResolveEntity(segment.Reference);
+			return null;
+		}
+	}
+
+	/// 
+	/// Navigate > Find Usages for Rename (F2): the read-only rename preview for the symbol under
+	/// the caret of the focused code view, or else for the member selected in the assembly tree.
+	/// 
+	[ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.FindUsagesForRename), MenuCategory = "Rename", MenuOrder = 70, InputGestureText = "F2")]
+	[Shared]
+	[method: ImportingConstructor]
+	sealed class FindUsagesForRenameCommand(FindUsagesForRenameService service, AssemblyTreeModel assemblyTreeModel) : SimpleCommand
+	{
+		public override bool CanExecute(object? parameter) => GetTarget() != null;
+
+		public override void Execute(object? parameter)
+		{
+			if (GetTarget() is { } entity)
+				service.ShowAsync(entity).HandleExceptions();
+		}
+
+		IEntity? GetTarget()
+		{
+			if (ActiveTextViewLocator.Focused() is { } view)
+			{
+				return view.GetReferenceSegmentAtCaret() is { } segment
+					? FindUsagesForRenameService.GetTarget(new TextViewContext { TextView = view, Reference = segment })
+					: null;
+			}
+			return assemblyTreeModel.SelectedItem is IMemberTreeNode { Member: { } member } ? member : null;
+		}
+	}
+
+	/// Context-menu "Find Usages for Rename" on a code reference or a single tree member.
+	[ExportContextMenuEntry(Header = nameof(Resources.FindUsagesForRename), Category = "Navigation", InputGestureText = "F2", Order = 420)]
+	[Shared]
+	[method: ImportingConstructor]
+	public sealed class FindUsagesForRenameContextMenuEntry(FindUsagesForRenameService service) : IContextMenuEntry
+	{
+		public bool IsVisible(TextViewContext context) => FindUsagesForRenameService.GetTarget(context) != null;
+
+		public bool IsEnabled(TextViewContext context) => true;
+
+		public void Execute(TextViewContext context)
+		{
+			if (FindUsagesForRenameService.GetTarget(context) is { } entity)
+				service.ShowAsync(entity).HandleExceptions();
+		}
+	}
+}
diff --git a/ILSpy/Navigation/GoToContextMenuEntries.cs b/ILSpy/Navigation/GoToContextMenuEntries.cs
new file mode 100644
index 00000000000..2d4be709966
--- /dev/null
+++ b/ILSpy/Navigation/GoToContextMenuEntries.cs
@@ -0,0 +1,156 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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.Composition;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpy.Properties;
+
+using ICSharpCode.ILSpy.AssemblyTree;
+using ICSharpCode.ILSpy.Docking;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+
+namespace ICSharpCode.ILSpy.Navigation
+{
+	/// 
+	/// Shared behavior of the go-to context-menu entries: they act on the entity of a right-clicked
+	/// code reference, or of a single selected tree node, and hand it to .
+	/// 
+	public abstract class GoToContextMenuEntry : IContextMenuEntry
+	{
+		/// The category every navigation action is exported under, so the Navigate To popup lists it.
+		public const string NavigationCategory = "Navigation";
+
+		readonly GoToNavigator navigator;
+
+		protected GoToContextMenuEntry(GoToNavigator navigator)
+		{
+			this.navigator = navigator;
+		}
+
+		protected abstract GoToKind Kind { get; }
+
+		public bool IsVisible(TextViewContext context)
+			=> GetTarget(context) is { } entity && SymbolHierarchy.IsApplicable(Kind, entity);
+
+		public bool IsEnabled(TextViewContext context) => true;
+
+		public void Execute(TextViewContext context)
+		{
+			if (GetTarget(context) is { } entity)
+				navigator.GoTo(Kind, entity, ActiveNavigationContext.AnchorOf(context));
+		}
+
+		/// The entity a go-to command acts on in , or null.
+		public static IEntity? GetTarget(TextViewContext context)
+		{
+			if (context.SelectedTreeNodes is { Length: > 0 } nodes)
+				return nodes.Length == 1 && nodes[0] is IMemberTreeNode { Member: { } member } ? member : null;
+			return context.Reference?.Reference as IEntity;
+		}
+	}
+
+	[ExportContextMenuEntry(Header = nameof(Resources.GoToDeclaration), Category = NavigationCategory, Order = 151, InputGestureText = "F12")]
+	[Shared]
+	[method: ImportingConstructor]
+	public sealed class GoToDeclarationContextMenuEntry(GoToNavigator navigator) : GoToContextMenuEntry(navigator)
+	{
+		protected override GoToKind Kind => GoToKind.Declaration;
+	}
+
+	[ExportContextMenuEntry(Header = nameof(Resources.GoToImplementation), Category = NavigationCategory, Order = 152, InputGestureText = "Ctrl+F12")]
+	[Shared]
+	[method: ImportingConstructor]
+	public sealed class GoToImplementationContextMenuEntry(GoToNavigator navigator) : GoToContextMenuEntry(navigator)
+	{
+		protected override GoToKind Kind => GoToKind.Implementation;
+	}
+
+	[ExportContextMenuEntry(Header = nameof(Resources.GoToBaseSymbols), Category = NavigationCategory, Order = 153, InputGestureText = "Alt+Home")]
+	[Shared]
+	[method: ImportingConstructor]
+	public sealed class GoToBaseSymbolsContextMenuEntry(GoToNavigator navigator) : GoToContextMenuEntry(navigator)
+	{
+		protected override GoToKind Kind => GoToKind.BaseSymbols;
+	}
+
+	[ExportContextMenuEntry(Header = nameof(Resources.GoToDerivedSymbols), Category = NavigationCategory, Order = 154, InputGestureText = "Alt+End")]
+	[Shared]
+	[method: ImportingConstructor]
+	public sealed class GoToDerivedSymbolsContextMenuEntry(GoToNavigator navigator) : GoToContextMenuEntry(navigator)
+	{
+		protected override GoToKind Kind => GoToKind.DerivedSymbols;
+	}
+
+	/// 
+	/// Selects and reveals, in the assembly tree, the node of the symbol under the caret or, when
+	/// the caret is not on a symbol, the node the document was decompiled from.
+	/// 
+	[ExportContextMenuEntry(Header = nameof(Resources.LocateInAssemblyExplorer), Category = GoToContextMenuEntry.NavigationCategory, Order = 160, InputGestureText = "Shift+Alt+L")]
+	[Shared]
+	[method: ImportingConstructor]
+	public sealed class LocateInAssemblyExplorerContextMenuEntry(
+		AssemblyTreeModel assemblyTreeModel,
+		DockWorkspace dockWorkspace,
+		GoToNavigator navigator) : IContextMenuEntry
+	{
+		public bool IsVisible(TextViewContext context)
+			=> context.TextView != null
+				&& (context.Reference?.Reference is IEntity || DocumentOf(context)?.CurrentNode != null);
+
+		public bool IsEnabled(TextViewContext context) => true;
+
+		public void Execute(TextViewContext context) => Locate(context);
+
+		/// Runs the locate action; returns false (after showing a notice) when there is nothing to locate.
+		public bool Locate(TextViewContext context)
+		{
+			var anchor = ActiveNavigationContext.AnchorOf(context);
+			ILSpyTreeNode? node;
+			if (context.Reference?.Reference is IEntity entity)
+			{
+				node = assemblyTreeModel.FindTreeNode(entity);
+				if (node == null)
+				{
+					navigator.ShowNotice($"{navigator.Describe(entity)} is not in the assembly list", anchor);
+					return false;
+				}
+			}
+			else
+			{
+				node = DocumentOf(context)?.CurrentNode;
+				if (node == null)
+				{
+					navigator.ShowNotice("Nothing to locate in the assembly explorer", anchor);
+					return false;
+				}
+			}
+			assemblyTreeModel.SelectNode(node);
+			dockWorkspace.ShowToolPane(AssemblyTreeModel.PaneContentId);
+			return true;
+		}
+
+		// The document shown by the context's text view, or the active decompiler tab when the
+		// context does not come from a text view.
+		DecompilerTabPageModel? DocumentOf(TextViewContext context)
+			=> context.TextView != null
+				? context.TextView.DataContext as DecompilerTabPageModel
+				: dockWorkspace.ActiveDecompilerTab;
+	}
+}
diff --git a/ILSpy/Navigation/GoToLine.cs b/ILSpy/Navigation/GoToLine.cs
new file mode 100644
index 00000000000..3535822c59e
--- /dev/null
+++ b/ILSpy/Navigation/GoToLine.cs
@@ -0,0 +1,184 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+
+using System;
+using System.Composition;
+using System.Linq;
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Docking;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TextView;
+
+namespace ICSharpCode.ILSpy.Navigation
+{
+	/// Parses Go to Line input: "line", "line:column" or "line,column", all 1-based.
+	public static class GoToLineInput
+	{
+		public static bool TryParse(string? text, out int line, out int? column)
+		{
+			line = 0;
+			column = null;
+			if (string.IsNullOrWhiteSpace(text))
+				return false;
+			var parts = text.Trim().Split(':', ',');
+			if (parts.Length > 2 || !TryParsePositive(parts[0], out line))
+				return false;
+			if (parts.Length == 2)
+			{
+				if (!TryParsePositive(parts[1], out int col))
+					return false;
+				column = col;
+			}
+			return true;
+		}
+
+		static bool TryParsePositive(string text, out int value)
+			=> int.TryParse(text.Trim(), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out value)
+				&& value > 0;
+	}
+
+	/// 
+	/// The small Go to Line prompt: a text box prefilled with the current line. Enter jumps (and
+	/// closes) when the input parses, Escape closes.
+	/// 
+	public sealed class GoToLineWindow : Window
+	{
+		readonly TextBox input;
+		readonly TextBlock hint;
+		readonly Func goToLine;
+
+		public GoToLineWindow(int lineCount, int currentLine, Func goToLine)
+		{
+			this.goToLine = goToLine ?? throw new ArgumentNullException(nameof(goToLine));
+			Title = Properties.Resources.GoToLine.TrimEnd('.');
+			SizeToContent = SizeToContent.WidthAndHeight;
+			CanResize = false;
+			ShowInTaskbar = false;
+			WindowStartupLocation = WindowStartupLocation.CenterOwner;
+
+			hint = new TextBlock { Text = $"Line number (1 - {lineCount}), optionally followed by :column" };
+			input = new TextBox { Text = currentLine.ToString(System.Globalization.CultureInfo.InvariantCulture), MinWidth = 280 };
+			var ok = new Button { Content = Properties.Resources.OK, MinWidth = 80, IsDefault = true };
+			var cancel = new Button { Content = Properties.Resources.Cancel, MinWidth = 80, IsCancel = true };
+			ok.Click += (_, _) => Accept();
+			cancel.Click += (_, _) => Close();
+			Content = new StackPanel {
+				Margin = new Thickness(12),
+				Spacing = 8,
+				Children = {
+					hint,
+					input,
+					new StackPanel {
+						Orientation = Orientation.Horizontal,
+						HorizontalAlignment = HorizontalAlignment.Right,
+						Spacing = 8,
+						Children = { ok, cancel },
+					},
+				},
+			};
+			AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
+			Opened += (_, _) => Dispatcher.UIThread.Post(() => {
+				input.Focus();
+				input.SelectAll();
+			});
+		}
+
+		/// The text typed into the prompt.
+		public string? InputText {
+			get => input.Text;
+			set => input.Text = value;
+		}
+
+		/// Jumps to the entered position and closes; returns false (and keeps the prompt
+		/// open) when the input does not parse.
+		public bool Accept()
+		{
+			if (!GoToLineInput.TryParse(input.Text, out int line, out int? column))
+			{
+				hint.Foreground = Brushes.Red;
+				return false;
+			}
+			Close();
+			return goToLine(line, column);
+		}
+
+		void OnPreviewKeyDown(object? sender, KeyEventArgs e)
+		{
+			if (e.Key == Key.Enter)
+			{
+				Accept();
+				e.Handled = true;
+			}
+			else if (e.Key == Key.Escape)
+			{
+				Close();
+				e.Handled = true;
+			}
+		}
+	}
+
+	/// Finds the main window and the decompiler view the in-file commands act on.
+	static class ActiveTextViewLocator
+	{
+		public static Window? MainWindow
+			=> UiContext.MainWindow ?? AppComposition.TryGetExport();
+
+		/// The decompiler view holding keyboard focus, if any.
+		public static DecompilerTextView? Focused() => DecompilerTextView.FocusedView;
+
+		/// The focused decompiler view, or else the one showing the active document.
+		public static DecompilerTextView? FocusedOrActive(Window? window, DockWorkspace dockWorkspace)
+		{
+			if (Focused() is { } focused)
+				return focused;
+			if (window == null || dockWorkspace.ActiveContentTabPage?.Content is not DecompilerTabPageModel content)
+				return null;
+			return window.GetVisualDescendants().OfType()
+				.FirstOrDefault(v => ReferenceEquals(v.DataContext, content) && v.IsEffectivelyVisible);
+		}
+	}
+
+	/// 
+	/// Navigate > Go to Line. Ctrl+G is bound inside the code view only (see
+	/// ), because the same gesture is "Go to token" in metadata
+	/// grids; the menu entry therefore carries no window-wide gesture.
+	/// 
+	[ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.GoToLine), MenuCategory = "InFile", MenuOrder = 60)]
+	[Shared]
+	[method: ImportingConstructor]
+	sealed class GoToLineCommand(DockWorkspace dockWorkspace) : SimpleCommand
+	{
+		public override bool CanExecute(object? parameter)
+			=> dockWorkspace.ActiveContentTabPage?.Content is DecompilerTabPageModel { Text.Length: > 0 };
+
+		public override void Execute(object? parameter)
+			=> ActiveTextViewLocator.FocusedOrActive(ActiveTextViewLocator.MainWindow, dockWorkspace)?.ShowGoToLinePrompt();
+	}
+}
diff --git a/ILSpy/Navigation/GoToNavigator.cs b/ILSpy/Navigation/GoToNavigator.cs
new file mode 100644
index 00000000000..ad9056aa50a
--- /dev/null
+++ b/ILSpy/Navigation/GoToNavigator.cs
@@ -0,0 +1,202 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Collections.Generic;
+using System.Composition;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Threading;
+
+using ICSharpCode.Decompiler.Output;
+using ICSharpCode.Decompiler.TypeSystem;
+
+using ICSharpCode.ILSpy.AssemblyTree;
+using ICSharpCode.ILSpy.Languages;
+
+namespace ICSharpCode.ILSpy.Navigation
+{
+	/// 
+	/// Runs a go-to command and presents its outcome: one target is navigated to directly through
+	/// the assembly tree (so the jump lands in the navigation history like any other selection),
+	/// several targets open a , and none shows a short notice.
+	/// 
+	[Export]
+	[Shared]
+	public sealed class GoToNavigator
+	{
+		static readonly TimeSpan NoticeDuration = TimeSpan.FromSeconds(3);
+
+		readonly AssemblyTreeModel assemblyTreeModel;
+		readonly LanguageService languageService;
+		Flyout? noticeFlyout;
+
+		[ImportingConstructor]
+		public GoToNavigator(AssemblyTreeModel assemblyTreeModel, LanguageService languageService)
+		{
+			this.assemblyTreeModel = assemblyTreeModel;
+			this.languageService = languageService;
+		}
+
+		/// The most recently started go-to operation; completes once its outcome is presented.
+		public Task LastOperation { get; private set; } = Task.CompletedTask;
+
+		/// The chooser currently open, or null.
+		public NavigationChooser? ActiveChooser { get; private set; }
+
+		/// The text of the most recent notice.
+		public string? LastNotice { get; private set; }
+
+		/// Starts resolving  for  and presents the result.
+		public void GoTo(GoToKind kind, IEntity entity, Control? anchor)
+		{
+			ArgumentNullException.ThrowIfNull(entity);
+			LastOperation = GoToAsync(kind, entity, anchor);
+		}
+
+		async Task GoToAsync(GoToKind kind, IEntity entity, Control? anchor)
+		{
+			IReadOnlyList targets;
+			if (kind == GoToKind.Declaration)
+			{
+				targets = new[] { entity };
+			}
+			else
+			{
+				var assemblyList = assemblyTreeModel.AssemblyList;
+				if (assemblyList == null)
+					return;
+				// The inheritor search scans every module in scope; keep it off the UI thread.
+				targets = await Task.Run(() => SymbolHierarchy.Find(kind, entity, assemblyList, CancellationToken.None));
+			}
+
+			switch (targets.Count)
+			{
+				case 0:
+					ShowNotice(NothingFoundText(kind), anchor);
+					break;
+				case 1:
+					NavigateTo(targets[0], anchor);
+					break;
+				default:
+					ShowChoices(ChooserTitle(kind, entity),
+						targets.Select(t => new NavigationChoice(Describe(t), () => NavigateTo(t, anchor))).ToList(),
+						anchor);
+					break;
+			}
+		}
+
+		/// Selects the tree node of , or shows a notice when it is not in the assembly list.
+		public void NavigateTo(IEntity entity, Control? anchor)
+		{
+			var node = assemblyTreeModel.FindTreeNode(entity);
+			if (node == null)
+			{
+				ShowNotice($"{Describe(entity)} is not in the assembly list", anchor);
+				return;
+			}
+			assemblyTreeModel.SelectNode(node);
+		}
+
+		/// Opens a chooser over , replacing any chooser already open.
+		public NavigationChooser ShowChoices(string title, IReadOnlyList choices, Control? anchor)
+		{
+			ActiveChooser?.Close();
+			var chooser = new NavigationChooser(title, choices);
+			chooser.Closed += (_, _) => {
+				if (ReferenceEquals(ActiveChooser, chooser))
+					ActiveChooser = null;
+			};
+			ActiveChooser = chooser;
+			if (ResolveAnchor(anchor) is { } target)
+				chooser.Show(target);
+			return chooser;
+		}
+
+		/// Shows  in a small non-modal flyout that closes by itself.
+		public void ShowNotice(string text, Control? anchor)
+		{
+			LastNotice = text;
+			noticeFlyout?.Hide();
+			var target = ResolveAnchor(anchor);
+			if (target == null)
+				return;
+			var flyout = new Flyout {
+				Content = new TextBlock { Text = text, Margin = new Thickness(4) },
+				Placement = PlacementMode.Center,
+			};
+			noticeFlyout = flyout;
+			flyout.ShowAt(target);
+			DispatcherTimer.RunOnce(() => {
+				flyout.Hide();
+				if (ReferenceEquals(noticeFlyout, flyout))
+					noticeFlyout = null;
+			}, NoticeDuration);
+		}
+
+		/// Display text for a navigation target: its signature plus the assembly it lives in.
+		public string Describe(IEntity entity)
+		{
+			string text;
+			try
+			{
+				text = languageService.CurrentLanguage.EntityToString(entity,
+					ConversionFlags.ShowDeclaringType | ConversionFlags.UseFullyQualifiedEntityNames);
+			}
+			catch (Exception)
+			{
+				text = entity.FullName;
+			}
+			var assemblyName = entity.ParentModule?.AssemblyName;
+			return string.IsNullOrEmpty(assemblyName) ? text : $"{text}  [{assemblyName}]";
+		}
+
+		static string NothingFoundText(GoToKind kind) => kind switch {
+			GoToKind.Implementation => "No implementations found",
+			GoToKind.BaseSymbols => "No base symbols found",
+			GoToKind.DerivedSymbols => "No derived symbols found",
+			_ => "No declaration found",
+		};
+
+		static string ChooserTitle(GoToKind kind, IEntity entity) => kind switch {
+			GoToKind.Implementation => $"Implementations of {entity.Name}",
+			GoToKind.BaseSymbols => $"Base symbols of {entity.Name}",
+			GoToKind.DerivedSymbols => $"Derived symbols of {entity.Name}",
+			_ => entity.Name,
+		};
+
+		/// 
+		/// The control a popup is shown over:  when it is part of a live
+		/// visual tree, otherwise the application's main window.
+		/// 
+		internal static Control? ResolveAnchor(Control? preferred)
+		{
+			if (preferred != null && TopLevel.GetTopLevel(preferred) != null)
+				return preferred;
+			if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } mainWindow })
+				return mainWindow;
+			var window = AppEnv.AppComposition.TryGetExport();
+			return window is { IsVisible: true } ? window : null;
+		}
+	}
+}
diff --git a/ILSpy/Navigation/NavigateMenuCommands.cs b/ILSpy/Navigation/NavigateMenuCommands.cs
new file mode 100644
index 00000000000..3d07d54b111
--- /dev/null
+++ b/ILSpy/Navigation/NavigateMenuCommands.cs
@@ -0,0 +1,208 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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.Composition;
+using System.Linq;
+
+using ICSharpCode.ILSpy.Properties;
+
+using ICSharpCode.ILSpy.Analyzers;
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.AssemblyTree;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Docking;
+
+namespace ICSharpCode.ILSpy.Navigation
+{
+	// Main-menu (Navigate) counterparts of the navigation context-menu entries. They act on the
+	// surface the user was working in (see ActiveNavigationContext): the symbol under the caret of
+	// the last focused decompiler view, otherwise the assembly-tree selection. CanExecute stays true
+	// because caret moves raise no command re-query; when there is nothing to act on, a short notice
+	// says so instead.
+
+	/// Base for the Navigate-menu go-to commands.
+	public abstract class GoToMenuCommand : SimpleCommand
+	{
+		readonly AssemblyTreeModel assemblyTreeModel;
+		readonly GoToNavigator navigator;
+
+		protected GoToMenuCommand(AssemblyTreeModel assemblyTreeModel, GoToNavigator navigator)
+		{
+			this.assemblyTreeModel = assemblyTreeModel;
+			this.navigator = navigator;
+			ActiveNavigationContext.EnsureFocusTracking();
+		}
+
+		protected abstract GoToKind Kind { get; }
+
+		public override void Execute(object? parameter)
+		{
+			var context = ActiveNavigationContext.Current(assemblyTreeModel);
+			var anchor = ActiveNavigationContext.AnchorOf(context);
+			if (GoToContextMenuEntry.GetTarget(context) is not { } entity)
+			{
+				navigator.ShowNotice("No symbol to navigate from", anchor);
+				return;
+			}
+			if (!SymbolHierarchy.IsApplicable(Kind, entity))
+			{
+				navigator.ShowNotice(Kind switch {
+					GoToKind.BaseSymbols => "No base symbols found",
+					GoToKind.DerivedSymbols => "No derived symbols found",
+					_ => "No implementations found",
+				}, anchor);
+				return;
+			}
+			navigator.GoTo(Kind, entity, anchor);
+		}
+	}
+
+	[ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.GoToDeclaration), MenuCategory = "GoTo", MenuOrder = 10, InputGestureText = "F12")]
+	[Shared]
+	[method: ImportingConstructor]
+	sealed class GoToDeclarationCommand(AssemblyTreeModel assemblyTreeModel, GoToNavigator navigator)
+		: GoToMenuCommand(assemblyTreeModel, navigator)
+	{
+		protected override GoToKind Kind => GoToKind.Declaration;
+	}
+
+	[ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.GoToImplementation), MenuCategory = "GoTo", MenuOrder = 11, InputGestureText = "Ctrl+F12")]
+	[Shared]
+	[method: ImportingConstructor]
+	sealed class GoToImplementationCommand(AssemblyTreeModel assemblyTreeModel, GoToNavigator navigator)
+		: GoToMenuCommand(assemblyTreeModel, navigator)
+	{
+		protected override GoToKind Kind => GoToKind.Implementation;
+	}
+
+	[ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.GoToBaseSymbols), MenuCategory = "GoTo", MenuOrder = 12, InputGestureText = "Alt+Home")]
+	[Shared]
+	[method: ImportingConstructor]
+	sealed class GoToBaseSymbolsCommand(AssemblyTreeModel assemblyTreeModel, GoToNavigator navigator)
+		: GoToMenuCommand(assemblyTreeModel, navigator)
+	{
+		protected override GoToKind Kind => GoToKind.BaseSymbols;
+	}
+
+	[ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.GoToDerivedSymbols), MenuCategory = "GoTo", MenuOrder = 13, InputGestureText = "Alt+End")]
+	[Shared]
+	[method: ImportingConstructor]
+	sealed class GoToDerivedSymbolsCommand(AssemblyTreeModel assemblyTreeModel, GoToNavigator navigator)
+		: GoToMenuCommand(assemblyTreeModel, navigator)
+	{
+		protected override GoToKind Kind => GoToKind.DerivedSymbols;
+	}
+
+	/// 
+	/// Navigate To: a chooser over every context-menu entry exported under the Navigation category
+	/// that is visible and enabled for the current context; picking one runs it on that context.
+	/// 
+	[ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.NavigateTo), MenuCategory = "NavigateTo", MenuOrder = 0, InputGestureText = "Ctrl+Shift+G")]
+	[Shared]
+	sealed class NavigateToCommand : SimpleCommand
+	{
+		readonly AssemblyTreeModel assemblyTreeModel;
+		readonly GoToNavigator navigator;
+		readonly ContextMenuEntryRegistry registry;
+
+		[ImportingConstructor]
+		public NavigateToCommand(AssemblyTreeModel assemblyTreeModel, GoToNavigator navigator, ContextMenuEntryRegistry registry)
+		{
+			this.assemblyTreeModel = assemblyTreeModel;
+			this.navigator = navigator;
+			this.registry = registry;
+			ActiveNavigationContext.EnsureFocusTracking();
+		}
+
+		public override void Execute(object? parameter)
+		{
+			var context = ActiveNavigationContext.Current(assemblyTreeModel);
+			var anchor = ActiveNavigationContext.AnchorOf(context);
+			var choices = registry.Entries
+				.Where(e => e.Metadata.Category == GoToContextMenuEntry.NavigationCategory)
+				.OrderBy(e => e.Metadata.Order)
+				.Where(e => e.Value.IsVisible(context) && e.Value.IsEnabled(context))
+				.Select(e => new NavigationChoice(
+					ResourceHelper.GetString(e.Metadata.Header).Replace("_", ""),
+					() => e.Value.Execute(context)))
+				.ToList();
+			if (choices.Count == 0)
+			{
+				navigator.ShowNotice("No navigation available here", anchor);
+				return;
+			}
+			navigator.ShowChoices(ResourceHelper.GetString(nameof(Resources.NavigateTo)), choices, anchor);
+		}
+	}
+
+	[ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.LocateInAssemblyExplorer), MenuCategory = "Locate", MenuOrder = 20, InputGestureText = "Shift+Alt+L")]
+	[Shared]
+	sealed class LocateInAssemblyExplorerCommand : SimpleCommand
+	{
+		readonly AssemblyTreeModel assemblyTreeModel;
+		readonly LocateInAssemblyExplorerContextMenuEntry locate;
+
+		[ImportingConstructor]
+		public LocateInAssemblyExplorerCommand(AssemblyTreeModel assemblyTreeModel, DockWorkspace dockWorkspace, GoToNavigator navigator)
+		{
+			this.assemblyTreeModel = assemblyTreeModel;
+			locate = new LocateInAssemblyExplorerContextMenuEntry(assemblyTreeModel, dockWorkspace, navigator);
+			ActiveNavigationContext.EnsureFocusTracking();
+		}
+
+		// From the tree the selection is not passed on: locating works on the active document.
+		public override void Execute(object? parameter)
+		{
+			var context = ActiveNavigationContext.Current(assemblyTreeModel);
+			locate.Locate(context.TextView != null ? context : new TextViewContext());
+		}
+	}
+
+	/// Find Usages: analyzes the current symbol, like the Analyze context-menu entry.
+	[ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.Analyze), MenuCategory = "Analyze", MenuOrder = 30, InputGestureText = "Shift+F12")]
+	[Shared]
+	sealed class FindUsagesCommand : SimpleCommand
+	{
+		readonly AssemblyTreeModel assemblyTreeModel;
+		readonly AnalyzerTreeViewModel analyzerTreeViewModel;
+		readonly DockWorkspace dockWorkspace;
+		readonly GoToNavigator navigator;
+
+		[ImportingConstructor]
+		public FindUsagesCommand(AssemblyTreeModel assemblyTreeModel, AnalyzerTreeViewModel analyzerTreeViewModel,
+			DockWorkspace dockWorkspace, GoToNavigator navigator)
+		{
+			this.assemblyTreeModel = assemblyTreeModel;
+			this.analyzerTreeViewModel = analyzerTreeViewModel;
+			this.dockWorkspace = dockWorkspace;
+			this.navigator = navigator;
+			ActiveNavigationContext.EnsureFocusTracking();
+		}
+
+		public override void Execute(object? parameter)
+		{
+			var context = ActiveNavigationContext.Current(assemblyTreeModel);
+			if (!AnalyzeContextMenuEntry.IsVisibleForContext(context)
+				|| !AnalyzeContextMenuEntry.IsEnabledForContext(context)
+				|| !AnalyzeContextMenuEntry.Analyze(context, analyzerTreeViewModel, dockWorkspace))
+			{
+				navigator.ShowNotice("No symbol to analyze", ActiveNavigationContext.AnchorOf(context));
+			}
+		}
+	}
+}
diff --git a/ILSpy/Navigation/NavigationChooser.cs b/ILSpy/Navigation/NavigationChooser.cs
new file mode 100644
index 00000000000..ac5324a9dd4
--- /dev/null
+++ b/ILSpy/Navigation/NavigationChooser.cs
@@ -0,0 +1,180 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+
+namespace ICSharpCode.ILSpy.Navigation
+{
+	/// One row of a : the text shown and the action run when it is picked.
+	public sealed record NavigationChoice(string Text, Action Execute);
+
+	/// 
+	/// Small searchable list shown in a flyout: a filter box over a list of choices. Typing filters
+	/// the list (every whitespace-separated term must occur, case-insensitively), Up/Down move the
+	/// selection, Enter or a click runs the selected choice, Escape closes without running anything.
+	/// 
+	public sealed class NavigationChooser
+	{
+		readonly IReadOnlyList choices;
+		readonly ListBox list;
+		readonly Flyout flyout;
+		bool isOpen;
+
+		public NavigationChooser(string title, IReadOnlyList choices)
+		{
+			ArgumentNullException.ThrowIfNull(choices);
+			this.choices = choices;
+			Title = title;
+
+			FilterBox = new TextBox {
+				PlaceholderText = "Type to filter",
+				MinWidth = 380,
+			};
+			FilterBox.PropertyChanged += (_, e) => {
+				if (e.Property == TextBox.TextProperty)
+					ApplyFilter();
+			};
+			list = new ListBox {
+				MaxHeight = 360,
+				ItemTemplate = new FuncDataTemplate((choice, _) => new TextBlock { Text = choice?.Text }),
+			};
+			list.Tapped += (_, _) => {
+				if (list.SelectedItem is NavigationChoice)
+					Accept();
+			};
+
+			var root = new StackPanel {
+				Orientation = Orientation.Vertical,
+				Spacing = 4,
+				Margin = new Thickness(4),
+				Children = {
+					new TextBlock { Text = title, FontWeight = FontWeight.SemiBold },
+					FilterBox,
+					list,
+				},
+			};
+			root.AddHandler(InputElement.KeyDownEvent, OnKeyDown, RoutingStrategies.Tunnel);
+
+			flyout = new Flyout {
+				Content = root,
+				Placement = PlacementMode.Center,
+			};
+			flyout.Opened += (_, _) => Dispatcher.UIThread.Post(() => FilterBox.Focus());
+			flyout.Closed += (_, _) => {
+				isOpen = false;
+				Closed?.Invoke(this, EventArgs.Empty);
+			};
+			ApplyFilter();
+		}
+
+		public string Title { get; }
+
+		/// The filter text box; it holds keyboard focus while the chooser is open.
+		internal TextBox FilterBox { get; }
+
+		/// The choices matching the current filter, in display order.
+		public IReadOnlyList VisibleChoices { get; private set; } = Array.Empty();
+
+		public string Filter {
+			get => FilterBox.Text ?? string.Empty;
+			set => FilterBox.Text = value;
+		}
+
+		public bool IsOpen => isOpen;
+
+		/// Raised once the chooser has closed, whether a choice ran or not.
+		public event EventHandler? Closed;
+
+		public void Show(Control anchor)
+		{
+			ArgumentNullException.ThrowIfNull(anchor);
+			isOpen = true;
+			flyout.ShowAt(anchor);
+		}
+
+		public void Close()
+		{
+			if (!isOpen)
+				return;
+			flyout.Hide();
+			// Flyout.Hide raises Closed only when the popup was really open; keep the state
+			// consistent when it was not (e.g. the anchor left the visual tree).
+			if (isOpen)
+			{
+				isOpen = false;
+				Closed?.Invoke(this, EventArgs.Empty);
+			}
+		}
+
+		/// Closes the chooser and runs the selected choice, or the first visible one when none is selected.
+		public void Accept()
+		{
+			var choice = list.SelectedItem as NavigationChoice ?? VisibleChoices.FirstOrDefault();
+			if (choice == null)
+				return;
+			Close();
+			choice.Execute();
+		}
+
+		void ApplyFilter()
+		{
+			var terms = Filter.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
+			VisibleChoices = choices
+				.Where(c => terms.All(t => c.Text.Contains(t, StringComparison.OrdinalIgnoreCase)))
+				.ToList();
+			list.ItemsSource = VisibleChoices;
+			list.SelectedIndex = VisibleChoices.Count > 0 ? 0 : -1;
+		}
+
+		void OnKeyDown(object? sender, KeyEventArgs e)
+		{
+			switch (e.Key)
+			{
+				case Key.Enter:
+					Accept();
+					e.Handled = true;
+					break;
+				case Key.Escape:
+					Close();
+					e.Handled = true;
+					break;
+				case Key.Down:
+				case Key.Up:
+					if (VisibleChoices.Count == 0)
+						break;
+					var step = e.Key == Key.Down ? 1 : -1;
+					list.SelectedIndex = Math.Clamp(list.SelectedIndex + step, 0, VisibleChoices.Count - 1);
+					if (list.SelectedItem != null)
+						list.ScrollIntoView(list.SelectedItem);
+					e.Handled = true;
+					break;
+			}
+		}
+	}
+}
diff --git a/ILSpy/Navigation/QuickPick.cs b/ILSpy/Navigation/QuickPick.cs
new file mode 100644
index 00000000000..2d5312992c7
--- /dev/null
+++ b/ILSpy/Navigation/QuickPick.cs
@@ -0,0 +1,242 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace ICSharpCode.ILSpy.Navigation
+{
+	/// One row of a : a primary text, an optional secondary
+	/// detail (assembly, code preview, ...), an optional icon and the object the row stands for.
+	public sealed class QuickPickItem(string text, string? detail, IImage? icon, object payload)
+	{
+		public string Text { get; } = text ?? string.Empty;
+		public string? Detail { get; } = detail;
+		public IImage? Icon { get; } = icon;
+		public object Payload { get; } = payload ?? throw new ArgumentNullException(nameof(payload));
+	}
+
+	/// 
+	/// State of a searchable pick list: the full item list, the filter text, the items matching it
+	/// and the selected one. Every whitespace-separated filter term must occur (case-insensitively)
+	/// in the item's text or detail. Accepting hands the selected item to the callback and asks the
+	/// hosting window to close.
+	/// 
+	public sealed partial class QuickPickModel : ObservableObject
+	{
+		readonly IReadOnlyList allItems;
+		readonly Action onAccept;
+
+		public QuickPickModel(IEnumerable items, Action onAccept)
+		{
+			ArgumentNullException.ThrowIfNull(items);
+			this.onAccept = onAccept ?? throw new ArgumentNullException(nameof(onAccept));
+			allItems = items.ToList();
+			Refilter();
+		}
+
+		public ObservableCollection FilteredItems { get; } = new();
+
+		[ObservableProperty]
+		string filter = string.Empty;
+
+		[ObservableProperty]
+		QuickPickItem? selectedItem;
+
+		/// Raised when the model is done (an item was accepted) and its host should close.
+		public event EventHandler? CloseRequested;
+
+		partial void OnFilterChanged(string value) => Refilter();
+
+		void Refilter()
+		{
+			var terms = (Filter ?? string.Empty).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
+			FilteredItems.Clear();
+			foreach (var item in allItems)
+			{
+				if (terms.All(t => Matches(item, t)))
+					FilteredItems.Add(item);
+			}
+			SelectedItem = FilteredItems.Count > 0 ? FilteredItems[0] : null;
+		}
+
+		static bool Matches(QuickPickItem item, string term)
+			=> item.Text.Contains(term, StringComparison.OrdinalIgnoreCase)
+				|| (item.Detail?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false);
+
+		/// Moves the selection by  rows, clamped to the filtered list.
+		public void MoveSelection(int delta)
+		{
+			if (FilteredItems.Count == 0)
+				return;
+			int index = SelectedItem is { } current ? FilteredItems.IndexOf(current) : -1;
+			index = Math.Clamp(index + delta, 0, FilteredItems.Count - 1);
+			SelectedItem = FilteredItems[index];
+		}
+
+		/// Accepts the selected item. Returns false when nothing is selected.
+		public bool Accept()
+		{
+			if (SelectedItem is not { } item)
+				return false;
+			CloseRequested?.Invoke(this, EventArgs.Empty);
+			onAccept(item);
+			return true;
+		}
+	}
+
+	/// 
+	/// Small searchable popup window over a : a filter box above a list.
+	/// Typing filters, Up/Down move the selection, Enter or a double-click accepts, Escape closes.
+	/// 
+	public sealed class QuickPickWindow : Window
+	{
+		readonly TextBox filterBox;
+		readonly ListBox list;
+		bool syncingSelection;
+
+		public QuickPickModel Model { get; }
+
+		public QuickPickWindow(string title, QuickPickModel model)
+		{
+			Model = model ?? throw new ArgumentNullException(nameof(model));
+			Title = title;
+			Width = 640;
+			Height = 420;
+			CanResize = true;
+			ShowInTaskbar = false;
+			WindowStartupLocation = WindowStartupLocation.CenterOwner;
+
+			filterBox = new TextBox { Margin = new Thickness(6), Text = model.Filter };
+			list = new ListBox {
+				Margin = new Thickness(6, 0, 6, 6),
+				ItemsSource = model.FilteredItems,
+				ItemTemplate = new FuncDataTemplate((item, _) => BuildRow(item), supportsRecycling: false),
+				SelectedItem = model.SelectedItem,
+			};
+			var root = new DockPanel();
+			DockPanel.SetDock(filterBox, global::Avalonia.Controls.Dock.Top);
+			root.Children.Add(filterBox);
+			root.Children.Add(list);
+			Content = root;
+
+			filterBox.TextChanged += (_, _) => Model.Filter = filterBox.Text ?? string.Empty;
+			list.SelectionChanged += (_, _) => {
+				if (!syncingSelection && list.SelectedItem is QuickPickItem item)
+					Model.SelectedItem = item;
+			};
+			list.DoubleTapped += (_, _) => Model.Accept();
+			Model.PropertyChanged += (_, e) => {
+				if (e.PropertyName == nameof(QuickPickModel.SelectedItem))
+					SyncSelection();
+			};
+			Model.CloseRequested += (_, _) => Close();
+			AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
+			Opened += (_, _) => Dispatcher.UIThread.Post(() => filterBox.Focus());
+		}
+
+		/// Creates the popup for  and shows it owned by .
+		public static QuickPickWindow Show(Window owner, string title, QuickPickModel model)
+		{
+			ArgumentNullException.ThrowIfNull(owner);
+			var window = new QuickPickWindow(title, model);
+			window.Show(owner);
+			return window;
+		}
+
+		void SyncSelection()
+		{
+			syncingSelection = true;
+			try
+			{
+				list.SelectedItem = Model.SelectedItem;
+				if (Model.SelectedItem is { } item)
+					list.ScrollIntoView(item);
+			}
+			finally
+			{
+				syncingSelection = false;
+			}
+		}
+
+		void OnPreviewKeyDown(object? sender, KeyEventArgs e)
+		{
+			switch (e.Key)
+			{
+				case Key.Escape:
+					Close();
+					e.Handled = true;
+					break;
+				case Key.Enter:
+					Model.Accept();
+					e.Handled = true;
+					break;
+				case Key.Down:
+					Model.MoveSelection(1);
+					e.Handled = true;
+					break;
+				case Key.Up:
+					Model.MoveSelection(-1);
+					e.Handled = true;
+					break;
+				case Key.PageDown:
+					Model.MoveSelection(10);
+					e.Handled = true;
+					break;
+				case Key.PageUp:
+					Model.MoveSelection(-10);
+					e.Handled = true;
+					break;
+			}
+		}
+
+		static Control BuildRow(QuickPickItem? item)
+		{
+			var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 };
+			if (item == null)
+				return row;
+			if (item.Icon != null)
+				row.Children.Add(new Image { Source = item.Icon, Width = 16, Height = 16, VerticalAlignment = VerticalAlignment.Center });
+			row.Children.Add(new TextBlock { Text = item.Text, VerticalAlignment = VerticalAlignment.Center });
+			if (!string.IsNullOrEmpty(item.Detail))
+			{
+				row.Children.Add(new TextBlock {
+					Text = item.Detail,
+					Opacity = 0.65,
+					VerticalAlignment = VerticalAlignment.Center,
+					TextTrimming = TextTrimming.CharacterEllipsis,
+				});
+			}
+			return row;
+		}
+	}
+}
diff --git a/ILSpy/Navigation/RecentCommands.cs b/ILSpy/Navigation/RecentCommands.cs
new file mode 100644
index 00000000000..cddcc9ad575
--- /dev/null
+++ b/ILSpy/Navigation/RecentCommands.cs
@@ -0,0 +1,76 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+
+using System.Collections.Generic;
+using System.Composition;
+using System.Linq;
+
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Docking;
+using ICSharpCode.ILSpy.Properties;
+
+namespace ICSharpCode.ILSpy.Navigation
+{
+	/// 
+	/// Navigate > Recent Files (Ctrl+Comma, the ReSharper Visual Studio scheme; dotPeek's Ctrl+E
+	/// is ILSpy's search). Opens a searchable list of recently viewed tree nodes, most recent first.
+	/// 
+	[ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.RecentFiles), MenuCategory = "Recent", MenuOrder = 50, InputGestureText = "Ctrl+OemComma")]
+	[Shared]
+	[method: ImportingConstructor]
+	sealed class RecentFilesCommand(DockWorkspace dockWorkspace) : SimpleCommand
+	{
+		public override void Execute(object? parameter)
+		{
+			if (ActiveTextViewLocator.MainWindow is not { } owner)
+				return;
+			var items = dockWorkspace.RecentNavigation.Files
+				.Select(f => new QuickPickItem(f.DisplayText, RecentNavigation.GetAssemblyName(f.Node), f.Icon, f))
+				.ToList();
+			var model = new QuickPickModel(items, item => dockWorkspace.NavigateToRecentFile((RecentFile)item.Payload));
+			QuickPickWindow.Show(owner, Resources.RecentFiles.TrimEnd('.'), model);
+		}
+	}
+
+	/// 
+	/// Navigate > Recent Locations (Ctrl+Shift+Comma). Lists the caret positions recently left
+	/// behind -- the current one first -- with a preview of the code at each; choosing one re-opens
+	/// the node with the caret and scroll position restored.
+	/// 
+	[ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.RecentLocations), MenuCategory = "Recent", MenuOrder = 51, InputGestureText = "Ctrl+Shift+OemComma")]
+	[Shared]
+	[method: ImportingConstructor]
+	sealed class RecentLocationsCommand(DockWorkspace dockWorkspace) : SimpleCommand
+	{
+		public override void Execute(object? parameter)
+		{
+			if (ActiveTextViewLocator.MainWindow is not { } owner)
+				return;
+			var locations = new List();
+			if (dockWorkspace.CaptureCurrentLocation() is { } current)
+				locations.Add(current);
+			locations.AddRange(dockWorkspace.RecentNavigation.Locations.Where(l => !locations.Any(c => c.IsSamePlace(l))));
+			var items = locations
+				.Select(l => new QuickPickItem($"{l.DisplayText} ({l.Line}:{l.Column})", l.Preview, l.Icon, l))
+				.ToList();
+			var model = new QuickPickModel(items, item => dockWorkspace.NavigateToRecentLocation((RecentLocation)item.Payload));
+			QuickPickWindow.Show(owner, Resources.RecentLocations.TrimEnd('.'), model);
+		}
+	}
+}
diff --git a/ILSpy/Navigation/RecentNavigation.cs b/ILSpy/Navigation/RecentNavigation.cs
new file mode 100644
index 00000000000..f222bafcaab
--- /dev/null
+++ b/ILSpy/Navigation/RecentNavigation.cs
@@ -0,0 +1,163 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+using Avalonia.Media;
+
+using ICSharpCode.ILSpyX.TreeView;
+
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.ViewModels;
+
+namespace ICSharpCode.ILSpy.Navigation
+{
+	/// 
+	/// Most-recently-used lists behind the Recent Files and Recent Locations popups. Both lists are
+	/// most-recent-first, distinct and capped at : a file is one tree node, a
+	/// location is one line of one node's document. Fed by  as
+	/// navigation history is recorded; entries of unloaded assemblies are dropped through
+	/// .
+	/// 
+	public sealed class RecentNavigation
+	{
+		public const int Capacity = 50;
+
+		readonly List files = new();
+		readonly List locations = new();
+
+		public IReadOnlyList Files => files;
+		public IReadOnlyList Locations => locations;
+
+		/// Moves  to the top of the recent files.
+		public void RecordFile(SharpTreeNode node)
+		{
+			ArgumentNullException.ThrowIfNull(node);
+			files.RemoveAll(f => ReferenceEquals(f.Node, node));
+			files.Insert(0, new RecentFile(node));
+			Trim(files);
+		}
+
+		/// Moves  to the top of the recent locations, replacing an
+		/// older entry for the same line of the same node.
+		public void RecordLocation(RecentLocation location)
+		{
+			ArgumentNullException.ThrowIfNull(location);
+			locations.RemoveAll(l => l.IsSamePlace(location));
+			locations.Insert(0, location);
+			Trim(locations);
+		}
+
+		/// Drops every file and location whose node matches .
+		public void RemoveAll(Predicate predicate)
+		{
+			ArgumentNullException.ThrowIfNull(predicate);
+			files.RemoveAll(f => predicate(f.Node));
+			locations.RemoveAll(l => predicate(l.Node));
+		}
+
+		public void Clear()
+		{
+			files.Clear();
+			locations.Clear();
+		}
+
+		static void Trim(List list)
+		{
+			if (list.Count > Capacity)
+				list.RemoveRange(Capacity, list.Count - Capacity);
+		}
+
+		internal static string GetDisplayText(SharpTreeNode node)
+			=> ((node is ILSpyTreeNode ilspy ? ilspy.NavigationText : node.Text) ?? string.Empty).ToString() ?? string.Empty;
+
+		internal static string? GetAssemblyName(SharpTreeNode node)
+			=> node.AncestorsAndSelf().OfType().LastOrDefault()?.LoadedAssembly.ShortName;
+	}
+
+	/// A tree node (type, member, resource, ...) the user recently viewed.
+	public sealed class RecentFile(SharpTreeNode node)
+	{
+		public SharpTreeNode Node { get; } = node ?? throw new ArgumentNullException(nameof(node));
+
+		public string DisplayText => RecentNavigation.GetDisplayText(Node);
+
+		public IImage? Icon => Node.Icon as IImage;
+	}
+
+	/// 
+	/// A caret position the user recently left: the node, the tab that showed it, the captured view
+	/// state, and the 1-based line/column plus the trimmed text of that line as a preview.
+	/// 
+	public sealed class RecentLocation
+	{
+		const int MaxPreviewLength = 200;
+
+		public SharpTreeNode Node { get; }
+		public TabPageModel? Tab { get; }
+		public DecompilerTextViewState State { get; }
+		public int Line { get; }
+		public int Column { get; }
+		public string Preview { get; }
+
+		public RecentLocation(SharpTreeNode node, TabPageModel? tab, DecompilerTextViewState state, int line, int column, string preview)
+		{
+			Node = node ?? throw new ArgumentNullException(nameof(node));
+			Tab = tab;
+			State = state;
+			Line = line;
+			Column = column;
+			Preview = preview ?? string.Empty;
+		}
+
+		public string DisplayText => RecentNavigation.GetDisplayText(Node);
+
+		public IImage? Icon => Node.Icon as IImage;
+
+		/// 
+		/// Builds a location from the document  the caret offset of
+		///  refers to. The offset is clamped to the text.
+		/// 
+		public static RecentLocation Create(SharpTreeNode node, TabPageModel? tab, DecompilerTextViewState state, string? text)
+		{
+			text ??= string.Empty;
+			int offset = Math.Clamp(state.CaretOffset, 0, text.Length);
+			int lineStart = offset == 0 ? 0 : text.LastIndexOf('\n', offset - 1) + 1;
+			int lineEnd = text.IndexOf('\n', offset);
+			if (lineEnd < 0)
+				lineEnd = text.Length;
+			int line = 1;
+			for (int i = 0; i < lineStart; i++)
+			{
+				if (text[i] == '\n')
+					line++;
+			}
+			var preview = text.Substring(lineStart, lineEnd - lineStart).Trim();
+			if (preview.Length > MaxPreviewLength)
+				preview = preview.Substring(0, MaxPreviewLength);
+			return new RecentLocation(node, tab, state, line, offset - lineStart + 1, preview);
+		}
+
+		internal bool IsSamePlace(RecentLocation other)
+			=> ReferenceEquals(Node, other.Node) && Line == other.Line;
+	}
+}
diff --git a/ILSpy/Navigation/SymbolHierarchy.cs b/ILSpy/Navigation/SymbolHierarchy.cs
new file mode 100644
index 00000000000..44da912c5ba
--- /dev/null
+++ b/ILSpy/Navigation/SymbolHierarchy.cs
@@ -0,0 +1,206 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpyX;
+using ICSharpCode.ILSpyX.Analyzers;
+
+namespace ICSharpCode.ILSpy.Navigation
+{
+	/// The relation a go-to command follows from a symbol.
+	public enum GoToKind
+	{
+		/// The symbol's own definition.
+		Declaration,
+		/// Non-abstract types / members that implement or override the symbol, at any depth.
+		Implementation,
+		/// Direct base types, or the members the symbol overrides or implements.
+		BaseSymbols,
+		/// Direct inheritors: derived / implementing types, or the members that directly override or implement the symbol.
+		DerivedSymbols,
+	}
+
+	/// 
+	/// Resolves the inheritance relations behind the go-to commands. Inheritors are searched over
+	/// the same module scope the analyzers use (): every loaded module
+	/// that can see the symbol. Entities from different type systems are matched by metadata token
+	/// and module, since each scanned module gets its own compilation.
+	/// 
+	public static class SymbolHierarchy
+	{
+		/// True when  can produce a result for .
+		public static bool IsApplicable(GoToKind kind, IEntity entity)
+		{
+			ArgumentNullException.ThrowIfNull(entity);
+			return kind switch {
+				GoToKind.Declaration => true,
+				GoToKind.Implementation or GoToKind.DerivedSymbols => CanHaveInheritors(entity),
+				GoToKind.BaseSymbols => FindBaseSymbols(entity).Count > 0,
+				_ => false,
+			};
+		}
+
+		/// True for interfaces, non-sealed classes, and overridable or interface members.
+		public static bool CanHaveInheritors(IEntity entity)
+		{
+			switch (entity)
+			{
+				case ITypeDefinition type:
+					return type.Kind == TypeKind.Interface || (type.Kind == TypeKind.Class && !type.IsSealed);
+				case IMember member when IsInheritableMember(member):
+					return member.IsOverridable
+						|| (member.DeclaringTypeDefinition?.Kind == TypeKind.Interface && (member.IsAbstract || member.IsVirtual));
+				default:
+					return false;
+			}
+		}
+
+		/// Finds the targets of  for , sorted by full name.
+		public static IReadOnlyList Find(GoToKind kind, IEntity entity, AssemblyList assemblyList, CancellationToken cancellationToken)
+		{
+			ArgumentNullException.ThrowIfNull(entity);
+			ArgumentNullException.ThrowIfNull(assemblyList);
+			IEnumerable result;
+			switch (kind)
+			{
+				case GoToKind.Declaration:
+					return new[] { entity };
+				case GoToKind.BaseSymbols:
+					result = FindBaseSymbols(entity);
+					break;
+				case GoToKind.DerivedSymbols:
+					result = CanHaveInheritors(entity)
+						? FindInheritors(entity, assemblyList, cancellationToken).Where(i => i.IsDirect).Select(i => i.Entity)
+						: Enumerable.Empty();
+					break;
+				case GoToKind.Implementation:
+					result = CanHaveInheritors(entity)
+						? FindInheritors(entity, assemblyList, cancellationToken).Select(i => i.Entity).Where(IsImplementation)
+						: Enumerable.Empty();
+					break;
+				default:
+					throw new ArgumentOutOfRangeException(nameof(kind));
+			}
+			return result.OrderBy(e => e.FullName, StringComparer.Ordinal).ToList();
+		}
+
+		/// Direct base types of a type, or the members a member overrides or implements.
+		public static IReadOnlyList FindBaseSymbols(IEntity entity)
+		{
+			IEnumerable candidates = entity switch {
+				ITypeDefinition type => type.DirectBaseTypes.Select(t => t.GetDefinition()).OfType(),
+				IMember member when IsInheritableMember(member)
+					=> InheritanceHelper.GetBaseMembers(member, includeImplementedInterfaces: true).Select(m => m.MemberDefinition),
+				_ => Enumerable.Empty(),
+			};
+			var result = new List();
+			foreach (var candidate in candidates)
+			{
+				if (!IsSameDefinition(candidate, entity) && !result.Any(r => IsSameDefinition(r, candidate)))
+					result.Add(candidate);
+			}
+			return result;
+		}
+
+		/// True when both entities denote the same metadata definition, whatever compilation they come from.
+		public static bool IsSameDefinition(IEntity a, IEntity b)
+		{
+			if (ReferenceEquals(a, b))
+				return true;
+			return !a.MetadataToken.IsNil
+				&& a.MetadataToken == b.MetadataToken
+				&& a.ParentModule?.MetadataFile is { } module
+				&& ReferenceEquals(module, b.ParentModule?.MetadataFile);
+		}
+
+		static bool IsInheritableMember(IMember member)
+			=> (member.SymbolKind is SymbolKind.Method or SymbolKind.Property or SymbolKind.Indexer or SymbolKind.Event)
+				&& (!member.IsStatic || (member.DeclaringTypeDefinition?.Kind == TypeKind.Interface && member.IsAbstract));
+
+		static bool IsImplementation(IEntity entity) => entity switch {
+			ITypeDefinition type => type.Kind != TypeKind.Interface && !type.IsAbstract,
+			IMember member => !member.IsAbstract,
+			_ => false,
+		};
+
+		readonly record struct Inheritor(IEntity Entity, bool IsDirect);
+
+		static List FindInheritors(IEntity entity, AssemblyList assemblyList, CancellationToken cancellationToken)
+		{
+			return entity switch {
+				ITypeDefinition type => FindDerivedTypes(type, assemblyList, cancellationToken),
+				IMember member => FindOverridingMembers(member, assemblyList, cancellationToken),
+				_ => new List(),
+			};
+		}
+
+		static List FindDerivedTypes(ITypeDefinition type, AssemblyList assemblyList, CancellationToken cancellationToken)
+		{
+			var result = new List();
+			var scope = new AnalyzerScope(assemblyList, type);
+			foreach (var candidate in scope.GetTypesInScope(cancellationToken))
+			{
+				cancellationToken.ThrowIfCancellationRequested();
+				if (IsSameDefinition(candidate, type) || result.Any(r => IsSameDefinition(r.Entity, candidate)))
+					continue;
+				if (!candidate.GetAllBaseTypeDefinitions().Any(b => IsSameDefinition(b, type)))
+					continue;
+				bool isDirect = candidate.DirectBaseTypes.Any(b => b.GetDefinition() is { } d && IsSameDefinition(d, type));
+				result.Add(new Inheritor(candidate, isDirect));
+			}
+			return result;
+		}
+
+		static List FindOverridingMembers(IMember member, AssemblyList assemblyList, CancellationToken cancellationToken)
+		{
+			var declaringType = member.DeclaringTypeDefinition;
+			if (declaringType == null)
+				return new List();
+			var found = new List<(IMember Member, List Bases)>();
+			var scope = new AnalyzerScope(assemblyList, member);
+			foreach (var candidateType in scope.GetTypesInScope(cancellationToken))
+			{
+				cancellationToken.ThrowIfCancellationRequested();
+				if (IsSameDefinition(candidateType, declaringType))
+					continue;
+				if (!candidateType.GetAllBaseTypeDefinitions().Any(b => IsSameDefinition(b, declaringType)))
+					continue;
+				foreach (var candidate in candidateType.Members)
+				{
+					if (candidate.SymbolKind != member.SymbolKind)
+						continue;
+					var bases = InheritanceHelper.GetBaseMembers(candidate, includeImplementedInterfaces: true).ToList();
+					if (bases.Any(b => IsSameDefinition(b, member)) && !found.Any(f => IsSameDefinition(f.Member, candidate)))
+						found.Add((candidate, bases));
+				}
+			}
+			// A member is a direct inheritor unless one of the members it overrides is itself an
+			// inheritor of the target (e.g. an override of an abstract member that implements the
+			// interface member being searched).
+			return found
+				.Select(f => new Inheritor(f.Member,
+					!f.Bases.Any(b => found.Any(other => IsSameDefinition(other.Member, b)))))
+				.ToList();
+		}
+	}
+}
diff --git a/ILSpy/Options/DisplaySettingReactions.cs b/ILSpy/Options/DisplaySettingReactions.cs
index b8a66f9a77b..d08bfb7fa16 100644
--- a/ILSpy/Options/DisplaySettingReactions.cs
+++ b/ILSpy/Options/DisplaySettingReactions.cs
@@ -86,6 +86,8 @@ public static class DisplaySettingReactions
 			[nameof(DisplaySettings.EnableOmnibar)] = DisplaySettingReaction.EditorLive,
 			// Read at click time inside the text view; nothing needs re-decompilation.
 			[nameof(DisplaySettings.HighlightMemberReferences)] = DisplaySettingReaction.EditorLive,
+			// The text view re-evaluates the caret highlight itself; nothing needs re-decompilation.
+			[nameof(DisplaySettings.HighlightUsagesAtCaret)] = DisplaySettingReaction.EditorLive,
 
 			// No model-side reaction.
 			[nameof(DisplaySettings.StyleWindowTitleBar)] = DisplaySettingReaction.None,
diff --git a/ILSpy/Options/DisplaySettings.cs b/ILSpy/Options/DisplaySettings.cs
index 932c15185dd..54f9a3d4b00 100644
--- a/ILSpy/Options/DisplaySettings.cs
+++ b/ILSpy/Options/DisplaySettings.cs
@@ -111,6 +111,9 @@ public sealed partial class DisplaySettings : ObservableObject, ISettingsSection
 		[ObservableProperty]
 		bool highlightMemberReferences;
 
+		[ObservableProperty]
+		bool highlightUsagesAtCaret = true;
+
 		public XName SectionName => "DisplaySettings";
 
 		public void LoadFromXml(XElement section)
@@ -140,6 +143,7 @@ public void LoadFromXml(XElement section)
 			DecodeCustomAttributeBlobs = (bool?)section.Attribute(nameof(DecodeCustomAttributeBlobs)) ?? false;
 			EnableOmnibar = (bool?)section.Attribute(nameof(EnableOmnibar)) ?? false;
 			HighlightMemberReferences = (bool?)section.Attribute(nameof(HighlightMemberReferences)) ?? false;
+			HighlightUsagesAtCaret = (bool?)section.Attribute(nameof(HighlightUsagesAtCaret)) ?? true;
 		}
 
 		public XElement SaveToXml()
@@ -170,6 +174,7 @@ public XElement SaveToXml()
 			section.SetAttributeValue(nameof(DecodeCustomAttributeBlobs), DecodeCustomAttributeBlobs);
 			section.SetAttributeValue(nameof(EnableOmnibar), EnableOmnibar);
 			section.SetAttributeValue(nameof(HighlightMemberReferences), HighlightMemberReferences);
+			section.SetAttributeValue(nameof(HighlightUsagesAtCaret), HighlightUsagesAtCaret);
 			return section;
 		}
 	}
diff --git a/ILSpy/Options/DisplaySettingsPanel.axaml b/ILSpy/Options/DisplaySettingsPanel.axaml
index ecf99f963cd..2a4fdc01d71 100644
--- a/ILSpy/Options/DisplaySettingsPanel.axaml
+++ b/ILSpy/Options/DisplaySettingsPanel.axaml
@@ -79,6 +79,8 @@
 					          Content="{x:Static res:Resources.HighlightCurrentLine}" />
 					
+					
 					
 					 options.Gesture = new Avalonia.Input.KeyGesture(Avalonia.Input.Key.F12, Avalonia.Input.KeyModifiers.Control | Avalonia.Input.KeyModifiers.Shift));
 #endif
 			return builder.LogToTrace();
 		}
diff --git a/ILSpy/Properties/Resources.Designer.cs b/ILSpy/Properties/Resources.Designer.cs
index 72895e48eb2..6e13f486fad 100644
--- a/ILSpy/Properties/Resources.Designer.cs
+++ b/ILSpy/Properties/Resources.Designer.cs
@@ -3187,6 +3187,186 @@ public static string SelectList {
             }
         }
         
+        /// 
+        ///   Looks up a localized string similar to _Navigate.
+        /// 
+        public static string _Navigate {
+            get {
+                return ResourceManager.GetString("_Navigate", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to Navigate To....
+        /// 
+        public static string NavigateTo {
+            get {
+                return ResourceManager.GetString("NavigateTo", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to Go to Declaration.
+        /// 
+        public static string GoToDeclaration {
+            get {
+                return ResourceManager.GetString("GoToDeclaration", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to Go to Implementation.
+        /// 
+        public static string GoToImplementation {
+            get {
+                return ResourceManager.GetString("GoToImplementation", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to Go to Base Symbols.
+        /// 
+        public static string GoToBaseSymbols {
+            get {
+                return ResourceManager.GetString("GoToBaseSymbols", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to Go to Derived Symbols.
+        /// 
+        public static string GoToDerivedSymbols {
+            get {
+                return ResourceManager.GetString("GoToDerivedSymbols", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to Locate in Assembly Explorer.
+        /// 
+        public static string LocateInAssemblyExplorer {
+            get {
+                return ResourceManager.GetString("LocateInAssemblyExplorer", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to Type Hierarchy.
+        /// 
+        public static string TypeHierarchy {
+            get {
+                return ResourceManager.GetString("TypeHierarchy", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to File Structure.
+        /// 
+        public static string FileStructure {
+            get {
+                return ResourceManager.GetString("FileStructure", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to Go to File Member....
+        /// 
+        public static string GoToFileMember {
+            get {
+                return ResourceManager.GetString("GoToFileMember", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to Recent Files....
+        /// 
+        public static string RecentFiles {
+            get {
+                return ResourceManager.GetString("RecentFiles", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to Recent Locations....
+        /// 
+        public static string RecentLocations {
+            get {
+                return ResourceManager.GetString("RecentLocations", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to Go to Line....
+        /// 
+        public static string GoToLine {
+            get {
+                return ResourceManager.GetString("GoToLine", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to Highlight usages of the symbol under the caret.
+        /// 
+        public static string HighlightUsagesAtCaret {
+            get {
+                return ResourceManager.GetString("HighlightUsagesAtCaret", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to Find Usages for Rename.
+        /// 
+        public static string FindUsagesForRename {
+            get {
+                return ResourceManager.GetString("FindUsagesForRename", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to Referenced By.
+        /// 
+        public static string ReferencedBy {
+            get {
+                return ResourceManager.GetString("ReferencedBy", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to Dependent Code.
+        /// 
+        public static string DependentCode {
+            get {
+                return ResourceManager.GetString("DependentCode", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to Show Assembly Dependency Diagram.
+        /// 
+        public static string AssemblyDependencyDiagram {
+            get {
+                return ResourceManager.GetString("AssemblyDependencyDiagram", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to Export Dependency Diagram (Mermaid HTML)....
+        /// 
+        public static string ExportDependencyDiagramMermaid {
+            get {
+                return ResourceManager.GetString("ExportDependencyDiagramMermaid", resourceCulture);
+            }
+        }
+        
+        /// 
+        ///   Looks up a localized string similar to Unresolved References Report.
+        /// 
+        public static string UnresolvedReferencesReport {
+            get {
+                return ResourceManager.GetString("UnresolvedReferencesReport", resourceCulture);
+            }
+        }
+        
         /// 
         ///   Looks up a localized string similar to Select PDB....
         /// 
diff --git a/ILSpy/Properties/Resources.resx b/ILSpy/Properties/Resources.resx
index 44f9900b28d..a41ffdfd162 100644
--- a/ILSpy/Properties/Resources.resx
+++ b/ILSpy/Properties/Resources.resx
@@ -149,6 +149,9 @@ Do you want to continue?
   
     Assembly
   
+  
+    Show Assembly Dependency Diagram
+  
   
     The directory is not empty. File will be overwritten.
 Are you sure you want to continue?
@@ -678,6 +681,9 @@ Are you sure you want to continue?
   
     Dependencies
   
+  
+    Dependent Code
+  
   
     Derived Types
   
@@ -723,6 +729,9 @@ Are you sure you want to continue?
   
     Expand XML documentation comments after decompilation
   
+  
+    Export Dependency Diagram (Mermaid HTML)...
+  
   
     Export Project/Solution...
   
@@ -732,6 +741,12 @@ Are you sure you want to continue?
   
     Extract package entry
   
+  
+    File Structure
+  
+  
+    Find Usages for Rename
+  
   
     Folding
   
@@ -759,6 +774,24 @@ Are you sure you want to continue?
   
     Generation was cancelled.
   
+  
+    Go to Base Symbols
+  
+  
+    Go to Declaration
+  
+  
+    Go to Derived Symbols
+  
+  
+    Go to File Member...
+  
+  
+    Go to Implementation
+  
+  
+    Go to Line...
+  
   
     Go to token
   
@@ -774,6 +807,9 @@ Are you sure you want to continue?
   
     Single-click highlights member references (Ctrl+Click navigates)
   
+  
+    Highlight usages of the symbol under the caret
+  
   
     ILSpyAboutPage.txt
   
@@ -813,6 +849,9 @@ Are you sure you want to continue?
   
     Loading...
   
+  
+    Locate in Assembly Explorer
+  
   
     Location
   
@@ -834,6 +873,9 @@ Are you sure you want to continue?
   
     Name
   
+  
+    Navigate To...
+  
   
     Navigation
   
@@ -1010,9 +1052,18 @@ If this does not solve the problem and your system supports long paths, you can
   
     R_ename
   
+  
+    Recent Files...
+  
+  
+    Recent Locations...
+  
   
     Reference Name
   
+  
+    Referenced By
+  
   
     Referenced Types
   
@@ -1213,12 +1264,18 @@ Do you want to continue?
   
     Type
   
+  
+    Type Hierarchy
+  
   
     UI Language
   
   
     System
   
+  
+    Unresolved References Report
+  
   
     No update for ILSpy found.
   
@@ -1306,6 +1363,9 @@ Do you want to continue?
   
     _Load Dependencies
   
+  
+    _Navigate
+  
   
     _New
   
diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs
index d4fb52d4f96..fa1f1365842 100644
--- a/ILSpy/TextView/DecompilerTabPageModel.cs
+++ b/ILSpy/TextView/DecompilerTabPageModel.cs
@@ -258,6 +258,13 @@ void ResetProgress()
 		/// 
 		public DecompilerTextViewState? PendingViewState { get; set; }
 
+		/// 
+		/// Applies a view state (caret, scroll) to the live editor immediately, for navigation that
+		/// lands on the already-displayed document (no document-apply step runs to consume
+		/// ). Set by the text view; null until a view is attached.
+		/// 
+		public System.Action? ApplyViewState { get; set; }
+
 		/// 
 		/// A bookmark to scroll to the next time this document is (re)applied. Set by bookmark
 		/// navigation before the target node is decompiled; the text view reads it in its
@@ -278,6 +285,12 @@ void ResetProgress()
 		/// Moves to the next (true) / previous (false) bookmark within this document. Set by the text view.
 		public System.Action? NavigateBookmarkInFile { get; set; }
 
+		/// 
+		/// Moves the caret to a document offset, scrolls it into view, flashes the caret highlight and
+		/// focuses the editor. Set by the text view; null while no view is attached.
+		/// 
+		public System.Action? NavigateToOffset { get; set; }
+
 		/// 
 		/// Fired when the user clicks a cross-document reference. The host (DockWorkspace)
 		/// resolves the target on the assembly tree side.
diff --git a/ILSpy/TextView/DecompilerTextView.InFileNavigation.cs b/ILSpy/TextView/DecompilerTextView.InFileNavigation.cs
new file mode 100644
index 00000000000..cf0eab0a7ea
--- /dev/null
+++ b/ILSpy/TextView/DecompilerTextView.InFileNavigation.cs
@@ -0,0 +1,197 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using Avalonia.Threading;
+
+using ICSharpCode.Decompiler.TypeSystem;
+
+using ICSharpCode.ILSpy.Navigation;
+using ICSharpCode.ILSpy.Options;
+
+namespace ICSharpCode.ILSpy.TextView
+{
+	/// 
+	/// In-file navigation of the decompiler view: highlight usages of the symbol under the caret,
+	/// Go to Line (Ctrl+G while the editor has focus), and the reference under the caret for
+	/// caret-driven commands.
+	/// 
+	public partial class DecompilerTextView
+	{
+		// Translucent so the caret-usage marks stay readable on top of the click-highlight marks.
+		static readonly Color CaretUsageBackground = Color.FromArgb(0x50, 0x87, 0xCE, 0xFA);
+		static readonly Color CaretUsageDefinitionBackground = Color.FromArgb(0x80, 0x87, 0xCE, 0xFA);
+
+		readonly List caretUsageMarks = new();
+		// The segment the current caret-usage marks were computed for; re-computing is skipped while
+		// the caret moves within that same segment.
+		ReferenceSegment? caretUsageSegment;
+
+		// The view holding keyboard focus, tracked so caret-driven commands can find it without
+		// walking (or constructing) a window.
+		static WeakReference? focusedView;
+
+		/// The decompiler view that currently holds keyboard focus, or null.
+		internal static DecompilerTextView? FocusedView
+			=> focusedView != null && focusedView.TryGetTarget(out var view) && view.IsKeyboardFocusWithin ? view : null;
+
+		/// Marks painted by the highlight-usages-at-caret feature (exposed for tests).
+		internal IReadOnlyList CaretUsageMarks => caretUsageMarks;
+
+		void SetupInFileNavigation()
+		{
+			Editor.TextArea.Caret.PositionChanged += (_, _) => UpdateCaretUsageHighlight();
+			if (currentDisplaySettings is INotifyPropertyChanged settings)
+			{
+				settings.PropertyChanged += (_, e) => {
+					if (e.PropertyName == nameof(DisplaySettings.HighlightUsagesAtCaret))
+						UpdateCaretUsageHighlight();
+				};
+			}
+			// Ctrl+G is scoped to the editor: elsewhere (e.g. metadata grids) the same gesture means
+			// "Go to token". Tunnel so the gesture wins before AvaloniaEdit's own key handling.
+			AddHandler(KeyDownEvent, OnGoToLineKeyDown, RoutingStrategies.Tunnel);
+			PropertyChanged += (_, e) => {
+				if (e.Property == IsKeyboardFocusWithinProperty && IsKeyboardFocusWithin)
+					focusedView = new WeakReference(this);
+			};
+		}
+
+		void OnGoToLineKeyDown(object? sender, KeyEventArgs e)
+		{
+			if (e.Key == Key.G && e.KeyModifiers == KeyModifiers.Control && Editor.TextArea.IsKeyboardFocusWithin)
+			{
+				e.Handled = ShowGoToLinePrompt();
+			}
+		}
+
+		/// 
+		/// The reference segment under the caret: one containing the caret offset, or ending right
+		/// before it (caret just after an identifier). Hover-only segments are skipped.
+		/// 
+		internal ReferenceSegment? GetReferenceSegmentAtCaret()
+		{
+			if (DataContext is not DecompilerTabPageModel { References: { } references })
+				return null;
+			int offset = Editor.TextArea.Caret.Offset;
+			return Pick(references.FindSegmentsContaining(offset))
+				?? (offset > 0 ? Pick(references.FindSegmentsContaining(offset - 1)) : null);
+
+			static ReferenceSegment? Pick(IEnumerable segments)
+				=> segments.FirstOrDefault(s => s.Reference != null && s.Kind != ReferenceMode.HoverOnly);
+		}
+
+		void UpdateCaretUsageHighlight()
+		{
+			if (currentDisplaySettings is not { HighlightUsagesAtCaret: true }
+				|| DataContext is not DecompilerTabPageModel { References: { } references }
+				|| GetReferenceSegmentAtCaret() is not { } segment
+				|| !IsCaretHighlightable(segment))
+			{
+				ClearCaretUsageMarks();
+				return;
+			}
+			if (ReferenceEquals(segment, caretUsageSegment) && caretUsageMarks.Count > 0)
+				return;
+
+			ClearCaretUsageMarks();
+			caretUsageSegment = segment;
+			int textLength = Editor.Document.TextLength;
+			foreach (var r in references)
+			{
+				if (r.Kind == ReferenceMode.HoverOnly || !AreSameReference(segment.Reference!, r.Reference))
+					continue;
+				if (r.StartOffset < 0 || r.EndOffset > textLength)
+					continue;
+				var mark = textMarkerService.Create(r.StartOffset, r.Length);
+				mark.BackgroundColor = r.IsDefinition ? CaretUsageDefinitionBackground : CaretUsageBackground;
+				caretUsageMarks.Add(mark);
+			}
+		}
+
+		// Locals and parameters, members, types and unresolved entity references. Opcodes and other
+		// link targets are excluded: marking every occurrence of an IL opcode would be noise.
+		static bool IsCaretHighlightable(ReferenceSegment segment)
+			=> segment.Kind == ReferenceMode.LocalHighlight
+				|| segment.Reference is IMember or IType or EntityReference;
+
+		void ClearCaretUsageMarks()
+		{
+			caretUsageSegment = null;
+			foreach (var mark in caretUsageMarks)
+				textMarkerService.Remove(mark);
+			caretUsageMarks.Clear();
+		}
+
+		/// 
+		/// Moves the caret to  (1-based) and optional
+		///  (1-based), both clamped to the document, centres the line and
+		/// plays the line highlight. Returns false when there is no document to move in.
+		/// 
+		internal bool GoToLine(int line, int? column = null)
+		{
+			var document = Editor.Document;
+			if (document == null || document.LineCount == 0)
+				return false;
+			line = Math.Clamp(line, 1, document.LineCount);
+			var documentLine = document.GetLineByNumber(line);
+			int col = Math.Clamp(column ?? 1, 1, documentLine.Length + 1);
+			Editor.TextArea.Caret.Offset = documentLine.Offset + col - 1;
+			Editor.TextArea.Caret.BringCaretToView();
+			Dispatcher.UIThread.Post(() => {
+				if (!ReferenceEquals(Editor.Document, document) || line > document.LineCount)
+					return;
+				CenterLineInView(document, line);
+				LineHighlightAdorner.DisplayLineHighlight(Editor.TextArea, line);
+			}, DispatcherPriority.Background);
+			Editor.TextArea.Focus();
+			return true;
+		}
+
+		/// 
+		/// Opens the Go to Line prompt owned by this view's window. Returns false when the view has
+		/// no window or no document.
+		/// 
+		internal bool ShowGoToLinePrompt()
+		{
+			if (TopLevel.GetTopLevel(this) is not Window owner || Editor.Document is not { LineCount: > 0 } document)
+				return false;
+			var prompt = new GoToLineWindow(document.LineCount, Editor.TextArea.Caret.Line,
+				(line, column) => GoToLine(line, column));
+			prompt.Show(owner);
+			return true;
+		}
+
+		// Applies a captured view state to the editor right away; installed on the bound model as
+		// DecompilerTabPageModel.ApplyViewState.
+		void ApplyViewStateNow(DecompilerTextViewState state)
+		{
+			RestoreOrResetViewState(state);
+			Editor.TextArea.Focus();
+		}
+	}
+}
diff --git a/ILSpy/TextView/DecompilerTextView.OutlineNavigation.cs b/ILSpy/TextView/DecompilerTextView.OutlineNavigation.cs
new file mode 100644
index 00000000000..d4f2c2feba1
--- /dev/null
+++ b/ILSpy/TextView/DecompilerTextView.OutlineNavigation.cs
@@ -0,0 +1,44 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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 Avalonia.Threading;
+
+namespace ICSharpCode.ILSpy.TextView
+{
+	public partial class DecompilerTextView
+	{
+		/// 
+		/// Backs : places the caret at
+		/// , centres its line, flashes the caret highlight and focuses the
+		/// editor. Offsets outside the current document are ignored.
+		/// 
+		void NavigateToOffsetNow(int offset)
+		{
+			var document = Editor.Document;
+			if (document == null || offset < 0 || offset > document.TextLength)
+				return;
+			Editor.TextArea.Caret.Offset = offset;
+			Editor.TextArea.Caret.BringCaretToView();
+			int line = document.GetLineByOffset(offset).LineNumber;
+			// Background priority lets a just-applied document finish measuring before centring.
+			Dispatcher.UIThread.Post(() => CenterLineInView(document, line), DispatcherPriority.Background);
+			CaretHighlightAdorner.DisplayCaretHighlightAnimation(Editor.TextArea);
+			Dispatcher.UIThread.Post(() => Editor.TextArea.Focus());
+		}
+	}
+}
diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs
index 3293f14daca..ee5cb77152b 100644
--- a/ILSpy/TextView/DecompilerTextView.axaml.cs
+++ b/ILSpy/TextView/DecompilerTextView.axaml.cs
@@ -151,6 +151,7 @@ public DecompilerTextView()
 			Editor.TextArea.Caret.PositionChanged += OnCaretPositionChanged;
 
 			SetupZoomAndCopy();
+			SetupInFileNavigation();
 
 			// Ctrl+L focuses the omnibar into search mode (browser address-bar gesture). Tunnel so
 			// it wins before AvaloniaEdit's own key handling while focus is anywhere in the editor.
@@ -1061,6 +1062,8 @@ protected override void OnDataContextChanged(System.EventArgs e)
 				previous.CaptureViewState = null;
 				previous.NavigateBookmarkInFile = null;
 				previous.ScrollToBookmark = null;
+				previous.ApplyViewState = null;
+				previous.NavigateToOffset = null;
 			}
 
 			boundModel = DataContext as DecompilerTabPageModel;
@@ -1076,6 +1079,10 @@ protected override void OnDataContextChanged(System.EventArgs e)
 				// Direct scroll for a bookmark activated on the already-displayed node, where no
 				// document-apply step runs to consume PendingBookmark.
 				model.ScrollToBookmark = bookmark => ApplyBookmark(bookmark);
+				// Direct caret/scroll restore for navigation to the already-displayed document.
+				model.ApplyViewState = ApplyViewStateNow;
+				// Caret jumps requested by the File Structure pane and the Go to File Member popup.
+				model.NavigateToOffset = NavigateToOffsetNow;
 				ApplyDocument(model);
 				// Point the breadcrumb at this tab's node (the bar owns its own VM, so feed it the
 				// node rather than letting it inherit the document DataContext).
@@ -1153,6 +1160,7 @@ void ApplyDocument(DecompilerTabPageModel model, bool restoreViewState = true)
 			// force-close even if the popup currently wants to stay (mouseClick: true).
 			TryCloseExistingPopup(mouseClick: true);
 			ClearLocalReferenceMarks();
+			ClearCaretUsageMarks();
 			ClearDebugStepMarks();
 			Editor.SyntaxHighlighting = HighlightingService.GetByExtension(model.SyntaxExtension);
 			Editor.Document.Text = model.Text;
diff --git a/ILSpy/TreeNodes/AssemblyTreeNode.cs b/ILSpy/TreeNodes/AssemblyTreeNode.cs
index f2e8bc57607..4fd383f7c34 100644
--- a/ILSpy/TreeNodes/AssemblyTreeNode.cs
+++ b/ILSpy/TreeNodes/AssemblyTreeNode.cs
@@ -456,6 +456,8 @@ protected override void LoadChildren()
 			}
 
 			Children.Add(new ReferenceFolderTreeNode(module, this));
+			if (module.IsAssembly)
+				Children.Add(new ICSharpCode.ILSpy.Dependencies.ReferencedByFolderTreeNode(module, this));
 
 			if (module.Resources.Any())
 				Children.Add(new ResourceListTreeNode(module));
diff --git a/ILSpy/TypeHierarchy/TypeHierarchyCommands.cs b/ILSpy/TypeHierarchy/TypeHierarchyCommands.cs
new file mode 100644
index 00000000000..222cd4626d1
--- /dev/null
+++ b/ILSpy/TypeHierarchy/TypeHierarchyCommands.cs
@@ -0,0 +1,117 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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.Composition;
+using System.Reflection.Metadata;
+
+using ICSharpCode.Decompiler.TypeSystem;
+
+using ICSharpCode.ILSpy.AssemblyTree;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Docking;
+using ICSharpCode.ILSpy.FileStructure;
+using ICSharpCode.ILSpy.Properties;
+
+namespace ICSharpCode.ILSpy.TypeHierarchy
+{
+	/// 
+	/// Navigate > Type Hierarchy: opens the Type Hierarchy pane for the symbol under the caret
+	/// when the code view has the focus, otherwise for the node selected in the assembly tree,
+	/// falling back to the caret of the active document. Members resolve to their declaring type.
+	/// 
+	[ExportMainMenuCommand(ParentMenuID = nameof(Resources._Navigate), Header = nameof(Resources.TypeHierarchy),
+		MenuCategory = "Structure", MenuOrder = 100, InputGestureText = Gesture)]
+	[Shared]
+	[method: ImportingConstructor]
+	public sealed class TypeHierarchyCommand(AssemblyTreeModel assemblyTreeModel, DockWorkspace dockWorkspace,
+		TypeHierarchyViewModel pane) : SimpleCommand
+	{
+		public const string Gesture = "Ctrl+Alt+H";
+
+		public override void Execute(object? parameter)
+		{
+			if (FindTargetType() is { } type)
+				Show(type, pane, dockWorkspace);
+			else
+				dockWorkspace.ShowToolPane(TypeHierarchyViewModel.PaneContentId);
+		}
+
+		ITypeDefinition? FindTargetType()
+		{
+			var list = assemblyTreeModel.AssemblyList;
+			if (ActiveDocument.GetFocused() is { } focused
+				&& TypeHierarchyViewModel.ResolveType(ActiveDocument.GetReferenceAtCaret(focused), list) is { } atCaret)
+			{
+				return atCaret;
+			}
+			if (TypeHierarchyViewModel.ResolveType(assemblyTreeModel.SelectedItem, list) is { } selected)
+				return selected;
+			return ActiveDocument.Get() is { } document
+				? TypeHierarchyViewModel.ResolveType(ActiveDocument.GetReferenceAtCaret(document), list)
+				: null;
+		}
+
+		internal static void Show(ITypeDefinition type, TypeHierarchyViewModel pane, DockWorkspace dockWorkspace)
+		{
+			pane.ShowHierarchy(type);
+			dockWorkspace.ShowToolPane(TypeHierarchyViewModel.PaneContentId);
+		}
+	}
+
+	/// 
+	/// Context-menu "Type Hierarchy" on a type or member, in the code view or the assembly tree.
+	/// The Navigation category also lists it in the Navigate To popup.
+	/// 
+	[ExportContextMenuEntry(Header = nameof(Resources.TypeHierarchy), Category = "Navigation",
+		InputGestureText = TypeHierarchyCommand.Gesture, Order = 150)]
+	[Shared]
+	[method: ImportingConstructor]
+	public sealed class TypeHierarchyContextMenuEntry(AssemblyTreeModel assemblyTreeModel, DockWorkspace dockWorkspace,
+		TypeHierarchyViewModel pane) : IContextMenuEntry
+	{
+		// Visibility is evaluated every time a menu opens, so an unresolved metadata reference (IL
+		// view) is judged by its handle kind instead of building a type system to resolve it.
+		public bool IsVisible(TextViewContext context)
+		{
+			if (context.SelectedTreeNodes is { Length: > 0 } || context.Reference?.Reference is not EntityReference reference)
+				return ResolveType(context) != null;
+			return reference.Protocol == "decompile" && reference.Handle.Kind is HandleKind.TypeDefinition
+				or HandleKind.TypeReference or HandleKind.MethodDefinition or HandleKind.FieldDefinition
+				or HandleKind.PropertyDefinition or HandleKind.EventDefinition or HandleKind.MemberReference;
+		}
+
+		public bool IsEnabled(TextViewContext context) => true;
+
+		public void Execute(TextViewContext context)
+		{
+			if (ResolveType(context) is { } type)
+				TypeHierarchyCommand.Show(type, pane, dockWorkspace);
+		}
+
+		ITypeDefinition? ResolveType(TextViewContext context)
+		{
+			if (context.SelectedTreeNodes is { Length: > 0 } nodes)
+			{
+				return nodes.Length == 1
+					? TypeHierarchyViewModel.ResolveType(nodes[0], assemblyTreeModel.AssemblyList)
+					: null;
+			}
+			return TypeHierarchyViewModel.ResolveType(context.Reference?.Reference, assemblyTreeModel.AssemblyList);
+		}
+	}
+}
diff --git a/ILSpy/TypeHierarchy/TypeHierarchyNode.cs b/ILSpy/TypeHierarchy/TypeHierarchyNode.cs
new file mode 100644
index 00000000000..0e0144b7634
--- /dev/null
+++ b/ILSpy/TypeHierarchy/TypeHierarchyNode.cs
@@ -0,0 +1,139 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Avalonia.Media;
+
+using CommunityToolkit.Mvvm.ComponentModel;
+
+using ICSharpCode.Decompiler.TypeSystem;
+
+namespace ICSharpCode.ILSpy.TypeHierarchy
+{
+	/// The role a  plays in the hierarchy tree.
+	public enum TypeHierarchyNodeKind
+	{
+		/// The type the hierarchy was opened for.
+		Target,
+		/// Groups the target's direct supertypes.
+		BaseTypesGroup,
+		/// Groups the target's direct subtypes and implementations.
+		DerivedTypesGroup,
+		/// A supertype; expands to its own supertypes.
+		BaseType,
+		/// A subtype or implementation; expands to its own subtypes.
+		DerivedType,
+		/// Stand-in child shown until the real children are loaded.
+		Placeholder,
+	}
+
+	/// 
+	/// One row of the type-hierarchy tree. Children are produced on demand by a loader so that
+	/// the (potentially slow) derived-type search only runs for the nodes the user expands. Until
+	/// the loader has run, a placeholder child keeps the expander visible.
+	/// 
+	public sealed partial class TypeHierarchyNode : ObservableObject
+	{
+		readonly Func>>? loader;
+		readonly CancellationToken cancellationToken;
+		Task? loading;
+
+		[ObservableProperty]
+		private bool isExpanded;
+
+		[ObservableProperty]
+		private bool isLoading;
+
+		internal TypeHierarchyNode(TypeHierarchyNodeKind kind, string text, IImage? icon, ITypeDefinition? type,
+			Func>>? loader, CancellationToken cancellationToken)
+		{
+			Kind = kind;
+			Text = text;
+			Icon = icon;
+			Type = type;
+			this.loader = loader;
+			this.cancellationToken = cancellationToken;
+			if (loader != null)
+				Children.Add(CreatePlaceholder());
+		}
+
+		public TypeHierarchyNodeKind Kind { get; }
+
+		public string Text { get; }
+
+		public IImage? Icon { get; }
+
+		/// The type this row stands for; null for group and placeholder rows.
+		public ITypeDefinition? Type { get; }
+
+		public ObservableCollection Children { get; } = new();
+
+		/// True for rows that stand for a type and can therefore be navigated to.
+		public bool IsNavigable => Type != null && Kind != TypeHierarchyNodeKind.BaseTypesGroup
+			&& Kind != TypeHierarchyNodeKind.DerivedTypesGroup;
+
+		partial void OnIsExpandedChanged(bool value)
+		{
+			if (value)
+				LoadChildrenAsync().HandleExceptions();
+		}
+
+		/// 
+		/// Runs the loader once and replaces the placeholder with the loaded children. Later calls
+		/// return the same task, so expanding a node repeatedly does not search again. A cancelled
+		/// load leaves the placeholder in place.
+		/// 
+		public Task LoadChildrenAsync() => loading ??= LoadCoreAsync();
+
+		async Task LoadCoreAsync()
+		{
+			if (loader == null)
+				return;
+			IsLoading = true;
+			try
+			{
+				IReadOnlyList loaded;
+				try
+				{
+					loaded = await loader(cancellationToken).ConfigureAwait(true);
+				}
+				catch (OperationCanceledException)
+				{
+					return;
+				}
+				Children.Clear();
+				foreach (var child in loaded)
+					Children.Add(child);
+			}
+			finally
+			{
+				IsLoading = false;
+			}
+		}
+
+		static TypeHierarchyNode CreatePlaceholder()
+			=> new(TypeHierarchyNodeKind.Placeholder, "...", null, null, null, CancellationToken.None);
+
+		public override string ToString() => Text;
+	}
+}
diff --git a/ILSpy/TypeHierarchy/TypeHierarchyView.axaml b/ILSpy/TypeHierarchy/TypeHierarchyView.axaml
new file mode 100644
index 00000000000..1df8409649c
--- /dev/null
+++ b/ILSpy/TypeHierarchy/TypeHierarchyView.axaml
@@ -0,0 +1,33 @@
+
+	
+		
+		
+		
+			
+				
+			
+			
+				
+					
+						
+						
+					
+				
+			
+		
+	
+
diff --git a/ILSpy/TypeHierarchy/TypeHierarchyView.axaml.cs b/ILSpy/TypeHierarchy/TypeHierarchyView.axaml.cs
new file mode 100644
index 00000000000..abdb259bf22
--- /dev/null
+++ b/ILSpy/TypeHierarchy/TypeHierarchyView.axaml.cs
@@ -0,0 +1,57 @@
+// Copyright (c) 2026 Piero Viano
+//
+// 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 Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+
+namespace ICSharpCode.ILSpy.TypeHierarchy
+{
+	/// 
+	/// Renders  as a tree. Double-click or Enter on a type row
+	/// navigates to that type.
+	/// 
+	public partial class TypeHierarchyView : UserControl
+	{
+		public TypeHierarchyView()
+		{
+			InitializeComponent();
+			HierarchyTree.DoubleTapped += OnTreeDoubleTapped;
+			// TreeViewItem consumes Enter to toggle expansion before the event bubbles, so listen
+			// in the tunnel phase to turn Enter on a type row into navigation.
+			HierarchyTree.AddHandler(KeyDownEvent, OnTreeKeyDown, RoutingStrategies.Tunnel, handledEventsToo: true);
+		}
+
+		void OnTreeDoubleTapped(object? sender, TappedEventArgs e)
+		{
+			if (ActivateSelection())
+				e.Handled = true;
+		}
+
+		void OnTreeKeyDown(object? sender, KeyEventArgs e)
+		{
+			if (e.Key == Key.Enter && e.KeyModifiers == KeyModifiers.None && ActivateSelection())
+				e.Handled = true;
+		}
+
+		bool ActivateSelection()
+			=> DataContext is TypeHierarchyViewModel model
+				&& HierarchyTree.SelectedItem is TypeHierarchyNode node
+				&& model.Activate(node);
+	}
+}
diff --git a/ILSpy/TypeHierarchy/TypeHierarchyViewModel.cs b/ILSpy/TypeHierarchy/TypeHierarchyViewModel.cs
new file mode 100644
index 00000000000..52c7a4ad1af
--- /dev/null
+++ b/ILSpy/TypeHierarchy/TypeHierarchyViewModel.cs
@@ -0,0 +1,217 @@
+// Copyright (c) 2026 Piero Viano
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Composition;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using CommunityToolkit.Mvvm.ComponentModel;
+
+using ICSharpCode.Decompiler.TypeSystem;
+using ICSharpCode.ILSpy.AssemblyTree;
+using ICSharpCode.ILSpy.Commands;
+using ICSharpCode.ILSpy.Languages;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.ViewModels;
+using ICSharpCode.ILSpyX;
+using ICSharpCode.ILSpyX.Analyzers;
+
+namespace ICSharpCode.ILSpy.TypeHierarchy
+{
+	/// 
+	/// The Type Hierarchy tool pane: for one target type, a tree with its supertypes (base class
+	/// and implemented interfaces, each expandable further up) and its subtypes (derived classes
+	/// and implementations across the loaded assembly list, each expandable further down).
+	/// The subtype search runs off the UI thread and is cancelled when the pane is retargeted.
+	/// 
+	[Export]
+	[ExportToolPane(ContentId = PaneContentId, Alignment = ToolPaneAlignment.Bottom, Order = 2, IsVisibleByDefault = false)]
+	[Shared]
+	public sealed partial class TypeHierarchyViewModel : ToolPaneModel
+	{
+		public const string PaneContentId = "TypeHierarchy";
+
+		readonly AssemblyTreeModel assemblyTreeModel;
+		readonly LanguageService languageService;
+		CancellationTokenSource searchCts = new();
+
+		[ObservableProperty]
+		private TypeHierarchyNode? target;
+
+		[ObservableProperty]
+		private TypeHierarchyNode? selectedNode;
+
+		[ImportingConstructor]
+		public TypeHierarchyViewModel(AssemblyTreeModel assemblyTreeModel, LanguageService languageService)
+		{
+			this.assemblyTreeModel = assemblyTreeModel;
+			this.languageService = languageService;
+			Id = PaneContentId;
+			Title = Resources.TypeHierarchy;
+		}
+
+		/// The tree's top level: the target node alone, or nothing before the first use.
+		public ObservableCollection Roots { get; } = new();
+
+		/// Token of the subtype searches started for the current target.
+		internal CancellationToken SearchCancellationToken => searchCts.Token;
+
+		/// 
+		/// Replaces the tree with the hierarchy of . Any subtype search still
+		/// running for the previous target is cancelled.
+		/// 
+		public void ShowHierarchy(ITypeDefinition type)
+		{
+			ArgumentNullException.ThrowIfNull(type);
+			searchCts.Cancel();
+			searchCts = new CancellationTokenSource();
+			var token = searchCts.Token;
+
+			var root = CreateTypeNode(TypeHierarchyNodeKind.Target, type, loader: null, token);
+			var bases = new TypeHierarchyNode(TypeHierarchyNodeKind.BaseTypesGroup, Resources.BaseTypes,
+				Images.SuperTypes, type, loader: null, token);
+			foreach (var baseNode in CreateBaseTypeNodes(type, token))
+				bases.Children.Add(baseNode);
+			var derived = new TypeHierarchyNode(TypeHierarchyNodeKind.DerivedTypesGroup, Resources.DerivedTypes,
+				Images.SubTypes, type, ct => LoadDerivedTypesAsync(type, ct), token);
+			root.Children.Add(bases);
+			root.Children.Add(derived);
+
+			Roots.Clear();
+			Roots.Add(root);
+			Target = root;
+			SelectedNode = root;
+			root.IsExpanded = true;
+			bases.IsExpanded = true;
+			derived.IsExpanded = true;
+		}
+
+		/// 
+		/// Navigates to the type behind  by selecting it in the assembly tree,
+		/// which records a navigation-history entry like any other selection. Returns false when the
+		/// node has no type or the type's assembly is not in the loaded list.
+		/// 
+		public bool Activate(TypeHierarchyNode? node)
+		{
+			if (node is not { IsNavigable: true, Type: { } type })
+				return false;
+			return assemblyTreeModel.JumpToType(type);
+		}
+
+		/// 
+		/// The type a hierarchy request is about: a type itself, the declaring type of a member, the
+		/// entity behind a tree node, or the entity an unresolved metadata reference points at.
+		/// Null for anything else (locals, opcodes, namespaces, ...).
+		/// 
+		public static ITypeDefinition? ResolveType(object? reference, AssemblyList? assemblyList = null)
+		{
+			switch (reference)
+			{
+				case ITypeDefinition definition:
+					return definition;
+				case IType type:
+					return type.GetDefinition();
+				case IMember member:
+					return member.DeclaringTypeDefinition;
+				case IMemberTreeNode node:
+					return ResolveType(node.Member, assemblyList);
+				case EntityReference entityReference when assemblyList != null:
+					var entity = entityReference.Resolve(assemblyList);
+					return entity == null ? null : ResolveType(entity, assemblyList);
+				default:
+					return null;
+			}
+		}
+
+		TypeHierarchyNode CreateTypeNode(TypeHierarchyNodeKind kind, ITypeDefinition type,
+			Func>>? loader, CancellationToken token)
+			=> new(kind, FormatType(type), TypeTreeNode.GetIcon(type), type, loader, token);
+
+		string FormatType(ITypeDefinition type)
+		{
+			try
+			{
+				return languageService.CurrentLanguage?.TypeToString(type) ?? type.FullName;
+			}
+			catch (Exception)
+			{
+				return type.FullName;
+			}
+		}
+
+		// The direct supertypes of a type are cheap to read, so they are produced synchronously;
+		// each one again expands to its own supertypes.
+		IEnumerable CreateBaseTypeNodes(ITypeDefinition type, CancellationToken token)
+		{
+			foreach (var baseType in type.DirectBaseTypes)
+			{
+				if (baseType.GetDefinition() is not { } definition)
+					continue;
+				// The type system reports System.Object as a base of every interface; an interface's
+				// supertypes are only the interfaces it extends (as in the assembly tree's Base Types).
+				if (type.Kind == TypeKind.Interface && definition.Kind != TypeKind.Interface)
+					continue;
+				yield return CreateTypeNode(TypeHierarchyNodeKind.BaseType, definition,
+					ct => Task.FromResult>(CreateBaseTypeNodes(definition, ct).ToList()),
+					token);
+			}
+		}
+
+		async Task> LoadDerivedTypesAsync(ITypeDefinition type, CancellationToken token)
+		{
+			var assemblyList = assemblyTreeModel.AssemblyList;
+			if (assemblyList == null)
+				return Array.Empty();
+			var found = await Task.Run(() => FindDirectSubtypes(assemblyList, type, token), token).ConfigureAwait(true);
+			token.ThrowIfCancellationRequested();
+			return found
+				.Select(t => CreateTypeNode(TypeHierarchyNodeKind.DerivedType, t, ct => LoadDerivedTypesAsync(t, ct), token))
+				.ToList();
+		}
+
+		/// 
+		/// Every type in the scope that can see  whose direct base list names
+		/// it: derived classes for a class, implementations and derived interfaces for an interface.
+		/// Mirrors the matching done by the assembly tree's Derived Types node.
+		/// 
+		internal static List FindDirectSubtypes(AssemblyList assemblyList, ITypeDefinition type, CancellationToken token)
+		{
+			var result = new List();
+			var scope = new AnalyzerScope(assemblyList, type);
+			foreach (var candidate in scope.GetTypesInScope(token))
+			{
+				token.ThrowIfCancellationRequested();
+				foreach (var baseType in candidate.DirectBaseTypes)
+				{
+					if (baseType.FullName == type.FullName && baseType.TypeParameterCount == type.TypeParameterCount)
+					{
+						result.Add(candidate);
+						break;
+					}
+				}
+			}
+			result.Sort((a, b) => string.CompareOrdinal(a.FullName, b.FullName));
+			return result;
+		}
+	}
+}
diff --git a/ILSpy/Views/MainMenu.axaml.cs b/ILSpy/Views/MainMenu.axaml.cs
index 710d347009a..09a8c2692ca 100644
--- a/ILSpy/Views/MainMenu.axaml.cs
+++ b/ILSpy/Views/MainMenu.axaml.cs
@@ -59,15 +59,22 @@ public static void Attach(Window window)
 		var menu = new NativeMenu();
 		var topLevelByTag = new Dictionary(StringComparer.Ordinal);
 
-		// Pre-build the three known top-level slots so MEF commands with ParentMenuID set
-		// to "_File" / "_View" / "_Window" attach to them. Additional top-level groups
+		// Pre-build the known top-level slots so MEF commands with ParentMenuID set
+		// to "_File" / "_View" / "_Navigate" / "_Window" attach to them. Additional top-level groups
 		// can still be created later from MEF metadata.
 		AddTopLevel(menu, topLevelByTag, "_File", nameof(Resources._File));
 		var viewItem = AddTopLevel(menu, topLevelByTag, "_View", nameof(Resources._View));
+		var navigateItem = AddTopLevel(menu, topLevelByTag, "_Navigate", nameof(Resources._Navigate));
 		var windowItem = AddTopLevel(menu, topLevelByTag, "_Window", nameof(Resources._Window));
 
 		PopulateViewMenu(viewItem.Menu!, settings.SessionSettings, setThemeCommand);
 		AppendRegistryCommands(menu, topLevelByTag, registry.Commands);
+		// A top-level slot no command attached to would render as an empty, unclickable menu.
+		if (navigateItem.Menu!.Items.Count == 0)
+		{
+			menu.Items.Remove(navigateItem);
+			topLevelByTag.Remove("_Navigate");
+		}
 		AppendWindowDynamicContent(windowItem.Menu!, dockWorkspace);
 
 		if (OperatingSystem.IsMacOS())