diff --git a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj index c936871452..29015d86ae 100644 --- a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj +++ b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj @@ -149,6 +149,7 @@ + diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Ugly/NoLocalFunctions.Expected.cs b/ICSharpCode.Decompiler.Tests/TestCases/Ugly/NoLocalFunctions.Expected.cs index 0d6eecae98..a3f374d42d 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/Ugly/NoLocalFunctions.Expected.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/Ugly/NoLocalFunctions.Expected.cs @@ -44,9 +44,9 @@ private static void UseLocalFunctionReference() private static void SimpleCapture() { - _003C_003Ec__DisplayClass1_0 _003C_003Ec__DisplayClass1_1 = default(_003C_003Ec__DisplayClass1_0); - _003C_003Ec__DisplayClass1_1.x = 1; - _003CSimpleCapture_003Eg__F_007C1_0(ref _003C_003Ec__DisplayClass1_1); + _003C_003Ec__DisplayClass1_0 obj = default(_003C_003Ec__DisplayClass1_0); + obj.x = 1; + _003CSimpleCapture_003Eg__F_007C1_0(ref obj); } private static void SimpleCaptureWithRef() @@ -56,9 +56,9 @@ private static void SimpleCaptureWithRef() obj.x = 1; new Handle(new Func(obj._003CSimpleCaptureWithRef_003Eg__F_007C0)); #else - _003C_003Ec__DisplayClass2_0 _003C_003Ec__DisplayClass2_1 = new _003C_003Ec__DisplayClass2_0(); - _003C_003Ec__DisplayClass2_1.x = 1; - Handle handle = new Handle(new Func(_003C_003Ec__DisplayClass2_1._003CSimpleCaptureWithRef_003Eg__F_007C0)); + _003C_003Ec__DisplayClass2_0 obj = new _003C_003Ec__DisplayClass2_0(); + obj.x = 1; + Handle handle = new Handle(new Func(obj._003CSimpleCaptureWithRef_003Eg__F_007C0)); #endif } diff --git a/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.cs b/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.cs new file mode 100644 index 0000000000..dde61d8b2d --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Runtime.CompilerServices; +#if LEGACY_VBC +using System.Text; +#endif + +using Microsoft.VisualBasic.CompilerServices; + +// A VB anonymous type. Its properties are settable and only those declared 'Key' +// take part in Equals and GetHashCode, so it cannot be written as a C# anonymous +// type and is declared here instead. +#if LEGACY_VBC && OPT +[DebuggerDisplay("Value={Value}, Name={Name}")] +[CompilerGenerated] +#else +[CompilerGenerated] +[DebuggerDisplay("Value={Value}, Name={Name}")] +#endif +internal sealed class VB_AnonymousType_0 +{ +#if !OPT && !LEGACY_VBC + [DebuggerBrowsable(DebuggerBrowsableState.Never)] +#endif + private T0 _Value; + +#if !OPT && !LEGACY_VBC + [DebuggerBrowsable(DebuggerBrowsableState.Never)] +#endif + private T1 _Name; + + public T0 Value { + get { + return _Value; + } + set { + _Value = value; + } + } + + public T1 Name { + get { + return _Name; + } + set { + _Name = value; + } + } + +#if !OPT && !LEGACY_VBC + [DebuggerHidden] +#endif + public VB_AnonymousType_0(T0 Value, T1 Name) + { + _Value = Value; + _Name = Name; + } + +#if !OPT && !LEGACY_VBC + [DebuggerHidden] +#endif + public override string ToString() + { +#if LEGACY_VBC + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.Append("{ "); + stringBuilder.AppendFormat("{0} = {1}, ", "Value", _Value); + stringBuilder.AppendFormat("{0} = {1} ", "Name", _Name); + stringBuilder.Append("}"); + return stringBuilder.ToString(); +#else + return string.Format(null, "{{ Value = {0}, Name = {1} }}", new object[2] { _Value, _Name }); +#endif + } +} +[StandardModule] +public sealed class VBAnonymousTypes +{ + public static void MutableAnonymousType() + { + VB_AnonymousType_0 obj = new VB_AnonymousType_0(1, "test"); + Console.WriteLine(obj.Value); + Console.WriteLine(obj.Name); + } + + public static void KeyAnonymousType() + { + var anon = new { + Value = 1, + Name = "test" + }; + Console.WriteLine(anon.Value); + Console.WriteLine(anon.Name); + } + + public static void AnonymousTypeAsArgument() + { + Console.WriteLine(new { + Value = 1, + Name = "test" + }.ToString()); + } + + public static void SelectAnonymousType(IEnumerable items) + { + var enumerable = items.Select([SpecialName] (int i) => new { + Value = i, + Square = checked(i * i) + }); + foreach (var item in enumerable) + { + Console.WriteLine(item.Value); + Console.WriteLine(item.Square); + } + } + + public static void LetWhereSelect(IEnumerable items) + { + var enumerable = from i in items + let square = checked(i * i) + where square > 4 + select new { i, square }; + foreach (var item in enumerable) + { + Console.WriteLine(item.i); + Console.WriteLine(item.square); + } + } + + public static void JoinSelect(IEnumerable left, IEnumerable right) + { + var enumerable = from x in left + join y in right on x equals y + select new { x, y }; + foreach (var item in enumerable) + { + Console.WriteLine(item.x); + Console.WriteLine(item.y); + } + } + + public static void OrderBySelect(IEnumerable items) + { + var enumerable = from i in items + let doubled = checked(i * 2) + orderby doubled + select new { i, doubled }; + foreach (var item in enumerable) + { + Console.WriteLine(item.i); + Console.WriteLine(item.doubled); + } + } +} diff --git a/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.vb b/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.vb new file mode 100644 index 0000000000..b789de35e3 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/VBAnonymousTypes.vb @@ -0,0 +1,62 @@ +Imports System +Imports System.Collections.Generic +Imports System.Linq + +Public Module VBAnonymousTypes + Public Sub MutableAnonymousType() + Dim value = New With {.Value = 1, .Name = "test"} + Console.WriteLine(value.Value) + Console.WriteLine(value.Name) + End Sub + + Public Sub KeyAnonymousType() + Dim value = New With {Key .Value = 1, Key .Name = "test"} + Console.WriteLine(value.Value) + Console.WriteLine(value.Name) + End Sub + + Public Sub AnonymousTypeAsArgument() + Console.WriteLine(New With {Key .Value = 1, Key .Name = "test"}.ToString()) + End Sub + + Public Sub SelectAnonymousType(items As IEnumerable(Of Integer)) + Dim query = From i In items + Select New With {Key .Value = i, Key .Square = i * i} + For Each item In query + Console.WriteLine(item.Value) + Console.WriteLine(item.Square) + Next + End Sub + + Public Sub LetWhereSelect(items As IEnumerable(Of Integer)) + Dim query = From i In items + Let square = i * i + Where square > 4 + Select i, square + For Each item In query + Console.WriteLine(item.i) + Console.WriteLine(item.square) + Next + End Sub + + Public Sub JoinSelect(left As IEnumerable(Of Integer), right As IEnumerable(Of Integer)) + Dim query = From x In left + Join y In right On x Equals y + Select x, y + For Each item In query + Console.WriteLine(item.x) + Console.WriteLine(item.y) + Next + End Sub + + Public Sub OrderBySelect(items As IEnumerable(Of Integer)) + Dim query = From i In items + Let doubled = i * 2 + Order By doubled + Select i, doubled + For Each item In query + Console.WriteLine(item.i) + Console.WriteLine(item.doubled) + Next + End Sub +End Module diff --git a/ICSharpCode.Decompiler.Tests/VBPrettyTestRunner.cs b/ICSharpCode.Decompiler.Tests/VBPrettyTestRunner.cs index ff2feb797c..3cd9f3b09b 100644 --- a/ICSharpCode.Decompiler.Tests/VBPrettyTestRunner.cs +++ b/ICSharpCode.Decompiler.Tests/VBPrettyTestRunner.cs @@ -125,6 +125,13 @@ public async Task Select([ValueSource(nameof(defaultOptions))] CompilerOptions o await Run(options: options | CompilerOptions.Library); } + [Test] + public async Task VBAnonymousTypes([ValueSource(nameof(defaultOptions))] CompilerOptions options) + { + IgnoreIfVbRuntimeSubstituted(options); + await Run(options: options | CompilerOptions.Library); + } + [Test] public async Task Issue1906([ValueSource(nameof(defaultOptions))] CompilerOptions options) { diff --git a/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs b/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs index 3acaceccd8..b613b71f50 100644 --- a/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs +++ b/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs @@ -250,6 +250,7 @@ public static List GetAstTransforms() new CombineQueryExpressions(), new NormalizeBlockStatements(), new FlattenSwitchBlocks(), + new RenameVisualBasicAnonymousTypes(), // must run before FixNameCollisions new FixNameCollisions(), new AddXmlDocumentationTransform(), }; @@ -645,8 +646,13 @@ static bool IsClosureType(SRM.TypeDefinition type, MetadataReader metadata) internal static bool IsTransparentIdentifier(string identifier) { - return identifier.StartsWith("<>", StringComparison.Ordinal) - && (identifier.Contains("TransparentIdentifier") || identifier.Contains("TranspIdent")); + if (identifier.StartsWith("<>", StringComparison.Ordinal)) + { + return identifier.Contains("TransparentIdentifier") || identifier.Contains("TranspIdent"); + } + // The VB compiler names the carriers of its query range variables + // $VB$It, $VB$It1, $VB$It2 and $VB$ItAnonymous. + return identifier.StartsWith("$VB$It", StringComparison.Ordinal); } #endregion diff --git a/ICSharpCode.Decompiler/CSharp/Transforms/RenameVisualBasicAnonymousTypes.cs b/ICSharpCode.Decompiler/CSharp/Transforms/RenameVisualBasicAnonymousTypes.cs new file mode 100644 index 0000000000..9566f67afa --- /dev/null +++ b/ICSharpCode.Decompiler/CSharp/Transforms/RenameVisualBasicAnonymousTypes.cs @@ -0,0 +1,77 @@ +// 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.Linq; + +using ICSharpCode.Decompiler.CSharp.Syntax; +using ICSharpCode.Decompiler.TypeSystem; + +namespace ICSharpCode.Decompiler.CSharp.Transforms +{ + /// + /// Gives the anonymous types of a VB assembly that are declared rather than written as C# + /// anonymous types a name that is legal in C#. + /// + /// + /// An anonymous type with a settable property has no C# anonymous type equivalent + /// (see ), so its declaration is emitted and + /// referred to by name. The VB compiler separates the parts of that name with '$', which is + /// not a legal identifier character in C#, and does the same for the backing fields. + /// Renaming happens per identifier via its symbol, so a reference is renamed the same way + /// whether or not the declaration is part of the same output. + /// + public class RenameVisualBasicAnonymousTypes : IAstTransform + { + public void Run(AstNode rootNode, TransformContext context) + { + foreach (var identifier in rootNode.DescendantsAndSelf.OfType()) + { + if (!identifier.Name.Contains("$")) + continue; + // A field declaration carries its symbol on the FieldDeclaration, not on the + // VariableInitializer holding the name. + if (FindEntity(identifier.Parent) is not IEntity entity || entity.Name != identifier.Name) + continue; + var declaringType = entity as ITypeDefinition ?? entity.DeclaringTypeDefinition; + if (declaringType == null || !declaringType.IsAnonymousTypeDeclaredAsNamedType()) + continue; + identifier.Name = identifier.Name.Replace('$', '_'); + } + + foreach (var typeDeclaration in rootNode.DescendantsAndSelf.OfType()) + { + if (typeDeclaration.GetSymbol() is not ITypeDefinition type + || !type.IsAnonymousTypeDeclaredAsNamedType()) + { + continue; + } + typeDeclaration.AddLeadingTrivia(new Comment( + " A VB anonymous type. Its properties are settable and only those declared 'Key'")); + typeDeclaration.AddLeadingTrivia(new Comment( + " take part in Equals and GetHashCode, so it cannot be written as a C# anonymous")); + typeDeclaration.AddLeadingTrivia(new Comment( + " type and is declared here instead.")); + } + + static IEntity FindEntity(AstNode node) + { + return node?.GetSymbol() as IEntity ?? node?.Parent?.GetSymbol() as IEntity; + } + } + } +} diff --git a/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs b/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs index 9a3cce0c8d..7aba498bce 100644 --- a/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs +++ b/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs @@ -882,6 +882,12 @@ static string CleanUpVariableName(string name) if (name.Length == 0) return "obj"; + if (!IsValidName(name)) + { + // A name taken from metadata may be legal there but not in C#; VB, for one, + // separates the parts of its generated names with '$'. + return null; + } string lowerCaseName = char.ToLower(name[0]) + name.Substring(1); if (CSharp.OutputVisitor.CSharpOutputVisitor.IsKeyword(lowerCaseName)) return null; diff --git a/ICSharpCode.Decompiler/NRExtensions.cs b/ICSharpCode.Decompiler/NRExtensions.cs index db299ee9aa..87663b7cb3 100644 --- a/ICSharpCode.Decompiler/NRExtensions.cs +++ b/ICSharpCode.Decompiler/NRExtensions.cs @@ -50,7 +50,37 @@ public static bool HasGeneratedName(this IMember member) public static bool HasGeneratedName(this IType type) { - return type.Name.StartsWith("<", StringComparison.Ordinal) || type.Name.Contains("<"); + return SRMExtensions.IsGeneratedName(type.Name); + } + + /// + /// A C# anonymous type is immutable and compares all of its members, so only an anonymous + /// type with no settable property can be written as one. The C# compiler emits nothing + /// else, but VB's properties are settable unless declared 'Key', and only 'Key' members + /// take part in Equals/GetHashCode: those types keep their own declaration. + /// + static bool HasOnlyReadOnlyProperties(ITypeDefinition type) + { + foreach (var property in type.Properties) + { + if (property.CanSet) + return false; + } + return true; + } + + /// + /// An anonymous type that keeps its own declaration because it cannot be written as a C# + /// anonymous type: a VB anonymous type with at least one settable, non-'Key' property. + /// + public static bool IsAnonymousTypeDeclaredAsNamedType(this ITypeDefinition type) + { + return type != null + && string.IsNullOrEmpty(type.Namespace) + && type.HasGeneratedName() + && (type.Name.Contains("AnonType") || type.Name.Contains("AnonymousType")) + && type.IsCompilerGenerated() + && !HasOnlyReadOnlyProperties(type); } public static bool IsAnonymousType(this IType type) @@ -61,7 +91,7 @@ public static bool IsAnonymousType(this IType type) && (type.Name.Contains("AnonType") || type.Name.Contains("AnonymousType"))) { ITypeDefinition td = type.GetDefinition(); - return td != null && td.IsCompilerGenerated(); + return td != null && td.IsCompilerGenerated() && HasOnlyReadOnlyProperties(td); } return false; } diff --git a/ICSharpCode.Decompiler/SRMExtensions.cs b/ICSharpCode.Decompiler/SRMExtensions.cs index 2be174f6c0..dddf0e53a6 100644 --- a/ICSharpCode.Decompiler/SRMExtensions.cs +++ b/ICSharpCode.Decompiler/SRMExtensions.cs @@ -474,13 +474,27 @@ public static FullTypeName GetFullTypeName(this ExportedType type, MetadataReade } } + /// + /// See NRExtensions.HasOnlyReadOnlyProperties: an anonymous type with a settable property + /// cannot be written as a C# anonymous type, so its declaration must not be hidden. + /// + static bool HasOnlyReadOnlyProperties(TypeDefinition type, MetadataReader metadata) + { + foreach (var handle in type.GetProperties()) + { + if (!metadata.GetPropertyDefinition(handle).GetAccessors().Setter.IsNil) + return false; + } + return true; + } + public static bool IsAnonymousType(this TypeDefinition type, MetadataReader metadata) { string name = metadata.GetString(type.Name); if (type.Namespace.IsNil && type.HasGeneratedName(metadata) && (name.Contains("AnonType") || name.Contains("AnonymousType"))) { - return type.IsCompilerGenerated(metadata); + return type.IsCompilerGenerated(metadata) && HasOnlyReadOnlyProperties(type, metadata); } return false; } @@ -489,9 +503,20 @@ public static bool IsAnonymousType(this TypeDefinition type, MetadataReader meta public static bool IsGeneratedName(this StringHandle handle, MetadataReader metadata) { - return !handle.IsNil - && (metadata.GetString(handle).StartsWith("<", StringComparison.Ordinal) - || metadata.GetString(handle).Contains("$")); + return !handle.IsNil && IsGeneratedName(metadata.GetString(handle)); + } + + /// + /// Detects the mangled names compilers give to entities that have no user-written + /// declaration. The C# compiler prefixes them with '<', the VB compiler separates + /// the parts with '$' (VB$AnonymousType_0, VB$StateMachine_1_Foo). Neither character + /// is legal in a C# or VB identifier. + /// Note that a name may legitimately contain '<' without being generated: explicit + /// implementations of generic interface members are named after the interface. + /// + internal static bool IsGeneratedName(string name) + { + return name.StartsWith("<", StringComparison.Ordinal) || name.Contains("$"); } public static bool HasGeneratedName(this MethodDefinitionHandle handle, MetadataReader metadata) diff --git a/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs b/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs index 4685b2d012..52e147f18c 100644 --- a/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs +++ b/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs @@ -23,8 +23,6 @@ using Avalonia.Threading; using Avalonia.VisualTree; -using AvaloniaEdit.Rendering; - using AwesomeAssertions; using ICSharpCode.ILSpy.AppEnv; @@ -97,11 +95,11 @@ public async Task Navigating_From_NonDecompiler_Content_Scrolls_To_And_Highlight await vm.DockWorkspace.WaitForDecompiledTextAsync(); view = await window.WaitForComponent(); - // Wait until the one-shot highlight has registered, then assert without any further delay: the - // adorner self-dismisses after an ~800 ms lifetime, so a fixed-length pump on a loaded CI runner - // can outlast it and observe an empty collection. The same deferred apply also lands the caret. - await Waiters.WaitForAsync(() => view.Editor.TextArea.TextView.BackgroundRenderers - .OfType().Any()); + // Wait until the one-shot highlight has played. The adorner itself self-dismisses after an + // ~800 ms lifetime and a stalled CI dispatcher can register and dismiss it inside a single + // pump, so polling the renderer collection can miss the entire play; LastHighlightPlayedLine + // is the view's persistent record of it. The same deferred apply also lands the caret. + await Waiters.WaitForAsync(() => view.LastHighlightPlayedLine != null); int targetLine = view.GetLineForBookmark(bookmark) ?? -1; targetLine.Should().BeGreaterThan(1, "the bookmark resolves to a line below the top in the freshly shown document"); @@ -110,9 +108,9 @@ await Waiters.WaitForAsync(() => view.Editor.TextArea.TextView.BackgroundRendere view.Editor.TextArea.Caret.Line.Should().Be(targetLine, "bookmark navigation from non-decompiler content must scroll to the saved line"); - // P2: the one-shot line highlight is playing on the freshly shown view. - view.Editor.TextArea.TextView.BackgroundRenderers.OfType() - .Should().ContainSingle("the destination line must be highlighted after the content switch"); + // P2: the one-shot line highlight played on the freshly shown view, on the right line. + view.LastHighlightPlayedLine.Should().Be(targetLine, + "the destination line must be highlighted after the content switch"); } // Regression: when the active tab is frozen, navigating to a bookmark in a different node must @@ -160,21 +158,21 @@ public async Task Navigating_To_Bookmark_With_A_Frozen_Active_Tab_Opens_And_Posi var activeModel = vm.DockWorkspace.ActiveDecompilerTab; activeModel.Should().NotBeNull("navigation must surface a decompiler tab"); - // Wait until the fresh preview's view exists and its one-shot highlight has registered, then - // assert without any further delay: the adorner self-dismisses after an ~800 ms lifetime, so a - // fixed-length pump on a loaded CI runner can outlast it and observe an empty collection. + // Wait until the fresh preview's view exists and its one-shot highlight has played. The + // adorner itself self-dismisses after an ~800 ms lifetime and a stalled CI dispatcher can + // register and dismiss it inside a single pump, so polling the renderer collection can miss + // the entire play; LastHighlightPlayedLine is the view's persistent record of it. DecompilerTextView? ActiveView() => window.GetVisualDescendants().OfType() .FirstOrDefault(v => ReferenceEquals(v.DataContext, activeModel)); - await Waiters.WaitForAsync(() => ActiveView()?.Editor.TextArea.TextView.BackgroundRenderers - .OfType().Any() == true); + await Waiters.WaitForAsync(() => ActiveView()?.LastHighlightPlayedLine != null); var activeView = ActiveView()!; int targetLine = activeView.GetLineForBookmark(bookmark) ?? -1; targetLine.Should().BeGreaterThan(1, "the fresh preview shows System.String with the bookmarked line below the top"); activeView.Editor.TextArea.Caret.Line.Should().Be(targetLine, "opening a fresh preview for a frozen-tab navigation must still scroll to the bookmark"); - activeView.Editor.TextArea.TextView.BackgroundRenderers.OfType() - .Should().ContainSingle("the destination line must be highlighted in the fresh preview"); + activeView.LastHighlightPlayedLine.Should().Be(targetLine, + "the destination line must be highlighted in the fresh preview"); } // Regression: a bookmark re-anchors by token/IL offset, so a decompiler-setting change that diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 4bac40bff1..cd5f39ab88 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -843,6 +843,13 @@ bool ApplyBookmark(Bookmarks.Bookmark bookmark) return false; } + // The last line the one-shot navigation highlight was played on in this view, or null when + // none has played yet. The adorner itself self-dismisses after its ~800 ms lifetime, so an + // observer polling the renderer collection can miss the entire play when the dispatcher + // stalls (a headless test on a loaded CI runner); this record is the persistent evidence + // that the highlight ran, and where. + internal int? LastHighlightPlayedLine { get; private set; } + void ScrollToLine(int line, Bookmarks.BookmarkViewState? viewState = null) { var document = Editor.Document; @@ -862,6 +869,7 @@ void ScrollToLine(int line, Bookmarks.BookmarkViewState? viewState = null) RestoreBookmarkFoldings(viewState); CenterLineInView(document, line); LineHighlightAdorner.DisplayLineHighlight(Editor.TextArea, line); + LastHighlightPlayedLine = line; bookmarkMargin?.PulseLine(line); }, DispatcherPriority.Background); }