diff --git a/ILSpy.Tests/ContextMenus/DecompileReferenceContextMenuTests.cs b/ILSpy.Tests/ContextMenus/DecompileReferenceContextMenuTests.cs new file mode 100644 index 000000000..0a4e73496 --- /dev/null +++ b/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() + .GetEntry(nameof(Resources.Decompile)); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "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("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"); + } +} diff --git a/ILSpy.Tests/Editor/FoldingContextMenuTests.cs b/ILSpy.Tests/Editor/FoldingContextMenuTests.cs new file mode 100644 index 000000000..fa66ee433 --- /dev/null +++ b/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( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + vm.AssemblyTreeModel.SelectNode(typeNode); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + var view = await window.WaitForComponent(); + 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(); + 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"); + } +} diff --git a/ILSpy/Commands/DecompileReferenceContextMenuEntry.cs b/ILSpy/Commands/DecompileReferenceContextMenuEntry.cs new file mode 100644 index 000000000..8371bcb0e --- /dev/null +++ b/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 +{ + /// + /// 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 . + /// + [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)); + } + } +} diff --git a/ILSpy/Commands/FoldingContextMenuEntries.cs b/ILSpy/Commands/FoldingContextMenuEntries.cs new file mode 100644 index 000000000..4e068a95f --- /dev/null +++ b/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 +{ + /// + /// 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. + /// + [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(); + } + + /// + /// 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. + /// + [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(); + } +} diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 737b2a64d..6cfd448bd 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -254,6 +254,47 @@ namespace ILSpy.TextView e.Handled = true; } + /// True when the active document has any code foldings (gates the folding menu entries). + public bool HasFoldings => activeFoldingManager is { } mgr && mgr.AllFoldings.Any(); + + /// Number of currently-collapsed folds (test observation point for the folding commands). + internal int FoldedFoldingCount => activeFoldingManager is { } mgr ? mgr.AllFoldings.Count(f => f.IsFolded) : 0; + + /// Toggles the innermost fold containing the caret (the "Toggle folding" command / Ctrl+M). + 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; + } + + /// Collapses every fold when any is open, otherwise expands them all ("Toggle all folding" + /// / Ctrl+Shift+M). + 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 // 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; }