Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions ILSpy.Tests/Analyzers/AnalyzerTreeKeyboardTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,77 @@ await Waiters.WaitForAsync(() => analyzed.IsExpanded,
description: "Right must expand the node via SharpTreeView.OnKeyDown on the analyzer tree");
}

[AvaloniaTest]
public async Task Enter_Activates_The_Selected_Analyzer_Node()
{
// Enter on a single selected analyzer row activates it -- for an entity node that means
// navigating to the member's home in the assembly tree, like 10.x did. The key must reach
// SharpTreeView.OnKeyDown: the container is a ListBoxItem, and Avalonia's default key
// selection triggers treat Enter/Space as selection input and mark the event handled
// before it bubbles, so SharpTreeView suppresses that trigger for the activation case.
var (window, vm) = await TestHarness.BootAsync(3);
var dockWorkspace = AppComposition.Current.GetExport<DockWorkspace>();
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();

var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
var entity = (ITypeDefinition)typeNode.Member!;
var analyzed = analyzerVm.Analyze(entity);

dockWorkspace.ShowToolPane(AnalyzerTreeViewModel.PaneContentId);
var view = await window.WaitForComponent<ICSharpCode.ILSpy.Analyzers.AnalyzerTreeView>();
var tree = await view.WaitForComponent<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeView>();
tree.SelectedItem = analyzed;
Dispatcher.UIThread.RunJobs();
tree.FocusNode(analyzed);
Dispatcher.UIThread.RunJobs();

((object?)vm.AssemblyTreeModel.SelectedItem).Should().NotBeSameAs(typeNode,
"precondition: the assembly tree must not already sit on the target node");

window.KeyPress(Key.Enter, RawInputModifiers.None, PhysicalKey.Enter, null);
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, typeNode),
description: "Enter must activate the analyzer node and select the type in the assembly tree");
}

[AvaloniaTest]
public async Task Delete_Removes_The_Selected_Top_Level_Analyzer_Node()
{
// Delete on a selected top-level analyzer row removes it from the pane (the keyboard
// equivalent of the "Remove" context-menu entry). Rows below the top level are not
// deletable, so Delete on one of them leaves the pane untouched.
var (window, vm) = await TestHarness.BootAsync(3);
var dockWorkspace = AppComposition.Current.GetExport<DockWorkspace>();
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();

var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
var analyzed = analyzerVm.Analyze((ITypeDefinition)typeNode.Member!);
analyzed.IsExpanded = true;
var child = analyzed.Children.First();

dockWorkspace.ShowToolPane(AnalyzerTreeViewModel.PaneContentId);
var view = await window.WaitForComponent<ICSharpCode.ILSpy.Analyzers.AnalyzerTreeView>();
var tree = await view.WaitForComponent<ICSharpCode.ILSpy.Controls.TreeView.SharpTreeView>();

tree.SelectedItem = child;
Dispatcher.UIThread.RunJobs();
tree.FocusNode(child);
Dispatcher.UIThread.RunJobs();
window.KeyPress(Key.Delete, RawInputModifiers.None, PhysicalKey.Delete, null);
Dispatcher.UIThread.RunJobs();
analyzed.Children.Should().Contain(child, "Delete must not remove a nested analyzer row");
analyzerVm.Root.Children.Should().Contain(analyzed, "Delete on a nested row must not remove its top-level node");

tree.SelectedItem = analyzed;
Dispatcher.UIThread.RunJobs();
tree.FocusNode(analyzed);
Dispatcher.UIThread.RunJobs();
window.KeyPress(Key.Delete, RawInputModifiers.None, PhysicalKey.Delete, null);
await Waiters.WaitForAsync(() => !analyzerVm.Root.Children.Contains(analyzed),
description: "Delete must remove the selected top-level analyzer node from the pane");
}

[AvaloniaTest]
public async Task Ctrl_R_Analyzes_The_Selected_Member()
{
Expand Down
29 changes: 29 additions & 0 deletions ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1539,6 +1539,35 @@ public async Task Type_Tree_Node_Exposes_DerivedTypes_Subtree_For_Non_Sealed_Cla
"the loaded assembly list contains several Exception subclasses (e.g. SystemException, ArgumentException)");
}

[AvaloniaTest]
public async Task Derived_Type_Entries_Stay_Visible_When_The_DerivedTypes_Node_Is_Expanded()
{
// The filter cascade runs for children added under a visible parent. A derived-type
// entry must report FilterResult.Match there: the Recurse handling force-loads the
// entry's own (lazy) children and hides the entry when all of them are hidden -- a
// leaf derived type has none, so every entry under "Derived Types" ended up hidden.

var (_, vm) = await TestHarness.BootAsync(3);

var coreLibName = typeof(object).Assembly.GetName().Name!;
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
coreLibName, "System", "System.Exception");
// Expand the full ancestor chain so the type node is IsVisible -- the cascade only
// fires for children of visible parents, which is the state the real tree is in.
foreach (var ancestor in typeNode.Ancestors())
ancestor.IsExpanded = true;
typeNode.IsExpanded = true;

var derived = typeNode.Children.OfType<DerivedTypesTreeNode>().Single();
derived.IsExpanded = true;

var entries = derived.Children.OfType<DerivedTypesEntryNode>().ToList();
entries.Should().NotBeEmpty(
"the loaded assembly list contains several Exception subclasses");
entries.Should().OnlyContain(e => e.IsVisible,
"public derived-type entries must show under the expanded Derived Types node");
}

[AvaloniaTest]
public async Task Sealed_Class_Has_No_DerivedTypes_Node()
{
Expand Down
44 changes: 44 additions & 0 deletions ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
using System.Threading.Tasks;

using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Headless.NUnit;
using Avalonia.Input;
using Avalonia.VisualTree;
Expand Down Expand Up @@ -121,6 +122,49 @@ public async Task BrowseBack_MenuItem_Forwards_CanExecute_And_Execute_To_DockWor
"after one back-step the forward stack should be non-empty");
}

[AvaloniaTest]
public async Task Mouse_Back_And_Forward_Buttons_Navigate_The_History()
{
// The extra mouse buttons (XButton1 = back, XButton2 = forward) drive the same history
// as Alt+Left / Alt+Right, matching browsers and the WPF version (where WPF itself
// translated the buttons into BrowseBack/BrowseForward commands). Avalonia has no such
// translation, so MainWindow routes the pointer events to the navigation commands.

// Arrange — build a two-entry history exactly like the menu-driven test above.
var (window, vm) = await TestHarness.BootAsync(3);

var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
typeNode.IsExpanded = true;
var firstMethod = typeNode.Children.OfType<MethodTreeNode>()
.Single(m => m.MethodDefinition.Name == "AsEnumerable");
var secondMethod = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Empty");

vm.AssemblyTreeModel.SelectNode(firstMethod);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
await Task.Delay(600);
vm.AssemblyTreeModel.SelectNode(secondMethod);
await vm.DockWorkspace.WaitForDecompiledTextAsync();

// Act — click mouse-back anywhere in the window.
var point = new Avalonia.Point(100, 100);
window.MouseDown(point, MouseButton.XButton1);
window.MouseUp(point, MouseButton.XButton1);

// Assert — selection rewinds, then mouse-forward replays the step.
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, firstMethod),
description: "XButton1 must navigate back one history entry");
await Waiters.WaitForAsync(() => vm.DockWorkspace.NavigateForwardCommand.CanExecute(null),
description: "after one back-step the forward stack should be non-empty");

window.MouseDown(point, MouseButton.XButton2);
window.MouseUp(point, MouseButton.XButton2);

await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, secondMethod),
description: "XButton2 must navigate forward one history entry");
}

[AvaloniaTest]
public void BrowseBack_MenuItem_Carries_The_Alt_Left_Gesture()
{
Expand Down
33 changes: 1 addition & 32 deletions ILSpy/AssemblyTree/AssemblyListPane.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -232,26 +232,12 @@ void OnTreePointerPressed(object? sender, PointerPressedEventArgs e)

#endregion

#region Keyboard (assembly-specific: Delete, Ctrl+R)
#region Keyboard (assembly-specific: Ctrl+R; Delete is handled by SharpTreeView)

void OnTreeKeyDown(object? sender, KeyEventArgs e)
{
if (DataContext is not AssemblyTreeModel model)
return;
if (e.Key == Key.Delete && e.KeyModifiers == KeyModifiers.None && model.AssemblyList is { } list)
{
var selectedAssemblyNodes = model.SelectedItems.OfType<AssemblyTreeNode>().ToList();
if (selectedAssemblyNodes.Count == 0)
return;
int reselectIndex = FlattenedIndexOf(selectedAssemblyNodes[0]);
foreach (var node in selectedAssemblyNodes)
list.Unload(node.LoadedAssembly);
e.Handled = true;
global::Avalonia.Threading.Dispatcher.UIThread.Post(
() => ReselectAfterDelete(reselectIndex),
global::Avalonia.Threading.DispatcherPriority.Background);
return;
}
if (e.Key == Key.R && e.KeyModifiers == KeyModifiers.Control)
{
var members = model.SelectedItems.OfType<IMemberTreeNode>()
Expand All @@ -269,23 +255,6 @@ void OnTreeKeyDown(object? sender, KeyEventArgs e)
}
}

System.Collections.IList? Flattened => Tree.ItemsSource as System.Collections.IList;

int FlattenedIndexOf(SharpTreeNode node) => Flattened?.IndexOf(node) ?? -1;

void ReselectAfterDelete(int index)
{
if (DataContext is not AssemblyTreeModel model)
return;
var flattened = Flattened;
if (flattened == null || flattened.Count == 0 || index < 0)
{
model.SelectNode(null);
return;
}
model.SelectNode(flattened[Math.Clamp(index, 0, flattened.Count - 1)] as SharpTreeNode);
}

#endregion

#region Selection sync
Expand Down
50 changes: 49 additions & 1 deletion ILSpy/Controls/TreeView/SharpTreeView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,27 @@ void CenterNodeInView(SharpTreeNode node)
scrollViewer.Offset = new Vector(scrollViewer.Offset.X, newOffsetY);
}

/// <summary>
/// Avalonia's default key selection triggers treat plain Enter/Space as selection input:
/// the ListBoxItem container marks the KeyDown handled before it bubbles here, so the
/// activation handling in <see cref="OnKeyDown"/> would never see those keys. Suppress the
/// selection trigger exactly for the case OnKeyDown activates instead -- a single selected
/// row that is the row the key landed on. Multi-row selections keep the default behaviour
/// (Enter/Space collapses the selection to the focused row).
/// </summary>
protected override bool ShouldTriggerSelection(Visual selectable, KeyEventArgs eventArgs)
{
if (eventArgs.KeyModifiers == KeyModifiers.None
&& eventArgs.Key is Key.Enter or Key.Space
&& selectable is SharpTreeViewItem { Node: { } node }
&& SelectedItems?.Count == 1
&& ReferenceEquals(SelectedItem, node))
{
return false;
}
return base.ShouldTriggerSelection(selectable, eventArgs);
}

protected override void OnKeyDown(KeyEventArgs e)
{
// Ctrl+A select-all must work on the first press even before a current item is
Expand All @@ -307,6 +328,11 @@ protected override void OnKeyDown(KeyEventArgs e)
e.Handled = true;
return;
}
if (e.Key == Key.Delete && e.KeyModifiers == KeyModifiers.None && DeleteSelection())
{
e.Handled = true;
return;
}
var node = (e.Source as Visual)?.FindAncestorOfType<SharpTreeViewItem>(includeSelf: true)?.Node
?? SelectedItem as SharpTreeNode;
if (node != null && e.KeyModifiers == KeyModifiers.None)
Expand Down Expand Up @@ -361,6 +387,28 @@ protected override void OnKeyDown(KeyEventArgs e)
base.OnKeyDown(e);
}

/// <summary>
/// Deletes the top-level selection (see <see cref="GetTopLevelSelection"/>) when every node in it
/// supports deletion, then selects the row that takes the first deleted node's place so a
/// repeated Delete keeps working. Returns false without touching anything otherwise, e.g. for
/// a selection that mixes deletable and non-deletable rows.
/// </summary>
bool DeleteSelection()
{
if (flattener is null)
return false;
var nodes = GetTopLevelSelection().ToArray();
if (nodes.Length == 0 || !nodes.All(n => n.CanDelete()))
return false;
int index = nodes.Min(flattener.IndexOf);
foreach (var node in nodes)
node.Delete();
// The deleted rows leave the selection with the source; pick the nearest survivor.
if (SelectedItems!.Count == 0 && flattener.Count > 0)
SelectAndFocus((SharpTreeNode)flattener[Math.Clamp(index, 0, flattener.Count - 1)]!);
return true;
}

static void ExpandRecursively(SharpTreeNode node)
{
if (!node.CanExpandRecursively)
Expand Down Expand Up @@ -426,7 +474,7 @@ void OnSearchTimeout(object? sender, EventArgs e)
searchBuffer = string.Empty;
}

/// <summary>Selected items with no selected ancestor (used by Delete).</summary>
/// <summary>Selected items with no selected ancestor.</summary>
public IEnumerable<SharpTreeNode> GetTopLevelSelection()
{
var selection = SelectedItems!.OfType<SharpTreeNode>().ToHashSet();
Expand Down
12 changes: 7 additions & 5 deletions ILSpy/TreeNodes/DerivedTypesEntryNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,18 @@ protected override void LoadChildren()
};

/// <summary>
/// Drops non-public entries under PublicOnly visibility, otherwise recurses so the user
/// can drill into derived chains. The active search term is deliberately not consulted:
/// <see cref="LanguageSettings.SearchTermMatches"/> is a no-op so the assembly tree stays
/// independent of the search pane.
/// Drops non-public entries under PublicOnly visibility, otherwise reports a match. It must
/// not report Recurse: the filter cascade's Recurse handling force-loads this node's lazy
/// children and hides the node when all of them are hidden, so a leaf derived type (no
/// further subclasses, hence no children) would vanish from the tree. The active search term
/// is deliberately not consulted: <see cref="LanguageSettings.SearchTermMatches"/> is a
/// no-op so the assembly tree stays independent of the search pane.
/// </summary>
public override FilterResult Filter(LanguageSettings settings)
{
if (settings.ShowApiLevel == ApiVisibility.PublicOnly && !IsPublicAPI)
return FilterResult.Hidden;
return FilterResult.Recurse;
return FilterResult.Match;
}

public override void ActivateItem(IPlatformRoutedEventArgs e)
Expand Down
4 changes: 2 additions & 2 deletions ILSpy/Views/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
</Design.DataContext>

<Window.KeyBindings>
<!-- Browser-style navigation. XButton1/2 (mouse Back/Forward) aren't first-class in
Avalonia 12 yet, so we wire keyboard only for now. -->
<!-- Browser-style keyboard navigation. The mouse back/forward buttons drive the same
commands from code-behind (KeyBindings cannot express pointer buttons). -->
<KeyBinding Gesture="Alt+Left" Command="{Binding DockWorkspace.NavigateBackCommand}" />
<KeyBinding Gesture="Alt+Right" Command="{Binding DockWorkspace.NavigateForwardCommand}" />
<!-- Search pane shortcuts. Ctrl+Shift+F is the WPF default; Ctrl+E mirrors the
Expand Down
24 changes: 24 additions & 0 deletions ILSpy/Views/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;

using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.ViewModels;
Expand Down Expand Up @@ -60,6 +62,12 @@ public MainWindow(MainWindowViewModel viewModel, SettingsService settingsService
// gesture that triggered the DBus call. No-op unless that category is enabled.
InputDiagnostics.Attach(this);
ICSharpCode.ILSpy.MainMenu.Attach(this);
// Mouse back/forward buttons navigate the history, like Alt+Left / Alt+Right. There is
// no KeyBinding equivalent for pointer buttons, so listen window-wide; handledEventsToo
// because inner controls handle pointer events for their own gestures without ever
// using the X buttons.
AddHandler(PointerReleasedEvent, OnBrowserNavigationPointerReleased,
RoutingStrategies.Bubble, handledEventsToo: true);
ApplySessionSettings(settingsService.SessionSettings);
Opened += async (_, _) => {
AppLog.Mark("MainWindow.Opened fired");
Expand All @@ -77,6 +85,22 @@ public MainWindow(MainWindowViewModel viewModel, SettingsService settingsService
AppLog.Mark("MainWindow ctor exited");
}

void OnBrowserNavigationPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (DataContext is not MainWindowViewModel viewModel)
return;
var command = e.InitialPressMouseButton switch {
MouseButton.XButton1 => viewModel.DockWorkspace.NavigateBackCommand,
MouseButton.XButton2 => viewModel.DockWorkspace.NavigateForwardCommand,
_ => null
};
if (command?.CanExecute(null) == true)
{
command.Execute(null);
e.Handled = true;
}
}

static void SurfaceCompositionErrors()
{
if (!AppEnv.CompositionErrors.Any)
Expand Down
Loading