diff --git a/ILSpy.Tests/Analyzers/AnalyzerNavigationTests.cs b/ILSpy.Tests/Analyzers/AnalyzerNavigationTests.cs new file mode 100644 index 000000000..54d6fa090 --- /dev/null +++ b/ILSpy.Tests/Analyzers/AnalyzerNavigationTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpyX.TreeView.PlatformAbstractions; + +using ILSpy.Analyzers.TreeNodes; +using ILSpy.AppEnv; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Analyzers; + +[TestFixture] +public class AnalyzerNavigationTests +{ + sealed class StubRoutedEventArgs : IPlatformRoutedEventArgs + { + public bool Handled { get; set; } + } + + [AvaloniaTest] + public async Task ActivateItem_On_An_AnalyzedTypeTreeNode_Selects_That_TypeTreeNode_In_The_Assembly_Tree() + { + // Double-click on an analyser result must navigate back to the entity's home in + // the assembly tree — same UX as clicking a hyperlink in the decompiler view. + + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var entity = (ICSharpCode.Decompiler.TypeSystem.ITypeDefinition)typeNode.Member!; + + var analyzed = new AnalyzedTypeTreeNode(entity, source: null); + analyzed.ActivateItem(new StubRoutedEventArgs()); + + ((object?)vm.AssemblyTreeModel.SelectedItem).Should().BeSameAs(typeNode, + "ActivateItem must move the assembly-tree selection to the entity's tree node"); + } + + [AvaloniaTest] + public async Task ActivateItem_On_An_AnalyzedMethodTreeNode_Selects_The_MethodTreeNode_Sibling() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + typeNode.EnsureLazyChildren(); + var methodTreeNode = typeNode.Children + .OfType().First(n => n.MethodDefinition.Name == "Empty"); + var method = (ICSharpCode.Decompiler.TypeSystem.IMethod)methodTreeNode.Member!; + + var analyzed = new AnalyzedMethodTreeNode(method, source: null); + analyzed.ActivateItem(new StubRoutedEventArgs()); + + ((object?)vm.AssemblyTreeModel.SelectedItem).Should().BeSameAs(methodTreeNode, + "ActivateItem must walk down to the right method tree-node under its declaring type"); + } +} diff --git a/ILSpy.Tests/Analyzers/AnalyzerPaneCopyResultsTests.cs b/ILSpy.Tests/Analyzers/AnalyzerPaneCopyResultsTests.cs new file mode 100644 index 000000000..c60aa3aef --- /dev/null +++ b/ILSpy.Tests/Analyzers/AnalyzerPaneCopyResultsTests.cs @@ -0,0 +1,118 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpyX.TreeView; + +using ILSpy; +using ILSpy.Analyzers; +using ILSpy.AppEnv; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Analyzers; + +[TestFixture] +public class AnalyzerPaneCopyResultsTests +{ + [AvaloniaTest] + public async Task CopyResults_Entry_Is_Visible_For_AnalyzerSearchTreeNode_And_Hidden_Otherwise() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var registry = AppComposition.Current.GetExport(); + var entry = registry.Entries + .Single(e => e.Metadata.Header == "Copy results") + .Value; + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var target = (ICSharpCode.Decompiler.TypeSystem.ITypeDefinition)typeNode.Member!; + var analyzer = AnalyzerTreeNode.Analyzers + .Where(a => a.Metadata?.Header == "Used By") + .Select(a => a.CreateExport().Value) + .First(a => a.Show(target)); + var search = new AnalyzerSearchTreeNode(target, analyzer, "Used By"); + + entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { search } }) + .Should().BeTrue("Copy results applies to analyzer search-node rows"); + + entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { typeNode } }) + .Should().BeFalse("the entry must NOT surface on the assembly tree"); + } + + [AvaloniaTest] + public async Task CopyResults_Execute_Writes_Each_Child_On_Its_Own_Line() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var registry = AppComposition.Current.GetExport(); + var entry = registry.Entries + .Single(e => e.Metadata.Header == "Copy results") + .Value; + + // Build a search node and stuff some deterministic children in directly, bypassing + // the analyzer altogether — the copy entry should only care about Text values. + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var target = (ICSharpCode.Decompiler.TypeSystem.ITypeDefinition)typeNode.Member!; + var analyzer = AnalyzerTreeNode.Analyzers + .Where(a => a.Metadata?.Header == "Used By") + .Select(a => a.CreateExport().Value) + .First(a => a.Show(target)); + var search = new AnalyzerSearchTreeNode(target, analyzer, "Used By"); + search.Children.Add(new StubResultNode("alpha")); + search.Children.Add(new StubResultNode("beta")); + + entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { search } }); + + // We can't inspect the clipboard contract in a headless test without a window with + // a real lifetime — the test asserts the entry handled execution without throwing + // and the children Text values are still intact (i.e. Execute didn't mutate them). + search.Children[0].Text.Should().Be("alpha"); + search.Children[1].Text.Should().Be("beta"); + } + + sealed class StubResultNode : AnalyzerTreeNode + { + readonly string text; + + public StubResultNode(string text) { this.text = text; } + + public override object Text => text; + + public override bool HandleAssemblyListChanged( + System.Collections.Generic.ICollection removedAssemblies, + System.Collections.Generic.ICollection addedAssemblies) => true; + } +} diff --git a/ILSpy.Tests/Analyzers/AnalyzerPaneRemoveTests.cs b/ILSpy.Tests/Analyzers/AnalyzerPaneRemoveTests.cs new file mode 100644 index 000000000..ccd7e0d38 --- /dev/null +++ b/ILSpy.Tests/Analyzers/AnalyzerPaneRemoveTests.cs @@ -0,0 +1,94 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpyX.TreeView; + +using ILSpy; +using ILSpy.Analyzers; +using ILSpy.AppEnv; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Analyzers; + +[TestFixture] +public class AnalyzerPaneRemoveTests +{ + [AvaloniaTest] + public async Task Remove_Entry_Is_Visible_Only_For_Top_Level_Analysed_Entities() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var registry = AppComposition.Current.GetExport(); + var entry = registry.Entries + .Single(e => e.Metadata.Header == "Remove" + && e.Value is RemoveAnalyzeContextMenuEntry) + .Value; + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var analyzerVm = AppComposition.Current.GetExport(); + var added = analyzerVm.Analyze((ICSharpCode.Decompiler.TypeSystem.IEntity)typeNode.Member!); + + entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { added } }) + .Should().BeTrue("Remove applies to top-level analysed entities"); + + entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { typeNode } }) + .Should().BeFalse("Remove must not surface on the assembly tree"); + } + + [AvaloniaTest] + public async Task Remove_Execute_Drops_The_Selected_Root_Children() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var registry = AppComposition.Current.GetExport(); + var entry = registry.Entries + .Single(e => e.Metadata.Header == "Remove" + && e.Value is RemoveAnalyzeContextMenuEntry) + .Value; + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var analyzerVm = AppComposition.Current.GetExport(); + var added = analyzerVm.Analyze((ICSharpCode.Decompiler.TypeSystem.IEntity)typeNode.Member!); + var beforeCount = analyzerVm.Root.Children.Count; + + entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { added } }); + + analyzerVm.Root.Children.Should().NotContain(added, + "Execute on Remove must drop the row from the pane root"); + analyzerVm.Root.Children.Count.Should().Be(beforeCount - 1); + } +} diff --git a/ILSpy/Analyzers/AnalyzedModuleTreeNode.cs b/ILSpy/Analyzers/AnalyzedModuleTreeNode.cs index 4afb4abf3..881af7f11 100644 --- a/ILSpy/Analyzers/AnalyzedModuleTreeNode.cs +++ b/ILSpy/Analyzers/AnalyzedModuleTreeNode.cs @@ -18,9 +18,15 @@ using System; using System.Collections.Generic; +using System.Linq; using ICSharpCode.Decompiler.TypeSystem; using ICSharpCode.ILSpyX; +using ICSharpCode.ILSpyX.TreeView.PlatformAbstractions; + +using ILSpy.AppEnv; +using ILSpy.AssemblyTree; +using ILSpy.TreeNodes; namespace ILSpy.Analyzers.TreeNodes { @@ -50,6 +56,32 @@ namespace ILSpy.Analyzers.TreeNodes public IModule Module => analyzedModule; + public override void ActivateItem(IPlatformRoutedEventArgs e) + { + e.Handled = true; + if (analyzedModule.MetadataFile == null) + return; + try + { + var assemblyTreeModel = AppComposition.Current.GetExport(); + var loaded = assemblyTreeModel.AssemblyList? + .GetAssemblies() + .FirstOrDefault(a => a.GetMetadataFileOrNull() == analyzedModule.MetadataFile); + if (loaded == null) + return; + var listRoot = assemblyTreeModel.Root as AssemblyListTreeNode; + if (listRoot == null) + return; + var node = listRoot.FindAssemblyNode(loaded); + if (node != null) + assemblyTreeModel.SelectedItem = node; + } + catch + { + // Design-time / minimal-test path. + } + } + protected override void LoadChildren() { foreach (var factory in Analyzers) diff --git a/ILSpy/Analyzers/AnalyzerEntityTreeNode.cs b/ILSpy/Analyzers/AnalyzerEntityTreeNode.cs index 6134a6ce2..44be4fa15 100644 --- a/ILSpy/Analyzers/AnalyzerEntityTreeNode.cs +++ b/ILSpy/Analyzers/AnalyzerEntityTreeNode.cs @@ -21,6 +21,10 @@ using System.Collections.Generic; using ICSharpCode.Decompiler.TypeSystem; using ICSharpCode.ILSpyX; using ICSharpCode.ILSpyX.TreeView; +using ICSharpCode.ILSpyX.TreeView.PlatformAbstractions; + +using ILSpy.AppEnv; +using ILSpy.AssemblyTree; namespace ILSpy.Analyzers { @@ -33,6 +37,23 @@ namespace ILSpy.Analyzers /// public abstract class AnalyzerEntityTreeNode : AnalyzerTreeNode { + public override void ActivateItem(IPlatformRoutedEventArgs e) + { + e.Handled = true; + if (Member == null || Member.MetadataToken.IsNil) + return; + try + { + var assemblyTreeModel = AppComposition.Current.GetExport(); + if (assemblyTreeModel.FindTreeNode(Member) is { } resolved) + assemblyTreeModel.SelectedItem = resolved; + } + catch + { + // Composition isn't available in design-time previews — gesture is a no-op there. + } + } + /// /// The entity this row analyses (or represents as an analyser result). Subclasses /// return only for non-entity rows such as diff --git a/ILSpy/Analyzers/AnalyzerTreeView.axaml.cs b/ILSpy/Analyzers/AnalyzerTreeView.axaml.cs index 2e9b6dfa6..00f474f04 100644 --- a/ILSpy/Analyzers/AnalyzerTreeView.axaml.cs +++ b/ILSpy/Analyzers/AnalyzerTreeView.axaml.cs @@ -21,10 +21,16 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.DataGridHierarchical; +using Avalonia.Input; +using Avalonia.VisualTree; using ICSharpCode.ILSpyX.TreeView; +using ICSharpCode.ILSpyX.TreeView.PlatformAbstractions; + +using ILSpy.AppEnv; namespace ILSpy.Analyzers { @@ -32,10 +38,85 @@ namespace ILSpy.Analyzers { bool syncingSelection; AnalyzerTreeViewModel? boundModel; + IReadOnlyList contextMenuEntries = Array.Empty(); public AnalyzerTreeView() { InitializeComponent(); + TreeGrid.DoubleTapped += OnTreeGridDoubleTapped; + var registry = TryGetContextMenuRegistry(); + AttachContextMenu(registry?.Entries ?? Array.Empty()); + } + + static ContextMenuEntryRegistry? TryGetContextMenuRegistry() + { + try + { + return AppComposition.Current.GetExport(); + } + catch + { + return null; + } + } + + internal void AttachContextMenu(IReadOnlyList entries) + { + contextMenuEntries = entries; + var menu = new ContextMenu(); + menu.Opening += OnContextMenuOpening; + TreeGrid.ContextMenu = menu; + } + + void OnContextMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e) + { + if (sender is not ContextMenu menu) + return; + var built = BuildContextMenuForCurrentState(contextMenuEntries); + if (built == null) + { + e.Cancel = true; + return; + } + menu.Items.Clear(); + foreach (var item in built.Items.OfType().ToArray()) + { + built.Items.Remove(item); + menu.Items.Add(item); + } + } + + internal ContextMenu? BuildContextMenuForCurrentState(IReadOnlyList entries) + => ContextMenuProvider.Build(entries, CreateContextMenuContext()); + + TextViewContext CreateContextMenuContext() + { + var nodes = boundModel?.SelectedItems.ToArray() ?? Array.Empty(); + return new TextViewContext { + TreeGrid = TreeGrid, + SelectedTreeNodes = nodes, + }; + } + + void OnTreeGridDoubleTapped(object? sender, TappedEventArgs e) + { + // Walk up the visual tree to find the row's HierarchicalNode wrapper, unwrap it + // to the SharpTreeNode, then route to ActivateItem so the entity row navigates + // back to its node in the assembly tree. + var visual = e.Source as Visual; + while (visual != null && visual.DataContext is not HierarchicalNode) + visual = visual.GetVisualParent(); + if (visual?.DataContext is not HierarchicalNode hn) + return; + if (hn.Item is not SharpTreeNode node) + return; + node.ActivateItem(new StubRoutedEventArgs()); + e.Handled = true; + } + + sealed class StubRoutedEventArgs : IPlatformRoutedEventArgs + { + public bool Handled { get; set; } } protected override void OnDataContextChanged(EventArgs e) diff --git a/ILSpy/Analyzers/CopyAnalysisResultsContextMenuEntry.cs b/ILSpy/Analyzers/CopyAnalysisResultsContextMenuEntry.cs new file mode 100644 index 000000000..15727ae9c --- /dev/null +++ b/ILSpy/Analyzers/CopyAnalysisResultsContextMenuEntry.cs @@ -0,0 +1,75 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Composition; +using System.Linq; +using System.Text; + +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Input.Platform; + +namespace ILSpy.Analyzers +{ + /// + /// Right-click on an analyser-result row → "Copy results" — puts each child node's + /// Text on the clipboard, one per line. Visible only when the selection is an + /// (the headers under an analysed entity). + /// + [ExportContextMenuEntry(Header = "Copy results", Order = 9100)] + [Shared] + public sealed class CopyAnalysisResultsContextMenuEntry : IContextMenuEntry + { + public bool IsVisible(TextViewContext context) + { + if (context.SelectedTreeNodes is not { Length: > 0 } nodes) + return false; + return nodes.All(n => n is AnalyzerSearchTreeNode); + } + + public bool IsEnabled(TextViewContext context) => IsVisible(context); + + public void Execute(TextViewContext context) + { + if (context.SelectedTreeNodes is not { Length: > 0 } nodes) + return; + var builder = new StringBuilder(); + foreach (var node in nodes.OfType()) + { + foreach (var child in node.Children) + { + var text = child.Text?.ToString(); + if (!string.IsNullOrEmpty(text)) + builder.AppendLine(text); + } + } + if (builder.Length == 0) + return; + TryWriteToClipboard(builder.ToString()); + } + + static void TryWriteToClipboard(string payload) + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime lifetime + || lifetime.MainWindow?.Clipboard is not { } clipboard) + return; + // SetTextAsync is the extension method on IClipboard — needs Avalonia.Input.Platform. + _ = clipboard.SetTextAsync(payload); + } + } +} diff --git a/ILSpy/Analyzers/RemoveAnalyzeContextMenuEntry.cs b/ILSpy/Analyzers/RemoveAnalyzeContextMenuEntry.cs new file mode 100644 index 000000000..6efc53ac9 --- /dev/null +++ b/ILSpy/Analyzers/RemoveAnalyzeContextMenuEntry.cs @@ -0,0 +1,56 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Composition; +using System.Linq; + +using ICSharpCode.ILSpyX.TreeView; + +namespace ILSpy.Analyzers +{ + /// + /// Right-click on a top-level analysed entity → "Remove" — drops the row from the + /// pane root. Only visible on rows whose parent is the analyzer root (the per-entity + /// rows); search-tree-node headers and result rows can't be removed individually. + /// + [ExportContextMenuEntry(Header = "Remove", Order = 9200)] + [Shared] + public sealed class RemoveAnalyzeContextMenuEntry : IContextMenuEntry + { + public bool IsVisible(TextViewContext context) + { + if (context.SelectedTreeNodes is not { Length: > 0 } nodes) + return false; + return nodes.All(IsRemovableRoot); + } + + public bool IsEnabled(TextViewContext context) => IsVisible(context); + + public void Execute(TextViewContext context) + { + if (context.SelectedTreeNodes is not { Length: > 0 } nodes) + return; + foreach (var node in nodes.Where(IsRemovableRoot).ToArray()) + node.Parent?.Children.Remove(node); + } + + static bool IsRemovableRoot(SharpTreeNode node) + => node is AnalyzerTreeNode and AnalyzerEntityTreeNode + && node.Parent is { IsRoot: true }; + } +}