diff --git a/ILSpy.Tests/Docking/DocumentTabStripModeTests.cs b/ILSpy.Tests/Docking/DocumentTabStripModeTests.cs
new file mode 100644
index 000000000..6743f4316
--- /dev/null
+++ b/ILSpy.Tests/Docking/DocumentTabStripModeTests.cs
@@ -0,0 +1,192 @@
+// 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;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Headless;
+using Avalonia.Input;
+using Avalonia.Headless.NUnit;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+
+using AwesomeAssertions;
+
+using Dock.Avalonia.Controls;
+
+using ILSpy;
+using ILSpy.AppEnv;
+using ILSpy.TextView;
+using ILSpy.ViewModels;
+using ILSpy.Views;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Docking;
+
+///
+/// The document tab strip toggles between a single scrolling row and multiple wrapped rows, driven by
+/// the persisted setting and the mouse wheel. The
+/// overflow dropdown that lists open documents shows only in single-line mode.
+///
+[TestFixture]
+public class DocumentTabStripModeTests
+{
+ static SessionSettings Settings => AppComposition.Current.GetExport().SessionSettings;
+
+ static async Task<(MainWindow window, DocumentTabStrip strip)> BootWithTabsAsync(bool multiLine)
+ {
+ var (window, vm) = await TestHarness.BootAsync(1);
+ Settings.MultiLineDocumentTabs = multiLine;
+ for (int i = 0; i < 30; i++)
+ vm.DockWorkspace.OpenNewTab(new DecompilerTabPageModel { Title = $"Tab {i:00}" });
+ await Pump(window);
+ var strip = window.GetVisualDescendants().OfType().First();
+ return (window, strip);
+ }
+
+ static async Task Pump(MainWindow window)
+ {
+ for (int i = 0; i < 12; i++)
+ {
+ Dispatcher.UIThread.RunJobs();
+ window.UpdateLayout();
+ await Task.Delay(20);
+ }
+ }
+
+ static Button? Dropdown(DocumentTabStrip strip)
+ => strip.GetVisualDescendants().OfType()
+ .FirstOrDefault(b => (b.Tag as string) == "DocumentSwitcherDropdown");
+
+ [AvaloniaTest]
+ public async Task Single_Line_Mode_Has_No_Wrap_Panel_And_A_Visible_Dropdown()
+ {
+ var saved = Settings.MultiLineDocumentTabs;
+ try
+ {
+ var (_, strip) = await BootWithTabsAsync(multiLine: false);
+
+ strip.GetVisualDescendants().OfType().Should().BeEmpty(
+ "single-line mode keeps the default StackPanel item layout");
+ Dropdown(strip).Should().NotBeNull("the overflow dropdown is injected on the strip");
+ Dropdown(strip)!.IsVisible.Should().BeTrue("the dropdown shows in single-line mode");
+ }
+ finally
+ { Settings.MultiLineDocumentTabs = saved; }
+ }
+
+ [AvaloniaTest]
+ public async Task Multi_Line_Mode_Wraps_And_Hides_The_Dropdown()
+ {
+ var saved = Settings.MultiLineDocumentTabs;
+ try
+ {
+ var (_, strip) = await BootWithTabsAsync(multiLine: true);
+
+ strip.GetVisualDescendants().OfType().Should().NotBeEmpty(
+ "multi-line mode flows tabs through a WrapPanel");
+ Dropdown(strip)!.IsVisible.Should().BeFalse(
+ "every tab is visible across rows, so the dropdown hides");
+ }
+ finally
+ { Settings.MultiLineDocumentTabs = saved; }
+ }
+
+ [AvaloniaTest]
+ public async Task Mouse_Wheel_Over_The_Strip_Toggles_The_Mode()
+ {
+ var saved = Settings.MultiLineDocumentTabs;
+ try
+ {
+ var (window, strip) = await BootWithTabsAsync(multiLine: false);
+ var point = strip.TranslatePoint(new Point(8, 8), window) ?? new Point(8, 8);
+
+ window.MouseWheel(point, new Vector(0, 1));
+ await Pump(window);
+ Settings.MultiLineDocumentTabs.Should().BeTrue("wheel up expands to multiple rows");
+
+ window.MouseWheel(point, new Vector(0, -1));
+ await Pump(window);
+ Settings.MultiLineDocumentTabs.Should().BeFalse("wheel down collapses to a single row");
+ }
+ finally
+ { Settings.MultiLineDocumentTabs = saved; }
+ }
+
+ [AvaloniaTest]
+ public async Task Clicking_The_Dropdown_Opens_A_Populated_Menu()
+ {
+ var saved = Settings.MultiLineDocumentTabs;
+ try
+ {
+ var (window, strip) = await BootWithTabsAsync(multiLine: false);
+ var button = Dropdown(strip);
+ button.Should().NotBeNull();
+
+ var menu = button!.ContextMenu!;
+ bool opened = false;
+ menu.Opened += (_, _) => opened = true;
+
+ // Drive a real pointer click through the input pipeline (open is deferred a dispatcher
+ // turn, which Pump runs), so this would have caught the menu failing to open on a live click.
+ var centre = button.TranslatePoint(new Point(button.Bounds.Width / 2, button.Bounds.Height / 2), window)
+ ?? new Point(8, 8);
+ window.MouseDown(centre, MouseButton.Left);
+ window.MouseUp(centre, MouseButton.Left);
+ await Pump(window);
+
+ opened.Should().BeTrue("clicking the dropdown must open its menu");
+ menu.Items.OfType().Should().NotBeEmpty("the open menu lists the strip's documents");
+ }
+ finally
+ { Settings.MultiLineDocumentTabs = saved; }
+ }
+
+ [AvaloniaTest]
+ public async Task Picking_A_Menu_Entry_Switches_To_That_Document()
+ {
+ var saved = Settings.MultiLineDocumentTabs;
+ try
+ {
+ var (window, strip) = await BootWithTabsAsync(multiLine: false);
+ var button = Dropdown(strip);
+ var menu = button!.ContextMenu!;
+
+ var centre = button.TranslatePoint(new Point(button.Bounds.Width / 2, button.Bounds.Height / 2), window)
+ ?? new Point(8, 8);
+ window.MouseDown(centre, MouseButton.Left);
+ window.MouseUp(centre, MouseButton.Left);
+ await Pump(window);
+
+ var target = strip.Items.OfType().Last();
+ var targetItem = menu.Items.OfType().Single(i => (string?)i.Header == target.Title);
+ targetItem.RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(MenuItem.ClickEvent));
+ await Pump(window);
+
+ strip.SelectedItem.Should().Be(target, "picking a document switches the strip to it");
+ }
+ finally
+ { Settings.MultiLineDocumentTabs = saved; }
+ }
+}
diff --git a/ILSpy.Tests/Docking/MultiRowTabStripTests.cs b/ILSpy.Tests/Docking/MultiRowTabStripTests.cs
index e19da8a49..b3cc80deb 100644
--- a/ILSpy.Tests/Docking/MultiRowTabStripTests.cs
+++ b/ILSpy.Tests/Docking/MultiRowTabStripTests.cs
@@ -28,6 +28,7 @@ using AwesomeAssertions;
using Dock.Avalonia.Controls;
+using ILSpy;
using ILSpy.AppEnv;
using ILSpy.TextView;
using ILSpy.ViewModels;
@@ -38,8 +39,8 @@ using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Docking;
///
-/// PROTOTYPE check: with MultiRowTabStripBehavior enabled (App.axaml), many document tabs flow onto
-/// multiple rows (a WrapPanel) instead of a single scrolling row.
+/// With multi-line mode enabled ( ), many document
+/// tabs flow onto multiple rows (a WrapPanel) instead of a single scrolling row.
///
[TestFixture]
public class MultiRowTabStripTests
@@ -47,32 +48,40 @@ public class MultiRowTabStripTests
[AvaloniaTest]
public async Task Many_Document_Tabs_Wrap_To_Multiple_Rows()
{
- var window = AppComposition.Current.GetExport();
- window.Show();
- var vm = (MainWindowViewModel)window.DataContext!;
- await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
+ var settings = AppComposition.Current.GetExport().SessionSettings;
+ var saved = settings.MultiLineDocumentTabs;
+ try
+ {
+ var window = AppComposition.Current.GetExport();
+ window.Show();
+ var vm = (MainWindowViewModel)window.DataContext!;
+ await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
- for (int i = 0; i < 40; i++)
- vm.DockWorkspace.OpenNewTab(new DecompilerTabPageModel { Title = $"Tab number {i:00}" });
+ settings.MultiLineDocumentTabs = true;
+ for (int i = 0; i < 40; i++)
+ vm.DockWorkspace.OpenNewTab(new DecompilerTabPageModel { Title = $"Tab number {i:00}" });
- for (int i = 0; i < 12; i++)
- {
- Dispatcher.UIThread.RunJobs();
- window.UpdateLayout();
- await Task.Delay(20);
- }
+ for (int i = 0; i < 12; i++)
+ {
+ Dispatcher.UIThread.RunJobs();
+ window.UpdateLayout();
+ await Task.Delay(20);
+ }
- var strip = window.GetVisualDescendants().OfType().FirstOrDefault();
- strip.Should().NotBeNull("the document tab strip must be realised");
+ var strip = window.GetVisualDescendants().OfType().FirstOrDefault();
+ strip.Should().NotBeNull("the document tab strip must be realised");
- strip!.GetVisualDescendants().OfType().Should().NotBeEmpty(
- "the behaviour must swap the strip's ItemsPanel to a WrapPanel");
+ strip!.GetVisualDescendants().OfType().Should().NotBeEmpty(
+ "the behaviour must swap the strip's ItemsPanel to a WrapPanel");
- var rows = strip.GetVisualDescendants().OfType()
- .Where(it => it.Bounds.Width > 0)
- .Select(it => System.Math.Round(it.Bounds.Y))
- .Distinct().ToList();
+ var rows = strip.GetVisualDescendants().OfType()
+ .Where(it => it.Bounds.Width > 0)
+ .Select(it => System.Math.Round(it.Bounds.Y))
+ .Distinct().ToList();
- rows.Count.Should().BeGreaterThan(1, "40 tabs must wrap onto more than one row");
+ rows.Count.Should().BeGreaterThan(1, "40 tabs must wrap onto more than one row");
+ }
+ finally
+ { settings.MultiLineDocumentTabs = saved; }
}
}
diff --git a/ILSpy.Tests/Settings/SessionSettingsMultiLineTabsTests.cs b/ILSpy.Tests/Settings/SessionSettingsMultiLineTabsTests.cs
new file mode 100644
index 000000000..2c87f4df8
--- /dev/null
+++ b/ILSpy.Tests/Settings/SessionSettingsMultiLineTabsTests.cs
@@ -0,0 +1,59 @@
+// 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.Xml.Linq;
+
+using AwesomeAssertions;
+
+using ILSpy;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Settings;
+
+///
+/// The document-tab layout mode persists across sessions: it defaults to single-line (false) and
+/// round-trips through the SessionSettings XML.
+///
+[TestFixture]
+public class SessionSettingsMultiLineTabsTests
+{
+ static SessionSettings Load(XElement section)
+ {
+ var settings = new SessionSettings();
+ settings.LoadFromXml(section);
+ return settings;
+ }
+
+ [Test]
+ public void Defaults_To_Single_Line_When_Absent()
+ {
+ Load(new XElement("SessionSettings")).MultiLineDocumentTabs.Should().BeFalse();
+ }
+
+ [Test]
+ public void Round_Trips_Through_Xml()
+ {
+ var settings = Load(new XElement("SessionSettings"));
+ settings.MultiLineDocumentTabs = true;
+
+ var reloaded = Load(settings.SaveToXml());
+
+ reloaded.MultiLineDocumentTabs.Should().BeTrue("the persisted mode must survive save + load");
+ }
+}
diff --git a/ILSpy/App.axaml b/ILSpy/App.axaml
index b015ea473..94f1e7f02 100644
--- a/ILSpy/App.axaml
+++ b/ILSpy/App.axaml
@@ -285,9 +285,12 @@
-
+