From 8060f21ebb37083601ffe5bed5cf1eaad7b8802c Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 1 May 2026 22:41:03 +0200 Subject: [PATCH] Multi-selection in the assembly tree Assisted-by: Claude:claude-opus-4-7:Claude Code Assisted-by: Claude:claude-opus-4-7:Claude Code --- ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs | 47 +++++++++++++ ILSpy.Tests/Editor/DecompilerViewTests.cs | 51 ++++++++++++++ ILSpy.Tests/Waiters.cs | 20 ++++++ ILSpy/AssemblyTree/AssemblyListPane.axaml | 2 +- ILSpy/AssemblyTree/AssemblyListPane.axaml.cs | 36 +++++++--- ILSpy/AssemblyTree/AssemblyTreeModel.cs | 57 +++++++++++---- ILSpy/Docking/DockWorkspace.cs | 7 +- ILSpy/TextView/DecompilerTabPageModel.cs | 70 ++++++++++++++----- 8 files changed, 248 insertions(+), 42 deletions(-) diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs index 056a91f03..dece8f493 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs @@ -31,8 +31,12 @@ using ICSharpCode.ILSpy.Properties; using ICSharpCode.Decompiler.TypeSystem; using ICSharpCode.ILSpyX; +using Avalonia.Controls; +using Avalonia.VisualTree; + using ILSpy; using ILSpy.AppEnv; +using ILSpy.AssemblyTree; using ILSpy.Commands; using ILSpy.Images; using ILSpy.TreeNodes; @@ -67,6 +71,49 @@ public class AssemblyTreeTests resources.Children.Should().AllBeAssignableTo(); } + [AvaloniaTest] + public async Task DataGrid_Has_Extended_Selection_Mode() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + + var pane = await window.WaitForComponent(); + var treeGrid = await pane.WaitForComponent(); + treeGrid.SelectionMode.Should().Be(DataGridSelectionMode.Extended, + "the assembly tree must let users Ctrl-click multiple rows"); + } + + [AvaloniaTest] + public async Task Multi_Selection_Tracks_Multiple_Nodes_And_Marks_Them_IsSelected() + { + 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.EnsureLazyChildren(); + var first = typeNode.Children.OfType() + .Single(m => m.MethodDefinition.Name == "AsEnumerable"); + var second = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Empty"); + + vm.AssemblyTreeModel.SelectedItems.Add(first); + vm.AssemblyTreeModel.SelectedItems.Add(second); + + vm.AssemblyTreeModel.SelectedItems.Should().Contain(first); + vm.AssemblyTreeModel.SelectedItems.Should().Contain(second); + first.IsSelected.Should().BeTrue(); + second.IsSelected.Should().BeTrue(); + + vm.AssemblyTreeModel.SelectedItems.Remove(first); + first.IsSelected.Should().BeFalse(); + second.IsSelected.Should().BeTrue(); + } + [AvaloniaTest] public async Task Loading_Zip_Package_Surfaces_Folders_And_Entries() { diff --git a/ILSpy.Tests/Editor/DecompilerViewTests.cs b/ILSpy.Tests/Editor/DecompilerViewTests.cs index 87b87d265..1d93113c7 100644 --- a/ILSpy.Tests/Editor/DecompilerViewTests.cs +++ b/ILSpy.Tests/Editor/DecompilerViewTests.cs @@ -263,6 +263,57 @@ public class DecompilerViewTests } } + [AvaloniaTest] + public async Task Multi_Selection_Tab_Title_Joins_All_Selected_Names_With_Comma() + { + 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.EnsureLazyChildren(); + var asEnumerable = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "AsEnumerable"); + var empty = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Empty"); + + vm.AssemblyTreeModel.SelectedItems.Clear(); + vm.AssemblyTreeModel.SelectedItems.Add(asEnumerable); + vm.AssemblyTreeModel.SelectedItems.Add(empty); + + var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + tab.Title.Should().Be($"{asEnumerable.Text}, {empty.Text}"); + } + + [AvaloniaTest] + public async Task Multi_Selecting_Methods_Decompiles_All_Of_Them_Into_One_View() + { + 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.EnsureLazyChildren(); + var asEnumerable = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "AsEnumerable"); + var empty = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Empty"); + + vm.AssemblyTreeModel.SelectedItems.Clear(); + vm.AssemblyTreeModel.SelectedItems.Add(asEnumerable); + vm.AssemblyTreeModel.SelectedItems.Add(empty); + + var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + tab.Text.Should().Contain("AsEnumerable"); + tab.Text.Should().Contain("Empty"); + // Body fragment from AsEnumerable to confirm both bodies actually rendered. + tab.Text.Should().Contain("return source"); + } + [AvaloniaTest] public async Task Selecting_Assembly_Node_Emits_Header_And_Assembly_Attributes() { diff --git a/ILSpy.Tests/Waiters.cs b/ILSpy.Tests/Waiters.cs index 3a79b9461..99a61929a 100644 --- a/ILSpy.Tests/Waiters.cs +++ b/ILSpy.Tests/Waiters.cs @@ -21,7 +21,9 @@ using System.Linq; using System.Runtime.CompilerServices; using System.Threading.Tasks; +using Avalonia; using Avalonia.Threading; +using Avalonia.VisualTree; using global::ILSpy.AssemblyTree; using global::ILSpy.Docking; @@ -66,6 +68,24 @@ public static class Waiters await Task.WhenAll(assemblies.Select(a => a.GetLoadResultAsync())); } + /// + /// Waits until exactly one descendant of exists in the + /// 's visual tree, then returns it. Replaces the brittle + /// root.GetVisualDescendants().OfType<T>().Single() pattern that races against + /// the layout/composition cycle - Avalonia.Headless tests routinely query the visual tree + /// before lazily-templated panes (DataGrid, dock panes, etc.) have materialised. + /// + public static async Task WaitForComponent(this Visual root, TimeSpan? timeout = null) + where T : Visual + { + ArgumentNullException.ThrowIfNull(root); + await WaitForAsync( + () => root.GetVisualDescendants().OfType().Any(), + timeout, + $"a {typeof(T).Name} descendant to materialise in the visual tree"); + return root.GetVisualDescendants().OfType().First(); + } + public static async Task WaitForDecompiledTextAsync( this DockWorkspace dock, TimeSpan? timeout = null) diff --git a/ILSpy/AssemblyTree/AssemblyListPane.axaml b/ILSpy/AssemblyTree/AssemblyListPane.axaml index 8dffa1231..229d3bc3f 100644 --- a/ILSpy/AssemblyTree/AssemblyListPane.axaml +++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml @@ -20,7 +20,7 @@ GridLinesVisibility="None" IsReadOnly="True" CanUserResizeColumns="False" - SelectionMode="Single" + SelectionMode="Extended" SelectionChanged="OnTreeGridSelectionChanged"> (); + foreach (var item in TreeGrid.SelectedItems) + { + var node = item is HierarchicalNode hn && hn.Item is SharpTreeNode t ? t + : item as SharpTreeNode; + if (node != null) + current.Add(node); + } + for (int i = model.SelectedItems.Count - 1; i >= 0; i--) + { + if (!current.Contains(model.SelectedItems[i])) + model.SelectedItems.RemoveAt(i); + } + foreach (var node in current) + { + if (!model.SelectedItems.Contains(node)) + model.SelectedItems.Add(node); + } + } + finally + { + EndSync(); + } } void SyncSelectionFromModel(SharpTreeNode? target) diff --git a/ILSpy/AssemblyTree/AssemblyTreeModel.cs b/ILSpy/AssemblyTree/AssemblyTreeModel.cs index f7f612191..c2d8e480e 100644 --- a/ILSpy/AssemblyTree/AssemblyTreeModel.cs +++ b/ILSpy/AssemblyTree/AssemblyTreeModel.cs @@ -19,6 +19,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Collections.Specialized; using System.Composition; using System.Linq; using System.Reflection.Metadata.Ecma335; @@ -54,9 +55,34 @@ namespace ILSpy.AssemblyTree [property: IgnoreDataMember] private SharpTreeNode? root; - [ObservableProperty] - [property: IgnoreDataMember] - private SharpTreeNode? selectedItem; + /// + /// Multi-selection set. Each entry is kept in sync with its + /// . is a convenience + /// wrapper around the *primary* (last-added) entry — drives decompilation, navigation + /// history, and tree-view-path persistence — but the underlying state is single-sourced + /// here, mirroring the WPF host's MultiSelectorExtensions.SelectionBinding pattern. + /// + [IgnoreDataMember] + public ObservableCollection SelectedItems { get; } = []; + + /// + /// Primary (last) selection. Get returns the most recently selected entry of + /// , or null; set replaces the entire selection + /// with the supplied node (clears the collection then adds it). All + /// PropertyChanged(SelectedItem) notifications are fired by + /// . + /// + [IgnoreDataMember] + public SharpTreeNode? SelectedItem { + get => SelectedItems.Count > 0 ? SelectedItems[^1] : null; + set { + if (SelectedItem == value) + return; + SelectedItems.Clear(); + if (value != null) + SelectedItems.Add(value); + } + } [ObservableProperty] [property: IgnoreDataMember] @@ -77,11 +103,27 @@ namespace ILSpy.AssemblyTree if (e.PropertyName == nameof(LanguageService.CurrentLanguage) && Root != null) NotifyTextChanged(Root); }; + SelectedItems.CollectionChanged += OnSelectedItemsChanged; Id = PaneContentId; Title = "Assemblies"; CanClose = false; } + void OnSelectedItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.NewItems != null) + foreach (SharpTreeNode n in e.NewItems) + n.IsSelected = true; + if (e.OldItems != null) + foreach (SharpTreeNode n in e.OldItems) + n.IsSelected = false; + + // SelectedItem is a wrapper over this collection — anyone bound to it must be + // notified, and the saved path must follow the new primary. + OnPropertyChanged(nameof(SelectedItem)); + settingsService.SessionSettings.ActiveTreeViewPath = GetPathForNode(SelectedItem); + } + // Walks already-materialized children and re-raises Text PropertyChanged so the cell // templates pick up the new language's formatting -- without collapsing the user's // expanded state. Lazy-loaded subtrees that haven't been opened yet are skipped (they'll @@ -137,15 +179,6 @@ namespace ILSpy.AssemblyTree } } - partial void OnSelectedItemChanged(SharpTreeNode? oldValue, SharpTreeNode? newValue) - { - if (oldValue != null) - oldValue.IsSelected = false; - if (newValue != null) - newValue.IsSelected = true; - settingsService.SessionSettings.ActiveTreeViewPath = GetPathForNode(newValue); - } - void ShowAssemblyList(string name) { if (listManager == null) diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 92bf641b7..ac42db3c3 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -20,6 +20,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; using System.Composition; +using System.Linq; using CommunityToolkit.Mvvm.Input; @@ -82,6 +83,7 @@ namespace ILSpy.Docking } assemblyTreeModel.PropertyChanged += OnAssemblyTreePropertyChanged; + assemblyTreeModel.SelectedItems.CollectionChanged += (_, _) => ShowSelectedNode(); languageService.PropertyChanged += OnLanguagePropertyChanged; // Layout/factory initialization (locators, parent/factory wiring) is done by // the DockControl in MainWindow.axaml via InitializeFactory/InitializeLayout. @@ -201,7 +203,8 @@ namespace ILSpy.Docking void ShowSelectedNode() { - if (assemblyTreeModel.SelectedItem is not ILSpyTreeNode node) + var nodes = assemblyTreeModel.SelectedItems.OfType().ToArray(); + if (nodes.Length == 0) return; var tab = GetActiveDecompilerTab(); if (tab == null) @@ -214,7 +217,7 @@ namespace ILSpy.Docking factory.SetFocusedDockable(factory.Documents, tab); } tab.Language = languageService.CurrentLanguage; - tab.CurrentNode = node; + tab.CurrentNodes = nodes; } DecompilerTabPageModel? GetActiveDecompilerTab() diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index 60248b97d..858556cb0 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -19,6 +19,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -108,7 +109,7 @@ namespace ILSpy.TextView internal void RaiseNavigateRequested(ReferenceSegment segment) => NavigateRequested?.Invoke(segment); - ILSpyTreeNode? currentNode; + IReadOnlyList currentNodes = System.Array.Empty(); [RelayCommand] void CancelDecompilation() @@ -116,16 +117,33 @@ namespace ILSpy.TextView activeCts?.Cancel(); } + /// + /// Single-selection convenience wrapper over — get returns + /// the first node (or null), set replaces the list with the single node. + /// public ILSpyTreeNode? CurrentNode { - get => currentNode; + get => currentNodes.Count > 0 ? currentNodes[0] : null; + set => CurrentNodes = value == null + ? System.Array.Empty() + : new[] { value }; + } + + /// + /// All tree nodes whose decompiled output appears in this tab. Setting fires a fresh + /// that iterates each node and writes its output, blank + /// line in between. The first node drives the tab title. + /// + public IReadOnlyList CurrentNodes { + get => currentNodes; set { - if (currentNode == value) + ArgumentNullException.ThrowIfNull(value); + if (currentNodes.SequenceEqual(value)) return; - if (currentNode != null) - currentNode.PropertyChanged -= OnCurrentNodePropertyChanged; - currentNode = value; - if (currentNode != null) - currentNode.PropertyChanged += OnCurrentNodePropertyChanged; + foreach (var n in currentNodes) + n.PropertyChanged -= OnCurrentNodePropertyChanged; + currentNodes = value.ToArray(); + foreach (var n in currentNodes) + n.PropertyChanged += OnCurrentNodePropertyChanged; _ = DecompileAsync(); } } @@ -136,10 +154,17 @@ namespace ILSpy.TextView // from ShortName to "ShortName (version, tfm)" once the assembly finishes loading). // While a decompile is running the spinner prefixes the title — keep the prefix and // just refresh the suffix; otherwise replace the title outright. - if (e.PropertyName != nameof(ILSpyTreeNode.Text) || sender is not ILSpyTreeNode node) + if (e.PropertyName != nameof(ILSpyTreeNode.Text)) return; - var text = node.Text?.ToString() ?? "(unnamed)"; - Title = IsDecompiling ? ComposeSpinnerTitle(0, text) : text; + var baseTitle = ComposeBaseTitle(); + Title = IsDecompiling ? ComposeSpinnerTitle(0, baseTitle) : baseTitle; + } + + string ComposeBaseTitle() + { + if (currentNodes.Count == 0) + return "(unnamed)"; + return string.Join(", ", currentNodes.Select(n => n.Text?.ToString() ?? "(unnamed)")); } // Resolved lazily so unit tests / design-time previews that bypass the composition host @@ -166,9 +191,9 @@ namespace ILSpy.TextView { activeCts?.Cancel(); var cts = activeCts = new CancellationTokenSource(); - var node = currentNode; + var nodes = currentNodes; var language = Language; - if (node == null || language == null) + if (nodes.Count == 0 || language == null) { Text = string.Empty; IsDecompiling = false; @@ -180,7 +205,7 @@ namespace ILSpy.TextView // Spinner appears as a glyph prefix on the tab title while the decompile runs; // editor state is left untouched so cancellation falls back cleanly. - Title = ComposeSpinnerTitle(0, currentNode?.Text?.ToString() ?? "(unnamed)"); + Title = ComposeSpinnerTitle(0, ComposeBaseTitle()); TaskbarProgress?.SetState(TaskbarProgressState.Indeterminate); _ = RunSpinnerAsync(cts.Token); @@ -191,7 +216,14 @@ namespace ILSpy.TextView var options = new DecompilationOptions { CancellationToken = cts.Token }; try { - node.Decompile(language, output, options); + for (int i = 0; i < nodes.Count; i++) + { + if (cts.Token.IsCancellationRequested) + break; + if (i > 0) + output.WriteLine(); + nodes[i].Decompile(language, output, options); + } } catch (OperationCanceledException) { @@ -220,7 +252,7 @@ namespace ILSpy.TextView // Re-read Text now (instead of capturing it before decompile started) — for // freshly-opened assemblies, Text only has the rich "(version, tfm)" form // after the load completes during decompile. - Title = currentNode?.Text?.ToString() ?? "(unnamed)"; + Title = ComposeBaseTitle(); SyntaxExtension = newSyntaxExtension; HighlightingModel = model; Foldings = collectedFoldings; @@ -247,8 +279,8 @@ namespace ILSpy.TextView IsDecompiling = false; // If we cancelled before producing fresh output, drop the spinner glyph // from the title — the editor still shows the previous decompile. - if (currentNode != null) - Title = currentNode.Text?.ToString() ?? "(unnamed)"; + if (currentNodes.Count > 0) + Title = ComposeBaseTitle(); TaskbarProgress?.SetState(TaskbarProgressState.None); } if (Dispatcher.UIThread.CheckAccess()) @@ -282,7 +314,7 @@ namespace ILSpy.TextView } if (token.IsCancellationRequested || !IsDecompiling) return; - Title = ComposeSpinnerTitle(frame++, currentNode?.Text?.ToString() ?? "(unnamed)"); + Title = ComposeSpinnerTitle(frame++, ComposeBaseTitle()); } } }