Browse Source

Multi-selection in the assembly tree

Assisted-by: Claude:claude-opus-4-7:Claude Code

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
8060f21ebb
  1. 47
      ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs
  2. 51
      ILSpy.Tests/Editor/DecompilerViewTests.cs
  3. 20
      ILSpy.Tests/Waiters.cs
  4. 2
      ILSpy/AssemblyTree/AssemblyListPane.axaml
  5. 36
      ILSpy/AssemblyTree/AssemblyListPane.axaml.cs
  6. 57
      ILSpy/AssemblyTree/AssemblyTreeModel.cs
  7. 7
      ILSpy/Docking/DockWorkspace.cs
  8. 70
      ILSpy/TextView/DecompilerTabPageModel.cs

47
ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs

@ -31,8 +31,12 @@ using ICSharpCode.ILSpy.Properties; @@ -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 @@ -67,6 +71,49 @@ public class AssemblyTreeTests
resources.Children.Should().AllBeAssignableTo<ResourceTreeNode>();
}
[AvaloniaTest]
public async Task DataGrid_Has_Extended_Selection_Mode()
{
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var pane = await window.WaitForComponent<AssemblyListPane>();
var treeGrid = await pane.WaitForComponent<DataGrid>();
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<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.EnsureLazyChildren();
var first = typeNode.Children.OfType<MethodTreeNode>()
.Single(m => m.MethodDefinition.Name == "AsEnumerable");
var second = typeNode.Children.OfType<MethodTreeNode>()
.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()
{

51
ILSpy.Tests/Editor/DecompilerViewTests.cs

@ -263,6 +263,57 @@ public class DecompilerViewTests @@ -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<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.EnsureLazyChildren();
var asEnumerable = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "AsEnumerable");
var empty = typeNode.Children.OfType<MethodTreeNode>()
.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<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.EnsureLazyChildren();
var asEnumerable = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "AsEnumerable");
var empty = typeNode.Children.OfType<MethodTreeNode>()
.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()
{

20
ILSpy.Tests/Waiters.cs

@ -21,7 +21,9 @@ using System.Linq; @@ -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 @@ -66,6 +68,24 @@ public static class Waiters
await Task.WhenAll(assemblies.Select(a => a.GetLoadResultAsync()));
}
/// <summary>
/// Waits until exactly one descendant of <typeparamref name="T"/> exists in the
/// <paramref name="root"/>'s visual tree, then returns it. Replaces the brittle
/// <c>root.GetVisualDescendants().OfType&lt;T&gt;().Single()</c> 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.
/// </summary>
public static async Task<T> WaitForComponent<T>(this Visual root, TimeSpan? timeout = null)
where T : Visual
{
ArgumentNullException.ThrowIfNull(root);
await WaitForAsync(
() => root.GetVisualDescendants().OfType<T>().Any(),
timeout,
$"a {typeof(T).Name} descendant to materialise in the visual tree");
return root.GetVisualDescendants().OfType<T>().First();
}
public static async Task<DecompilerTabPageModel> WaitForDecompiledTextAsync(
this DockWorkspace dock,
TimeSpan? timeout = null)

2
ILSpy/AssemblyTree/AssemblyListPane.axaml

@ -20,7 +20,7 @@ @@ -20,7 +20,7 @@
GridLinesVisibility="None"
IsReadOnly="True"
CanUserResizeColumns="False"
SelectionMode="Single"
SelectionMode="Extended"
SelectionChanged="OnTreeGridSelectionChanged">
<DataGrid.Columns>
<DataGridHierarchicalColumn Header="Name" Width="*"

36
ILSpy/AssemblyTree/AssemblyListPane.axaml.cs

@ -50,14 +50,34 @@ namespace ILSpy.AssemblyTree @@ -50,14 +50,34 @@ namespace ILSpy.AssemblyTree
{
if (syncingSelection || DataContext is not AssemblyTreeModel model)
return;
SharpTreeNode? selected = null;
if (TreeGrid.SelectedItem is HierarchicalNode node && node.Item is SharpTreeNode tree)
selected = tree;
else if (TreeGrid.SelectedItem is SharpTreeNode direct)
selected = direct;
if (model.SelectedItem == selected)
return;
model.SelectedItem = selected;
// SelectedItem is a wrapper over SelectedItems on the model — touching it would
// Clear+Add and clobber Ctrl-click multi-selection. Only mutate the collection.
BeginSync();
try
{
var current = new System.Collections.Generic.HashSet<SharpTreeNode>();
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)

57
ILSpy/AssemblyTree/AssemblyTreeModel.cs

@ -19,6 +19,7 @@ @@ -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 @@ -54,9 +55,34 @@ namespace ILSpy.AssemblyTree
[property: IgnoreDataMember]
private SharpTreeNode? root;
[ObservableProperty]
[property: IgnoreDataMember]
private SharpTreeNode? selectedItem;
/// <summary>
/// Multi-selection set. Each entry is kept in sync with its
/// <see cref="SharpTreeNode.IsSelected"/>. <see cref="SelectedItem"/> 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.
/// </summary>
[IgnoreDataMember]
public ObservableCollection<SharpTreeNode> SelectedItems { get; } = [];
/// <summary>
/// Primary (last) selection. Get returns the most recently selected entry of
/// <see cref="SelectedItems"/>, or <c>null</c>; set replaces the entire selection
/// with the supplied node (clears the collection then adds it). All
/// <c>PropertyChanged(SelectedItem)</c> notifications are fired by
/// <see cref="SelectedItems.CollectionChanged"/>.
/// </summary>
[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 @@ -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 @@ -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)

7
ILSpy/Docking/DockWorkspace.cs

@ -20,6 +20,7 @@ using System; @@ -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 @@ -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 @@ -201,7 +203,8 @@ namespace ILSpy.Docking
void ShowSelectedNode()
{
if (assemblyTreeModel.SelectedItem is not ILSpyTreeNode node)
var nodes = assemblyTreeModel.SelectedItems.OfType<ILSpyTreeNode>().ToArray();
if (nodes.Length == 0)
return;
var tab = GetActiveDecompilerTab();
if (tab == null)
@ -214,7 +217,7 @@ namespace ILSpy.Docking @@ -214,7 +217,7 @@ namespace ILSpy.Docking
factory.SetFocusedDockable(factory.Documents, tab);
}
tab.Language = languageService.CurrentLanguage;
tab.CurrentNode = node;
tab.CurrentNodes = nodes;
}
DecompilerTabPageModel? GetActiveDecompilerTab()

70
ILSpy/TextView/DecompilerTabPageModel.cs

@ -19,6 +19,7 @@ @@ -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 @@ -108,7 +109,7 @@ namespace ILSpy.TextView
internal void RaiseNavigateRequested(ReferenceSegment segment)
=> NavigateRequested?.Invoke(segment);
ILSpyTreeNode? currentNode;
IReadOnlyList<ILSpyTreeNode> currentNodes = System.Array.Empty<ILSpyTreeNode>();
[RelayCommand]
void CancelDecompilation()
@ -116,16 +117,33 @@ namespace ILSpy.TextView @@ -116,16 +117,33 @@ namespace ILSpy.TextView
activeCts?.Cancel();
}
/// <summary>
/// Single-selection convenience wrapper over <see cref="CurrentNodes"/> — get returns
/// the first node (or null), set replaces the list with the single node.
/// </summary>
public ILSpyTreeNode? CurrentNode {
get => currentNode;
get => currentNodes.Count > 0 ? currentNodes[0] : null;
set => CurrentNodes = value == null
? System.Array.Empty<ILSpyTreeNode>()
: new[] { value };
}
/// <summary>
/// All tree nodes whose decompiled output appears in this tab. Setting fires a fresh
/// <see cref="DecompileAsync"/> that iterates each node and writes its output, blank
/// line in between. The first node drives the tab title.
/// </summary>
public IReadOnlyList<ILSpyTreeNode> 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -282,7 +314,7 @@ namespace ILSpy.TextView
}
if (token.IsCancellationRequested || !IsDecompiling)
return;
Title = ComposeSpinnerTitle(frame++, currentNode?.Text?.ToString() ?? "(unnamed)");
Title = ComposeSpinnerTitle(frame++, ComposeBaseTitle());
}
}
}

Loading…
Cancel
Save