mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
Three pieces complete the analyzer feature. Assisted-by: Claude:claude-opus-4-7:Claude Codepull/3755/head
8 changed files with 567 additions and 0 deletions
@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
// software and associated documentation files (the "Software"), to deal in the Software
|
||||
// without restriction, including without limitation the rights to use, copy, modify, merge,
|
||||
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
|
||||
// to whom the Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all copies or
|
||||
// substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
||||
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
using System.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<MainWindow>(); |
||||
window.Show(); |
||||
var vm = (MainWindowViewModel)window.DataContext!; |
||||
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); |
||||
|
||||
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( |
||||
"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<MainWindow>(); |
||||
window.Show(); |
||||
var vm = (MainWindowViewModel)window.DataContext!; |
||||
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); |
||||
|
||||
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( |
||||
"System.Linq", "System.Linq", "System.Linq.Enumerable"); |
||||
typeNode.EnsureLazyChildren(); |
||||
var methodTreeNode = typeNode.Children |
||||
.OfType<MethodTreeNode>().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"); |
||||
} |
||||
} |
||||
@ -0,0 +1,118 @@
@@ -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<MainWindow>(); |
||||
window.Show(); |
||||
var vm = (MainWindowViewModel)window.DataContext!; |
||||
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); |
||||
|
||||
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>(); |
||||
var entry = registry.Entries |
||||
.Single(e => e.Metadata.Header == "Copy results") |
||||
.Value; |
||||
|
||||
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( |
||||
"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<MainWindow>(); |
||||
window.Show(); |
||||
var vm = (MainWindowViewModel)window.DataContext!; |
||||
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); |
||||
|
||||
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>(); |
||||
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<TypeTreeNode>( |
||||
"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<ICSharpCode.ILSpyX.LoadedAssembly> removedAssemblies, |
||||
System.Collections.Generic.ICollection<ICSharpCode.ILSpyX.LoadedAssembly> addedAssemblies) => true; |
||||
} |
||||
} |
||||
@ -0,0 +1,94 @@
@@ -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<MainWindow>(); |
||||
window.Show(); |
||||
var vm = (MainWindowViewModel)window.DataContext!; |
||||
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); |
||||
|
||||
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>(); |
||||
var entry = registry.Entries |
||||
.Single(e => e.Metadata.Header == "Remove" |
||||
&& e.Value is RemoveAnalyzeContextMenuEntry) |
||||
.Value; |
||||
|
||||
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( |
||||
"System.Linq", "System.Linq", "System.Linq.Enumerable"); |
||||
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>(); |
||||
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<MainWindow>(); |
||||
window.Show(); |
||||
var vm = (MainWindowViewModel)window.DataContext!; |
||||
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); |
||||
|
||||
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>(); |
||||
var entry = registry.Entries |
||||
.Single(e => e.Metadata.Header == "Remove" |
||||
&& e.Value is RemoveAnalyzeContextMenuEntry) |
||||
.Value; |
||||
|
||||
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( |
||||
"System.Linq", "System.Linq", "System.Linq.Enumerable"); |
||||
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>(); |
||||
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); |
||||
} |
||||
} |
||||
@ -0,0 +1,75 @@
@@ -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 |
||||
{ |
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="AnalyzerSearchTreeNode"/> (the headers under an analysed entity).
|
||||
/// </summary>
|
||||
[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<AnalyzerSearchTreeNode>()) |
||||
{ |
||||
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); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,56 @@
@@ -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 |
||||
{ |
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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 }; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue