Browse Source

Add Go to Definition and folding toggles to the code context menu

The decompiler text-view context menu was missing the navigation and folding entries the previous version had. Add Decompile (go to definition) on a right-clicked symbol -- it navigates the tree to the entity's definition via the existing NavigateToReference bus message -- and Toggle folding / Toggle all folding, which reuse the editor's existing Ctrl+M logic (extracted into public DecompilerTextView methods). The folding entries only show when the document has foldings.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
5f10fd9e70
  1. 70
      ILSpy.Tests/ContextMenus/DecompileReferenceContextMenuTests.cs
  2. 84
      ILSpy.Tests/Editor/FoldingContextMenuTests.cs
  3. 47
      ILSpy/Commands/DecompileReferenceContextMenuEntry.cs
  4. 54
      ILSpy/Commands/FoldingContextMenuEntries.cs
  5. 74
      ILSpy/TextView/DecompilerTextView.axaml.cs

70
ILSpy.Tests/ContextMenus/DecompileReferenceContextMenuTests.cs

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Threading.Tasks;
using Avalonia.Headless.NUnit;
using Avalonia.Threading;
using AwesomeAssertions;
using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpy.Properties;
using ILSpy;
using ILSpy.AppEnv;
using ILSpy.TextView;
using ILSpy.TreeNodes;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class DecompileReferenceContextMenuTests
{
[AvaloniaTest]
public async Task Decompile_On_A_Code_Reference_Navigates_To_The_Entity_Definition()
{
// Right-clicking a symbol in the decompiled code and choosing "Decompile" (go to definition)
// must move the assembly tree to that entity's definition node, like clicking the hyperlink.
var (_, vm) = await TestHarness.BootAsync(3);
var entry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>()
.GetEntry(nameof(Resources.Decompile));
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
var entity = (IEntity)typeNode.Member!;
var context = new TextViewContext { Reference = new ReferenceSegment { Reference = entity } };
entry.IsVisible(context).Should().BeTrue("a clicked entity reference must surface Decompile/go-to-definition");
// Park the selection elsewhere so the jump is observable.
var other = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
vm.AssemblyTreeModel.SelectNode(other);
ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, other).Should().BeTrue("precondition: parked on another node");
entry.Execute(context);
Dispatcher.UIThread.RunJobs();
var navigatedTo = (vm.AssemblyTreeModel.SelectedItem as IMemberTreeNode)?.Member;
((object?)navigatedTo).Should().NotBeNull("Decompile must navigate to the entity's definition node");
navigatedTo!.MetadataToken.Should().Be(entity.MetadataToken,
"the navigated node must be the clicked entity's definition");
}
}

84
ILSpy.Tests/Editor/FoldingContextMenuTests.cs

@ -0,0 +1,84 @@ @@ -0,0 +1,84 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Threading.Tasks;
using Avalonia.Headless.NUnit;
using Avalonia.Threading;
using AwesomeAssertions;
using ICSharpCode.ILSpy.Properties;
using ILSpy;
using ILSpy.AppEnv;
using ILSpy.TextView;
using ILSpy.TreeNodes;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class FoldingContextMenuTests
{
[AvaloniaTest]
public async Task Toggle_All_Folding_Entry_Collapses_Then_Expands_The_Document()
{
// Decompiling a type yields brace foldings; the "Toggle all folding" menu entry must collapse
// them all, then expand them all on a second invocation -- via DecompilerTextView.ToggleAllFoldings.
var (window, vm) = await TestHarness.BootAsync(3);
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectNode(typeNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var view = await window.WaitForComponent<DecompilerTextView>();
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
view.HasFoldings.Should().BeTrue("decompiling a type produces brace foldings");
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
var toggleAll = registry.GetEntry(nameof(Resources.ToggleFolding)); // "Toggle All Folding"
var toggleOne = registry.GetEntry(nameof(Resources._ToggleFolding)); // "Toggle Folding"
var context = new TextViewContext { TextView = view };
toggleAll.IsVisible(context).Should().BeTrue("the folding entries show when the document has foldings");
toggleOne.IsVisible(context).Should().BeTrue();
toggleAll.IsVisible(new TextViewContext { TextView = null }).Should()
.BeFalse("with no text view there is nothing to fold");
// Toggle-all forces a uniform state: collapse-all when any fold is open, otherwise expand-all.
// So consecutive invocations flip between "fully collapsed" (>0) and "fully expanded" (0).
toggleAll.Execute(context);
Dispatcher.UIThread.RunJobs();
int after1 = view.FoldedFoldingCount;
toggleAll.Execute(context);
Dispatcher.UIThread.RunJobs();
int after2 = view.FoldedFoldingCount;
after1.Should().NotBe(after2, "Toggle all folding must flip the fold state");
System.Math.Min(after1, after2).Should().Be(0, "one toggle state is fully expanded");
System.Math.Max(after1, after2).Should().BeGreaterThan(0, "the other toggle state is fully collapsed");
}
}

47
ILSpy/Commands/DecompileReferenceContextMenuEntry.cs

@ -0,0 +1,47 @@ @@ -0,0 +1,47 @@
// 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 ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpy.Properties;
namespace ILSpy.Commands
{
/// <summary>
/// Right-click → "Decompile" (go to definition) on a symbol in the decompiled code. Navigates
/// the assembly tree to the clicked entity's definition and decompiles it -- the menu equivalent
/// of clicking the hyperlink. Visible only when the click landed on a reference that resolves to
/// an <see cref="IEntity"/>.
/// </summary>
[ExportContextMenuEntry(Header = nameof(Resources.Decompile), Category = "Navigation", Order = 10)]
[Shared]
public sealed class DecompileReferenceContextMenuEntry : IContextMenuEntry
{
public bool IsVisible(TextViewContext context) => context.Reference?.Reference is IEntity;
public bool IsEnabled(TextViewContext context) => true;
public void Execute(TextViewContext context)
{
if (context.Reference?.Reference is not IEntity entity)
return;
Util.MessageBus.Send(this, new Util.NavigateToReferenceEventArgs(entity));
}
}
}

54
ILSpy/Commands/FoldingContextMenuEntries.cs

@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
// 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 ICSharpCode.ILSpy.Properties;
namespace ILSpy.Commands
{
/// <summary>
/// Right-click in the decompiled code → "Toggle folding": folds/unfolds the innermost block at the
/// caret. The menu equivalent of Ctrl+M. Only shown when the document actually has foldings.
/// </summary>
[ExportContextMenuEntry(Header = nameof(Resources._ToggleFolding), Category = nameof(Resources.Folding), Order = 300)]
[Shared]
public sealed class ToggleFoldingContextMenuEntry : IContextMenuEntry
{
public bool IsVisible(TextViewContext context) => context.TextView?.HasFoldings == true;
public bool IsEnabled(TextViewContext context) => true;
public void Execute(TextViewContext context) => context.TextView?.ToggleFoldingAtCaret();
}
/// <summary>
/// Right-click in the decompiled code → "Toggle all folding": collapses every fold when any is open,
/// otherwise expands them all. The menu equivalent of Ctrl+Shift+M.
/// </summary>
[ExportContextMenuEntry(Header = nameof(Resources.ToggleFolding), Category = nameof(Resources.Folding), Order = 310)]
[Shared]
public sealed class ToggleAllFoldingsContextMenuEntry : IContextMenuEntry
{
public bool IsVisible(TextViewContext context) => context.TextView?.HasFoldings == true;
public bool IsEnabled(TextViewContext context) => true;
public void Execute(TextViewContext context) => context.TextView?.ToggleAllFoldings();
}
}

74
ILSpy/TextView/DecompilerTextView.axaml.cs

@ -254,6 +254,47 @@ namespace ILSpy.TextView @@ -254,6 +254,47 @@ namespace ILSpy.TextView
e.Handled = true;
}
/// <summary>True when the active document has any code foldings (gates the folding menu entries).</summary>
public bool HasFoldings => activeFoldingManager is { } mgr && mgr.AllFoldings.Any();
/// <summary>Number of currently-collapsed folds (test observation point for the folding commands).</summary>
internal int FoldedFoldingCount => activeFoldingManager is { } mgr ? mgr.AllFoldings.Count(f => f.IsFolded) : 0;
/// <summary>Toggles the innermost fold containing the caret (the "Toggle folding" command / Ctrl+M).</summary>
public void ToggleFoldingAtCaret()
{
if (activeFoldingManager is not { } mgr)
return;
var caret = Editor.TextArea.Caret.Offset;
FoldingSection? target = null;
foreach (var f in mgr.AllFoldings)
{
if (f.StartOffset <= caret && caret <= f.EndOffset)
{
if (target == null || f.StartOffset > target.StartOffset)
target = f;
}
}
if (target != null)
target.IsFolded = !target.IsFolded;
}
/// <summary>Collapses every fold when any is open, otherwise expands them all ("Toggle all folding"
/// / Ctrl+Shift+M).</summary>
public void ToggleAllFoldings()
{
if (activeFoldingManager is not { } mgr)
return;
bool anyOpen = false;
foreach (var f in mgr.AllFoldings)
{
if (!f.IsFolded)
{ anyOpen = true; break; }
}
foreach (var f in mgr.AllFoldings)
f.IsFolded = anyOpen;
}
void OnEditorKeyDownForZoom(object? sender, KeyEventArgs e)
{
// Folding keyboard commands (Ctrl+M family). Mirrors typical IDE conventions:
@ -263,37 +304,12 @@ namespace ILSpy.TextView @@ -263,37 +304,12 @@ namespace ILSpy.TextView
// machine wired through the TextArea which AvaloniaEdit doesn't ship.
if ((e.KeyModifiers & KeyModifiers.Control) == KeyModifiers.Control && e.Key == Key.M)
{
if (activeFoldingManager is { } mgr)
if (activeFoldingManager is not null)
{
bool shift = (e.KeyModifiers & KeyModifiers.Shift) == KeyModifiers.Shift;
if (shift)
{
// Collapse all when any fold is open; expand all otherwise.
bool anyOpen = false;
foreach (var f in mgr.AllFoldings)
{
if (!f.IsFolded)
{ anyOpen = true; break; }
}
foreach (var f in mgr.AllFoldings)
f.IsFolded = anyOpen;
}
if ((e.KeyModifiers & KeyModifiers.Shift) == KeyModifiers.Shift)
ToggleAllFoldings();
else
{
// Toggle the innermost fold containing the caret offset.
var caret = Editor.TextArea.Caret.Offset;
FoldingSection? target = null;
foreach (var f in mgr.AllFoldings)
{
if (f.StartOffset <= caret && caret <= f.EndOffset)
{
if (target == null || f.StartOffset > target.StartOffset)
target = f;
}
}
if (target != null)
target.IsFolded = !target.IsFolded;
}
ToggleFoldingAtCaret();
e.Handled = true;
return;
}

Loading…
Cancel
Save