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;
using ICSharpCode.Decompiler.TypeSystem; using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpyX; using ICSharpCode.ILSpyX;
using Avalonia.Controls;
using Avalonia.VisualTree;
using ILSpy; using ILSpy;
using ILSpy.AppEnv; using ILSpy.AppEnv;
using ILSpy.AssemblyTree;
using ILSpy.Commands; using ILSpy.Commands;
using ILSpy.Images; using ILSpy.Images;
using ILSpy.TreeNodes; using ILSpy.TreeNodes;
@ -67,6 +71,49 @@ public class AssemblyTreeTests
resources.Children.Should().AllBeAssignableTo<ResourceTreeNode>(); 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] [AvaloniaTest]
public async Task Loading_Zip_Package_Surfaces_Folders_And_Entries() public async Task Loading_Zip_Package_Surfaces_Folders_And_Entries()
{ {

51
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<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] [AvaloniaTest]
public async Task Selecting_Assembly_Node_Emits_Header_And_Assembly_Attributes() public async Task Selecting_Assembly_Node_Emits_Header_And_Assembly_Attributes()
{ {

20
ILSpy.Tests/Waiters.cs

@ -21,7 +21,9 @@ using System.Linq;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree;
using global::ILSpy.AssemblyTree; using global::ILSpy.AssemblyTree;
using global::ILSpy.Docking; using global::ILSpy.Docking;
@ -66,6 +68,24 @@ public static class Waiters
await Task.WhenAll(assemblies.Select(a => a.GetLoadResultAsync())); 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( public static async Task<DecompilerTabPageModel> WaitForDecompiledTextAsync(
this DockWorkspace dock, this DockWorkspace dock,
TimeSpan? timeout = null) TimeSpan? timeout = null)

2
ILSpy/AssemblyTree/AssemblyListPane.axaml

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

36
ILSpy/AssemblyTree/AssemblyListPane.axaml.cs

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

57
ILSpy/AssemblyTree/AssemblyTreeModel.cs

@ -19,6 +19,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Composition; using System.Composition;
using System.Linq; using System.Linq;
using System.Reflection.Metadata.Ecma335; using System.Reflection.Metadata.Ecma335;
@ -54,9 +55,34 @@ namespace ILSpy.AssemblyTree
[property: IgnoreDataMember] [property: IgnoreDataMember]
private SharpTreeNode? root; private SharpTreeNode? root;
[ObservableProperty] /// <summary>
[property: IgnoreDataMember] /// Multi-selection set. Each entry is kept in sync with its
private SharpTreeNode? selectedItem; /// <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] [ObservableProperty]
[property: IgnoreDataMember] [property: IgnoreDataMember]
@ -77,11 +103,27 @@ namespace ILSpy.AssemblyTree
if (e.PropertyName == nameof(LanguageService.CurrentLanguage) && Root != null) if (e.PropertyName == nameof(LanguageService.CurrentLanguage) && Root != null)
NotifyTextChanged(Root); NotifyTextChanged(Root);
}; };
SelectedItems.CollectionChanged += OnSelectedItemsChanged;
Id = PaneContentId; Id = PaneContentId;
Title = "Assemblies"; Title = "Assemblies";
CanClose = false; 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 // 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 // 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 // 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) void ShowAssemblyList(string name)
{ {
if (listManager == null) if (listManager == null)

7
ILSpy/Docking/DockWorkspace.cs

@ -20,6 +20,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Composition; using System.Composition;
using System.Linq;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@ -82,6 +83,7 @@ namespace ILSpy.Docking
} }
assemblyTreeModel.PropertyChanged += OnAssemblyTreePropertyChanged; assemblyTreeModel.PropertyChanged += OnAssemblyTreePropertyChanged;
assemblyTreeModel.SelectedItems.CollectionChanged += (_, _) => ShowSelectedNode();
languageService.PropertyChanged += OnLanguagePropertyChanged; languageService.PropertyChanged += OnLanguagePropertyChanged;
// Layout/factory initialization (locators, parent/factory wiring) is done by // Layout/factory initialization (locators, parent/factory wiring) is done by
// the DockControl in MainWindow.axaml via InitializeFactory/InitializeLayout. // the DockControl in MainWindow.axaml via InitializeFactory/InitializeLayout.
@ -201,7 +203,8 @@ namespace ILSpy.Docking
void ShowSelectedNode() void ShowSelectedNode()
{ {
if (assemblyTreeModel.SelectedItem is not ILSpyTreeNode node) var nodes = assemblyTreeModel.SelectedItems.OfType<ILSpyTreeNode>().ToArray();
if (nodes.Length == 0)
return; return;
var tab = GetActiveDecompilerTab(); var tab = GetActiveDecompilerTab();
if (tab == null) if (tab == null)
@ -214,7 +217,7 @@ namespace ILSpy.Docking
factory.SetFocusedDockable(factory.Documents, tab); factory.SetFocusedDockable(factory.Documents, tab);
} }
tab.Language = languageService.CurrentLanguage; tab.Language = languageService.CurrentLanguage;
tab.CurrentNode = node; tab.CurrentNodes = nodes;
} }
DecompilerTabPageModel? GetActiveDecompilerTab() DecompilerTabPageModel? GetActiveDecompilerTab()

70
ILSpy/TextView/DecompilerTabPageModel.cs

@ -19,6 +19,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -108,7 +109,7 @@ namespace ILSpy.TextView
internal void RaiseNavigateRequested(ReferenceSegment segment) internal void RaiseNavigateRequested(ReferenceSegment segment)
=> NavigateRequested?.Invoke(segment); => NavigateRequested?.Invoke(segment);
ILSpyTreeNode? currentNode; IReadOnlyList<ILSpyTreeNode> currentNodes = System.Array.Empty<ILSpyTreeNode>();
[RelayCommand] [RelayCommand]
void CancelDecompilation() void CancelDecompilation()
@ -116,16 +117,33 @@ namespace ILSpy.TextView
activeCts?.Cancel(); 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 { 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 { set {
if (currentNode == value) ArgumentNullException.ThrowIfNull(value);
if (currentNodes.SequenceEqual(value))
return; return;
if (currentNode != null) foreach (var n in currentNodes)
currentNode.PropertyChanged -= OnCurrentNodePropertyChanged; n.PropertyChanged -= OnCurrentNodePropertyChanged;
currentNode = value; currentNodes = value.ToArray();
if (currentNode != null) foreach (var n in currentNodes)
currentNode.PropertyChanged += OnCurrentNodePropertyChanged; n.PropertyChanged += OnCurrentNodePropertyChanged;
_ = DecompileAsync(); _ = DecompileAsync();
} }
} }
@ -136,10 +154,17 @@ namespace ILSpy.TextView
// from ShortName to "ShortName (version, tfm)" once the assembly finishes loading). // 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 // While a decompile is running the spinner prefixes the title — keep the prefix and
// just refresh the suffix; otherwise replace the title outright. // 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; return;
var text = node.Text?.ToString() ?? "(unnamed)"; var baseTitle = ComposeBaseTitle();
Title = IsDecompiling ? ComposeSpinnerTitle(0, text) : text; 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 // Resolved lazily so unit tests / design-time previews that bypass the composition host
@ -166,9 +191,9 @@ namespace ILSpy.TextView
{ {
activeCts?.Cancel(); activeCts?.Cancel();
var cts = activeCts = new CancellationTokenSource(); var cts = activeCts = new CancellationTokenSource();
var node = currentNode; var nodes = currentNodes;
var language = Language; var language = Language;
if (node == null || language == null) if (nodes.Count == 0 || language == null)
{ {
Text = string.Empty; Text = string.Empty;
IsDecompiling = false; IsDecompiling = false;
@ -180,7 +205,7 @@ namespace ILSpy.TextView
// Spinner appears as a glyph prefix on the tab title while the decompile runs; // Spinner appears as a glyph prefix on the tab title while the decompile runs;
// editor state is left untouched so cancellation falls back cleanly. // 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); TaskbarProgress?.SetState(TaskbarProgressState.Indeterminate);
_ = RunSpinnerAsync(cts.Token); _ = RunSpinnerAsync(cts.Token);
@ -191,7 +216,14 @@ namespace ILSpy.TextView
var options = new DecompilationOptions { CancellationToken = cts.Token }; var options = new DecompilationOptions { CancellationToken = cts.Token };
try 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) catch (OperationCanceledException)
{ {
@ -220,7 +252,7 @@ namespace ILSpy.TextView
// Re-read Text now (instead of capturing it before decompile started) — for // Re-read Text now (instead of capturing it before decompile started) — for
// freshly-opened assemblies, Text only has the rich "(version, tfm)" form // freshly-opened assemblies, Text only has the rich "(version, tfm)" form
// after the load completes during decompile. // after the load completes during decompile.
Title = currentNode?.Text?.ToString() ?? "(unnamed)"; Title = ComposeBaseTitle();
SyntaxExtension = newSyntaxExtension; SyntaxExtension = newSyntaxExtension;
HighlightingModel = model; HighlightingModel = model;
Foldings = collectedFoldings; Foldings = collectedFoldings;
@ -247,8 +279,8 @@ namespace ILSpy.TextView
IsDecompiling = false; IsDecompiling = false;
// If we cancelled before producing fresh output, drop the spinner glyph // If we cancelled before producing fresh output, drop the spinner glyph
// from the title — the editor still shows the previous decompile. // from the title — the editor still shows the previous decompile.
if (currentNode != null) if (currentNodes.Count > 0)
Title = currentNode.Text?.ToString() ?? "(unnamed)"; Title = ComposeBaseTitle();
TaskbarProgress?.SetState(TaskbarProgressState.None); TaskbarProgress?.SetState(TaskbarProgressState.None);
} }
if (Dispatcher.UIThread.CheckAccess()) if (Dispatcher.UIThread.CheckAccess())
@ -282,7 +314,7 @@ namespace ILSpy.TextView
} }
if (token.IsCancellationRequested || !IsDecompiling) if (token.IsCancellationRequested || !IsDecompiling)
return; return;
Title = ComposeSpinnerTitle(frame++, currentNode?.Text?.ToString() ?? "(unnamed)"); Title = ComposeSpinnerTitle(frame++, ComposeBaseTitle());
} }
} }
} }

Loading…
Cancel
Save