diff --git a/ILSpy.Tests/Metadata/PEHeaderTreeTests.cs b/ILSpy.Tests/Metadata/PEHeaderTreeTests.cs index 70754ad9f..775caf5a4 100644 --- a/ILSpy.Tests/Metadata/PEHeaderTreeTests.cs +++ b/ILSpy.Tests/Metadata/PEHeaderTreeTests.cs @@ -105,6 +105,46 @@ public class PEHeaderTreeTests lastRow.Member.Should().Be("e_lfanew"); } + [AvaloniaTest] + public async Task Selecting_A_Second_Metadata_Node_Reuses_The_Existing_Grid_Tab() + { + // Clicking DOS Header → COFF Header → DOS Header should leave the dock with a + // single metadata tab whose state has been updated each time, mirroring how the + // decompiler tab reuses itself across tree-node selections. Otherwise every click + // piles up a new dockable and the tab strip grows without bound. + + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var coreLibName = typeof(object).Assembly.GetName().Name!; + var assemblyNode = vm.AssemblyTreeModel.FindNode(coreLibName); + assemblyNode.EnsureLazyChildren(); + var metadataNode = assemblyNode.Children.OfType().Single(); + metadataNode.EnsureLazyChildren(); + + var dosNode = metadataNode.Children.OfType().Single(); + var coffNode = metadataNode.Children.OfType().Single(); + + vm.AssemblyTreeModel.SelectNode(dosNode); + var firstTab = await vm.DockWorkspace.WaitForMetadataTabAsync(); + firstTab.Title.Should().Be("DOS Header"); + + vm.AssemblyTreeModel.SelectNode(coffNode); + var secondTab = await vm.DockWorkspace.WaitForMetadataTabAsync(); + secondTab.Should().BeSameAs(firstTab, "metadata clicks reuse the existing grid tab"); + secondTab.Title.Should().Be("COFF Header"); + + vm.AssemblyTreeModel.SelectNode(dosNode); + var thirdTab = await vm.DockWorkspace.WaitForMetadataTabAsync(); + 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(); + } + [AvaloniaTest] public async Task CoffHeaderTreeNode_Opens_A_DataGrid_Tab_With_Machine_And_Characteristics_Rows() { diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 334f5bfbd..43b33e2bd 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -282,11 +282,49 @@ namespace ILSpy.Docking { if (factory.Documents == null) return; + + // Reuse an existing tab of the same concrete type — same model the decompiler tab + // uses, so DOS Header → COFF Header → DOS Header doesn't pile up three dockables. + // Prefer the active dockable when it's already the right type so explicit + // "switch tabs and pick a node" interactions land where the user expects. + if (FindReusableTab(tab.GetType()) is { } existing) + { + CopyTabState(tab, existing); + factory.SetActiveDockable(existing); + factory.SetFocusedDockable(factory.Documents, existing); + return; + } + factory.AddDockable(factory.Documents, tab); factory.SetActiveDockable(tab); factory.SetFocusedDockable(factory.Documents, tab); } + TabPageModel? FindReusableTab(Type tabType) + { + if (factory.Documents == null) + return null; + if (factory.Documents.ActiveDockable is TabPageModel active && active.GetType() == tabType) + return active; + if (factory.Documents.VisibleDockables == null) + return null; + foreach (var d in factory.Documents.VisibleDockables) + if (d is TabPageModel m && m.GetType() == tabType) + return m; + return null; + } + + static void CopyTabState(TabPageModel source, TabPageModel target) + { + if (source is MetadataTablePageModel newMeta && target is MetadataTablePageModel oldMeta) + { + oldMeta.Title = newMeta.Title; + oldMeta.Columns = newMeta.Columns; + oldMeta.Items = newMeta.Items; + oldMeta.ScrollToRow = newMeta.ScrollToRow; + } + } + public DecompilerTabPageModel? ActiveDecompilerTab => GetDecompilerContentTab(); ///