diff --git a/ILSpy.Tests/Docking/ContentPageHierarchyTests.cs b/ILSpy.Tests/Docking/ContentPageHierarchyTests.cs
new file mode 100644
index 000000000..e66aeff8a
--- /dev/null
+++ b/ILSpy.Tests/Docking/ContentPageHierarchyTests.cs
@@ -0,0 +1,62 @@
+// 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 AwesomeAssertions;
+
+using ILSpy.ViewModels;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+///
+/// Locks the single inner-content hierarchy: every viewmodel that may occupy a
+/// 's Content slot derives from the one
+/// base, and ContentTabPage.Content is typed to it (not object). Guards against a
+/// future content type sneaking back in as a bare ObservableObject or the slot weakening to object.
+///
+[TestFixture]
+public class ContentPageHierarchyTests
+{
+ [Test]
+ public void All_Four_Content_Types_Derive_From_ContentPageModel()
+ {
+ typeof(global::ILSpy.TextView.DecompilerTabPageModel).Should().BeAssignableTo();
+ typeof(MetadataTablePageModel).Should().BeAssignableTo();
+ typeof(global::ILSpy.Compare.CompareTabPageModel).Should().BeAssignableTo();
+ typeof(global::ILSpy.Options.OptionsPageModel).Should().BeAssignableTo(
+ "OptionsPageModel must join the content hierarchy, not stay a bare ObservableObject");
+ }
+
+ [Test]
+ public void ContentTabPage_Content_Is_Typed_To_ContentPageModel()
+ {
+ typeof(ContentTabPage).GetProperty(nameof(ContentTabPage.Content))!.PropertyType
+ .Should().Be(typeof(ContentPageModel), "the Content slot must be strongly typed, not object");
+ }
+
+ [Test]
+ public void IsStaticContent_Is_A_Single_Inherited_Member()
+ {
+ // One IsStaticContent, declared on the base -- not duck-typed across unrelated classes.
+ typeof(ContentPageModel).GetProperty(nameof(ContentPageModel.IsStaticContent))
+ .Should().NotBeNull("IsStaticContent lives on ContentPageModel");
+ typeof(global::ILSpy.Options.OptionsPageModel).GetProperty("IsStaticContent")!.DeclaringType
+ .Should().Be(typeof(ContentPageModel), "OptionsPageModel must inherit IsStaticContent, not redeclare it");
+ }
+}
diff --git a/ILSpy.Tests/Docking/LayoutPersistenceTests.cs b/ILSpy.Tests/Docking/LayoutPersistenceTests.cs
index dae004f0a..95832ff79 100644
--- a/ILSpy.Tests/Docking/LayoutPersistenceTests.cs
+++ b/ILSpy.Tests/Docking/LayoutPersistenceTests.cs
@@ -183,9 +183,9 @@ public class LayoutPersistenceTests
// Open two extra documents on top of the persistent MainTab. Content type is
// irrelevant for this test — the bug is structural, about ContentTabPage shells
- // being persisted at all.
- dockWorkspace.OpenNewTab(new object());
- dockWorkspace.OpenNewTab(new object());
+ // being persisted at all — so any ContentPageModel will do.
+ dockWorkspace.OpenNewTab(new global::ILSpy.TextView.DecompilerTabPageModel());
+ dockWorkspace.OpenNewTab(new global::ILSpy.TextView.DecompilerTabPageModel());
TestCapture.Step("three-document-tabs-open");
var sourceDocs = dockWorkspace.Documents!.VisibleDockables!
.OfType().Count();
diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs
index 4534c08aa..ff857c61c 100644
--- a/ILSpy/Docking/DockWorkspace.cs
+++ b/ILSpy/Docking/DockWorkspace.cs
@@ -724,7 +724,7 @@ namespace ILSpy.Docking
// deterministic without going through Dock's add+close lifecycle.
// Carve-outs ("Open in new tab", "Freeze tab") aren't implemented yet and would
// branch off this path before the Content swap.
- TabPageModel? customContent;
+ ContentPageModel? customContent;
using (ILSpy.AppEnv.AppLog.Phase("ShowSelectedNode: node.CreateTab"))
customContent = nodes.Length == 1 ? nodes[0].CreateTab() : null;
@@ -778,7 +778,7 @@ namespace ILSpy.Docking
}
// Pure predicate: returns the tab if it is a writable preview ContentTabPage -- IsPreview
- // true AND not hosting static content (Options, About). Otherwise null. Options/About are
+ // true AND not hosting static content (Options, About). Otherwise null. Static pages are
// born frozen (IsPreview=false) AND carry IsStaticContent=true; either flag alone would
// suffice, both guard against drift.
static ContentTabPage? IsWritablePreview(IDockable? dockable)
@@ -787,9 +787,7 @@ namespace ILSpy.Docking
return null;
if (!tab.IsPreview)
return null;
- if (tab.Content is DecompilerTabPageModel { IsStaticContent: true })
- return null;
- if (tab.Content is Options.OptionsPageModel)
+ if (tab.Content is { IsStaticContent: true })
return null;
return tab;
}
@@ -810,7 +808,7 @@ namespace ILSpy.Docking
factory.SetActiveDockable(main);
}
- void AttachCustomContent(ContentTabPage main, TabPageModel newContent)
+ void AttachCustomContent(ContentTabPage main, ContentPageModel newContent)
{
// Detach navigation handlers from the outgoing content; subscribe on the
// incoming one so token clicks route through OnMetadataCellClicked and
@@ -996,7 +994,7 @@ namespace ILSpy.Docking
return tab;
}
- static void CopyContentState(object source, object target)
+ static void CopyContentState(ContentPageModel source, ContentPageModel target)
{
if (source is MetadataTablePageModel newMeta && target is MetadataTablePageModel oldMeta)
{
@@ -1101,7 +1099,7 @@ namespace ILSpy.Docking
}
}
- public ContentTabPage OpenNewTab(object content, SharpTreeNode? sourceNode = null)
+ public ContentTabPage OpenNewTab(ContentPageModel content, SharpTreeNode? sourceNode = null)
{
// Carve-outs are born frozen — they survive tree-node selections instead of
// being replaced like the preview MainTab.
diff --git a/ILSpy/Metadata/BlobHeapTreeNode.cs b/ILSpy/Metadata/BlobHeapTreeNode.cs
index b015a60c8..615280a5a 100644
--- a/ILSpy/Metadata/BlobHeapTreeNode.cs
+++ b/ILSpy/Metadata/BlobHeapTreeNode.cs
@@ -45,7 +45,7 @@ namespace ILSpy.Metadata
public override object Text => $"Blob Heap ({EnsureEntries().Count})";
public override string ToString() => "Blob Heap";
- public override TabPageModel CreateTab()
+ public override ContentPageModel CreateTab()
{
var page = new MetadataTablePageModel {
Title = "Blob Heap",
diff --git a/ILSpy/Metadata/CoffHeaderTreeNode.cs b/ILSpy/Metadata/CoffHeaderTreeNode.cs
index 7b3390f2e..e0629ebe5 100644
--- a/ILSpy/Metadata/CoffHeaderTreeNode.cs
+++ b/ILSpy/Metadata/CoffHeaderTreeNode.cs
@@ -45,7 +45,7 @@ namespace ILSpy.Metadata
public override object Icon => Images.Images.Header;
public override string ToString() => "COFF Header";
- public override TabPageModel CreateTab()
+ public override ContentPageModel CreateTab()
{
var page = new MetadataTablePageModel {
Title = "COFF Header",
diff --git a/ILSpy/Metadata/DataDirectoriesTreeNode.cs b/ILSpy/Metadata/DataDirectoriesTreeNode.cs
index 672e47ffa..cdb27f071 100644
--- a/ILSpy/Metadata/DataDirectoriesTreeNode.cs
+++ b/ILSpy/Metadata/DataDirectoriesTreeNode.cs
@@ -47,7 +47,7 @@ namespace ILSpy.Metadata
public override object ExpandedIcon => Images.Images.ListFolderOpen;
public override string ToString() => "Data Directories";
- public override TabPageModel CreateTab()
+ public override ContentPageModel CreateTab()
{
var page = new MetadataTablePageModel {
Title = "Data Directories",
diff --git a/ILSpy/Metadata/DebugDirectoryTreeNode.cs b/ILSpy/Metadata/DebugDirectoryTreeNode.cs
index ecd643339..61b867212 100644
--- a/ILSpy/Metadata/DebugDirectoryTreeNode.cs
+++ b/ILSpy/Metadata/DebugDirectoryTreeNode.cs
@@ -51,7 +51,7 @@ namespace ILSpy.Metadata
public override object ExpandedIcon => Images.Images.ListFolderOpen;
public override string ToString() => "Debug Directory";
- public override TabPageModel CreateTab()
+ public override ContentPageModel CreateTab()
{
var page = new MetadataTablePageModel {
Title = "Debug Directory",
diff --git a/ILSpy/Metadata/DosHeaderTreeNode.cs b/ILSpy/Metadata/DosHeaderTreeNode.cs
index 6c0cabfe0..33ad6f1d7 100644
--- a/ILSpy/Metadata/DosHeaderTreeNode.cs
+++ b/ILSpy/Metadata/DosHeaderTreeNode.cs
@@ -44,7 +44,7 @@ namespace ILSpy.Metadata
public override object Icon => Images.Images.Header;
public override string ToString() => "DOS Header";
- public override TabPageModel CreateTab()
+ public override ContentPageModel CreateTab()
{
var page = new MetadataTablePageModel {
Title = "DOS Header",
diff --git a/ILSpy/Metadata/GuidHeapTreeNode.cs b/ILSpy/Metadata/GuidHeapTreeNode.cs
index 1a00525e5..cd2f4c0bf 100644
--- a/ILSpy/Metadata/GuidHeapTreeNode.cs
+++ b/ILSpy/Metadata/GuidHeapTreeNode.cs
@@ -44,7 +44,7 @@ namespace ILSpy.Metadata
public override object Text => $"Guid Heap ({EnsureEntries().Count})";
public override string ToString() => "Guid Heap";
- public override TabPageModel CreateTab()
+ public override ContentPageModel CreateTab()
{
var page = new MetadataTablePageModel {
Title = "Guid Heap",
diff --git a/ILSpy/Metadata/MetadataTableTreeNode.cs b/ILSpy/Metadata/MetadataTableTreeNode.cs
index e3825b26d..9f716f721 100644
--- a/ILSpy/Metadata/MetadataTableTreeNode.cs
+++ b/ILSpy/Metadata/MetadataTableTreeNode.cs
@@ -72,7 +72,7 @@ namespace ILSpy.Metadata
protected abstract IReadOnlyList LoadTable();
- public override TabPageModel CreateTab()
+ public override ContentPageModel CreateTab()
{
// IReadOnlyList is covariant on T, so a strongly-typed list passes through to
// MetadataTablePageModel.Items (declared as IReadOnlyList