Browse Source

Assembly-tree selection follows the active tab

Each ContentTabPage now carries the SharpTreeNode it represents (SourceNode),
stamped at construction time so the activation event fires with it already
populated. DockWorkspace subscribes to factory.ActiveDockableChanged: when a
new tab becomes active — whether by MMB carve-out, the "Decompile to new tab"
context-menu entry, or the user clicking a different tab in the strip — the
assembly-tree selection is pulled across to that tab's SourceNode. A guard
prevents the change from cascading back into ShowSelectedNode and overwriting
MainTab's content. Existing single-tab reuse path stamps MainTab.SourceNode
on every selection so flipping back to it stays a no-op.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
2f6f67d788
  1. 50
      ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs
  2. 5
      ILSpy/Commands/DecompileInNewViewCommand.cs
  3. 49
      ILSpy/Docking/DockWorkspace.cs
  4. 27
      ILSpy/ViewModels/ContentTabPage.cs

50
ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs

@ -1095,11 +1095,12 @@ public class AssemblyTreeTests @@ -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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
@ -1130,9 +1131,48 @@ public class AssemblyTreeTests @@ -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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
typeNode.IsExpanded = true;
var first = typeNode.Children.OfType<MethodTreeNode>()
.Single(m => m.MethodDefinition.Name == "AsEnumerable");
var second = typeNode.Children.OfType<MethodTreeNode>()
.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]

5
ILSpy/Commands/DecompileInNewViewCommand.cs

@ -63,7 +63,10 @@ namespace ILSpy.Commands @@ -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;
}

49
ILSpy/Docking/DockWorkspace.cs

@ -91,7 +91,10 @@ namespace ILSpy.Docking @@ -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 @@ -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<T>
@ -141,15 +145,41 @@ namespace ILSpy.Docking @@ -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 @@ -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 @@ -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 @@ -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 @@ -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.
/// </summary>
public ContentTabPage OpenNewTab(object content)
/// <param name="sourceNode">
/// Optional assembly-tree node this tab represents. Set BEFORE the tab is activated
/// so <see cref="OnActiveDockableChanged"/> can pick it up and pull the tree's
/// selection across — setting it after-the-fact misses the activation event.
/// </param>
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);

27
ILSpy/ViewModels/ContentTabPage.cs

@ -22,25 +22,36 @@ using CommunityToolkit.Mvvm.ComponentModel; @@ -22,25 +22,36 @@ using CommunityToolkit.Mvvm.ComponentModel;
using Dock.Controls.DeferredContentControl;
using ICSharpCode.ILSpyX.TreeView;
namespace ILSpy.ViewModels
{
/// <summary>
/// The single Document the docking host puts in its document area. <see cref="Content"/>
/// holds the active inner viewmodel (decompiler text or metadata grid). The wrapper
/// view (<c>ContentTabPageView</c>) 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. <see cref="Content"/> holds the active
/// inner viewmodel (decompiler text or metadata grid). The wrapper view
/// (<c>ContentTabPageView</c>) 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.
/// </summary>
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;
/// <summary>
/// The assembly-tree node this tab represents. Used by <c>DockWorkspace</c> 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.
/// </summary>
[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).

Loading…
Cancel
Save