diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs index 936c4fc11..121a576c3 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs @@ -1095,11 +1095,12 @@ public class AssemblyTreeTests } [AvaloniaTest] - public async Task Pane_OpenNodeInNewTab_Spawns_A_Fresh_Decompiler_Tab_Without_Touching_Selection() + public async Task Pane_OpenNodeInNewTab_Spawns_A_Fresh_Decompiler_Tab_And_Selection_Follows_The_New_Active_Tab() { // MMB on a tree row maps to AssemblyListPane.OpenNodeInNewTab — a new decompiler // tab opens with the supplied node decompiled, the existing tab keeps its content, - // and the assembly-tree selection is unchanged (MMB doesn't alter the selection). + // and the assembly-tree selection is pulled across to the new tab's source node + // (the active tab and the tree are kept in lockstep). var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; @@ -1130,9 +1131,48 @@ public class AssemblyTreeTests "a fresh decompiler tab must be created instead of reusing the existing one"); newTab.Text.Should().Contain("Empty"); firstTab.Text.Should().Contain("AsEnumerable"); - // Selection didn't move — pinned remains the model's selection. - ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, pinned).Should().BeTrue( - "middle-click must not move the assembly-tree selection"); + // Selection has moved to the new tab's source node — the active tab and the + // assembly-tree selection stay in lockstep. + ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, newTabTarget).Should().BeTrue( + "the assembly-tree selection must follow the newly-active tab"); + } + + [AvaloniaTest] + public async Task Switching_Tabs_Pulls_Tree_Selection_To_The_Active_Tabs_Source_Node() + { + // Activate two tabs (each carrying a different SourceNode), then flip the active + // dockable back to the first one and confirm the assembly-tree selection follows. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + typeNode.IsExpanded = true; + var first = typeNode.Children.OfType() + .Single(m => m.MethodDefinition.Name == "AsEnumerable"); + var second = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Empty"); + + // First tab: tree-selection produces the MainTab content. + vm.AssemblyTreeModel.SelectNode(first); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; + var firstTab = documents.ActiveDockable; + + // Second tab: MMB-style carve-out — selection follows the new active tab. + vm.DockWorkspace.OpenNodeInNewTab(second); + await Waiters.WaitForAsync(() => + ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, second)); + + // Re-activate the first tab — selection must swing back. + vm.DockWorkspace.Factory.SetActiveDockable(firstTab!); + + await Waiters.WaitForAsync(() => + ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, first)); + ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, first).Should().BeTrue( + "clicking back to the previous tab must pull the tree selection with it"); } [AvaloniaTest] diff --git a/ILSpy/Commands/DecompileInNewViewCommand.cs b/ILSpy/Commands/DecompileInNewViewCommand.cs index 1e0bfb5da..3e59fe099 100644 --- a/ILSpy/Commands/DecompileInNewViewCommand.cs +++ b/ILSpy/Commands/DecompileInNewViewCommand.cs @@ -63,7 +63,10 @@ namespace ILSpy.Commands if (nodes.Length == 0) return; var content = new DecompilerTabPageModel { Language = languageService.CurrentLanguage }; - dockWorkspace.OpenNewTab(content); + // Single-node selection carries a SourceNode so the tab/tree stay in lockstep + // when the user flips tabs. Multi-node selections leave SourceNode null — + // no single tree row represents the union. + dockWorkspace.OpenNewTab(content, sourceNode: nodes.Length == 1 ? nodes[0] : null); content.CurrentNodes = nodes; } diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 10e4697d7..d270bf798 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -91,7 +91,10 @@ namespace ILSpy.Docking Layout = factory.CreateLayout(); assemblyTreeModel.PropertyChanged += OnAssemblyTreePropertyChanged; - assemblyTreeModel.SelectedItems.CollectionChanged += (_, _) => ShowSelectedNode(); + assemblyTreeModel.SelectedItems.CollectionChanged += (_, _) => { + if (!syncingTreeFromActiveTab) + ShowSelectedNode(); + }; languageService.PropertyChanged += OnLanguagePropertyChanged; // Layout/factory initialization (locators, parent/factory wiring) is done by // the DockControl in MainWindow.axaml via InitializeFactory/InitializeLayout. @@ -104,6 +107,7 @@ namespace ILSpy.Docking factory.DockableRemoved += OnDocumentMembershipChanged; factory.DockableClosed += OnDocumentMembershipChanged; factory.DockableClosing += OnDockableClosing; + factory.ActiveDockableChanged += OnActiveDockableChanged; // TODO: layout persistence (load on startup, save on exit). DockSerializer.SystemTextJson // trips System.Text.Json's MaxDepth=64 limit on our layout because Dock's JsonConverterList @@ -141,15 +145,41 @@ namespace ILSpy.Docking } } + // Set true while syncing the tree's selection FROM the active tab so the + // SelectionChanged handler doesn't bounce back into ShowSelectedNode and overwrite + // MainTab.Content with the carved-out tab's node. + bool syncingTreeFromActiveTab; + void OnAssemblyTreePropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(AssemblyTreeModel.SelectedItem)) { - ShowSelectedNode(); + if (!syncingTreeFromActiveTab) + ShowSelectedNode(); RecordTreeNodeSelection(assemblyTreeModel.SelectedItem); } } + void OnActiveDockableChanged(object? sender, ActiveDockableChangedEventArgs e) + { + // Carve-out tab → active: pull the tree's selection over to whatever entity the + // tab is showing. The same hook fires when the user clicks a different tab in + // the tab strip, keeping the tree in lockstep with the visible content. + if (e.Dockable is not ContentTabPage tab || tab.SourceNode is not { } node) + return; + if (ReferenceEquals(assemblyTreeModel.SelectedItem, node)) + return; + syncingTreeFromActiveTab = true; + try + { + assemblyTreeModel.SelectedItem = node; + } + finally + { + syncingTreeFromActiveTab = false; + } + } + void RecordTreeNodeSelection(SharpTreeNode? node) { if (suppressHistoryRecording || node == null) @@ -296,6 +326,7 @@ namespace ILSpy.Docking CopyContentState(customContent, main.Content); else AttachCustomContent(main, customContent); + main.SourceNode = nodes[0]; return; } @@ -303,6 +334,7 @@ namespace ILSpy.Docking main.Content = decTab; decTab.Language = languageService.CurrentLanguage; decTab.CurrentNodes = nodes; + main.SourceNode = nodes.Length == 1 ? nodes[0] : null; } void AttachCustomContent(ContentTabPage main, TabPageModel newContent) @@ -371,12 +403,12 @@ namespace ILSpy.Docking newMeta.NavigateToCellRequested += OnMetadataCellClicked; newMeta.RowActivated += OnMetadataRowActivated; } - OpenNewTab(customContent); + OpenNewTab(customContent, sourceNode: node); } else { var content = new DecompilerTabPageModel { Language = languageService.CurrentLanguage }; - OpenNewTab(content); + OpenNewTab(content, sourceNode: node); content.CurrentNodes = new[] { node }; } } @@ -508,9 +540,14 @@ namespace ILSpy.Docking /// 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) + /// + /// Optional assembly-tree node this tab represents. Set BEFORE the tab is activated + /// so can pick it up and pull the tree's + /// selection across — setting it after-the-fact misses the activation event. + /// + public ContentTabPage OpenNewTab(object content, SharpTreeNode? sourceNode = null) { - var tab = new ContentTabPage { Content = content }; + var tab = new ContentTabPage { Content = content, SourceNode = sourceNode }; if (factory.Documents != null) { factory.AddDockable(factory.Documents, tab); diff --git a/ILSpy/ViewModels/ContentTabPage.cs b/ILSpy/ViewModels/ContentTabPage.cs index 2310b3dde..852a13ced 100644 --- a/ILSpy/ViewModels/ContentTabPage.cs +++ b/ILSpy/ViewModels/ContentTabPage.cs @@ -22,25 +22,36 @@ using CommunityToolkit.Mvvm.ComponentModel; using Dock.Controls.DeferredContentControl; +using ICSharpCode.ILSpyX.TreeView; + 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. + /// One Document hosted by the dock workspace. 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, IDeferredContentPresentation { - // Opt out of Dock's deferred presentation: there's exactly one document tab in this - // app, the inner views are already pre-realised by the wrapper view, and headless - // tests can't reach descendants of a control that's still queued for realisation. + // Opt out of Dock's deferred presentation: the inner views are already pre-realised + // by the wrapper view, and headless tests can't reach descendants of a control + // that's still queued for realisation. bool IDeferredContentPresentation.DeferContentPresentation => false; [ObservableProperty] private object? content; + /// + /// The assembly-tree node this tab represents. Used by DockWorkspace to + /// synchronise the tree's selection when this tab becomes active — switching to a + /// tab pulls the tree to whatever entity the tab is showing. Null on the static + /// "(no selection)" placeholder. + /// + [ObservableProperty] + private SharpTreeNode? sourceNode; + // 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).