diff --git a/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs b/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs index 75a8bb5a8..929c2f0b0 100644 --- a/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs +++ b/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs @@ -57,8 +57,8 @@ public class CaretHighlightAdornerTests // AvaloniaEditTextOutput.AddReference), so `OnReferenceClicked` resolves the click // to an in-document jump and DecompilerTextView fires the caret highlight. // - // The lifecycle (register then unregister after ~1 s) is verified end-to-end — - // hand-tuned animation curve is left for eyeball validation. + // The lifecycle (register on click, then unregister via Dismiss) is verified end-to-end — + // the hand-tuned animation curve and the one-second timing are left for eyeball validation. var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; @@ -103,9 +103,12 @@ public class CaretHighlightAdornerTests "clicking a member definition must route through DecompilerTextView.OnReferenceClicked " + "and call CaretHighlightAdorner.DisplayCaretHighlightAnimation"); - // Wait for the lifetime timer (~1 s) to tear it back down. - await Waiters.WaitForAsync( - () => !renderers.Any(r => r is CaretHighlightAdorner), - timeout: TimeSpan.FromSeconds(3)); + // The 1 s lifetime is a real-time DispatcherTimer, which is unreliable to wait on in a + // headless run; invoke the same teardown the timer runs (Dismiss) directly so the + // unregister half is verified deterministically rather than against the wall clock. + var adorner = renderers.OfType().Single(); + adorner.Dismiss(); + renderers.Should().NotContain(r => r is CaretHighlightAdorner, + "Dismiss unregisters the caret-highlight adorner from the text view"); } } diff --git a/ILSpy.Tests/MainWindow/LanguageVersionPersistenceTests.cs b/ILSpy.Tests/MainWindow/LanguageVersionPersistenceTests.cs new file mode 100644 index 000000000..c664865ec --- /dev/null +++ b/ILSpy.Tests/MainWindow/LanguageVersionPersistenceTests.cs @@ -0,0 +1,80 @@ +// 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.Linq; +using System.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; + +using AwesomeAssertions; + +using ILSpy; +using ILSpy.AppEnv; +using ILSpy.Languages; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +[TestFixture] +public class LanguageVersionPersistenceTests +{ + [AvaloniaTest] + public async Task Switching_Away_From_And_Back_To_A_Versioned_Language_Restores_The_Version() + { + // Regression: selecting C# x.y, switching to a language with no versions (IL), then back to + // C# must restore x.y in the version combo. The toolbar's version ComboBox is bound TwoWay + // to LanguageService.CurrentVersion; when the language flips, its ItemsSource swaps to the + // new (empty) language first and writes a null selection back, so the outgoing version has + // to be stashed before that happens or the restore reads back null. + var (window, _) = await TestHarness.BootAsync(); + var toolbar = await window.WaitForComponent(); + + var languageCombo = toolbar.GetVisualDescendants().OfType() + .Single(c => c.Name == "LanguageComboBox"); + var versionCombo = toolbar.GetVisualDescendants().OfType() + .Single(c => c.Name == "LanguageVersionComboBox"); + + var languageService = AppComposition.Current.GetExport(); + var csharp = languageService.Languages.Single(l => l.Name == "C#"); + var noVersionLanguage = languageService.Languages.First(l => !l.HasLanguageVersions); + + // Start on C# and pick a version that is NOT the default latest, so a reset is detectable. + languageCombo.SelectedItem = csharp; + Dispatcher.UIThread.RunJobs(); + var chosen = csharp.LanguageVersions.First(); + chosen.Should().NotBe(csharp.LanguageVersions.Last(), "the test needs a non-default version"); + versionCombo.SelectedItem = chosen; + Dispatcher.UIThread.RunJobs(); + languageService.CurrentVersion.Should().Be(chosen); + + // Flip to a language with no versions, then back to C#. + languageCombo.SelectedItem = noVersionLanguage; + Dispatcher.UIThread.RunJobs(); + languageCombo.SelectedItem = csharp; + Dispatcher.UIThread.RunJobs(); + + languageService.CurrentVersion.Should().Be(chosen, + "switching away from C# and back must restore the previously selected C# version"); + versionCombo.SelectedItem.Should().Be(chosen, + "the version ComboBox must reflect the restored version"); + } +} diff --git a/ILSpy.Tests/Options/DisplaySettingsReactionTests.cs b/ILSpy.Tests/Options/DisplaySettingsReactionTests.cs index 5b09ca435..7646fb32e 100644 --- a/ILSpy.Tests/Options/DisplaySettingsReactionTests.cs +++ b/ILSpy.Tests/Options/DisplaySettingsReactionTests.cs @@ -59,9 +59,16 @@ public class DisplaySettingsReactionTests public async Task Toggling_A_Decompiler_Output_Setting_Re_Decompiles_The_Active_Tab() { var (_, vm) = await TestHarness.BootAsync(); + // Decompile a single small method rather than the whole Enumerable type: the full type + // takes >15 s in headless and times out WaitForDecompiledTextAsync under CI load. The + // re-decompile reaction under test fires on whatever the active tab shows, so one method + // exercises it just as well. var typeNode = vm.AssemblyTreeModel.FindNode( "System.Linq", "System.Linq", "System.Linq.Enumerable"); - vm.AssemblyTreeModel.SelectedItem = typeNode; + typeNode.IsExpanded = true; + var method = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Empty"); + vm.AssemblyTreeModel.SelectNode(method); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); int decompileStarts = 0; diff --git a/ILSpy.Tests/Options/OptionsTabTests.cs b/ILSpy.Tests/Options/OptionsTabTests.cs index 98e451bee..5722fd809 100644 --- a/ILSpy.Tests/Options/OptionsTabTests.cs +++ b/ILSpy.Tests/Options/OptionsTabTests.cs @@ -309,10 +309,15 @@ public class OptionsTabTests // pull focus back to MainTab and yank the user off the Options page they are editing. var (_, vm) = await TestHarness.BootAsync(3); - // Show some decompiled content first, so there is a decompiler tab to refresh. + // Show some decompiled content first, so there is a decompiler tab to refresh. Use a single + // small method, not the whole Enumerable type: the full type takes >15 s in headless and + // times out WaitForDecompiledTextAsync under CI load. var typeNode = vm.AssemblyTreeModel.FindNode( "System.Linq", "System.Linq", "System.Linq.Enumerable"); - vm.AssemblyTreeModel.SelectNode(typeNode); + typeNode.IsExpanded = true; + var method = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Empty"); + vm.AssemblyTreeModel.SelectNode(method); await vm.DockWorkspace.WaitForDecompiledTextAsync(); // Open Options and confirm it is the active document. diff --git a/ILSpy/ILSpy.csproj b/ILSpy/ILSpy.csproj index 39dd0ab8e..21385a682 100644 --- a/ILSpy/ILSpy.csproj +++ b/ILSpy/ILSpy.csproj @@ -6,6 +6,10 @@ file stays in sync for these targets without forcing per-RID restore onto the ILSpyCmd tool (PackAsTool + GeneratePackageOnBuild) or the test projects. --> win-x64;win-arm64;linux-x64;osx-arm64 + + major enable app.manifest Assets/ILSpy-Large.ico @@ -33,6 +37,15 @@ stop-the-world for long. --> true true + + 1 + + + + + true diff --git a/ILSpy/TextView/CaretHighlightAdorner.cs b/ILSpy/TextView/CaretHighlightAdorner.cs index f8f1f064b..a3b3510a3 100644 --- a/ILSpy/TextView/CaretHighlightAdorner.cs +++ b/ILSpy/TextView/CaretHighlightAdorner.cs @@ -47,9 +47,14 @@ namespace ILSpy.TextView readonly Rect maxRect; readonly IPen pen; readonly Stopwatch elapsed = Stopwatch.StartNew(); + readonly TextArea textArea; + readonly DispatcherTimer frameTimer; + readonly DispatcherTimer lifetimeTimer; CaretHighlightAdorner(TextArea textArea) { + this.textArea = textArea; + // Caret rect is in document coordinates; subtracting ScrollOffset converts to the // viewport-relative space IBackgroundRenderer.Draw paints into. var caretRect = textArea.Caret.CalculateCaretRectangle(); @@ -64,6 +69,13 @@ namespace ILSpy.TextView // black when unset (e.g. design-time / standalone-renderer tests). var brush = textArea.TextView.GetValue(TextElement.ForegroundProperty) ?? Brushes.Black; pen = new Pen(brush, 1).ToImmutable(); + + // The frame timer ticks at ~60 fps to invalidate the Caret layer so Draw re-runs with + // fresh elapsed time; the lifetime timer dismisses the adorner after one second. + frameTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; + frameTimer.Tick += (_, _) => textArea.TextView.InvalidateLayer(KnownLayer.Caret); + lifetimeTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(LifetimeMs) }; + lifetimeTimer.Tick += (_, _) => Dismiss(); } public KnownLayer Layer => KnownLayer.Caret; @@ -107,7 +119,7 @@ namespace ILSpy.TextView /// /// Registers a one-shot caret-highlight animation on . Spins /// up two timers: one ticks at ~60 fps to invalidate the Caret layer so - /// re-runs with fresh elapsed time, the other unregisters the adorner after one second. + /// re-runs with fresh elapsed time, the other calls after one second. /// public static void DisplayCaretHighlightAnimation(TextArea textArea) { @@ -115,18 +127,20 @@ namespace ILSpy.TextView var adorner = new CaretHighlightAdorner(textArea); textArea.TextView.BackgroundRenderers.Add(adorner); + adorner.frameTimer.Start(); + adorner.lifetimeTimer.Start(); + } - var frameTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; - frameTimer.Tick += (_, _) => textArea.TextView.InvalidateLayer(KnownLayer.Caret); - frameTimer.Start(); - - var lifetimeTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(LifetimeMs) }; - lifetimeTimer.Tick += (_, _) => { - lifetimeTimer.Stop(); - frameTimer.Stop(); - textArea.TextView.BackgroundRenderers.Remove(adorner); - }; - lifetimeTimer.Start(); + /// + /// Ends the animation immediately: stops both timers and unregisters the adorner from the + /// text view. Invoked by the one-second lifetime timer, and usable to tear the highlight + /// down on demand instead of waiting the lifetime out. + /// + public void Dismiss() + { + lifetimeTimer.Stop(); + frameTimer.Stop(); + textArea.TextView.BackgroundRenderers.Remove(this); } } } diff --git a/ILSpy/Views/MainToolBar.axaml.cs b/ILSpy/Views/MainToolBar.axaml.cs index 6ab39d2b7..92c589f29 100644 --- a/ILSpy/Views/MainToolBar.axaml.cs +++ b/ILSpy/Views/MainToolBar.axaml.cs @@ -24,6 +24,7 @@ using System.Reflection; using Avalonia.Controls; using Avalonia.Media; +using Avalonia.Threading; using ILSpy.AppEnv; using ILSpy.Commands; @@ -112,6 +113,26 @@ public partial class MainToolBar : UserControl if (menuRegistry != null) WireManageAssemblyListsButton(menuRegistry); + + WireLanguageVersionComboSync(); + } + + void WireLanguageVersionComboSync() + { + // The version ComboBox binds ItemsSource to CurrentLanguage.LanguageVersions and SelectedItem + // to CurrentVersion. On a language switch the model assigns CurrentVersion (pushing it onto + // SelectedItem) before the CurrentLanguage change propagates to ItemsSource, so SelectedItem + // is set against the previous language's list, rejected, and left null once ItemsSource + // repopulates. Re-assert the selection from the bound CurrentVersion after each ItemsSource + // swap has settled so flipping to a version-less language (e.g. IL) and back restores it. + LanguageVersionComboBox.PropertyChanged += (_, e) => { + if (e.Property != ItemsControl.ItemsSourceProperty) + return; + Dispatcher.UIThread.Post(() => { + if (DataContext is MainWindowViewModel vm) + LanguageVersionComboBox.SelectedItem = vm.LanguageService.CurrentVersion; + }, DispatcherPriority.Background); + }; } void DispatchToolbarCommands(ToolbarCommandRegistry registry) diff --git a/README.md b/README.md index f0f53440b..103a5c3c0 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ How to build - Clone the ILSpy repository using git. - Execute `git submodule update --init --recursive` to download the ILSpy-Tests submodule (used by some test cases). - Install Visual Studio (documented version: 18.0/2026). You need the following workload components: - - Workload ".NET Desktop Development". This workload includes the .NET Framework 4.8 SDK and the .NET Framework 4.7.2 targeting pack, as well as the [.NET 11.0 SDK](https://dotnet.microsoft.com/download/dotnet/11.0) (ILSpy.csproj targets .NET 11.0, but we have net472 projects too). + - Workload ".NET Desktop Development". This workload includes the .NET Framework 4.8 SDK and the .NET Framework 4.7.2 targeting pack, as well as the [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (ILSpy.csproj targets .NET 10.0, but we have net472 projects too). Our unit tests require [.NET 11.0 SDK](https://dotnet.microsoft.com/download/dotnet/11.0). - Workload "Visual Studio extension development" (Note: ILSpy.VSExtensions.sln is separate from ILSpy.sln and thus this workload is optional) - Individual Component "MSVC v143 - VS 2022 C++ x64/x86 build tools" (or similar) - _The VC++ toolset is optional_; if present it is used for `editbin.exe` to modify the stack size used by ILSpy.exe from 1MB to 16MB, because the decompiler makes heavy use of recursion, where small stack sizes lead to problems in very complex methods. @@ -67,7 +67,7 @@ If this problem occurs, please manually install the .NET 11.0 SDK from [here](ht #### Unix / Mac: -- Make sure [.NET 11.0 SDK](https://dotnet.microsoft.com/download/dotnet/11.0) is installed. +- Make sure [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (for run) and [.NET 11.0 SDK](https://dotnet.microsoft.com/download/dotnet/11.0) (for tests) is installed. - Make sure [PowerShell](https://github.com/PowerShell/PowerShell) is installed (formerly known as PowerShell Core) - Clone the repository using git. - Execute `git submodule update --init --recursive` to download the ILSpy-Tests submodule (used by some test cases).