Browse Source

Browser-style history dropdown on Back/Forward toolbar buttons

Replace the static Back/Forward buttons with SplitButtons whose chevron drops a
menu of recent navigation history entries (up to 20, newest first), letting the
user jump multiple steps in one click. Adds GoTo + entry views to NavigationHistory
and a NavigateToHistoryCommand on DockWorkspace. Recolors the SplitButton chevron
and the Simple-theme ComboBox toggle's pressed/checked/flyout-open states with
the toolbar accent palette so neither flips to a dark fill on click.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
8c3e4774c2
  1. 55
      ILSpy.Tests/Navigation/NavigationTests.cs
  2. 25
      ILSpy/Docking/DockWorkspace.cs
  3. 26
      ILSpy/NavigationHistory.cs
  4. 81
      ILSpy/Views/MainToolBar.axaml
  5. 49
      ILSpy/Views/MainToolBar.axaml.cs

55
ILSpy.Tests/Navigation/NavigationTests.cs

@ -19,7 +19,9 @@ @@ -19,7 +19,9 @@
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Headless.NUnit;
using Avalonia.VisualTree;
using AwesomeAssertions;
@ -74,4 +76,57 @@ public class NavigationTests @@ -74,4 +76,57 @@ public class NavigationTests
firstMethod.Should().Be().CenteredInView();
}
[AvaloniaTest]
public async Task Back_SplitButton_Dropdown_Lists_History_And_Jumps_To_Selected_Entry()
{
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 methodA = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "AsEnumerable");
var methodB = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Empty");
var methodC = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Range");
// Three distinct selections with >0.6s gaps so each lands as its own entry on the back stack
// (NavigationHistory collapses sub-0.5s rapid succession into one entry).
vm.AssemblyTreeModel.SelectedItem = methodA;
await vm.DockWorkspace.WaitForDecompiledTextAsync();
await Task.Delay(600);
vm.AssemblyTreeModel.SelectedItem = methodB;
await vm.DockWorkspace.WaitForDecompiledTextAsync();
await Task.Delay(600);
vm.AssemblyTreeModel.SelectedItem = methodC;
await vm.DockWorkspace.WaitForDecompiledTextAsync();
// Open the Back SplitButton's flyout — the Opening handler populates the menu from the
// current back history.
var backSplit = window.GetVisualDescendants().OfType<SplitButton>()
.Single(sb => sb.Name == "BackSplitButton");
var flyout = (MenuFlyout)backSplit.Flyout!;
flyout.ShowAt(backSplit);
await Waiters.WaitForAsync(() => flyout.Items.OfType<MenuItem>().Count() >= 2);
// Newest-first: index 0 is the immediate previous selection (methodB),
// index 1 is the one before that (methodA).
var items = flyout.Items.OfType<MenuItem>().ToList();
((string)items[0].Header!).Should().Be((string)methodB.Text);
((string)items[1].Header!).Should().Be((string)methodA.Text);
items[1].CommandParameter.Should().BeSameAs(methodA);
// Multi-step jump: clicking methodA pops two entries off the back stack in one go.
items[1].Command!.Execute(items[1].CommandParameter);
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, methodA));
// The two displaced entries (methodC, methodB) are now on the forward stack, so Forward
// becomes available.
vm.DockWorkspace.NavigateForwardCommand.CanExecute(null).Should().BeTrue();
}
}

25
ILSpy/Docking/DockWorkspace.cs

@ -58,6 +58,12 @@ namespace ILSpy.Docking @@ -58,6 +58,12 @@ namespace ILSpy.Docking
public IRelayCommand NavigateBackCommand { get; }
public IRelayCommand NavigateForwardCommand { get; }
public IRelayCommand<SharpTreeNode> NavigateToHistoryCommand { get; }
// Read-only history snapshots for the Back/Forward split-button dropdowns; oldest-first.
// The UI reverses these for newest-first display.
public IReadOnlyList<SharpTreeNode> BackHistory => history.BackEntries;
public IReadOnlyList<SharpTreeNode> ForwardHistory => history.ForwardEntries;
public IRootDock Layout { get; }
@ -74,6 +80,8 @@ namespace ILSpy.Docking @@ -74,6 +80,8 @@ namespace ILSpy.Docking
this.languageService = languageService;
NavigateBackCommand = new RelayCommand(NavigateBack, () => history.CanNavigateBack);
NavigateForwardCommand = new RelayCommand(NavigateForward, () => history.CanNavigateForward);
NavigateToHistoryCommand = new RelayCommand<SharpTreeNode>(NavigateToHistory,
node => node != null && (history.BackEntries.Contains(node) || history.ForwardEntries.Contains(node)));
factory = new ILSpyDockFactory(assemblyTreeModel, searchPaneModel, analyzerTreeViewModel);
Layout = factory.CreateLayout();
if (factory.InitialDecompilerTab is { } initialTab)
@ -161,6 +169,23 @@ namespace ILSpy.Docking @@ -161,6 +169,23 @@ namespace ILSpy.Docking
if (forward ? !history.CanNavigateForward : !history.CanNavigateBack)
return;
var target = forward ? history.GoForward() : history.GoBack();
ApplyNavigationTarget(target);
}
void NavigateToHistory(SharpTreeNode? node)
{
if (node == null)
return;
bool forward = history.ForwardEntries.Contains(node);
if (!forward && !history.BackEntries.Contains(node))
return;
var target = history.GoTo(node, forward);
if (target != null)
ApplyNavigationTarget(target);
}
void ApplyNavigationTarget(SharpTreeNode target)
{
suppressHistoryRecording = true;
try
{

26
ILSpy/NavigationHistory.cs

@ -39,6 +39,12 @@ namespace ILSpy.Navigation @@ -39,6 +39,12 @@ namespace ILSpy.Navigation
public bool CanNavigateBack => back.Count > 0;
public bool CanNavigateForward => forward.Count > 0;
// Read-only views over the history stacks for the toolbar's split-button dropdowns.
// Both lists are oldest-first (matches push/append order); the UI reverses for "newest
// first" display.
public IReadOnlyList<T> BackEntries => back;
public IReadOnlyList<T> ForwardEntries => forward;
public T GoBack()
{
if (current != null)
@ -57,6 +63,26 @@ namespace ILSpy.Navigation @@ -57,6 +63,26 @@ namespace ILSpy.Navigation
return current;
}
/// <summary>
/// Pops entries off the matching stack until <paramref name="target"/> becomes the current
/// entry. Lets the dropdown jump multiple steps at once (web-browser style). Returns the
/// new current entry, or null if <paramref name="target"/> isn't on the requested stack.
/// </summary>
public T? GoTo(T target, bool forward)
{
var stack = forward ? this.forward : back;
if (!stack.Contains(target))
return null;
while (!ReferenceEquals(stack[^1], target))
{
if (forward)
GoForward();
else
GoBack();
}
return forward ? GoForward() : GoBack();
}
public void Clear()
{
back.Clear();

81
ILSpy/Views/MainToolBar.axaml

@ -49,6 +49,35 @@ @@ -49,6 +49,35 @@
<Style Selector="StackPanel#ToolbarRoot > Button:disabled Image">
<Setter Property="Opacity" Value="0.35" />
</Style>
<!-- SplitButton's primary half lives in a nested template-internal Button, so the
descendant selector above doesn't reach the Image. Match it explicitly. -->
<Style Selector="StackPanel#ToolbarRoot > SplitButton:disabled Image">
<Setter Property="Opacity" Value="0.35" />
</Style>
<!-- Default chevron is ~12 px and reads as oversized next to a 16 px toolbar icon;
shrink it so the split button feels balanced — matches WPF's compact "▾" glyph. -->
<Style Selector="StackPanel#ToolbarRoot > SplitButton /template/ PathIcon">
<Setter Property="Width" Value="6" />
<Setter Property="Height" Value="6" />
</Style>
<!-- Default Fluent SplitButton paints both halves with an opaque dark fill on press
(looks black against our white toolbar). Replace with the toolbar accent palette
for both hover and pressed, on both primary and secondary halves. -->
<Style Selector="StackPanel#ToolbarRoot > SplitButton /template/ Button:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#330078D7" />
<Setter Property="BorderBrush" Value="#FF0078D7" />
</Style>
<Style Selector="StackPanel#ToolbarRoot > SplitButton /template/ Button:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="#660078D7" />
<Setter Property="BorderBrush" Value="#FF005A9E" />
</Style>
<!-- While the flyout is open, the default Fluent style holds the secondary button in a
dark "active" fill. Match the pressed-accent palette so it reads as a continuation
of the click rather than a black box. -->
<Style Selector="StackPanel#ToolbarRoot > SplitButton:flyout-open /template/ Button /template/ ContentPresenter">
<Setter Property="Background" Value="#660078D7" />
<Setter Property="BorderBrush" Value="#FF005A9E" />
</Style>
<!-- Vertical hairline between button groups. Avalonia's default Separator inside a
horizontal StackPanel renders as a thin horizontal line; force a vertical rule. -->
@ -69,21 +98,59 @@ @@ -69,21 +98,59 @@
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="BorderBrush" Value="#FFCCCEDB" />
</Style>
<!-- The Simple theme's ComboBox template (this project uses Avalonia.Themes.Simple,
not Fluent) places a separate ToggleButton named "toggle" in the chevron column
for the dropdown affordance. Its default :pressed / :checked background is dark,
which is the "dark dropdown button" we kept seeing — neither the ComboBox.Background
setter nor a Border#Background override reaches it because it's its own button.
Style its inner Border directly (the ToggleButton's template uses ContentPresenter
for backgrounds, hence /template/ ContentPresenter). -->
<Style Selector="StackPanel#ToolbarRoot ComboBox:pointerover">
<Setter Property="Background" Value="#330078D7" />
<Setter Property="BorderBrush" Value="#FF0078D7" />
</Style>
<Style Selector="StackPanel#ToolbarRoot ComboBox:pressed">
<Setter Property="Background" Value="#660078D7" />
<Setter Property="BorderBrush" Value="#FF005A9E" />
</Style>
<Style Selector="StackPanel#ToolbarRoot ComboBox:dropdownopen">
<Setter Property="Background" Value="#660078D7" />
<Setter Property="BorderBrush" Value="#FF005A9E" />
</Style>
<Style Selector="StackPanel#ToolbarRoot ComboBox /template/ ToggleButton#toggle:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#330078D7" />
</Style>
<Style Selector="StackPanel#ToolbarRoot ComboBox /template/ ToggleButton#toggle:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="#660078D7" />
</Style>
<Style Selector="StackPanel#ToolbarRoot ComboBox /template/ ToggleButton#toggle:checked /template/ ContentPresenter">
<Setter Property="Background" Value="#660078D7" />
</Style>
</UserControl.Styles>
<Border BorderThickness="0,0,0,1" BorderBrush="#FFA9B0B7"
MinHeight="29" Padding="3"
Background="White">
<StackPanel Name="ToolbarRoot" Orientation="Horizontal">
<!-- Navigation: Back / Forward. -->
<Button ToolTip.Tip="Back (Alt+Left)"
Command="{Binding DockWorkspace.NavigateBackCommand}">
<!-- Navigation: Back / Forward as web-browser-style split buttons. The primary half
navigates one step; the chevron drops a menu of recent history entries built on
demand by MainToolBar.axaml.cs. -->
<SplitButton Name="BackSplitButton"
ToolTip.Tip="Back (Alt+Left)"
Command="{Binding DockWorkspace.NavigateBackCommand}">
<Image Source="{x:Static images:Images.Back}" Width="16" Height="16" />
</Button>
<Button ToolTip.Tip="Forward (Alt+Right)"
Command="{Binding DockWorkspace.NavigateForwardCommand}">
<SplitButton.Flyout>
<MenuFlyout Placement="BottomEdgeAlignedLeft" />
</SplitButton.Flyout>
</SplitButton>
<SplitButton Name="ForwardSplitButton"
ToolTip.Tip="Forward (Alt+Right)"
Command="{Binding DockWorkspace.NavigateForwardCommand}">
<Image Source="{x:Static images:Images.Forward}" Width="16" Height="16" />
</Button>
<SplitButton.Flyout>
<MenuFlyout Placement="BottomEdgeAlignedLeft" />
</SplitButton.Flyout>
</SplitButton>
<Separator />
<!-- MEF-driven toolbar buttons (Open etc.) get inserted by MainToolBar.axaml.cs.
Anything between these two separators is replaced on Loaded. -->

49
ILSpy/Views/MainToolBar.axaml.cs

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Linq;
using System.Reflection;
@ -24,17 +25,63 @@ using Avalonia.Media; @@ -24,17 +25,63 @@ using Avalonia.Media;
using ILSpy.AppEnv;
using ILSpy.Commands;
using ILSpy.ViewModels;
namespace ILSpy;
public partial class MainToolBar : UserControl
{
const int MaxHistoryDropdownEntries = 20;
bool initialized;
public MainToolBar()
{
InitializeComponent();
Loaded += (_, _) => InitializeButtons();
Loaded += (_, _) => {
InitializeButtons();
WireHistoryUpdates();
};
}
void WireHistoryUpdates()
{
if (DataContext is not MainWindowViewModel vm)
return;
// Repopulate the menus eagerly on every history change. Doing this in the Flyout's
// Opened event would arrive too late — MenuFlyout's presenter has already been laid
// out by then and won't re-measure for items added afterward, so the popup would
// appear empty. NavigateBack/ForwardCommand.CanExecuteChanged fires on every push,
// pop, and Record (see DockWorkspace.RecordHistory / ApplyNavigationTarget), so it's
// the right signal.
void OnHistoryChanged(object? _, EventArgs __)
{
RebuildHistoryMenu(BackSplitButton, vm, forward: false);
RebuildHistoryMenu(ForwardSplitButton, vm, forward: true);
}
vm.DockWorkspace.NavigateBackCommand.CanExecuteChanged += OnHistoryChanged;
vm.DockWorkspace.NavigateForwardCommand.CanExecuteChanged += OnHistoryChanged;
OnHistoryChanged(null, EventArgs.Empty);
}
void RebuildHistoryMenu(SplitButton splitButton, MainWindowViewModel vm, bool forward)
{
var menu = (MenuFlyout)splitButton.Flyout!;
menu.Items.Clear();
var entries = forward ? vm.DockWorkspace.ForwardHistory : vm.DockWorkspace.BackHistory;
// Stacks are oldest-first; reverse so the most recent appears at the top of the menu.
// WPF caps the dropdown at 20 entries; mirror that.
foreach (var node in entries.Reverse().Take(MaxHistoryDropdownEntries))
{
var item = new MenuItem {
Header = node.Text?.ToString() ?? string.Empty,
Command = vm.DockWorkspace.NavigateToHistoryCommand,
CommandParameter = node,
};
if (node.Icon is IImage icon)
item.Icon = new Image { Width = 16, Height = 16, Source = icon };
menu.Items.Add(item);
}
}
void InitializeButtons()

Loading…
Cancel
Save