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. 64
      ILSpy/TextView/DecompilerTextView.axaml.cs

70
ILSpy.Tests/ContextMenus/DecompileReferenceContextMenuTests.cs

@ -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 @@
// 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 @@
// 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 @@
// 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();
}
}

64
ILSpy/TextView/DecompilerTextView.axaml.cs

@ -254,21 +254,37 @@ namespace ILSpy.TextView
e.Handled = true; e.Handled = true;
} }
void OnEditorKeyDownForZoom(object? sender, KeyEventArgs e) /// <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()
{ {
// Folding keyboard commands (Ctrl+M family). Mirrors typical IDE conventions: if (activeFoldingManager is not { } mgr)
// Ctrl+M Ctrl+M-ish toggle for the fold nearest the caret; Ctrl+Shift+M for return;
// collapse-all / expand-all by parity check. Implemented as single-key chords var caret = Editor.TextArea.Caret.Offset;
// (Ctrl+M / Ctrl+Shift+M) — full two-key chord support would need a key-state FoldingSection? target = null;
// machine wired through the TextArea which AvaloniaEdit doesn't ship. foreach (var f in mgr.AllFoldings)
if ((e.KeyModifiers & KeyModifiers.Control) == KeyModifiers.Control && e.Key == Key.M)
{ {
if (activeFoldingManager is { } mgr) if (f.StartOffset <= caret && caret <= f.EndOffset)
{ {
bool shift = (e.KeyModifiers & KeyModifiers.Shift) == KeyModifiers.Shift; if (target == null || f.StartOffset > target.StartOffset)
if (shift) 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()
{ {
// Collapse all when any fold is open; expand all otherwise. if (activeFoldingManager is not { } mgr)
return;
bool anyOpen = false; bool anyOpen = false;
foreach (var f in mgr.AllFoldings) foreach (var f in mgr.AllFoldings)
{ {
@ -278,22 +294,22 @@ namespace ILSpy.TextView
foreach (var f in mgr.AllFoldings) foreach (var f in mgr.AllFoldings)
f.IsFolded = anyOpen; f.IsFolded = anyOpen;
} }
else
void OnEditorKeyDownForZoom(object? sender, KeyEventArgs e)
{ {
// Toggle the innermost fold containing the caret offset. // Folding keyboard commands (Ctrl+M family). Mirrors typical IDE conventions:
var caret = Editor.TextArea.Caret.Offset; // Ctrl+M Ctrl+M-ish toggle for the fold nearest the caret; Ctrl+Shift+M for
FoldingSection? target = null; // collapse-all / expand-all by parity check. Implemented as single-key chords
foreach (var f in mgr.AllFoldings) // (Ctrl+M / Ctrl+Shift+M) — full two-key chord support would need a key-state
// machine wired through the TextArea which AvaloniaEdit doesn't ship.
if ((e.KeyModifiers & KeyModifiers.Control) == KeyModifiers.Control && e.Key == Key.M)
{ {
if (f.StartOffset <= caret && caret <= f.EndOffset) if (activeFoldingManager is not null)
{ {
if (target == null || f.StartOffset > target.StartOffset) if ((e.KeyModifiers & KeyModifiers.Shift) == KeyModifiers.Shift)
target = f; ToggleAllFoldings();
} else
} ToggleFoldingAtCaret();
if (target != null)
target.IsFolded = !target.IsFolded;
}
e.Handled = true; e.Handled = true;
return; return;
} }

Loading…
Cancel
Save