diff --git a/ILSpy.Tests/ContextMenus/KeyboardContextMenuFocusTests.cs b/ILSpy.Tests/ContextMenus/KeyboardContextMenuFocusTests.cs
new file mode 100644
index 000000000..48c838f62
--- /dev/null
+++ b/ILSpy.Tests/ContextMenus/KeyboardContextMenuFocusTests.cs
@@ -0,0 +1,101 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System.Linq;
+using System.Threading.Tasks;
+
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Headless;
+using Avalonia.Headless.NUnit;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpyX.TreeView;
+
+using ILSpy.AppEnv;
+using ILSpy.AssemblyTree;
+using ILSpy.TreeNodes;
+using ILSpy.ViewModels;
+using ILSpy.Views;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+///
+/// A context menu raised from the keyboard (Shift+F10 / Apps key) must not strand keyboard focus:
+/// closing the popup otherwise drops focus (and its focus adorner), leaving the tree un-navigable
+/// until the user clicks back in. The pane captures the pre-menu focus and restores it on close.
+///
+[TestFixture]
+public class KeyboardContextMenuFocusTests
+{
+ [AvaloniaTest]
+ public async Task Keyboard_Invoked_Menu_Returns_Focus_To_The_Row_On_Close()
+ {
+ var window = AppComposition.Current.GetExport();
+ window.Show();
+ var vm = (MainWindowViewModel)window.DataContext!;
+ await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
+ var pane = await window.WaitForComponent();
+ var grid = await pane.WaitForComponent();
+
+ var node = vm.AssemblyTreeModel.Root!.Children.OfType().First();
+ vm.AssemblyTreeModel.SelectNode(node);
+ for (int i = 0; i < 8; i++)
+ {
+ Dispatcher.UIThread.RunJobs();
+ grid.UpdateLayout();
+ await Task.Delay(25);
+ }
+
+ var row = grid.GetVisualDescendants()
+ .OfType().First();
+ row.Focus(NavigationMethod.Tab);
+ Dispatcher.UIThread.RunJobs();
+
+ var focusManager = TopLevel.GetTopLevel(window)!.FocusManager!;
+ (focusManager.GetFocusedElement() == row).Should().BeTrue("the row must hold focus before invoking the menu");
+
+ // Keyboard invocation raises ContextRequested with no pointer position (the Shift+F10 / Apps path).
+ row.RaiseEvent(new ContextRequestedEventArgs());
+ for (int i = 0; i < 6; i++)
+ {
+ Dispatcher.UIThread.RunJobs();
+ await Task.Delay(20);
+ }
+ grid.ContextMenu!.IsOpen.Should().BeTrue("the keyboard gesture must open the tree context menu");
+ row.Classes.Should().Contain("contextTarget",
+ "a keyboard-invoked menu must show the transient target highlight on the selected row, like the mouse path");
+
+ window.KeyPress(Key.Escape, RawInputModifiers.None, PhysicalKey.Escape, keySymbol: null);
+ for (int i = 0; i < 6; i++)
+ {
+ Dispatcher.UIThread.RunJobs();
+ await Task.Delay(20);
+ }
+
+ (focusManager.GetFocusedElement() == row).Should().BeTrue(
+ "closing a keyboard-invoked context menu must return focus to the row, not strand it");
+ row.Classes.Should().NotContain("contextTarget",
+ "the transient highlight must clear once the menu closes");
+ }
+}
diff --git a/ILSpy/App.axaml b/ILSpy/App.axaml
index 7511ba4d7..58978acb1 100644
--- a/ILSpy/App.axaml
+++ b/ILSpy/App.axaml
@@ -58,7 +58,10 @@
-
+
+
+
@@ -109,7 +112,10 @@
-
+
+
+
diff --git a/ILSpy/AssemblyTree/AssemblyListPane.axaml b/ILSpy/AssemblyTree/AssemblyListPane.axaml
index e24621816..9f14ceb44 100644
--- a/ILSpy/AssemblyTree/AssemblyListPane.axaml
+++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml
@@ -10,10 +10,12 @@
-
diff --git a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs
index 8e8fc8bc5..8e7265ce7 100644
--- a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs
+++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs
@@ -48,6 +48,13 @@ namespace ILSpy.AssemblyTree
SharpTreeViewItem? contextTargetItem;
SharpTreeViewItem? contextMenuOpenItem;
SharpTreeNode? contextMenuTargetNode;
+ // For a keyboard-invoked menu (Shift+F10 / Apps), the row to re-focus when the menu closes
+ // (closing the popup otherwise drops the keyboard focus and its focus adorner). Null for
+ // pointer-invoked menus.
+ SharpTreeViewItem? focusToRestoreAfterMenu;
+ // Whether the last ContextRequested came from the keyboard (no pointer position). The keyboard
+ // path carries no target row, so the menu adopts the selected row (see OnContextMenuOpening).
+ bool lastContextRequestWasKeyboard;
public AssemblyListPane()
{
@@ -123,6 +130,7 @@ namespace ILSpy.AssemblyTree
var menu = new ContextMenu();
menu.Opening += OnContextMenuOpening;
menu.Closed += (_, _) => {
+ RestoreFocusAfterKeyboardMenu();
if (!ReferenceEquals(contextTargetItem, contextMenuOpenItem))
return;
contextMenuTargetNode = null;
@@ -135,10 +143,28 @@ namespace ILSpy.AssemblyTree
{
if (sender is not ContextMenu menu)
return;
+ // A keyboard-invoked menu (Shift+F10 / Apps) carries no pointer position, so OnTreeContextRequested
+ // set no transient target. Adopt the keyboard-FOCUSED row (which may differ from the selection
+ // after Ctrl+Arrow) as the target: opening the popup steals focus and drops the row's focus
+ // adorner, so we mark that row with the same context-target highlight the mouse gives the
+ // right-clicked row, and restore its focus + adorner on close (Avalonia's ContextMenu does not).
+ // Captured here, before the popup opens and takes focus (Opening fires ahead of it), and before
+ // contextMenuOpenItem is latched so the Closed handler still clears the highlight.
+ var focusedRow = TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() as SharpTreeViewItem;
+ if (lastContextRequestWasKeyboard && focusedRow?.Node != null)
+ {
+ contextMenuTargetNode = focusedRow.Node;
+ SetContextTargetItem(focusedRow);
+ focusToRestoreAfterMenu = focusedRow;
+ }
contextMenuOpenItem = contextTargetItem;
var built = BuildContextMenuForCurrentState(contextMenuEntries);
if (built == null)
{
+ // Menu won't open (so Closed won't fire) -- undo the transient target + captured focus.
+ focusToRestoreAfterMenu = null;
+ contextMenuTargetNode = null;
+ SetContextTargetItem(null);
e.Cancel = true;
return;
}
@@ -150,6 +176,17 @@ namespace ILSpy.AssemblyTree
}
}
+ void RestoreFocusAfterKeyboardMenu()
+ {
+ if (focusToRestoreAfterMenu is not { } toFocus)
+ return;
+ focusToRestoreAfterMenu = null;
+ // Re-focus with a keyboard navigation method so the focus visual (the adorner) comes back,
+ // not just the logical focus. Posted so it runs after the popup has fully torn down.
+ global::Avalonia.Threading.Dispatcher.UIThread.Post(
+ () => toFocus.Focus(NavigationMethod.Tab));
+ }
+
internal ContextMenu? BuildContextMenuForCurrentState(IReadOnlyList entries)
=> ContextMenuProvider.Build(entries, CreateContextMenuContext());
@@ -195,7 +232,9 @@ namespace ILSpy.AssemblyTree
void OnTreeContextRequested(object? sender, ContextRequestedEventArgs e)
{
SharpTreeViewItem? item = null;
- if (e.TryGetPosition(Tree, out var pos) && Tree.InputHitTest(pos) is Visual hit)
+ // Keyboard invocation (Shift+F10 / Apps) raises ContextRequested with no pointer position.
+ lastContextRequestWasKeyboard = !e.TryGetPosition(Tree, out var pos);
+ if (!lastContextRequestWasKeyboard && Tree.InputHitTest(pos) is Visual hit)
item = hit.FindAncestorOfType(includeSelf: true);
contextMenuTargetNode = item?.Node;
SetContextTargetItem(item?.Node != null ? item : null);
diff --git a/ILSpy/Controls/TreeView/SharpTreeView.axaml b/ILSpy/Controls/TreeView/SharpTreeView.axaml
index f5d630e24..8331cc239 100644
--- a/ILSpy/Controls/TreeView/SharpTreeView.axaml
+++ b/ILSpy/Controls/TreeView/SharpTreeView.axaml
@@ -50,6 +50,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+