diff --git a/ILSpy.Tests/MainWindow/MainToolBarLayoutTests.cs b/ILSpy.Tests/MainWindow/MainToolBarLayoutTests.cs
new file mode 100644
index 000000000..2b7021b31
--- /dev/null
+++ b/ILSpy.Tests/MainWindow/MainToolBarLayoutTests.cs
@@ -0,0 +1,185 @@
+// 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.Controls.Primitives;
+using Avalonia.Headless.NUnit;
+using Avalonia.VisualTree;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpy.Properties;
+
+using ILSpy;
+using ILSpy.AppEnv;
+using ILSpy.Search;
+using ILSpy.ViewModels;
+using ILSpy.Views;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+///
+/// Pins the left-to-right order of the MainToolBar to match the WPF original. WPF lays out
+/// its toolbar by hand-coded XAML interleaved with two MEF injection points: Navigation goes
+/// at the very start, Open goes right after, then come the AssemblyList combo + Manage button,
+/// then the visibility toggles, then the language combos, and View-category commands (Sort,
+/// CollapseAll, Search) are appended after a separator at the very end (see
+/// ILSpy/Controls/MainToolBar.xaml + InitToolbar in MainToolBar.xaml.cs:55).
+///
+[TestFixture]
+public class MainToolBarLayoutTests
+{
+ [AvaloniaTest]
+ public async Task MainToolBar_Has_ManageAssemblyListsButton_Right_Of_AssemblyListComboBox()
+ {
+ // WPF places the Manage-Lists icon button immediately right of the assembly-list combo
+ // (MainToolBar.xaml lines 44-49). The Avalonia port previously only surfaced this
+ // command under the File menu — verify the inline toolbar affordance is wired and that
+ // clicking it pops the ManageAssemblyListsDialog.
+
+ var window = AppComposition.Current.GetExport();
+ window.Show();
+
+ var toolbar = await window.WaitForComponent();
+ var combo = toolbar.GetVisualDescendants().OfType()
+ .Single(c => c.Name == "AssemblyListComboBox");
+ var assemblyListGrid = combo.FindAncestorOfType()!;
+
+ // Walk the StackPanel children from the AssemblyList grid forward; the very next
+ // non-separator child must be a Button bound to the manage-lists command.
+ var rootPanel = toolbar.GetVisualDescendants().OfType()
+ .Single(s => s.Name == "ToolbarRoot");
+ var children = rootPanel.Children.ToList();
+ var assemblyListIndex = children.IndexOf(assemblyListGrid);
+ assemblyListIndex.Should().BeGreaterThan(-1);
+
+ var nextSibling = children.Skip(assemblyListIndex + 1)
+ .OfType
+
+
diff --git a/ILSpy/Views/MainToolBar.axaml.cs b/ILSpy/Views/MainToolBar.axaml.cs
index 97ade57e7..b661a1d11 100644
--- a/ILSpy/Views/MainToolBar.axaml.cs
+++ b/ILSpy/Views/MainToolBar.axaml.cs
@@ -17,6 +17,8 @@
// DEALINGS IN THE SOFTWARE.
using System;
+using System.Collections.Generic;
+using System.Composition;
using System.Linq;
using System.Reflection;
@@ -39,7 +41,9 @@ public partial class MainToolBar : UserControl
public MainToolBar()
{
InitializeComponent();
- Loaded += (_, _) => {
+ // AttachedToVisualTree fires reliably in both the live app and the Avalonia.Headless
+ // test harness; Loaded did not in headless, leaving the MEF anchors un-replaced.
+ AttachedToVisualTree += (_, _) => {
InitializeButtons();
WireHistoryUpdates();
};
@@ -91,42 +95,104 @@ public partial class MainToolBar : UserControl
return;
initialized = true;
- ToolbarCommandRegistry registry;
+ ToolbarCommandRegistry? toolbarRegistry = null;
+ MainMenuCommandRegistry? menuRegistry = null;
try
{
- registry = AppComposition.Current.GetExport();
+ toolbarRegistry = AppComposition.Current.GetExport();
+ menuRegistry = AppComposition.Current.GetExport();
}
catch
{
- // Design-time / test contexts that bypass composition just keep the static layout.
- return;
+ // Design-time / test contexts that bypass composition keep the static layout.
}
- int insertAt = ToolbarRoot.Children.IndexOf(ToolbarMefAnchor);
- if (insertAt < 0)
- return;
+ if (toolbarRegistry != null)
+ DispatchToolbarCommands(toolbarRegistry);
+
+ if (menuRegistry != null)
+ WireManageAssemblyListsButton(menuRegistry);
+ }
- // Insert one Button per [ExportToolbarCommand], grouped by Category, separated by
- // a thin Separator between categories. Order within a category honours ToolbarOrder.
- var groups = registry.Commands
+ void DispatchToolbarCommands(ToolbarCommandRegistry registry)
+ {
+ // Mirrors the WPF dispatch in MainToolBar.xaml.cs:55-90 — Navigation is hand-coded
+ // (Back/Forward SplitButtons in the AXAML), Open goes at OpenCategoryAnchor, every
+ // other category (View etc.) is appended after ViewCategoryAnchor.
+ var byCategory = registry.Commands
.GroupBy(c => c.Metadata.ToolbarCategory ?? string.Empty)
- .OrderBy(g => g.Key);
- bool firstGroup = true;
- foreach (var group in groups)
+ .ToDictionary(g => g.Key, g => g.OrderBy(c => c.Metadata.ToolbarOrder).ToList());
+
+ var openCategory = nameof(ICSharpCode.ILSpy.Properties.Resources.Open);
+ var viewCategory = nameof(ICSharpCode.ILSpy.Properties.Resources.View);
+
+ if (byCategory.TryGetValue(openCategory, out var openCommands))
+ ReplaceAnchor(OpenCategoryAnchor, openCommands, leadingSeparator: false);
+
+ // "View" group + any other unknown categories go at the View anchor with a leading
+ // Separator so they read as a distinct trailing group.
+ var trailingCategories = byCategory
+ .Where(kv => kv.Key != openCategory)
+ .OrderBy(kv => kv.Key == viewCategory ? 0 : 1)
+ .ThenBy(kv => kv.Key)
+ .ToList();
+
+ if (trailingCategories.Count == 0)
+ {
+ // Hide the bare anchor so we don't leave a trailing Separator on its own.
+ ToolbarRoot.Children.Remove(ViewCategoryAnchor);
+ return;
+ }
+
+ var firstTrailingCommands = trailingCategories[0].Value;
+ ReplaceAnchor(ViewCategoryAnchor, firstTrailingCommands, leadingSeparator: true);
+
+ // Any additional categories (rare, only via plugins) get their own separator + buttons
+ // appended at the end so the WPF "else: add Separator + commands" branch is preserved.
+ for (int i = 1; i < trailingCategories.Count; i++)
{
- if (!firstGroup)
- ToolbarRoot.Children.Insert(insertAt++, new Separator());
- firstGroup = false;
- foreach (var entry in group.OrderBy(c => c.Metadata.ToolbarOrder))
+ ToolbarRoot.Children.Add(new Separator());
+ foreach (var entry in trailingCategories[i].Value)
{
var button = BuildButton(entry);
if (button != null)
- ToolbarRoot.Children.Insert(insertAt++, button);
+ ToolbarRoot.Children.Add(button);
}
}
}
- static Button? BuildButton(System.Composition.ExportFactory entry)
+ void ReplaceAnchor(
+ Separator anchor,
+ IReadOnlyList> commands,
+ bool leadingSeparator)
+ {
+ var index = ToolbarRoot.Children.IndexOf(anchor);
+ if (index < 0)
+ return;
+ ToolbarRoot.Children.RemoveAt(index);
+ int insertAt = index;
+ if (leadingSeparator)
+ ToolbarRoot.Children.Insert(insertAt++, new Separator());
+ foreach (var entry in commands)
+ {
+ var button = BuildButton(entry);
+ if (button != null)
+ ToolbarRoot.Children.Insert(insertAt++, button);
+ }
+ }
+
+ void WireManageAssemblyListsButton(MainMenuCommandRegistry registry)
+ {
+ // Match the export by its main-menu header — File > Manage Assembly Lists.
+ var entry = registry.Commands.FirstOrDefault(
+ c => c.Metadata.Header == nameof(ICSharpCode.ILSpy.Properties.Resources.ManageAssembly_Lists));
+ if (entry == null)
+ return;
+ ManageAssemblyListsButton.Command = entry.CreateExport().Value;
+ ToolTip.SetTip(ManageAssemblyListsButton, ICSharpCode.ILSpy.Properties.Resources.ManageAssemblyLists);
+ }
+
+ static Button? BuildButton(ExportFactory entry)
{
var command = entry.CreateExport().Value;
var button = new Button {
@@ -158,7 +224,7 @@ public partial class MainToolBar : UserControl
{
if (string.IsNullOrEmpty(iconPath))
return null;
- var name = iconPath.StartsWith("Images/", System.StringComparison.Ordinal)
+ var name = iconPath.StartsWith("Images/", StringComparison.Ordinal)
? iconPath["Images/".Length..]
: iconPath;
var prop = typeof(Images.Images).GetField(name, BindingFlags.Public | BindingFlags.Static);