Browse Source

Make Options and About singleton document tabs

Closing and reopening Options or About built a fresh ContentTabPage (and a
fresh owned view) each time, losing the selected options page and
re-rendering the About output. Retain these static-content tabs by key on
DockWorkspace and re-add the same instance on reopen, so the dockable --
and the view it owns via IDockableViewOwner -- persists for the session.
Embedded About resource pages (license, third-party notices) are
singletons too.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
5a6465931b
  1. 103
      ILSpy.Tests/Docking/SingletonDocumentTabTests.cs
  2. 11
      ILSpy/Commands/AboutCommand.cs
  3. 23
      ILSpy/Commands/ShowOptionsCommand.cs
  4. 35
      ILSpy/Docking/DockWorkspace.cs

103
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;
/// <summary>
/// Options and About are static-content singletons: closing and reopening one must reuse the
/// same <see cref="ContentTabPage"/> 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.
/// </summary>
[TestFixture]
public class SingletonDocumentTabTests
{
static void Invoke(MainWindow window, string header)
{
var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>();
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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
var dock = vm.DockWorkspace;
Invoke(window, nameof(Resources._Options));
var first = dock.Documents!.VisibleDockables!.OfType<ContentTabPage>()
.Single(t => t.Content is OptionsPageModel);
var firstContent = first.Content;
dock.Factory.CloseDockable(first);
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
dock.Documents!.VisibleDockables!.OfType<ContentTabPage>()
.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<ContentTabPage>()
.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<MainWindow>();
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<ContentTabPage>().Single(IsAbout);
dock.Factory.CloseDockable(first);
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
Invoke(window, nameof(Resources._About));
var second = dock.Documents!.VisibleDockables!.OfType<ContentTabPage>().Single(IsAbout);
second.Should().BeSameAs(first,
"reopening About must reuse the retained singleton tab, not rebuild the page");
}
}

11
ILSpy/Commands/AboutCommand.cs

@ -58,10 +58,14 @@ namespace ILSpy.Commands
public override void Execute(object? parameter) public override void Execute(object? parameter)
{ {
// Singleton: one retained About tab, reused across close/reopen rather than rebuilt.
dockWorkspace.OpenSingletonTab("resource:aboutpage", () => {
var output = BuildAboutOutput(); var output = BuildAboutOutput();
var content = CreateTabContent(Resources.About, output, ".txt", isStaticContent: true); var content = CreateTabContent(Resources.About, output, ".txt", isStaticContent: true);
var tab = dockWorkspace.OpenNewTab(content); var tab = dockWorkspace.OpenNewTab(content);
dockWorkspace.RecordStaticPage(tab, new Uri("resource:aboutpage")); dockWorkspace.RecordStaticPage(tab, new Uri("resource:aboutpage"));
return tab;
});
} }
/// <summary> /// <summary>
@ -140,11 +144,16 @@ namespace ILSpy.Commands
if (stream == null) if (stream == null)
return; return;
using var reader = new StreamReader(stream); using var reader = new StreamReader(stream);
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 }; var output = new AvaloniaEditTextOutput { Title = resourceName };
output.Write(reader.ReadToEnd()); output.Write(text);
var content = CreateTabContent(resourceName, output, ".txt", isStaticContent: true); var content = CreateTabContent(resourceName, output, ".txt", isStaticContent: true);
var tab = dockWorkspace.OpenNewTab(content); var tab = dockWorkspace.OpenNewTab(content);
dockWorkspace.RecordStaticPage(tab, new Uri("resource:" + resourceName)); dockWorkspace.RecordStaticPage(tab, new Uri("resource:" + resourceName));
return tab;
});
} }
static string GetDotnetProductVersion() static string GetDotnetProductVersion()

23
ILSpy/Commands/ShowOptionsCommand.cs

@ -18,7 +18,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Composition; using System.Composition;
using System.Linq;
using ICSharpCode.ILSpy.Properties; using ICSharpCode.ILSpy.Properties;
@ -46,24 +45,10 @@ namespace ILSpy.Commands
{ {
public override void Execute(object? parameter) public override void Execute(object? parameter)
{ {
var existing = FindExistingOptionsTab(); // Singleton: one retained Options tab for the session. Reopening after close reuses
if (existing != null && dockWorkspace.Documents != null) // the same ContentTabPage (and its OptionsPageModel, so the selected page survives).
{ dockWorkspace.OpenSingletonTab("options",
dockWorkspace.Factory.SetActiveDockable(existing); () => dockWorkspace.OpenNewTab(new OptionsPageModel(settingsService, optionPages)));
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<ContentTabPage>().FirstOrDefault(t => t.Content is OptionsPageModel);
} }
} }
} }

35
ILSpy/Docking/DockWorkspace.cs

@ -651,6 +651,11 @@ namespace ILSpy.Docking
// Created lazily on first need. // Created lazily on first need.
DecompilerTabPageModel? decompilerContent; 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<string, ContentTabPage> singletonTabs = new();
ILSpyTreeNode[]? lastShownNodes; ILSpyTreeNode[]? lastShownNodes;
/// <summary> /// <summary>
@ -1091,6 +1096,36 @@ namespace ILSpy.Docking
return tab; return tab;
} }
/// <summary>
/// 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 <paramref name="create"/> builds it once and it is retained for the session.
/// </summary>
public ContentTabPage OpenSingletonTab(string key, Func<ContentTabPage> 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);
}
/// <summary> /// <summary>
/// Renders <paramref name="content"/> in the reusable MainTab as a transient welcome /// Renders <paramref name="content"/> in the reusable MainTab as a transient welcome
/// page (the About page shown at startup when nothing is selected). Unlike Help &gt; /// page (the About page shown at startup when nothing is selected). Unlike Help &gt;

Loading…
Cancel
Save