From 56183e817619d7af0e5a5f6a952a39b5f3c74fc6 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Thu, 7 May 2026 19:41:30 +0200 Subject: [PATCH] Single document with swappable inner content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tree-node selection now updates the inner Content of one persistent ContentTabPage instead of swapping the dockable in the dock. The wrapper view (ContentTabPageView) keeps both inner views — the decompiler text editor and the metadata grid — pre-realised in the visual tree from construction time and toggles which is visible based on Content's runtime type. Assisted-by: Claude:claude-opus-4-7:Claude Code --- ILSpy.Tests/Commands/HelpCommandTests.cs | 54 +++--- ILSpy.Tests/Editor/DecompilerViewTests.cs | 8 +- ILSpy.Tests/Metadata/PEHeaderTreeTests.cs | 42 +++-- ILSpy.Tests/Waiters.cs | 8 +- ILSpy/App.axaml | 3 + ILSpy/Commands/AboutCommand.cs | 17 +- ILSpy/Commands/DecompileInNewViewCommand.cs | 6 +- ILSpy/Docking/DockWorkspace.cs | 174 ++++++-------------- ILSpy/Docking/ILSpyDockFactory.cs | 12 +- ILSpy/ViewModels/ContentTabPage.cs | 64 +++++++ ILSpy/Views/ContentTabPageView.axaml | 22 +++ ILSpy/Views/ContentTabPageView.axaml.cs | 94 +++++++++++ 12 files changed, 314 insertions(+), 190 deletions(-) create mode 100644 ILSpy/ViewModels/ContentTabPage.cs create mode 100644 ILSpy/Views/ContentTabPageView.axaml create mode 100644 ILSpy/Views/ContentTabPageView.axaml.cs diff --git a/ILSpy.Tests/Commands/HelpCommandTests.cs b/ILSpy.Tests/Commands/HelpCommandTests.cs index 3be9341f3..5d287c38f 100644 --- a/ILSpy.Tests/Commands/HelpCommandTests.cs +++ b/ILSpy.Tests/Commands/HelpCommandTests.cs @@ -20,6 +20,7 @@ using System; using System.Linq; using System.Threading.Tasks; +using Avalonia.Controls; using Avalonia.Headless.NUnit; using Avalonia.VisualTree; @@ -72,9 +73,9 @@ public class HelpCommandTests // containing the version line and the MIT License mention from the embedded blurb. await Waiters.WaitForAsync( () => (documents.VisibleDockables?.Count ?? 0) > initialTabCount - && documents.ActiveDockable is DecompilerTabPageModel { Text.Length: > 0 }); + && documents.ActiveDockable is ContentTabPage { Content: DecompilerTabPageModel { Text.Length: > 0 } }); - var aboutTab = (DecompilerTabPageModel)documents.ActiveDockable!; + var aboutTab = (DecompilerTabPageModel)((ContentTabPage)documents.ActiveDockable!).Content!; aboutTab.Title.Should().Be(Resources.About); aboutTab.Text.Should().Contain(Resources.ILSpyVersion); aboutTab.Text.Should().Contain("MIT License"); @@ -102,8 +103,8 @@ public class HelpCommandTests var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; aboutCmd.Execute(null); await Waiters.WaitForAsync( - () => documents.ActiveDockable is DecompilerTabPageModel { Text.Length: > 0 }); - var aboutTab = (DecompilerTabPageModel)documents.ActiveDockable!; + () => documents.ActiveDockable is ContentTabPage { Content: DecompilerTabPageModel { Text.Length: > 0 } }); + var aboutTab = (DecompilerTabPageModel)((ContentTabPage)documents.ActiveDockable!).Content!; // Assert (mid-test) — the tab carries the custom hyperlink generators that // DecompilerTextView installs alongside the document. @@ -120,10 +121,10 @@ public class HelpCommandTests // Assert — a new tab opens with the license body. await Waiters.WaitForAsync( () => (documents.VisibleDockables?.Count ?? 0) > beforeCount - && documents.ActiveDockable is DecompilerTabPageModel licenseTab - && !ReferenceEquals(licenseTab, aboutTab) - && licenseTab.Text.Length > 0); - var licenseTab = (DecompilerTabPageModel)documents.ActiveDockable!; + && documents.ActiveDockable is ContentTabPage { Content: DecompilerTabPageModel licenseInner } + && !ReferenceEquals(licenseInner, aboutTab) + && licenseInner.Text.Length > 0); + var licenseTab = (DecompilerTabPageModel)((ContentTabPage)documents.ActiveDockable!).Content!; licenseTab.Text.Should().Contain("Permission is hereby granted"); } @@ -150,20 +151,24 @@ public class HelpCommandTests // realised editor — the value AvaloniaEdit's LinkElementGenerator (and our own // ResourceLinkGenerator) both consult when constructing VisualLineLinkText. - // Arrange — boot, select a node so the decompiler tab is realised + the editor inside - // it is actually constructed (the view is data-templated lazily on first activation). + // Arrange — boot, select a node so a decompiler viewmodel is alive on the active + // tab. Construct the view explicitly (the dock's deferred content presenter doesn't + // materialise its templated children in Avalonia.Headless mode, so walking the + // window's visual tree wouldn't find the editor — building one from the live + // viewmodel mirrors what the live app's DataTemplate produces). var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); var node = vm.AssemblyTreeModel.FindNode("System.Linq"); vm.AssemblyTreeModel.SelectNode(node); - await vm.DockWorkspace.WaitForDecompiledTextAsync(); - await Waiters.WaitForAsync( - () => window.GetVisualDescendants().OfType().Any()); + var content = await vm.DockWorkspace.WaitForDecompiledTextAsync(); - // Act — locate the editor inside the realised DecompilerTextView. - var view = await window.WaitForComponent(); + var view = new DecompilerTextView { DataContext = content }; + // Force layout so the templated TextEditor child is realised — the view's + // DataContextChanged-driven setup runs synchronously on assignment. + var host = new Window { Content = view, Width = 600, Height = 400 }; + host.Show(); var editor = await view.WaitForComponent(); // Assert — the editor's hyperlink-click option is off. @@ -206,10 +211,12 @@ public class HelpCommandTests .Single(c => c.Metadata.Header == nameof(Resources._About)) .CreateExport().Value; var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; + var mainTab = ((ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!; aboutCmd.Execute(null); await Waiters.WaitForAsync( - () => documents.ActiveDockable is DecompilerTabPageModel { IsStaticContent: true }); - var aboutTab = (DecompilerTabPageModel)documents.ActiveDockable!; + () => documents.ActiveDockable is ContentTabPage { Content: DecompilerTabPageModel { IsStaticContent: true } }); + var aboutWrapper = (ContentTabPage)documents.ActiveDockable!; + var aboutTab = (DecompilerTabPageModel)aboutWrapper.Content!; var aboutText = aboutTab.Text; // Assert (mid-test) — the back stack now ends with the tree-node entry; the About @@ -221,24 +228,23 @@ public class HelpCommandTests // Act 2 — press Back from the About page. vm.DockWorkspace.NavigateBackCommand.CanExecute(null).Should().BeTrue(); - // execute vm.DockWorkspace.NavigateBackCommand vm.DockWorkspace.NavigateBackCommand.Execute(null); await Waiters.WaitForAsync( - () => ReferenceEquals(documents.ActiveDockable, decompilerTab)); + () => ReferenceEquals(documents.ActiveDockable, mainTab)); - // Assert — the decompiler tab is active again; About tab still exists with content - // preserved. + // Assert — the main tab (with the decompiler content) is active again; the About + // sibling still exists with content preserved. ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, method).Should().BeTrue(); decompilerTab.Text.Should().Be(methodText); - documents.VisibleDockables!.Should().Contain(aboutTab); + mainTab.Content.Should().BeSameAs(decompilerTab); + documents.VisibleDockables!.Should().Contain(aboutWrapper); aboutTab.Text.Should().Be(aboutText, "the About tab content must survive a Back navigation"); // Act 3 — press Forward to return to the About page. vm.DockWorkspace.NavigateForwardCommand.CanExecute(null).Should().BeTrue(); - // execute vm.DockWorkspace.NavigateForwardCommand vm.DockWorkspace.NavigateForwardCommand.Execute(null); await Waiters.WaitForAsync( - () => ReferenceEquals(documents.ActiveDockable, aboutTab)); + () => ReferenceEquals(documents.ActiveDockable, aboutWrapper)); // Assert — About is active again, content intact. aboutTab.Text.Should().Be(aboutText); diff --git a/ILSpy.Tests/Editor/DecompilerViewTests.cs b/ILSpy.Tests/Editor/DecompilerViewTests.cs index 178364bd4..f661d8803 100644 --- a/ILSpy.Tests/Editor/DecompilerViewTests.cs +++ b/ILSpy.Tests/Editor/DecompilerViewTests.cs @@ -434,6 +434,13 @@ public class DecompilerViewTests vm.AssemblyTreeModel.SelectNode(assemblyNode); var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + // The dock's deferred content presenter doesn't materialise its templated children + // in Avalonia.Headless mode, so we construct the view explicitly from the live + // viewmodel — same shape the live app's DataTemplate produces. + var view = new DecompilerTextView { DataContext = tab }; + var host = new global::Avalonia.Controls.Window { Content = view, Width = 600, Height = 400 }; + host.Show(); + // Act — drop a synthetic XML resource into the tab. XmlResourceEntryNode triggers // SyntaxExtension=".xml" and the XmlFoldingStrategy install. var xml = "\n \n text\n \n \n \n"; @@ -441,7 +448,6 @@ public class DecompilerViewTests tab.CurrentNode = new XmlResourceEntryNode("test.xml", () => new System.IO.MemoryStream(bytes)); await Waiters.WaitForAsync(() => tab.SyntaxExtension == ".xml" && tab.Text.Contains("")); - var view = await window.WaitForComponent(); // Drain the layout so ApplyDocument's PropertyChanged handler has executed. for (int i = 0; i < 5; i++) { diff --git a/ILSpy.Tests/Metadata/PEHeaderTreeTests.cs b/ILSpy.Tests/Metadata/PEHeaderTreeTests.cs index 6ad59ee8b..dae99f914 100644 --- a/ILSpy.Tests/Metadata/PEHeaderTreeTests.cs +++ b/ILSpy.Tests/Metadata/PEHeaderTreeTests.cs @@ -139,8 +139,8 @@ public class PEHeaderTreeTests var decompilerTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); decompilerTab.Should().NotBeNull(); - var documents = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; - documents.ActiveDockable.Should().BeOfType(); + var mainTab = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!; + mainTab.Content.Should().BeOfType(); } [AvaloniaTest] @@ -169,18 +169,19 @@ public class PEHeaderTreeTests vm.AssemblyTreeModel.SelectNode(assemblyNode); await vm.DockWorkspace.WaitForDecompiledTextAsync(); - var documents = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; - documents.ActiveDockable.Should().BeOfType(); + var mainTab = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!; + mainTab.Content.Should().BeOfType(); } [AvaloniaTest] - public async Task Round_Tripping_Metadata_To_Entity_Keeps_The_Dock_Single_Tab() + public async Task Round_Tripping_Metadata_To_Entity_Reuses_The_Same_Document_Instance() { - // The dock holds at most one document tab at a time: switching from metadata to an - // entity closes the grid and surfaces a decompiler text view, switching back - // closes the decompiler tab and surfaces a fresh grid. Mirrors WPF's - // single-tab swap-content UX so the user never sees both views compete for the - // document area. + // The document area holds a single persistent ContentTabPage for the lifetime of + // the app; only its inner Content swaps between decompiler / metadata viewmodels. + // Keeping the Document instance stable is what makes the visual swap actually + // happen on content-type changes — replacing the dockable in place leaves the + // previous inner view rendered (the user would see a "Decompiling" spinner that + // never goes away because the prior decompiler tab is still on screen). var window = AppComposition.Current.GetExport(); window.Show(); @@ -194,24 +195,29 @@ public class PEHeaderTreeTests metadataNode.EnsureLazyChildren(); var dosNode = metadataNode.Children.OfType().Single(); var coffNode = metadataNode.Children.OfType().Single(); - var documents = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; + var factoryFactory = (global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory; + var documents = factoryFactory.Documents!; + var mainTab = factoryFactory.MainTab!; vm.AssemblyTreeModel.SelectNode(dosNode); await vm.DockWorkspace.WaitForMetadataTabAsync(); - documents.VisibleDockables!.Should().HaveCount(1); + documents.VisibleDockables!.Should().HaveCount(1).And.Contain(mainTab); + mainTab.Content.Should().BeOfType(); vm.AssemblyTreeModel.SelectNode(assemblyNode); await vm.DockWorkspace.WaitForDecompiledTextAsync(); - documents.VisibleDockables!.Should().HaveCount(1); + documents.VisibleDockables!.Should().HaveCount(1).And.Contain(mainTab); + mainTab.Content.Should().BeOfType(); vm.AssemblyTreeModel.SelectNode(coffNode); await vm.DockWorkspace.WaitForMetadataTabAsync(); - documents.VisibleDockables!.Should().HaveCount(1); + documents.VisibleDockables!.Should().HaveCount(1).And.Contain(mainTab); + mainTab.Content.Should().BeOfType(); vm.AssemblyTreeModel.SelectNode(assemblyNode); await vm.DockWorkspace.WaitForDecompiledTextAsync(); - documents.VisibleDockables!.Should().HaveCount(1); - documents.ActiveDockable.Should().BeOfType(); + documents.VisibleDockables!.Should().HaveCount(1).And.Contain(mainTab); + mainTab.Content.Should().BeOfType(); } [AvaloniaTest] @@ -250,8 +256,8 @@ public class PEHeaderTreeTests thirdTab.Should().BeSameAs(firstTab); thirdTab.Title.Should().Be("DOS Header"); - var documents = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; - documents.VisibleDockables!.OfType().Should().ContainSingle(); + var mainTab = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!; + mainTab.Content.Should().BeSameAs(firstTab, "the inner metadata viewmodel is reused in place"); } [AvaloniaTest] diff --git a/ILSpy.Tests/Waiters.cs b/ILSpy.Tests/Waiters.cs index 9e516e4db..977c17156 100644 --- a/ILSpy.Tests/Waiters.cs +++ b/ILSpy.Tests/Waiters.cs @@ -96,12 +96,12 @@ public static class Waiters ?? throw new InvalidOperationException("DockWorkspace has no document dock yet."); await WaitForAsync( - () => documents.ActiveDockable is DecompilerTabPageModel { IsDecompiling: false } tab + () => documents.ActiveDockable is ContentTabPage { Content: DecompilerTabPageModel { IsDecompiling: false } tab } && !string.IsNullOrEmpty(tab.Text), timeout, "active decompiler tab to finish decompiling and produce text"); - return (DecompilerTabPageModel)documents.ActiveDockable!; + return (DecompilerTabPageModel)((ContentTabPage)documents.ActiveDockable!).Content!; } public static async Task WaitForMetadataTabAsync( @@ -113,10 +113,10 @@ public static class Waiters ?? throw new InvalidOperationException("DockWorkspace has no document dock yet."); await WaitForAsync( - () => documents.ActiveDockable is MetadataTablePageModel { Items.Count: > 0 }, + () => documents.ActiveDockable is ContentTabPage { Content: MetadataTablePageModel { Items.Count: > 0 } }, timeout, "active dockable to be a metadata table tab with populated rows"); - return (MetadataTablePageModel)documents.ActiveDockable!; + return (MetadataTablePageModel)((ContentTabPage)documents.ActiveDockable!).Content!; } } diff --git a/ILSpy/App.axaml b/ILSpy/App.axaml index 0a586905a..2cb802502 100644 --- a/ILSpy/App.axaml +++ b/ILSpy/App.axaml @@ -38,6 +38,9 @@ + + + diff --git a/ILSpy/Commands/AboutCommand.cs b/ILSpy/Commands/AboutCommand.cs index 909a9c121..dd93c3a77 100644 --- a/ILSpy/Commands/AboutCommand.cs +++ b/ILSpy/Commands/AboutCommand.cs @@ -30,6 +30,7 @@ using ICSharpCode.ILSpy.Properties; using ILSpy.Docking; using ILSpy.TextView; +using ILSpy.ViewModels; namespace ILSpy.Commands { @@ -80,14 +81,13 @@ namespace ILSpy.Commands foreach (var (phrase, uri) in Links) output.AddVisualLineElementGenerator(new ResourceLinkGenerator(phrase, uri)); - var tab = OpenInNewTab(Resources.About, output, ".txt"); - tab.IsStaticContent = true; + var (tab, _) = OpenInNewTab(Resources.About, output, ".txt"); dockWorkspace.RecordStaticPage(tab, new Uri("resource:aboutpage")); } - DecompilerTabPageModel OpenInNewTab(string title, AvaloniaEditTextOutput output, string syntaxExtension) + (ContentTabPage Tab, DecompilerTabPageModel Content) OpenInNewTab(string title, AvaloniaEditTextOutput output, string syntaxExtension) { - var tab = new DecompilerTabPageModel { + var content = new DecompilerTabPageModel { Title = title, SyntaxExtension = syntaxExtension, Text = output.GetText(), @@ -96,13 +96,13 @@ namespace ILSpy.Commands DefinitionLookup = output.DefinitionLookup, UIElements = output.UIElements, Foldings = output.Foldings, + IsStaticContent = true, CustomElementGenerators = output.ElementGenerators.Count > 0 ? new List(output.ElementGenerators) : null, }; - tab.OpenUriRequested += OnOpenUri; - dockWorkspace.OpenNewTab(tab); - return tab; + content.OpenUriRequested += OnOpenUri; + return (dockWorkspace.OpenNewTab(content), content); } bool OnOpenUri(Uri uri) @@ -122,8 +122,7 @@ namespace ILSpy.Commands using var reader = new StreamReader(stream); var output = new AvaloniaEditTextOutput { Title = resourceName }; output.Write(reader.ReadToEnd()); - var tab = OpenInNewTab(resourceName, output, ".txt"); - tab.IsStaticContent = true; + var (tab, _) = OpenInNewTab(resourceName, output, ".txt"); dockWorkspace.RecordStaticPage(tab, new Uri("resource:" + resourceName)); } diff --git a/ILSpy/Commands/DecompileInNewViewCommand.cs b/ILSpy/Commands/DecompileInNewViewCommand.cs index 740780c23..1e0bfb5da 100644 --- a/ILSpy/Commands/DecompileInNewViewCommand.cs +++ b/ILSpy/Commands/DecompileInNewViewCommand.cs @@ -62,9 +62,9 @@ namespace ILSpy.Commands var nodes = SelectedNodes(context).ToArray(); if (nodes.Length == 0) return; - var tab = new DecompilerTabPageModel { Language = languageService.CurrentLanguage }; - dockWorkspace.OpenNewTab(tab); - tab.CurrentNodes = nodes; + var content = new DecompilerTabPageModel { Language = languageService.CurrentLanguage }; + dockWorkspace.OpenNewTab(content); + content.CurrentNodes = nodes; } static System.Collections.Generic.IEnumerable SelectedNodes(TextViewContext context) diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 43d18c76f..8fc2d268c 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -82,11 +82,6 @@ namespace ILSpy.Docking entry => entry != null && (history.BackEntries.Contains(entry) || history.ForwardEntries.Contains(entry))); factory = new ILSpyDockFactory(toolPaneRegistry); Layout = factory.CreateLayout(); - if (factory.InitialDecompilerTab is { } initialTab) - { - initialTab.Language = languageService.CurrentLanguage; - initialTab.NavigateRequested += OnNavigateRequested; - } assemblyTreeModel.PropertyChanged += OnAssemblyTreePropertyChanged; assemblyTreeModel.SelectedItems.CollectionChanged += (_, _) => ShowSelectedNode(); @@ -228,7 +223,7 @@ namespace ILSpy.Docking { if (e.PropertyName == nameof(LanguageService.CurrentLanguage)) { - if (GetDecompilerContentTab() is { } tab) + if (ActiveDecompilerTab is { } tab) { tab.Language = languageService.CurrentLanguage; // Re-decompile by re-assigning the same node so the tab refreshes for the new language. @@ -251,119 +246,55 @@ namespace ILSpy.Docking assemblyTreeModel.SelectedItem = node; } + // Long-lived decompiler viewmodel — kept alive across metadata interludes so going + // back to text doesn't lose the previous decompile until a fresh one supersedes it. + // Created lazily on first need. + DecompilerTabPageModel? decompilerContent; + void ShowSelectedNode() { var nodes = assemblyTreeModel.SelectedItems.OfType().ToArray(); if (nodes.Length == 0) return; - - // Tree-node selections always reuse the user's active document slot — custom-tab - // nodes (metadata grids) and the regular decompiler path both swap state into - // whatever's currently active. If the active dockable is the wrong concrete type - // it gets replaced in place; no sibling tabs are created. - // Carve-outs ("Open in new tab", "Freeze tab") aren't implemented yet and would - // branch off this path before the active-slot lookup. - var customTab = nodes.Length == 1 ? nodes[0].CreateTab() : null; - - if (customTab != null) - { - PutInActiveSlot(customTab); + if (factory.MainTab is not { } main) return; - } - if (AcquireActiveDecompilerTab() is { } decTab) - { - decTab.Language = languageService.CurrentLanguage; - decTab.CurrentNodes = nodes; - } - } - - // Drops into the active document slot. Same concrete - // type as what's already there → copy state in place. Different type → swap in the - // VisibleDockables list (single mutation fires INotifyCollectionChanged.Replace, - // which Dock translates into a clean visual swap; close-then-add in the same tick - // leaves the old view rendered until the next layout pass). - void PutInActiveSlot(TabPageModel prototype) - { - if (factory.Documents is not { } docs) - return; + // Tree-node selections always reuse the single document slot. The Document + // instance never changes; its inner Content swaps between viewmodels. The + // wrapper view (ContentTabPageView) holds pre-realised inner views and toggles + // which is visible based on Content's runtime type — keeps the visual swap + // 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. + var customContent = nodes.Length == 1 ? nodes[0].CreateTab() : null; - if (docs.ActiveDockable is TabPageModel active && active.GetType() == prototype.GetType()) + if (customContent != null) { - CopyTabState(prototype, active); + // Copy state into the existing inner viewmodel when types match — keeps + // scroll position / sort order across the navigation. Only swap to a new + // instance when the content type changes. + if (main.Content?.GetType() == customContent.GetType()) + CopyContentState(customContent, main.Content); + else + main.Content = customContent; return; } - var oldActive = docs.ActiveDockable; - if (oldActive != null && docs.VisibleDockables is { } list) - { - int idx = list.IndexOf(oldActive); - if (idx >= 0) - { - prototype.Owner = docs; - prototype.Factory = factory; - list[idx] = prototype; - docs.ActiveDockable = prototype; - factory.SetFocusedDockable(docs, prototype); - return; - } - } - - // Fallback (no current active): just add. - factory.AddDockable(docs, prototype); - factory.SetActiveDockable(prototype); - factory.SetFocusedDockable(docs, prototype); + var decTab = decompilerContent ??= CreateDecompilerContent(); + main.Content = decTab; + decTab.Language = languageService.CurrentLanguage; + decTab.CurrentNodes = nodes; } - // Once consumed and later replaced, the factory's InitialDecompilerTab can't be - // re-added to the dock — Dock treats it as a known closed dockable. Track whether - // it's still available so the first selection uses it and later selections build a - // fresh instance instead. - bool initialDecompilerTabConsumed; - - DecompilerTabPageModel? AcquireActiveDecompilerTab() + DecompilerTabPageModel CreateDecompilerContent() { - if (factory.Documents is not { } docs) - return null; - if (docs.ActiveDockable is DecompilerTabPageModel active) - return active; - - DecompilerTabPageModel fresh; - if (!initialDecompilerTabConsumed && factory.InitialDecompilerTab is { } initial) - { - fresh = initial; - initialDecompilerTabConsumed = true; - } - else - { - fresh = new DecompilerTabPageModel { Title = "(no selection)" }; - fresh.NavigateRequested += OnNavigateRequested; - } - - // Replace the active dockable in place (see PutInActiveSlot for why an in-place - // list mutation beats close-then-add for the visual swap). - var oldActive = docs.ActiveDockable; - if (oldActive != null && docs.VisibleDockables is { } list) - { - int idx = list.IndexOf(oldActive); - if (idx >= 0) - { - fresh.Owner = docs; - fresh.Factory = factory; - list[idx] = fresh; - docs.ActiveDockable = fresh; - factory.SetFocusedDockable(docs, fresh); - return fresh; - } - } - - factory.AddDockable(docs, fresh); - factory.SetActiveDockable(fresh); - factory.SetFocusedDockable(docs, fresh); - return fresh; + var tab = new DecompilerTabPageModel { Title = "(no selection)" }; + tab.Language = languageService.CurrentLanguage; + tab.NavigateRequested += OnNavigateRequested; + return tab; } - static void CopyTabState(TabPageModel source, TabPageModel target) + static void CopyContentState(object source, object target) { if (source is MetadataTablePageModel newMeta && target is MetadataTablePageModel oldMeta) { @@ -374,25 +305,17 @@ namespace ILSpy.Docking } } - public DecompilerTabPageModel? ActiveDecompilerTab => GetDecompilerContentTab(); - - // Decompiler-content tabs only — static-content tabs (About / License) are excluded so - // tree-node selections never overwrite them. - DecompilerTabPageModel? GetDecompilerContentTab() - { - if (factory.Documents?.ActiveDockable is DecompilerTabPageModel { IsStaticContent: false } active) - return active; - if (factory.Documents?.VisibleDockables != null) - { - foreach (var d in factory.Documents.VisibleDockables) - if (d is DecompilerTabPageModel { IsStaticContent: false } m) - return m; - } - return null; - } + public DecompilerTabPageModel? ActiveDecompilerTab + => factory.MainTab?.Content as DecompilerTabPageModel is { IsStaticContent: false } d ? d : null; - public TabPageModel OpenNewTab(TabPageModel tab) + /// + /// Opens a fresh sibling tab whose is the + /// supplied viewmodel. Used by explicit "Open in new tab" gestures and by static + /// pages (About, License) that must not be overwritten by tree-node selections. + /// + public ContentTabPage OpenNewTab(object content) { + var tab = new ContentTabPage { Content = content }; if (factory.Documents != null) { factory.AddDockable(factory.Documents, tab); @@ -415,7 +338,7 @@ namespace ILSpy.Docking public void ResetLayout() { // Rebuild from the factory's default layout. The active tree node, if any, will be - // re-projected onto the freshly-created decompiler tab through the existing + // re-projected onto the fresh MainTab through the existing // SelectedItem -> ShowSelectedNode plumbing. var newLayout = factory.CreateLayout(); factory.InitLayout(newLayout); @@ -425,14 +348,9 @@ namespace ILSpy.Docking currentRoot.ActiveDockable = root.ActiveDockable; currentRoot.FocusedDockable = root.FocusedDockable; } - // Fresh layout means the factory minted a new InitialDecompilerTab; AcquireActive - // can use it again. - initialDecompilerTabConsumed = false; - if (factory.InitialDecompilerTab is { } initialTab) - { - initialTab.Language = languageService.CurrentLanguage; - initialTab.NavigateRequested += OnNavigateRequested; - } + // Fresh MainTab means the cached decompiler content (with handlers wired to the + // old tab) is gone; rebuild on the next selection. + decompilerContent = null; ShowSelectedNode(); } } diff --git a/ILSpy/Docking/ILSpyDockFactory.cs b/ILSpy/Docking/ILSpyDockFactory.cs index 112b4ee29..86ed2edc2 100644 --- a/ILSpy/Docking/ILSpyDockFactory.cs +++ b/ILSpy/Docking/ILSpyDockFactory.cs @@ -36,7 +36,12 @@ namespace ILSpy.Docking public IDocumentDock? Documents { get; private set; } - public DecompilerTabPageModel? InitialDecompilerTab { get; private set; } + /// + /// The single persistent Document the host puts in . Its + /// swaps between viewmodels (decompiler text / + /// metadata grid) on tree-node selection — DockWorkspace owns the population. + /// + public ContentTabPage? MainTab { get; private set; } public ILSpyDockFactory(ToolPaneRegistry registry) { @@ -53,8 +58,9 @@ namespace ILSpy.Docking }; Documents = documents; - // Initial decompiler tab is added lazily on first selection (DockWorkspace.ShowSelectedNode). - InitialDecompilerTab = new DecompilerTabPageModel { Title = "(no selection)" }; + MainTab = new ContentTabPage { Title = "(no selection)" }; + documents.VisibleDockables = CreateList(MainTab); + documents.ActiveDockable = MainTab; ToolDock? leftToolDock = BuildToolDock("LeftTools", ToolPaneAlignment.Left, 0.25); ToolDock? topToolDock = BuildToolDock("TopTools", ToolPaneAlignment.Top, 0.2); diff --git a/ILSpy/ViewModels/ContentTabPage.cs b/ILSpy/ViewModels/ContentTabPage.cs new file mode 100644 index 000000000..9b5a404a0 --- /dev/null +++ b/ILSpy/ViewModels/ContentTabPage.cs @@ -0,0 +1,64 @@ +// 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.ComponentModel; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace ILSpy.ViewModels +{ + /// + /// The single Document the docking host puts in its document area. + /// holds the active inner viewmodel (decompiler text or metadata grid). The wrapper + /// view (ContentTabPageView) keeps both possible inner views pre-realised and + /// toggles which is visible — Dock.Avalonia's add+close-in-the-same-tick semantics + /// otherwise leave the previous view rendered when the tab type changes. + /// + public sealed partial class ContentTabPage : TabPageModel + { + [ObservableProperty] + private object? content; + + // Bubble the inner content's Title up to the Document's Title so the tab strip + // reflects whatever the active page chose (e.g. the decompiler tab's spinner glyph + // or "DOS Header" for a metadata grid). + partial void OnContentChanged(object? oldValue, object? newValue) + { + if (oldValue is INotifyPropertyChanged oldNotify) + oldNotify.PropertyChanged -= OnContentPropertyChanged; + if (newValue is INotifyPropertyChanged newNotify) + newNotify.PropertyChanged += OnContentPropertyChanged; + SyncTitleFromContent(); + } + + void OnContentPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Title)) + SyncTitleFromContent(); + } + + void SyncTitleFromContent() + { + if (Content is null) + return; + var titleProp = Content.GetType().GetProperty(nameof(Title), typeof(string)); + if (titleProp?.GetValue(Content) is string s) + Title = s; + } + } +} diff --git a/ILSpy/Views/ContentTabPageView.axaml b/ILSpy/Views/ContentTabPageView.axaml new file mode 100644 index 000000000..dc8a41969 --- /dev/null +++ b/ILSpy/Views/ContentTabPageView.axaml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/ILSpy/Views/ContentTabPageView.axaml.cs b/ILSpy/Views/ContentTabPageView.axaml.cs new file mode 100644 index 000000000..c7d6cd461 --- /dev/null +++ b/ILSpy/Views/ContentTabPageView.axaml.cs @@ -0,0 +1,94 @@ +// 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.ComponentModel; + +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +using ILSpy.TextView; +using ILSpy.ViewModels; + +namespace ILSpy.Views +{ + /// + /// View for . Toggles which pre-realised inner view shows + /// (decompiler text or metadata grid) based on 's + /// runtime type — both inner views live in the visual tree from construction time so + /// the dock's visual swap is just a visibility flip. + /// + public partial class ContentTabPageView : UserControl + { + ContentTabPage? boundPage; + DecompilerTextView decompilerView = null!; + MetadataTablePage metadataView = null!; + + public ContentTabPageView() + { + InitializeComponent(); + decompilerView = this.FindControl("DecompilerView")!; + metadataView = this.FindControl("MetadataView")!; + DataContextChanged += (_, _) => RebindPage(); + } + + void InitializeComponent() => AvaloniaXamlLoader.Load(this); + + void RebindPage() + { + if (boundPage != null) + boundPage.PropertyChanged -= OnPagePropertyChanged; + boundPage = DataContext as ContentTabPage; + if (boundPage != null) + boundPage.PropertyChanged += OnPagePropertyChanged; + ApplyContent(); + } + + void OnPagePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ContentTabPage.Content)) + ApplyContent(); + } + + void ApplyContent() + { + var content = boundPage?.Content; + + if (content is DecompilerTabPageModel decompiler) + { + decompilerView.DataContext = decompiler; + decompilerView.IsVisible = true; + } + else + { + decompilerView.IsVisible = false; + decompilerView.DataContext = null; + } + + if (content is MetadataTablePageModel metadata) + { + metadataView.DataContext = metadata; + metadataView.IsVisible = true; + } + else + { + metadataView.IsVisible = false; + metadataView.DataContext = null; + } + } + } +}