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);
}