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) without a diff --git a/ILSpy/Metadata/OptionalHeaderTreeNode.cs b/ILSpy/Metadata/OptionalHeaderTreeNode.cs index e25b4c470..7e5939bbf 100644 --- a/ILSpy/Metadata/OptionalHeaderTreeNode.cs +++ b/ILSpy/Metadata/OptionalHeaderTreeNode.cs @@ -46,7 +46,7 @@ namespace ILSpy.Metadata public override object Icon => Images.Images.Header; public override string ToString() => "Optional Header"; - public override TabPageModel CreateTab() + public override ContentPageModel CreateTab() { var page = new MetadataTablePageModel { Title = "Optional Header", diff --git a/ILSpy/Metadata/StringHeapTreeNode.cs b/ILSpy/Metadata/StringHeapTreeNode.cs index ae0d7838c..34e2e655b 100644 --- a/ILSpy/Metadata/StringHeapTreeNode.cs +++ b/ILSpy/Metadata/StringHeapTreeNode.cs @@ -45,7 +45,7 @@ namespace ILSpy.Metadata public override object Text => $"String Heap ({EnsureEntries().Count})"; public override string ToString() => "String Heap"; - public override TabPageModel CreateTab() + public override ContentPageModel CreateTab() { var page = new MetadataTablePageModel { Title = "String Heap", diff --git a/ILSpy/Metadata/UserStringHeapTreeNode.cs b/ILSpy/Metadata/UserStringHeapTreeNode.cs index 7dd660a43..093397ad3 100644 --- a/ILSpy/Metadata/UserStringHeapTreeNode.cs +++ b/ILSpy/Metadata/UserStringHeapTreeNode.cs @@ -44,7 +44,7 @@ namespace ILSpy.Metadata public override object Text => $"UserString Heap ({EnsureEntries().Count})"; public override string ToString() => "UserString Heap"; - public override TabPageModel CreateTab() + public override ContentPageModel CreateTab() { var page = new MetadataTablePageModel { Title = "UserString Heap", diff --git a/ILSpy/Options/OptionsPageModel.cs b/ILSpy/Options/OptionsPageModel.cs index 5e7bc70ab..3dec06582 100644 --- a/ILSpy/Options/OptionsPageModel.cs +++ b/ILSpy/Options/OptionsPageModel.cs @@ -25,6 +25,8 @@ using CommunityToolkit.Mvvm.Input; using ICSharpCode.ILSpy.Properties; +using ILSpy.ViewModels; + namespace ILSpy.Options { /// @@ -38,7 +40,7 @@ namespace ILSpy.Options /// at app exit. /// /// - public sealed partial class OptionsPageModel : ObservableObject + public sealed partial class OptionsPageModel : ContentPageModel { public OptionsPageModel(SettingsService settingsService, IEnumerable> pageFactories) { @@ -54,17 +56,12 @@ namespace ILSpy.Options // Bare "Options" string — the menu item's Resources._Options carries the // accelerator underscore + "..." suffix which are menu-only conventions. Title = Resources.Options; + // Tree-node selections must not replace the Options tab (ContentPageModel.IsStaticContent). + IsStaticContent = true; ResetCurrentPageCommand = new RelayCommand(ResetCurrentPage); } - public string Title { get; } - - /// Marker for the dock router: tree-node selections must not replace this - /// tab. Mirrors the - /// convention used for the About tab. - public bool IsStaticContent => true; - public IReadOnlyList Pages { get; } [ObservableProperty] diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index 6210212ad..49112f203 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -47,7 +47,7 @@ namespace ILSpy.TextView /// changes; previous in-flight decompilations are cancelled so a /// rapid tree-selection sweep doesn't pile up background work. /// - public sealed partial class DecompilerTabPageModel : TabPageModel + public sealed partial class DecompilerTabPageModel : ContentPageModel { CancellationTokenSource? activeCts; @@ -202,13 +202,9 @@ namespace ILSpy.TextView // between selection and the title update. string cachedBaseTitle = "(unnamed)"; - /// - /// True for tabs whose content is a static page (e.g. About) rather than the result - /// of decompiling a tree-node selection. Static tabs are excluded from the lookup - /// that resolves "the current decompile target", so subsequent tree-node clicks open - /// or reuse a different tab and leave the static content intact. - /// - public bool IsStaticContent { get; set; } + // IsStaticContent is inherited from ContentPageModel: true for static pages (e.g. About) + // excludes this tab from the "current decompile target" lookup, so later tree-node + // clicks reuse a different tab and leave the static content intact. [RelayCommand] void CancelDecompilation() diff --git a/ILSpy/TreeNodes/ILSpyTreeNode.cs b/ILSpy/TreeNodes/ILSpyTreeNode.cs index c3eaf9779..752ab5625 100644 --- a/ILSpy/TreeNodes/ILSpyTreeNode.cs +++ b/ILSpy/TreeNodes/ILSpyTreeNode.cs @@ -166,7 +166,7 @@ namespace ILSpy.TreeNodes /// through the decompiler-text path. Lets metadata-table nodes show their own /// DataGrid view while the rest of the tree keeps decompiling. /// - public virtual TabPageModel? CreateTab() => null; + public virtual ContentPageModel? CreateTab() => null; /// /// Applies to every newly-added child and writes the result diff --git a/ILSpy/ViewModels/CompareTabPageModel.cs b/ILSpy/ViewModels/CompareTabPageModel.cs index 04c1a8066..69a177489 100644 --- a/ILSpy/ViewModels/CompareTabPageModel.cs +++ b/ILSpy/ViewModels/CompareTabPageModel.cs @@ -31,7 +31,7 @@ namespace ILSpy.Compare /// assemblies and picks "Compare…". Static content (no language switching, no /// re-decompile) — the merge is computed once at construction. /// - public sealed partial class CompareTabPageModel : TabPageModel + public sealed partial class CompareTabPageModel : ContentPageModel { readonly LoadedAssembly leftAssembly; readonly LoadedAssembly rightAssembly; diff --git a/ILSpy/ViewModels/ContentPageModel.cs b/ILSpy/ViewModels/ContentPageModel.cs new file mode 100644 index 000000000..d609c9bdb --- /dev/null +++ b/ILSpy/ViewModels/ContentPageModel.cs @@ -0,0 +1,39 @@ +// 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. + +namespace ILSpy.ViewModels +{ + /// + /// Base for the concrete viewmodels that occupy a 's + /// slot: the decompiler text view, the metadata grid, + /// the compare view, and the Options panel. Collapsing them under one base lets + /// ContentTabPage type its Content strongly and read Title / + /// SupportsLanguageSwitching / directly instead of by + /// reflection or runtime type-tests scattered across the dock router. + /// + public abstract class ContentPageModel : TabPageModel + { + /// + /// True for content a tree-node selection must NOT replace in the preview tab -- the + /// static pages (Options, About, embedded resources). Defaults false (ordinary decompile + /// / metadata content); static pages set it true. The dock router's "is this the writable + /// preview?" and "what's the current decompile target?" checks key off this. + /// + public bool IsStaticContent { get; set; } + } +} diff --git a/ILSpy/ViewModels/ContentTabPage.cs b/ILSpy/ViewModels/ContentTabPage.cs index 9630e9b23..bdf653008 100644 --- a/ILSpy/ViewModels/ContentTabPage.cs +++ b/ILSpy/ViewModels/ContentTabPage.cs @@ -57,7 +57,7 @@ namespace ILSpy.ViewModels bool IDeferredContentPresentation.DeferContentPresentation => false; [ObservableProperty] - private object? content; + private ContentPageModel? content; /// /// The assembly-tree node this tab represents. Used by DockWorkspace to @@ -84,12 +84,12 @@ namespace ILSpy.ViewModels // or "DOS Header" for a metadata grid). Also mirror the Content's // SupportsLanguageSwitching flag so the toolbar pickers can bind to the wrapper // without drilling into Content's runtime type. - partial void OnContentChanged(object? oldValue, object? newValue) + partial void OnContentChanged(ContentPageModel? oldValue, ContentPageModel? newValue) { - if (oldValue is INotifyPropertyChanged oldNotify) - oldNotify.PropertyChanged -= OnContentPropertyChanged; - if (newValue is INotifyPropertyChanged newNotify) - newNotify.PropertyChanged += OnContentPropertyChanged; + if (oldValue is not null) + oldValue.PropertyChanged -= OnContentPropertyChanged; + if (newValue is not null) + newValue.PropertyChanged += OnContentPropertyChanged; SyncTitleFromContent(); SyncSupportsLanguageSwitchingFromContent(); } @@ -104,19 +104,15 @@ namespace ILSpy.ViewModels void SyncSupportsLanguageSwitchingFromContent() { - // Default true when Content is null or isn't a TabPageModel-derived viewmodel - // (e.g. the OptionsPageModel which inherits ObservableObject directly). The - // toolbar binds to this property; null/non-TabPageModel content means "no - // language-aware tab is active" — leave the pickers enabled. - SupportsLanguageSwitching = (Content as TabPageModel)?.SupportsLanguageSwitching ?? true; + // The toolbar binds to this property. Default true when no content is set — "no + // language-aware tab is active" leaves the pickers enabled; otherwise mirror the + // content's own flag (metadata / compare / options report false). + SupportsLanguageSwitching = Content?.SupportsLanguageSwitching ?? true; } void SyncTitleFromContent() { - if (Content is null) - return; - var titleProp = Content.GetType().GetProperty(nameof(Title), typeof(string)); - if (titleProp?.GetValue(Content) is string s) + if (Content?.Title is { } s) Title = s; } } diff --git a/ILSpy/ViewModels/MetadataTablePageModel.cs b/ILSpy/ViewModels/MetadataTablePageModel.cs index 925a14f17..d8e4790c4 100644 --- a/ILSpy/ViewModels/MetadataTablePageModel.cs +++ b/ILSpy/ViewModels/MetadataTablePageModel.cs @@ -39,7 +39,7 @@ namespace ILSpy.ViewModels /// Columns collection isn't an Avalonia property, so the model can't bind it /// declaratively — the view assigns it imperatively from . /// - public sealed partial class MetadataTablePageModel : TabPageModel + public sealed partial class MetadataTablePageModel : ContentPageModel { [ObservableProperty] private IReadOnlyList items = Array.Empty();