diff --git a/ILSpy.Tests/Docking/FrozenTabRefreshTests.cs b/ILSpy.Tests/Docking/FrozenTabRefreshTests.cs new file mode 100644 index 000000000..5387f1810 --- /dev/null +++ b/ILSpy.Tests/Docking/FrozenTabRefreshTests.cs @@ -0,0 +1,124 @@ +// 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; +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ILSpy; +using ILSpy.AppEnv; +using ILSpy.Docking; +using ILSpy.Languages; +using ILSpy.TextView; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +/// +/// Frozen tabs keep their own with cached output. +/// Output-affecting global changes — the language/version combos and the +/// Redecompile-class display settings — must re-decompile EVERY decompiler tab, not just +/// the active preview tab, or frozen tabs silently keep showing stale output. +/// +[TestFixture] +public class FrozenTabRefreshTests +{ + /// + /// Boots the app, decompiles Enumerable.Select into the preview tab, freezes it, then + /// routes Enumerable.Where into a fresh preview tab. Returns both tab models settled. + /// + static async Task<(DecompilerTabPageModel Frozen, DecompilerTabPageModel Preview, MainWindowViewModel Vm)> BootWithFrozenAndPreviewTabsAsync() + { + var (_, vm) = await TestHarness.BootAsync(3); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + typeNode.IsExpanded = true; + var selectNode = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Select" && m.MethodDefinition.Parameters.Count == 2); + var whereNode = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Where" && m.MethodDefinition.Parameters.Count == 2); + + vm.AssemblyTreeModel.SelectNode(selectNode); + vm.DockWorkspace.SettleSelection(); + var frozen = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + vm.DockWorkspace.FreezeCurrentTab(); + + vm.AssemblyTreeModel.SelectNode(whereNode); + vm.DockWorkspace.SettleSelection(); + var preview = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + preview.Should().NotBeSameAs(frozen, + "setup precondition — selecting after freeze must route to a fresh preview tab"); + return (frozen, preview, vm); + } + + [AvaloniaTest] + public async Task Switching_The_Language_Redecompiles_Frozen_Tabs_Too() + { + var (frozen, preview, _) = await BootWithFrozenAndPreviewTabsAsync(); + var frozenCSharp = frozen.Text; + var previewCSharp = preview.Text; + + var languageService = AppComposition.Current.GetExport(); + languageService.CurrentLanguage = languageService.GetLanguage("IL"); + + await Waiters.WaitForAsync( + () => !frozen.IsDecompiling && !preview.IsDecompiling + && frozen.Text != frozenCSharp && preview.Text != previewCSharp, + TimeSpan.FromSeconds(15), + "both the frozen tab and the preview tab to re-decompile after the language change"); + + frozen.Language.Name.Should().Be("IL", + "the frozen tab must adopt the newly selected language"); + frozen.Text.Should().Contain(".method", + "the frozen tab must show IL output after the switch"); + preview.Text.Should().Contain(".method", + "the preview tab must show IL output after the switch"); + } + + [AvaloniaTest] + public async Task Toggling_A_Redecompile_Display_Setting_Refreshes_Frozen_Tabs_Too() + { + var (frozen, preview, _) = await BootWithFrozenAndPreviewTabsAsync(); + var frozenBefore = frozen.Text; + var previewBefore = preview.Text; + + // IndentationUseTabs is in the Redecompile reaction class (DisplaySettingReactions) + // and changes every indented line, so the refresh is observable on any output. + var display = AppComposition.Current.GetExport().DisplaySettings; + display.IndentationUseTabs = !display.IndentationUseTabs; + + await Waiters.WaitForAsync( + () => !frozen.IsDecompiling && !preview.IsDecompiling + && frozen.Text != frozenBefore && preview.Text != previewBefore, + TimeSpan.FromSeconds(15), + "both the frozen tab and the preview tab to re-decompile after the display-setting change"); + + frozen.Text.Should().NotBe(frozenBefore, + "the frozen tab must re-render with the new indentation setting"); + } +} diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 3aff65801..d3a2b8731 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -440,17 +440,28 @@ namespace ILSpy.Docking /// internal Task CancelPendingOperationsAsync() { - if (Layout is not IDockable root) - return Task.CompletedTask; - var pending = FlattenDocumentDocks(root) - .SelectMany(dock => dock.VisibleDockables?.OfType() ?? Enumerable.Empty()) - .Select(tab => tab.Content) - .OfType() + var pending = AllDecompilerTabs() .Select(tab => tab.CancelPendingAsync()) .ToArray(); return pending.Length == 0 ? Task.CompletedTask : Task.WhenAll(pending); } + /// + /// Every decompiler tab model across all document docks, including docks in floating + /// windows: the preview tab, frozen tabs, and static-content pages alike. Callers + /// that must skip static content (e.g. output refreshes) filter on + /// themselves. + /// + IEnumerable AllDecompilerTabs() + { + if (Layout is not IDockable root) + return []; + return FlattenDocumentDocks(root) + .SelectMany(dock => dock.VisibleDockables?.OfType() ?? Enumerable.Empty()) + .Select(tab => tab.Content) + .OfType(); + } + // Set true while syncing the tree's selection FROM the active tab so the // SelectionChanged handler doesn't bounce back into ShowSelectedNode and overwrite // MainTab.Content with the carved-out tab's node. @@ -632,15 +643,16 @@ namespace ILSpy.Docking { if (e.PropertyName is nameof(LanguageService.CurrentLanguage) or nameof(LanguageService.CurrentVersion)) { - if (ActiveDecompilerTab is { } tab) + // Every decompiler tab caches its own output, so every one re-decompiles — + // frozen tabs included, not just the active preview tab. The language + // version is read per-run inside TryGetLiveDecompilerSettings, so assigning + // the language and re-running covers both combo boxes. + foreach (var tab in AllDecompilerTabs()) { + if (tab.IsStaticContent) + continue; tab.Language = languageService.CurrentLanguage; - // Re-decompile by re-assigning the same node so the tab refreshes for the new - // language or language version (the version is read inside - // TryGetLiveDecompilerSettings, which builds the per-run DecompilerSettings). - var node = tab.CurrentNode; - tab.CurrentNode = null; - tab.CurrentNode = node; + tab.Redecompile(); } } } @@ -720,14 +732,21 @@ namespace ILSpy.Docking } /// - /// Re-decompile the decompiler tab's content in place so an output-affecting display setting - /// takes effect, WITHOUT activating or navigating to it. Unlike - /// (which re-projects the tree selection via ShowSelectedNode and so activates the preview - /// tab), changing an option must not steal the user's current tab. Refreshes the cached - /// decompiler content directly, so it is up to date even when another tab is showing. + /// Re-decompiles every decompiler tab's content in place so an output-affecting display + /// setting takes effect, WITHOUT activating or navigating to any of them. Unlike + /// (which re-projects the tree selection via + /// ShowSelectedNode and so activates the preview tab), changing an option must not + /// steal the user's current tab. Covers frozen tabs and floated tabs, whose models each + /// cache their own output. /// public void RefreshDecompilerOutputInPlace() - => decompilerContent?.Redecompile(); + { + foreach (var tab in AllDecompilerTabs()) + { + if (!tab.IsStaticContent) + tab.Redecompile(); + } + } void ShowSelectedNode() {