diff --git a/ILSpy.Tests/Docking/SingletonDocumentTabTests.cs b/ILSpy.Tests/Docking/SingletonDocumentTabTests.cs new file mode 100644 index 000000000..c28ec06c4 --- /dev/null +++ b/ILSpy.Tests/Docking/SingletonDocumentTabTests.cs @@ -0,0 +1,103 @@ +// 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 Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Properties; + +using ILSpy.AppEnv; +using ILSpy.Commands; +using ILSpy.Options; +using ILSpy.TextView; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Docking; + +/// +/// Options and About are static-content singletons: closing and reopening one must reuse the +/// same instance (and therefore its owned view + content state, e.g. +/// the selected options page) rather than rebuilding a fresh tab each time. Pins that contract. +/// +[TestFixture] +public class SingletonDocumentTabTests +{ + static void Invoke(MainWindow window, string header) + { + var registry = AppComposition.Current.GetExport(); + registry.Commands.Single(c => c.Metadata.Header == header).CreateExport().Value.Execute(null); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + } + + [AvaloniaTest] + public void Reopening_Options_Reuses_The_Same_Tab() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + var dock = vm.DockWorkspace; + + Invoke(window, nameof(Resources._Options)); + var first = dock.Documents!.VisibleDockables!.OfType() + .Single(t => t.Content is OptionsPageModel); + var firstContent = first.Content; + + dock.Factory.CloseDockable(first); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + dock.Documents!.VisibleDockables!.OfType() + .Any(t => t.Content is OptionsPageModel).Should().BeFalse("closing must remove the Options tab"); + + Invoke(window, nameof(Resources._Options)); + var second = dock.Documents!.VisibleDockables!.OfType() + .Single(t => t.Content is OptionsPageModel); + + second.Should().BeSameAs(first, + "reopening Options must reuse the retained singleton tab, not build a fresh one"); + second.Content.Should().BeSameAs(firstContent, + "the OptionsPageModel (and its selected page) must survive close/reopen"); + } + + [AvaloniaTest] + public void Reopening_About_Reuses_The_Same_Tab() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + var dock = vm.DockWorkspace; + + bool IsAbout(ContentTabPage t) => t.Content is DecompilerTabPageModel { Title: var title } && title == Resources.About; + + Invoke(window, nameof(Resources._About)); + var first = dock.Documents!.VisibleDockables!.OfType().Single(IsAbout); + + dock.Factory.CloseDockable(first); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + + Invoke(window, nameof(Resources._About)); + var second = dock.Documents!.VisibleDockables!.OfType().Single(IsAbout); + + second.Should().BeSameAs(first, + "reopening About must reuse the retained singleton tab, not rebuild the page"); + } +} diff --git a/ILSpy/Commands/AboutCommand.cs b/ILSpy/Commands/AboutCommand.cs index ab66ca215..ca42bb896 100644 --- a/ILSpy/Commands/AboutCommand.cs +++ b/ILSpy/Commands/AboutCommand.cs @@ -58,10 +58,14 @@ namespace ILSpy.Commands public override void Execute(object? parameter) { - var output = BuildAboutOutput(); - var content = CreateTabContent(Resources.About, output, ".txt", isStaticContent: true); - var tab = dockWorkspace.OpenNewTab(content); - dockWorkspace.RecordStaticPage(tab, new Uri("resource:aboutpage")); + // Singleton: one retained About tab, reused across close/reopen rather than rebuilt. + dockWorkspace.OpenSingletonTab("resource:aboutpage", () => { + var output = BuildAboutOutput(); + var content = CreateTabContent(Resources.About, output, ".txt", isStaticContent: true); + var tab = dockWorkspace.OpenNewTab(content); + dockWorkspace.RecordStaticPage(tab, new Uri("resource:aboutpage")); + return tab; + }); } /// @@ -140,11 +144,16 @@ namespace ILSpy.Commands if (stream == null) return; using var reader = new StreamReader(stream); - var output = new AvaloniaEditTextOutput { Title = resourceName }; - output.Write(reader.ReadToEnd()); - var content = CreateTabContent(resourceName, output, ".txt", isStaticContent: true); - var tab = dockWorkspace.OpenNewTab(content); - dockWorkspace.RecordStaticPage(tab, new Uri("resource:" + resourceName)); + var text = reader.ReadToEnd(); + // Singleton per resource: reopening the same embedded page reuses its retained tab. + dockWorkspace.OpenSingletonTab("resource:" + resourceName, () => { + var output = new AvaloniaEditTextOutput { Title = resourceName }; + output.Write(text); + var content = CreateTabContent(resourceName, output, ".txt", isStaticContent: true); + var tab = dockWorkspace.OpenNewTab(content); + dockWorkspace.RecordStaticPage(tab, new Uri("resource:" + resourceName)); + return tab; + }); } static string GetDotnetProductVersion() diff --git a/ILSpy/Commands/ShowOptionsCommand.cs b/ILSpy/Commands/ShowOptionsCommand.cs index c7ac7671e..86c4868e1 100644 --- a/ILSpy/Commands/ShowOptionsCommand.cs +++ b/ILSpy/Commands/ShowOptionsCommand.cs @@ -18,7 +18,6 @@ using System.Collections.Generic; using System.Composition; -using System.Linq; using ICSharpCode.ILSpy.Properties; @@ -46,24 +45,10 @@ namespace ILSpy.Commands { public override void Execute(object? parameter) { - var existing = FindExistingOptionsTab(); - if (existing != null && dockWorkspace.Documents != null) - { - dockWorkspace.Factory.SetActiveDockable(existing); - dockWorkspace.Factory.SetFocusedDockable(dockWorkspace.Documents, existing); - return; - } - - var model = new OptionsPageModel(settingsService, optionPages); - dockWorkspace.OpenNewTab(model); - } - - ContentTabPage? FindExistingOptionsTab() - { - var docs = dockWorkspace.Documents?.VisibleDockables; - if (docs == null) - return null; - return docs.OfType().FirstOrDefault(t => t.Content is OptionsPageModel); + // Singleton: one retained Options tab for the session. Reopening after close reuses + // the same ContentTabPage (and its OptionsPageModel, so the selected page survives). + dockWorkspace.OpenSingletonTab("options", + () => dockWorkspace.OpenNewTab(new OptionsPageModel(settingsService, optionPages))); } } } diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 31e57cde1..7c3736cce 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -651,6 +651,11 @@ namespace ILSpy.Docking // Created lazily on first need. DecompilerTabPageModel? decompilerContent; + // Retained static-content singleton tabs (Options, About, embedded resource pages). + // Keeping the ContentTabPage instance across close/reopen preserves its owned view and + // content state (e.g. the selected options page) instead of rebuilding the tab each time. + readonly Dictionary singletonTabs = new(); + ILSpyTreeNode[]? lastShownNodes; /// @@ -1091,6 +1096,36 @@ namespace ILSpy.Docking return tab; } + /// + /// Open a retained static-content singleton tab (Options, About, an embedded resource + /// page). If the tab already exists -- visible or previously closed -- the same instance + /// is reactivated / re-added, so its owned view and content state survive close/reopen. + /// Otherwise builds it once and it is retained for the session. + /// + public ContentTabPage OpenSingletonTab(string key, Func create) + { + if (singletonTabs.TryGetValue(key, out var existing)) + { + ReopenTab(existing); + return existing; + } + var tab = create(); + singletonTabs[key] = tab; + return tab; + } + + // Re-display a retained tab: re-add it to the Documents dock if it was closed, then make + // it active. The dockable (and the view it owns) is the same instance throughout. + void ReopenTab(ContentTabPage tab) + { + if (factory.Documents is not { } docs) + return; + if (docs.VisibleDockables?.Contains(tab) != true) + factory.AddDockable(docs, tab); + factory.SetActiveDockable(tab); + factory.SetFocusedDockable(docs, tab); + } + /// /// Renders in the reusable MainTab as a transient welcome /// page (the About page shown at startup when nothing is selected). Unlike Help >