diff --git a/ICSharpCode.Decompiler.Tests/Output/TextTokenWriterTests.cs b/ICSharpCode.Decompiler.Tests/Output/TextTokenWriterTests.cs index 04320f810..20d3109f3 100644 --- a/ICSharpCode.Decompiler.Tests/Output/TextTokenWriterTests.cs +++ b/ICSharpCode.Decompiler.Tests/Output/TextTokenWriterTests.cs @@ -107,6 +107,10 @@ namespace ICSharpCode.Decompiler.Tests.Output LocalReferences.Add((text, reference, isDefinition)); } + public void MarkDefinitionStart() + { + } + public void MarkFoldStart(string collapsedText = "...", bool defaultCollapsed = false, bool isDefinition = false) { FoldStartDefaultCollapsed.Add(defaultCollapsed); diff --git a/ICSharpCode.Decompiler/Output/ITextOutput.cs b/ICSharpCode.Decompiler/Output/ITextOutput.cs index 08f881fe2..83d0ea89c 100644 --- a/ICSharpCode.Decompiler/Output/ITextOutput.cs +++ b/ICSharpCode.Decompiler/Output/ITextOutput.cs @@ -39,6 +39,12 @@ namespace ICSharpCode.Decompiler void WriteLocalReference(string text, object reference, bool isDefinition = false, bool isHoverOnly = false); void MarkFoldStart(string collapsedText = "...", bool defaultCollapsed = false, bool isDefinition = false); + /// + /// Marks the position where an entity declaration begins. The next fold marked with + /// isDefinition: true logically extends back to this position, so that leading + /// documentation comments and attributes count as part of the definition's region. + /// + void MarkDefinitionStart(); void MarkFoldEnd(); } diff --git a/ICSharpCode.Decompiler/Output/PlainTextOutput.cs b/ICSharpCode.Decompiler/Output/PlainTextOutput.cs index f4fa3aaba..f24203fed 100644 --- a/ICSharpCode.Decompiler/Output/PlainTextOutput.cs +++ b/ICSharpCode.Decompiler/Output/PlainTextOutput.cs @@ -152,6 +152,10 @@ namespace ICSharpCode.Decompiler Write(text); } + void ITextOutput.MarkDefinitionStart() + { + } + void ITextOutput.MarkFoldStart(string collapsedText, bool defaultCollapsed, bool isDefinition) { } @@ -199,9 +203,14 @@ namespace ICSharpCode.Decompiler actions.Add(target => target.MarkFoldEnd()); } + public void MarkDefinitionStart() + { + actions.Add(target => target.MarkDefinitionStart()); + } + public void MarkFoldStart(string collapsedText = "...", bool defaultCollapsed = false, bool isDefinition = false) { - actions.Add(target => target.MarkFoldStart(collapsedText, defaultCollapsed)); + actions.Add(target => target.MarkFoldStart(collapsedText, defaultCollapsed, isDefinition)); } public void Unindent() diff --git a/ICSharpCode.Decompiler/Output/TextTokenWriter.cs b/ICSharpCode.Decompiler/Output/TextTokenWriter.cs index 187e6ea8a..134fcc725 100644 --- a/ICSharpCode.Decompiler/Output/TextTokenWriter.cs +++ b/ICSharpCode.Decompiler/Output/TextTokenWriter.cs @@ -534,6 +534,13 @@ namespace ICSharpCode.Decompiler lastUsingDeclaration = false; } } + if (node is EntityDeclaration) + { + // The declaration's logical region starts here, before its documentation + // comments and attributes are written; the body fold marked later refers + // back to this position for group toggling in the UI. + output.MarkDefinitionStart(); + } nodeStack.Push(node); } diff --git a/ILSpy.Tests/Editor/FoldingGroupTests.cs b/ILSpy.Tests/Editor/FoldingGroupTests.cs new file mode 100644 index 000000000..f430534ce --- /dev/null +++ b/ILSpy.Tests/Editor/FoldingGroupTests.cs @@ -0,0 +1,182 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// 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; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; +using Avalonia.VisualTree; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.TextView; +using ICSharpCode.ILSpy.TreeNodes; +using ICSharpCode.ILSpy.ViewModels; +using ICSharpCode.ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.TextView; + +/// +/// Sample type decompiled by . Its XML documentation is +/// supplied by a hand-written documentation file placed next to the test assembly, so the +/// decompiled view renders a '///' fold above . +/// +public class FoldGroupSample +{ + public void Documented(int value) + { + Console.WriteLine(value); + } + + public void Plain() + { + Console.WriteLine(); + } +} + +/// +/// Pins the grouped fold-toggle behavior (issue #749): a member and its XML documentation +/// comment toggle as one logical unit, the toggle targets the member (not the enclosing +/// type) when invoked on the header line, the doc fold alone toggles when invoked inside +/// it, and Toggle All uses VS parity (mixed state expands everything). +/// +[TestFixture] +public class FoldingGroupTests +{ + [OneTimeSetUp] + public void WriteDocumentationFile() + { + string assemblyPath = typeof(FoldGroupSample).Assembly.Location; + string xmlPath = Path.ChangeExtension(assemblyPath, ".xml"); + string assemblyName = Path.GetFileNameWithoutExtension(assemblyPath); + File.WriteAllText(xmlPath, $""" + + + {assemblyName} + + + Summary line used by FoldingGroupTests. + Parameter line used by FoldingGroupTests. + + + + """); + } + + static async Task<(DecompilerTextView View, string Text)> SetupAsync() + { + var (window, vm) = await TestHarness.BootAsync(); + // Start from a fully expanded document so every toggle direction is deterministic. + var displaySettings = AppComposition.Current.GetExport().DisplaySettings; + displaySettings.ExpandMemberDefinitions = true; + displaySettings.ExpandXmlDocumentationComments = true; + await vm.OpenAssemblyAsync(typeof(FoldGroupSample).Assembly.Location); + var typeNode = vm.AssemblyTreeModel.FindNode( + "ILSpy.Tests", + "ICSharpCode.ILSpy.Tests.TextView", + "ICSharpCode.ILSpy.Tests.TextView.FoldGroupSample"); + vm.AssemblyTreeModel.SelectNode(typeNode); + var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + var view = window.GetVisualDescendants().OfType().First(); + string text = view.Editor.Document.Text; + text.Should().Contain("Summary line used by FoldingGroupTests", + "the hand-written documentation file must be picked up for these tests to be meaningful"); + return (view, text); + } + + static int OffsetOf(string text, string needle) + { + int offset = text.IndexOf(needle, StringComparison.Ordinal); + offset.Should().BeGreaterThan(-1, $"the decompiled text must contain '{needle}'"); + return offset; + } + + [AvaloniaTest] + public async Task Toggling_In_The_Body_Folds_Member_And_Documentation_Together() + { + var (view, text) = await SetupAsync(); + int docOffset = OffsetOf(text, "Summary line"); + int bodyOffset = OffsetOf(text, "Console.WriteLine(value)"); + int plainBodyOffset = OffsetOf(text, "Console.WriteLine()"); + + view.ToggleFoldingAt(bodyOffset); + + view.IsFoldedAt(bodyOffset).Should().BeTrue("the member body must fold"); + view.IsFoldedAt(docOffset).Should().BeTrue("the documentation comment must fold together with its member"); + view.IsFoldedAt(plainBodyOffset).Should().BeFalse("the sibling member is not part of the group"); + + view.ToggleFoldingAt(bodyOffset); + + view.IsFoldedAt(bodyOffset).Should().BeFalse("toggling again must unfold the member body"); + view.IsFoldedAt(docOffset).Should().BeFalse("toggling again must unfold the documentation comment"); + } + + [AvaloniaTest] + public async Task Toggling_On_The_Header_Line_Targets_The_Member_Not_The_Type() + { + var (view, text) = await SetupAsync(); + int headerOffset = OffsetOf(text, "void Documented"); + int bodyOffset = OffsetOf(text, "Console.WriteLine(value)"); + int plainBodyOffset = OffsetOf(text, "Console.WriteLine()"); + + view.ToggleFoldingAt(headerOffset); + + view.IsFoldedAt(bodyOffset).Should().BeTrue("the member body must fold from its header line"); + view.IsFoldedAt(plainBodyOffset).Should().BeFalse( + "the enclosing type must not fold when the toggle targets a member header"); + } + + [AvaloniaTest] + public async Task Toggling_Inside_The_Documentation_Folds_Only_The_Documentation() + { + var (view, text) = await SetupAsync(); + int docOffset = OffsetOf(text, "Summary line"); + int bodyOffset = OffsetOf(text, "Console.WriteLine(value)"); + + view.ToggleFoldingAt(docOffset); + + view.IsFoldedAt(docOffset).Should().BeTrue("the documentation fold must collapse"); + view.IsFoldedAt(bodyOffset).Should().BeFalse("the member body stays open when only the docs are toggled"); + } + + [AvaloniaTest] + public async Task Toggle_All_Expands_Everything_From_A_Mixed_State() + { + var (view, text) = await SetupAsync(); + int bodyOffset = OffsetOf(text, "Console.WriteLine(value)"); + + // Create a mixed state: one group folded, the rest open. + view.ToggleFoldingAt(bodyOffset); + view.FoldedFoldingCount.Should().BeGreaterThan(0); + view.GetFoldingsForTest().Should().Contain(f => !f.IsFolded, "the state must be mixed for this test"); + + view.ToggleAllFoldings(); + + view.FoldedFoldingCount.Should().Be(0, "VS parity: a mixed state expands all folds"); + + view.ToggleAllFoldings(); + + view.GetFoldingsForTest().Should().OnlyContain(f => f.IsFolded, + "a uniformly expanded document collapses everything"); + } +} diff --git a/ILSpy/TextView/AvaloniaEditTextOutput.cs b/ILSpy/TextView/AvaloniaEditTextOutput.cs index b4927b029..71e076651 100644 --- a/ILSpy/TextView/AvaloniaEditTextOutput.cs +++ b/ILSpy/TextView/AvaloniaEditTextOutput.cs @@ -254,17 +254,35 @@ namespace ICSharpCode.ILSpy.TextView }); } + int pendingDefinitionStart = -1; + + public void MarkDefinitionStart() + { + pendingDefinitionStart = builder.Length; + } + public void MarkFoldStart(string collapsedText = "...", bool defaultCollapsed = false, bool isDefinition = false) { WriteIndentIfNeeded(); - openFoldings.Push(( - new NewFolding { + NewFolding folding; + if (isDefinition && pendingDefinitionStart >= 0) + { + // The definition's logical region reaches back to the entity's first output + // character, so leading documentation folds count as part of the group. + folding = new DefinitionNewFolding { StartOffset = builder.Length, - Name = collapsedText, - DefaultClosed = defaultCollapsed, - IsDefinition = isDefinition, - }, - lineNumber)); + DefinitionStartOffset = pendingDefinitionStart, + }; + pendingDefinitionStart = -1; + } + else + { + folding = new NewFolding { StartOffset = builder.Length }; + } + folding.Name = collapsedText; + folding.DefaultClosed = defaultCollapsed; + folding.IsDefinition = isDefinition; + openFoldings.Push((folding, lineNumber)); } public void MarkFoldEnd() diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 1d94dfaa5..e987f9f55 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -457,8 +457,22 @@ namespace ICSharpCode.ILSpy.TextView /// Toggles the innermost fold containing the caret (the "Toggle folding" command / Ctrl+M). public void ToggleFoldingAtCaret() => ToggleFoldingAt(Editor.TextArea.Caret.Offset); - /// Toggles the innermost fold containing . The right-click menu - /// passes the offset under the pointer so it acts on the clicked line, not the caret line. + /// The offset where a fold's logical region begins: definition folds reach back to + /// their entity's first character (leading documentation comments and attributes included); + /// every other fold starts at its own first character. + static int GetLogicalStart(FoldingSection folding) + { + return folding.Tag is DefinitionNewFolding definition + ? Math.Min(definition.DefinitionStartOffset, folding.StartOffset) + : folding.StartOffset; + } + + /// Toggles the fold whose logical region innermost-contains . + /// The right-click menu passes the offset under the pointer so it acts on the clicked line, + /// not the caret line. A definition fold's logical region includes its header and leading + /// documentation, so toggling from the header line targets the member (not the enclosing + /// type), and the member's documentation folds toggle together with it. With the caret + /// inside the documentation, the doc fold itself is the innermost region and toggles alone. public void ToggleFoldingAt(int offset) { if (activeFoldingManager is not { } mgr) @@ -466,30 +480,49 @@ namespace ICSharpCode.ILSpy.TextView FoldingSection? target = null; foreach (var f in mgr.AllFoldings) { - if (f.StartOffset <= offset && offset <= f.EndOffset) + if (GetLogicalStart(f) > offset || offset > f.EndOffset) + continue; + if (target == null + || GetLogicalStart(f) > GetLogicalStart(target) + || (GetLogicalStart(f) == GetLogicalStart(target) && f.EndOffset < target.EndOffset)) + { + target = f; + } + } + if (target == null) + return; + bool folded = !target.IsFolded; + target.IsFolded = folded; + // Drag the attached leading folds (XML documentation) along with their member. + int logicalStart = GetLogicalStart(target); + if (logicalStart < target.StartOffset) + { + foreach (var f in mgr.AllFoldings) { - if (target == null || f.StartOffset > target.StartOffset) - target = f; + if (f != target && f.StartOffset >= logicalStart && f.EndOffset <= target.StartOffset) + f.IsFolded = folded; } } - if (target != null) - target.IsFolded = !target.IsFolded; } - /// Collapses every fold when any is open, otherwise expands them all ("Toggle all folding" - /// / Ctrl+Shift+M). + /// Sets all folds to the same state ("Toggle all folding" / Ctrl+Shift+M), with + /// Visual Studio's Toggle All Outlining parity: a mixed state expands everything, a uniform + /// state flips. public void ToggleAllFoldings() { if (activeFoldingManager is not { } mgr) return; - bool anyOpen = false; + bool anyOpen = false, anyFolded = false; foreach (var f in mgr.AllFoldings) { - if (!f.IsFolded) - { anyOpen = true; break; } + if (f.IsFolded) + anyFolded = true; + else + anyOpen = true; } + bool folded = anyOpen && anyFolded ? false : anyOpen; foreach (var f in mgr.AllFoldings) - f.IsFolded = anyOpen; + f.IsFolded = folded; } void OnEditorKeyDownForZoom(object? sender, KeyEventArgs e) diff --git a/ILSpy/TextView/DefinitionNewFolding.cs b/ILSpy/TextView/DefinitionNewFolding.cs new file mode 100644 index 000000000..8a9c396f7 --- /dev/null +++ b/ILSpy/TextView/DefinitionNewFolding.cs @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// 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 AvaloniaEdit.Folding; + +namespace ICSharpCode.ILSpy.TextView +{ + /// + /// A definition-body fold whose logical region starts before the fold itself: at the first + /// output character of its entity declaration, so leading XML documentation comments and + /// attributes belong to the same toggle group. AvaloniaEdit keeps the instance reachable + /// through . + /// + sealed class DefinitionNewFolding : NewFolding + { + public int DefinitionStartOffset { get; set; } + } +}