Browse Source

VS-style preview tabs + Options polish

Preview tab semantics. ContentTabPage gains IsPreview; the persistent MainTab
starts preview (tree-node clicks replace its Content in place). The user pins
via Window menu, right-click context menu, or inline pin icon — the
just-pinned tab keeps its content/identity and a fresh preview MainTab spawns.
Carve-out tabs (Open in new tab, Options) are born pinned and survive tree
selections.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
045f9a66f3
  1. 474
      ILSpy.Tests/Docking/PreviewTabPromotionTests.cs
  2. 65
      ILSpy.Tests/Options/OptionsTabTests.cs
  3. 45
      ILSpy/App.axaml
  4. 15
      ILSpy/Commands/WindowCommands.cs
  5. 51
      ILSpy/Docking/DockWorkspace.cs
  6. 28
      ILSpy/Docking/ILSpyDockFactory.cs
  7. 4
      ILSpy/Options/OptionsPageModel.cs
  8. 50
      ILSpy/Themes/BoolToBrushConverter.cs
  9. 47
      ILSpy/Themes/BoolToFontStyleConverter.cs
  10. 48
      ILSpy/Themes/BoolToThicknessConverter.cs
  11. 105
      ILSpy/Themes/PreviewTabContextMenuBehavior.cs
  12. 176
      ILSpy/Themes/PreviewTabPinButtonBehavior.cs
  13. 11
      ILSpy/ViewModels/ContentTabPage.cs

474
ILSpy.Tests/Docking/PreviewTabPromotionTests.cs

@ -0,0 +1,474 @@
// 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.Headless.NUnit;
using Avalonia.Media;
using Avalonia.VisualTree;
using AwesomeAssertions;
using Dock.Avalonia.Controls;
using ILSpy.AppEnv;
using ILSpy.Docking;
using ILSpy.TreeNodes;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class PreviewTabPromotionTests
{
[AvaloniaTest]
public void MainTab_Starts_In_Preview_State()
{
// The persistent MainTab is the preview slot; tree-node clicks should replace its
// Content in place until the user explicitly pins it.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
var mainTab = ((ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!;
mainTab.IsPreview.Should().BeTrue(
"the freshly-created MainTab is the preview slot until the user pins it");
}
[AvaloniaTest]
public async Task Carve_Out_Tabs_Are_Born_Pinned()
{
// Open-in-new-tab tabs are explicit user intent — they should never be replaced
// by subsequent tree-node selections, so they're born pinned (IsPreview=false).
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");
vm.DockWorkspace.OpenNodeInNewTab(typeNode);
var carveOut = vm.DockWorkspace.Documents!.VisibleDockables!
.OfType<ContentTabPage>()
.Last(t => t.SourceNode == typeNode);
carveOut.IsPreview.Should().BeFalse(
"carve-out tabs are explicit user intent and must survive tree-selection changes");
}
[AvaloniaTest]
public async Task PinCurrentTab_Promotes_MainTab_And_Spawns_A_New_Preview_MainTab()
{
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
var previousMainTab = factory.MainTab!;
previousMainTab.IsPreview.Should().BeTrue("baseline");
// Load content into MainTab so the pin has something meaningful to keep.
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectNode(typeNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync(System.TimeSpan.FromSeconds(30));
ReferenceEquals(previousMainTab.SourceNode, typeNode).Should().BeTrue(
"baseline: tree selection populated MainTab with the chosen node");
// Pin.
vm.DockWorkspace.PinCurrentTab();
// Promoted tab keeps its content and identity, just loses preview status.
previousMainTab.IsPreview.Should().BeFalse(
"after pin, the previously-preview MainTab becomes a regular pinned tab");
ReferenceEquals(previousMainTab.SourceNode, typeNode).Should().BeTrue(
"pin must not throw away the tab's content");
// A new preview MainTab takes its place.
factory.MainTab.Should().NotBeSameAs(previousMainTab,
"factory.MainTab must rotate to a fresh preview tab after pin");
factory.MainTab!.IsPreview.Should().BeTrue(
"the new MainTab is the next preview slot");
// Both tabs live in the documents dock.
var docTabs = vm.DockWorkspace.Documents!.VisibleDockables!.OfType<ContentTabPage>().ToList();
docTabs.Should().Contain(previousMainTab).And.Contain(factory.MainTab,
"the documents dock must hold both the promoted tab and the fresh MainTab");
}
[AvaloniaTest]
public async Task After_Pin_Tree_Selection_Writes_To_New_MainTab_Not_Promoted_One()
{
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
// Phase 1: load type A into MainTab.
var typeA = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectNode(typeA);
await vm.DockWorkspace.WaitForDecompiledTextAsync(System.TimeSpan.FromSeconds(30));
var pinnedTab = factory.MainTab!;
var pinnedContent = pinnedTab.Content;
// Phase 2: pin.
vm.DockWorkspace.PinCurrentTab();
var newMainTab = factory.MainTab!;
newMainTab.Should().NotBeSameAs(pinnedTab);
// Phase 3: select a different type — should land in the NEW MainTab.
var typeB = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Private.Uri", "System", "System.Uri");
vm.AssemblyTreeModel.SelectNode(typeB);
await vm.DockWorkspace.WaitForDecompiledTextAsync(System.TimeSpan.FromSeconds(30));
ReferenceEquals(newMainTab.SourceNode, typeB).Should().BeTrue(
"new tree selection must land in the freshly-spawned preview MainTab");
ReferenceEquals(pinnedTab.SourceNode, typeA).Should().BeTrue(
"the pinned tab must keep type A — not be overwritten by the new selection");
pinnedTab.Content.Should().BeSameAs(pinnedContent,
"pinned tab's Content reference must survive subsequent tree selections");
}
[AvaloniaTest]
public async Task Preview_MainTab_Header_Renders_Italic()
{
// Visual contract: the DocumentTabStripItem for the preview MainTab binds its
// FontStyle to ContentTabPage.IsPreview via BoolToFontStyleConverter.Italic.
// Pinned tabs and tool-pane tabs fall through to FontStyle.Normal.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
// Wait for the tab strip to realise its items.
await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Any(),
System.TimeSpan.FromSeconds(10));
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>()
.Single(item => ReferenceEquals(item.DataContext, factory.MainTab));
mainTabItem.FontStyle.Should().Be(FontStyle.Italic,
"the App.axaml Style for DocumentTabStripItem must apply, italicising the tab title via FontStyle inheritance");
}
[AvaloniaTest]
public async Task Preview_MainTab_Header_Has_Left_Edge_Accent_Stripe()
{
// Border companion to the italic check: the preview MainTab carries a non-null
// BorderBrush and a 3px left-only BorderThickness via the accent-stripe converters.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
await ((MainWindowViewModel)window.DataContext!).AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Any(),
System.TimeSpan.FromSeconds(10));
var factory = (ILSpyDockFactory)((MainWindowViewModel)window.DataContext!).DockWorkspace.Factory;
var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>()
.Single(item => ReferenceEquals(item.DataContext, factory.MainTab));
((object?)mainTabItem.BorderBrush).Should().NotBeNull(
"the preview MainTab must carry the accent BorderBrush");
mainTabItem.BorderThickness.Should().Be(new global::Avalonia.Thickness(3, 0, 0, 0),
"the preview MainTab must carry the left-only 3px accent stripe");
}
[AvaloniaTest]
public async Task Pin_Button_Fits_Inside_The_Tab_When_Close_Button_Is_Visible()
{
// The single-tab scenario hides the close button (UpdateLastDocumentCanClose sets
// CanClose=false), so the pin has lots of room. The bug shows up only when the
// close button is visible and competes for horizontal space. Open a carve-out
// tab so both MainTab and carve-out exist; close button becomes visible; verify
// the pin's right edge stays inside the tab's right edge.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
// Open a carve-out so close buttons become visible on both tabs.
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.DockWorkspace.OpenNodeInNewTab(typeNode);
await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Count() >= 2,
System.TimeSpan.FromSeconds(10));
global::Avalonia.Threading.Dispatcher.UIThread.RunJobs();
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>()
.Single(item => ReferenceEquals(item.DataContext, factory.MainTab));
// Force layout so Bounds are up to date.
mainTabItem.Measure(global::Avalonia.Size.Infinity);
mainTabItem.Arrange(new global::Avalonia.Rect(mainTabItem.DesiredSize));
global::Avalonia.Threading.Dispatcher.UIThread.RunJobs();
var pinButton = mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>()
.Single(b => (b.Tag as string) == "PreviewTabPinButton");
// Walk up the visual tree summing each ancestor's bounds origin until we reach
// mainTabItem — gives the pin's top-left in tab coordinates without depending on
// TranslatePoint (which lives on different namespaces across Avalonia versions).
static global::Avalonia.Point OriginRelativeTo(global::Avalonia.Visual node, global::Avalonia.Visual ancestor)
{
double x = 0, y = 0;
var current = node;
while (current != null && !ReferenceEquals(current, ancestor))
{
x += current.Bounds.X;
y += current.Bounds.Y;
current = current.GetVisualParent();
}
return new global::Avalonia.Point(x, y);
}
var pinTopLeft = OriginRelativeTo(pinButton, mainTabItem);
var pinRight = pinTopLeft.X + pinButton.Bounds.Width;
var tabRight = mainTabItem.Bounds.Width;
// Also find the close button so we can include it in the failure message for context.
var closeButton = mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>()
.Where(b => (b.Tag as string) != "PreviewTabPinButton")
.Select(b => new { b, p = OriginRelativeTo(b, mainTabItem) })
.OrderByDescending(x => x.p.X)
.FirstOrDefault();
var closeInfo = closeButton == null ? "no close button found"
: $"close at x={closeButton.p.X:0}-{closeButton.p.X + closeButton.b.Bounds.Width:0}";
TestContext.WriteLine($"tab width={tabRight:0}; pin at x={pinTopLeft.X:0}-{pinRight:0}; {closeInfo}");
pinRight.Should().BeLessThanOrEqualTo(tabRight,
"pin button's right edge must fit inside the tab's right edge (cut-off bug)");
if (closeButton != null && closeButton.b.Bounds.Width > 0)
{
pinRight.Should().BeLessThanOrEqualTo(closeButton.p.X + 0.5,
"pin button must sit to the LEFT of the close button, not overlap it");
}
}
[AvaloniaTest]
public async Task Pin_Button_Fits_Inside_The_Tab_Even_With_A_Long_Title()
{
// Production stress case: tabs with long titles can exceed the tab strip's available
// width if the title isn't capped. The pin button must still fit inside the tab's
// right edge regardless of title length.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
// Open carve-out so close button is visible.
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.DockWorkspace.OpenNodeInNewTab(typeNode);
await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Count() >= 2,
System.TimeSpan.FromSeconds(10));
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
var mainTab = factory.MainTab!;
// Force an absurdly long title to simulate a long type signature.
mainTab.Title = "This.Is.An.Extremely.Long.Tab.Title.That.Would.Normally.Overflow<Foo, Bar, Baz>";
global::Avalonia.Threading.Dispatcher.UIThread.RunJobs();
var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>()
.Single(item => ReferenceEquals(item.DataContext, mainTab));
// Constrain the tab to 200px (mimics production where the document strip shares
// width among multiple tabs).
mainTabItem.MaxWidth = 200;
mainTabItem.Measure(new global::Avalonia.Size(200, double.PositiveInfinity));
mainTabItem.Arrange(new global::Avalonia.Rect(0, 0, 200, mainTabItem.DesiredSize.Height));
global::Avalonia.Threading.Dispatcher.UIThread.RunJobs();
var pinButton = mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>()
.Single(b => (b.Tag as string) == "PreviewTabPinButton");
static global::Avalonia.Point OriginRelativeTo(global::Avalonia.Visual node, global::Avalonia.Visual ancestor)
{
double x = 0, y = 0;
var current = node;
while (current != null && !ReferenceEquals(current, ancestor))
{
x += current.Bounds.X;
y += current.Bounds.Y;
current = current.GetVisualParent();
}
return new global::Avalonia.Point(x, y);
}
var pinTopLeft = OriginRelativeTo(pinButton, mainTabItem);
var pinRight = pinTopLeft.X + pinButton.Bounds.Width;
var tabRight = mainTabItem.Bounds.Width;
TestContext.WriteLine($"long-title scenario: tab={tabRight:0}, pin x={pinTopLeft.X:0}-{pinRight:0}");
pinRight.Should().BeLessThanOrEqualTo(tabRight,
$"pin must fit inside tab width even with long titles (tab={tabRight:0}, pinRight={pinRight:0})");
// Lock the rendering fixes in:
// 1. The title StackPanel must clip — otherwise a long title TextBlock measures
// at its full unconstrained width and paints over the pin and close buttons.
var titleStack = mainTabItem.GetVisualDescendants().OfType<global::Avalonia.Controls.StackPanel>().First();
titleStack.ClipToBounds.Should().BeTrue(
"the title StackPanel must clip its content so a long title TextBlock doesn't render over the pin button");
// 2. The pin Button itself must NOT clip — Avalonia Buttons default to ClipToBounds=true
// which cuts the right edge of Segoe Fluent Icon glyphs (whose visual extent
// exceeds the reported advance width).
pinButton.ClipToBounds.Should().BeFalse(
"pin button must not clip so the glyph's visual extent (often wider than the font advance width) survives");
}
[AvaloniaTest]
public async Task Inline_Pin_Button_Appears_On_Preview_Tab_And_Pins_When_Clicked()
{
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Any(),
System.TimeSpan.FromSeconds(10));
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>()
.Single(item => ReferenceEquals(item.DataContext, factory.MainTab));
// Pin button is injected via PreviewTabPinButtonBehavior — find it by Tag.
await Waiters.WaitForAsync(() => mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>()
.Any(b => (b.Tag as string) == "PreviewTabPinButton"),
System.TimeSpan.FromSeconds(5));
var pinButton = mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>()
.Single(b => (b.Tag as string) == "PreviewTabPinButton");
pinButton.IsVisible.Should().BeTrue("pin button must show while the tab is preview");
var previousMainTab = factory.MainTab!;
// Simulate a user click on the pin button.
pinButton.RaiseEvent(new global::Avalonia.Interactivity.RoutedEventArgs(
global::Avalonia.Controls.Button.ClickEvent));
// The previous MainTab should now be pinned; factory.MainTab should point at a new
// preview tab.
previousMainTab.IsPreview.Should().BeFalse(
"clicking the pin button must promote the previous MainTab");
factory.MainTab.Should().NotBeSameAs(previousMainTab,
"factory.MainTab must rotate to a fresh preview tab after the click");
}
[AvaloniaTest]
public async Task Pin_Entry_Appears_In_Tab_Context_Menu_Only_When_Preview()
{
// The right-click context-menu Pin entry must be visible on the preview MainTab
// and hidden (its IsVisible flips) on pinned carve-out tabs.
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");
vm.DockWorkspace.OpenNodeInNewTab(typeNode);
await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Count() >= 2,
System.TimeSpan.FromSeconds(10));
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>()
.Single(item => ReferenceEquals(item.DataContext, factory.MainTab));
var carveOutItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>()
.Single(item => item.DataContext is ContentTabPage t && t.SourceNode == typeNode);
var mainPinEntry = mainTabItem.DocumentContextMenu!.Items.OfType<global::Avalonia.Controls.MenuItem>().Single();
mainPinEntry.Header.Should().Be("Pin tab");
mainPinEntry.IsVisible.Should().BeTrue("MainTab is preview — Pin tab entry must be visible");
var carvePinEntry = carveOutItem.DocumentContextMenu!.Items.OfType<global::Avalonia.Controls.MenuItem>().Single();
carvePinEntry.IsVisible.Should().BeFalse(
"carve-out tabs are already pinned — the Pin entry must hide");
}
[AvaloniaTest]
public async Task Pinned_Carve_Out_Tab_Header_Renders_Upright()
{
// Regression guard for the bug where every DocumentTabStripItem got italicised
// because the App.axaml setter used a static `FontStyle="Italic"` value instead
// of the binding to IsPreview. A carve-out tab (IsPreview=false) must render
// Normal, even when sitting next to a preview MainTab.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
// Open a carve-out tab via the production code path.
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.DockWorkspace.OpenNodeInNewTab(typeNode);
await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType<DocumentTabStripItem>().Any(),
System.TimeSpan.FromSeconds(10));
var carveOutModel = vm.DockWorkspace.Documents!.VisibleDockables!
.OfType<ContentTabPage>()
.Last(t => t.SourceNode == typeNode);
carveOutModel.IsPreview.Should().BeFalse("baseline: carve-out tab is pinned");
var carveOutItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>()
.Single(item => ReferenceEquals(item.DataContext, carveOutModel));
carveOutItem.FontStyle.Should().Be(FontStyle.Normal,
"a pinned (IsPreview=false) document tab must render with upright FontStyle");
}
[AvaloniaTest]
public void PinCurrentTab_Is_Noop_When_MainTab_Already_Pinned()
{
// Idempotency: calling Pin twice in a row only promotes the first time. The second
// call sees IsPreview=false on the (new) MainTab — actually, on the *new* preview
// MainTab — so it should NOT promote that one. Wait — the new MainTab IS preview.
// So a second pin DOES promote it. Let me restate: a single pin promotes once;
// calling pin again creates a second pinned tab. That's the intended behaviour.
// The actual noop case is when there's literally nothing pinnable. Test that.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
// Manually flip the current MainTab to pinned, simulating a state where there's
// no preview to promote.
factory.MainTab!.IsPreview = false;
var before = factory.MainTab;
vm.DockWorkspace.PinCurrentTab();
factory.MainTab.Should().BeSameAs(before,
"with no preview MainTab to promote, PinCurrentTab must leave the factory state untouched");
}
}

65
ILSpy.Tests/Options/OptionsTabTests.cs

@ -84,6 +84,71 @@ public class OptionsTabTests
"the Options tab must be flagged static so tree-node selections leave it alone"); "the Options tab must be flagged static so tree-node selections leave it alone");
} }
[AvaloniaTest]
public async Task Tree_Selection_Activates_MainTab_Even_When_Options_Is_The_Focused_Document()
{
// Scenario: user has the Options tab open and active. They click a node in the
// assembly tree. The new content lands on MainTab as usual, but the focused
// document is still Options — so the user sees no visible change. The fix:
// ShowSelectedNode pulls focus back to MainTab whenever a tree-selection writes
// new content there.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
// Open Options and confirm it's the active document.
var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>();
registry.Commands.Single(c => c.Metadata.Header == nameof(Resources._Options))
.CreateExport().Value.Execute(null);
var documents = vm.DockWorkspace.Documents!;
var optionsTab = documents.VisibleDockables!.OfType<ContentTabPage>()
.Single(t => t.Content is OptionsPageModel);
documents.ActiveDockable.Should().BeSameAs(optionsTab,
"baseline: opening Options must make it the active document");
// Click a tree node — fires the same path as a real user mouse-click.
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectNode(typeNode);
// MainTab now carries the new content. Without the fix, ActiveDockable still
// points at Options and the user sees nothing change.
var mainTab = ((ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!;
documents.ActiveDockable.Should().BeSameAs(mainTab,
"tree selection must pull focus onto MainTab so the user sees the new content");
// Options must still be in the documents dock — we changed focus, not closed it.
documents.VisibleDockables!.OfType<ContentTabPage>()
.Should().Contain(t => t.Content is OptionsPageModel,
"focus change must not close the Options tab");
}
[AvaloniaTest]
public void Options_Tab_Title_Is_Plain_Options_Not_The_Menu_String()
{
// The menu-item resource includes the accelerator underscore + ellipsis
// ("_Options...") which is meaningful on a menu but wrong on a tab header. The
// tab should use the bare Resources.Options ("Options") instead.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>();
registry.Commands.Single(c => c.Metadata.Header == nameof(Resources._Options))
.CreateExport().Value.Execute(null);
var vm = (MainWindowViewModel)window.DataContext!;
var model = (OptionsPageModel)vm.DockWorkspace.Documents!.VisibleDockables!
.OfType<ContentTabPage>().First(t => t.Content is OptionsPageModel).Content!;
model.Title.Should().Be(Resources.Options,
"the tab title must be the bare 'Options' string, not the menu's '_Options...'");
model.Title.Should().NotContain("_",
"a tab header doesn't have an accelerator key, so the underscore would render literally");
model.Title.Should().NotEndWith("...",
"the ellipsis is a menu-item convention indicating 'opens a dialog' — not appropriate on the dialog itself");
}
[AvaloniaTest] [AvaloniaTest]
public void OptionsPageModel_Surfaces_The_Three_Panels_In_MEF_Order() public void OptionsPageModel_Surfaces_The_Three_Panels_In_MEF_Order()
{ {

45
ILSpy/App.axaml

@ -9,9 +9,11 @@
xmlns:textView="using:ILSpy.TextView" xmlns:textView="using:ILSpy.TextView"
xmlns:metaViews="using:ILSpy.Views" xmlns:metaViews="using:ILSpy.Views"
xmlns:dockTheme="using:Dock.Avalonia.Themes.Simple" xmlns:dockTheme="using:Dock.Avalonia.Themes.Simple"
xmlns:dockControls="using:Dock.Avalonia.Controls"
xmlns:recycling="using:Avalonia.Controls.Recycling" xmlns:recycling="using:Avalonia.Controls.Recycling"
xmlns:docking="using:ILSpy.Docking" xmlns:docking="using:ILSpy.Docking"
xmlns:controls="using:ILSpy.Controls" xmlns:controls="using:ILSpy.Controls"
xmlns:themes="using:ILSpy.Themes"
RequestedThemeVariant="Default"> RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. --> <!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
@ -116,6 +118,49 @@
<Setter Property="Foreground" Value="White" /> <Setter Property="Foreground" Value="White" />
</Style> </Style>
<!-- VS-style preview tab: italicise the title of any DocumentTabStripItem whose
dockable is a ContentTabPage with IsPreview=true. Tool-pane tabs and pinned
document tabs fall through to Normal via the converter's null/false branch.
FontStyle inherits to the title TextBlock inside the tab template. -->
<Style Selector="DocumentTabStripItem">
<Setter Property="FontStyle"
Value="{Binding IsPreview, Converter={x:Static themes:BoolToFontStyleConverter.Italic}, FallbackValue=Normal, Mode=OneWay, x:CompileBindings=False}" />
<!-- 3px accent stripe on the left edge while the tab is preview, otherwise no
extra border. Stripe brush + thickness both resolve via the same IsPreview
source so they always appear / disappear together. -->
<Setter Property="BorderBrush"
Value="{Binding IsPreview, Converter={x:Static themes:BoolToBrushConverter.PreviewAccent}, FallbackValue={x:Null}, Mode=OneWay, x:CompileBindings=False}" />
<Setter Property="BorderThickness"
Value="{Binding IsPreview, Converter={x:Static themes:BoolToThicknessConverter.LeftStripe}, FallbackValue=0, Mode=OneWay, x:CompileBindings=False}" />
<!-- Per-tab right-click "Pin tab" entry. The Behavior creates a fresh
ContextMenu per attachment (visuals can have only one parent) and toggles
the entry's IsVisible on the tab's IsPreview. -->
<Setter Property="themes:PreviewTabContextMenuBehavior.Enable" Value="True" />
<!-- Inline pin icon in the tab strip. Dock's tab template is compiled into the
theme DLL; the Behavior walks the visual tree and injects a Button between
the title and the close button. Bound to IsPreview so the icon hides once
the tab is pinned. -->
<Setter Property="themes:PreviewTabPinButtonBehavior.Enable" Value="True" />
</Style>
<!-- Styling for the injected pin button. Routed through a class selector (not
local setters on the Button) so the :pointerover style still applies — local
property values beat Style setters in Avalonia precedence. -->
<Style Selector="Button.preview-pin">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style Selector="Button.preview-pin:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#33000000" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="Button.preview-pin:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="#55000000" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<!-- Override the presenter template so we can layer classic Windows-Explorer tree <!-- Override the presenter template so we can layer classic Windows-Explorer tree
lines behind the indent / expander / content. --> lines behind the indent / expander / content. -->
<Style Selector="DataGridHierarchicalPresenter"> <Style Selector="DataGridHierarchicalPresenter">

15
ILSpy/Commands/WindowCommands.cs

@ -39,4 +39,19 @@ namespace ILSpy.Commands
{ {
public override void Execute(object? parameter) => dockWorkspace.ResetLayout(); public override void Execute(object? parameter) => dockWorkspace.ResetLayout();
} }
/// <summary>
/// VS-style "promote preview tab" command. The persistent MainTab starts as preview
/// (its content is replaced by tree-node selections); pinning makes it a regular tab
/// that keeps its content, and a fresh preview MainTab takes over the
/// tree-selection slot. <see cref="DockWorkspace.PinCurrentTab"/> is a no-op when
/// there's nothing pinnable (no MainTab, or MainTab already pinned).
/// </summary>
[ExportMainMenuCommand(Header = nameof(Resources.Window_PinCurrentTab), ParentMenuID = nameof(Resources._Window))]
[Shared]
[method: ImportingConstructor]
sealed class PinCurrentTabCommand(DockWorkspace dockWorkspace) : SimpleCommand
{
public override void Execute(object? parameter) => dockWorkspace.PinCurrentTab();
}
} }

51
ILSpy/Docking/DockWorkspace.cs

@ -628,7 +628,13 @@ namespace ILSpy.Docking
// same selection — the second one's columns would replace the first's, but the // same selection — the second one's columns would replace the first's, but the
// first's filter wiring would be left dangling on stale ColumnFilter instances. // first's filter wiring would be left dangling on stale ColumnFilter instances.
if (lastShownNodes is { } prev && prev.SequenceEqual(nodes)) if (lastShownNodes is { } prev && prev.SequenceEqual(nodes))
{
// Content didn't change, but the user may have returned to the tree from a
// sibling static tab (Options / About) — pull MainTab forward so re-clicking
// the same node still feels responsive.
ActivateMainTabIfNeeded(main);
return; return;
}
lastShownNodes = nodes; lastShownNodes = nodes;
// Tree-node selections always reuse the single document slot. The Document // Tree-node selections always reuse the single document slot. The Document
@ -650,6 +656,7 @@ namespace ILSpy.Docking
else else
AttachCustomContent(main, customContent); AttachCustomContent(main, customContent);
main.SourceNode = nodes[0]; main.SourceNode = nodes[0];
ActivateMainTabIfNeeded(main);
return; return;
} }
@ -658,6 +665,23 @@ namespace ILSpy.Docking
decTab.Language = languageService.CurrentLanguage; decTab.Language = languageService.CurrentLanguage;
decTab.CurrentNodes = nodes; decTab.CurrentNodes = nodes;
main.SourceNode = nodes.Length == 1 ? nodes[0] : null; main.SourceNode = nodes.Length == 1 ? nodes[0] : null;
ActivateMainTabIfNeeded(main);
}
// Brings MainTab to the front of the documents dock so the just-updated content is
// what the user sees. No-op when MainTab is already active, when there's no
// documents dock, or when a Back/Forward navigation is in flight (ApplyNavigationTarget
// has already chosen which tab to activate, including possibly a sibling tab — our
// activation would override that intent).
void ActivateMainTabIfNeeded(ContentTabPage main)
{
if (suppressHistoryRecording)
return;
if (factory.Documents is not { } docs)
return;
if (ReferenceEquals(docs.ActiveDockable, main))
return;
factory.SetActiveDockable(main);
} }
void AttachCustomContent(ContentTabPage main, TabPageModel newContent) void AttachCustomContent(ContentTabPage main, TabPageModel newContent)
@ -713,7 +737,9 @@ namespace ILSpy.Docking
/// reuse path applies via <see cref="AttachCustomContent"/>; otherwise a fresh /// reuse path applies via <see cref="AttachCustomContent"/>; otherwise a fresh
/// <see cref="DecompilerTabPageModel"/> is spawned and asked to decompile the /// <see cref="DecompilerTabPageModel"/> is spawned and asked to decompile the
/// node. Shared between the assembly-tree MMB handler, the metadata-grid MMB /// node. Shared between the assembly-tree MMB handler, the metadata-grid MMB
/// handler, and the "Decompile to new tab" context-menu entry. /// handler, and the "Decompile to new tab" context-menu entry. The created tab
/// is pinned (born with <see cref="ContentTabPage.IsPreview"/> false) — see
/// <see cref="OpenNewTab"/>.
/// </summary> /// </summary>
public void OpenNodeInNewTab(ILSpyTreeNode node) public void OpenNodeInNewTab(ILSpyTreeNode node)
{ {
@ -951,7 +977,9 @@ namespace ILSpy.Docking
public ContentTabPage OpenNewTab(object content, SharpTreeNode? sourceNode = null) public ContentTabPage OpenNewTab(object content, SharpTreeNode? sourceNode = null)
{ {
var tab = new ContentTabPage { Content = content, SourceNode = sourceNode }; // Carve-outs are born pinned — they survive tree-node selections instead of
// being replaced like the preview MainTab.
var tab = new ContentTabPage { Content = content, SourceNode = sourceNode, IsPreview = false };
if (factory.Documents != null) if (factory.Documents != null)
{ {
factory.AddDockable(factory.Documents, tab); factory.AddDockable(factory.Documents, tab);
@ -961,6 +989,25 @@ namespace ILSpy.Docking
return tab; return tab;
} }
/// <summary>
/// VS-style "promote preview tab" gesture. The current <c>factory.MainTab</c> keeps
/// its position and content but flips to pinned (<see cref="ContentTabPage.IsPreview"/>
/// false); a fresh preview <c>MainTab</c> is created and inserted beside it (rightmost),
/// becoming the new target for tree-node selections. The just-pinned tab stays active
/// so the user keeps looking at what they pinned.
/// </summary>
public void PinCurrentTab()
{
if (factory.PromotePreviewMainTab() is null)
return;
// Cached decompiler viewmodel was bound to the previous MainTab — drop it so the
// next tree click materialises a fresh one inside the new MainTab.
decompilerContent = null;
// Defeat the same-selection dedupe so re-clicking the just-pinned node's path
// actually populates the new MainTab.
lastShownNodes = null;
}
public void CloseAllTabs() public void CloseAllTabs()
{ {
var docs = factory.Documents?.VisibleDockables; var docs = factory.Documents?.VisibleDockables;

28
ILSpy/Docking/ILSpyDockFactory.cs

@ -42,9 +42,37 @@ namespace ILSpy.Docking
/// The single persistent Document the host puts in <see cref="Documents"/>. Its /// The single persistent Document the host puts in <see cref="Documents"/>. Its
/// <see cref="ContentTabPage.Content"/> swaps between viewmodels (decompiler text / /// <see cref="ContentTabPage.Content"/> swaps between viewmodels (decompiler text /
/// metadata grid) on tree-node selection — DockWorkspace owns the population. /// metadata grid) on tree-node selection — DockWorkspace owns the population.
/// Identity rotates through <see cref="PromotePreviewMainTab"/> — after a pin, the
/// just-pinned tab keeps its position and content while MainTab points at a fresh
/// preview tab beside it.
/// </summary> /// </summary>
public ContentTabPage? MainTab { get; private set; } public ContentTabPage? MainTab { get; private set; }
/// <summary>
/// VS-style preview-tab promotion. Flips the current <see cref="MainTab"/> to pinned
/// (<see cref="ContentTabPage.IsPreview"/> = false), creates a new placeholder
/// preview tab, appends it to <see cref="Documents"/>.<c>VisibleDockables</c>, and
/// updates <see cref="MainTab"/> to point at the new one. The active dockable is
/// left as-is — the user keeps looking at what they just pinned. Returns the new
/// MainTab, or <see langword="null"/> when there's nothing to promote (no current
/// MainTab, MainTab already pinned, or Documents missing).
/// </summary>
public ContentTabPage? PromotePreviewMainTab()
{
if (MainTab is not { IsPreview: true } previous)
return null;
if (Documents is not { } docs)
return null;
previous.IsPreview = false;
var fresh = new ContentTabPage { Title = "(no selection)" };
// Use the framework's AddDockable so owner/factory wiring matches every other
// add path; default insertion is at the end (rightmost — VS convention for the
// preview slot).
AddDockable(docs, fresh);
MainTab = fresh;
return fresh;
}
public ILSpyDockFactory(ToolPaneRegistry registry) public ILSpyDockFactory(ToolPaneRegistry registry)
{ {
this.panes = registry.Panes; this.panes = registry.Panes;

4
ILSpy/Options/OptionsPageModel.cs

@ -51,7 +51,9 @@ namespace ILSpy.Options
page.Load(settingsService); page.Load(settingsService);
selectedPage = Pages.FirstOrDefault(); selectedPage = Pages.FirstOrDefault();
Title = Resources._Options; // Bare "Options" string — the menu item's Resources._Options carries the
// accelerator underscore + "..." suffix which are menu-only conventions.
Title = Resources.Options;
ResetCurrentPageCommand = new RelayCommand(ResetCurrentPage); ResetCurrentPageCommand = new RelayCommand(ResetCurrentPage);
} }

50
ILSpy/Themes/BoolToBrushConverter.cs

@ -0,0 +1,50 @@
// 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.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Media;
using Avalonia.Media.Immutable;
namespace ILSpy.Themes
{
/// <summary>
/// Maps a boolean to a configured <see cref="IBrush"/> when true, or <see langword="null"/>
/// when false / unset. Lets a Style setter resolve to a brush only on the true branch
/// while leaving the theme default in place otherwise. Used for the preview-tab accent
/// border stripe.
/// </summary>
public sealed class BoolToBrushConverter : IValueConverter
{
/// <summary>Static accent for the preview-tab left-edge stripe — matches the
/// toolbar accent palette (#0078D7).</summary>
public static readonly BoolToBrushConverter PreviewAccent = new() {
TrueBrush = new ImmutableSolidColorBrush(Color.FromRgb(0x00, 0x78, 0xD7)),
};
public IBrush? TrueBrush { get; init; }
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is bool b && b ? TrueBrush : null;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> global::Avalonia.Data.BindingOperations.DoNothing;
}
}

47
ILSpy/Themes/BoolToFontStyleConverter.cs

@ -0,0 +1,47 @@
// 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.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace ILSpy.Themes
{
/// <summary>
/// Maps a boolean to <see cref="FontStyle.Italic"/> (when true) or
/// <see cref="FontStyle.Normal"/> (when false, null, or unset). Used in the VS-style
/// preview-tab styling — the document tab header binds its <c>FontStyle</c> to the
/// underlying <c>ContentTabPage.IsPreview</c> through this converter so the active
/// preview tab renders in italics. Unset/non-boolean values fall back to Normal so
/// non-ContentTabPage dockables (tool panes, …) aren't accidentally italicised.
/// </summary>
public sealed class BoolToFontStyleConverter : IValueConverter
{
/// <summary>Shared instance for the italic mapping (<see langword="true"/> →
/// <see cref="FontStyle.Italic"/>).</summary>
public static readonly BoolToFontStyleConverter Italic = new();
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is bool b && b ? FontStyle.Italic : FontStyle.Normal;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> global::Avalonia.Data.BindingOperations.DoNothing;
}
}

48
ILSpy/Themes/BoolToThicknessConverter.cs

@ -0,0 +1,48 @@
// 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.Globalization;
using Avalonia;
using Avalonia.Data.Converters;
namespace ILSpy.Themes
{
/// <summary>
/// Maps a boolean to a configured <see cref="Thickness"/> when true, or
/// <see cref="Thickness"/>(0) when false / unset. Used to paint the preview-tab
/// left-edge accent stripe via a Style setter without overriding the theme's full
/// border when the tab is pinned.
/// </summary>
public sealed class BoolToThicknessConverter : IValueConverter
{
/// <summary>3px-wide left edge — matches the accent stripe width VS uses.</summary>
public static readonly BoolToThicknessConverter LeftStripe = new() {
TrueThickness = new Thickness(3, 0, 0, 0),
};
public Thickness TrueThickness { get; init; }
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is bool b && b ? TrueThickness : default(Thickness);
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> global::Avalonia.Data.BindingOperations.DoNothing;
}
}

105
ILSpy/Themes/PreviewTabContextMenuBehavior.cs

@ -0,0 +1,105 @@
// 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.ComponentModel;
using Avalonia;
using Avalonia.Controls;
using Dock.Avalonia.Controls;
using ILSpy.AppEnv;
using ILSpy.Docking;
using ILSpy.ViewModels;
namespace ILSpy.Themes
{
/// <summary>
/// Attached property that wires a per-instance <see cref="ContextMenu"/> onto every
/// <see cref="DocumentTabStripItem"/>. The menu hosts a single "Pin tab" entry whose
/// <see cref="MenuItem.IsVisible"/> tracks <see cref="ContentTabPage.IsPreview"/> on
/// the tab's underlying viewmodel — invoking it routes to
/// <see cref="DockWorkspace.PinCurrentTab"/>. Each tab gets a fresh ContextMenu so
/// the popup has a unique parent (Avalonia visuals must have at most one parent).
/// </summary>
public static class PreviewTabContextMenuBehavior
{
public static readonly AttachedProperty<bool> EnableProperty =
AvaloniaProperty.RegisterAttached<DocumentTabStripItem, bool>(
"Enable",
typeof(PreviewTabContextMenuBehavior));
public static void SetEnable(DocumentTabStripItem element, bool value)
=> element.SetValue(EnableProperty, value);
public static bool GetEnable(DocumentTabStripItem element)
=> element.GetValue(EnableProperty);
static PreviewTabContextMenuBehavior()
{
EnableProperty.Changed.AddClassHandler<DocumentTabStripItem>(OnEnableChanged);
}
static void OnEnableChanged(DocumentTabStripItem item, AvaloniaPropertyChangedEventArgs e)
{
if (e.NewValue is not true)
return;
// Bind the menu's per-tab state once on DataContext settlement. ContentTabPage
// is the only DataContext we know how to model here; other dockable types
// (tool panes) fall through to no menu.
item.DataContextChanged += (_, _) => Rebind(item);
Rebind(item);
}
static void Rebind(DocumentTabStripItem item)
{
if (item.DataContext is not ContentTabPage tab)
{
item.DocumentContextMenu = null;
return;
}
var pinItem = new MenuItem {
Header = "Pin tab",
IsVisible = tab.IsPreview,
};
pinItem.Click += (_, _) => TryGetDockWorkspace()?.PinCurrentTab();
// Keep IsVisible in sync with IsPreview so the entry hides once the tab is
// pinned (or after promotion happens through some other code path).
tab.PropertyChanged += OnTabPropertyChanged;
void OnTabPropertyChanged(object? _, PropertyChangedEventArgs args)
{
if (args.PropertyName == nameof(ContentTabPage.IsPreview))
pinItem.IsVisible = tab.IsPreview;
}
item.DocumentContextMenu = new ContextMenu {
ItemsSource = new[] { pinItem },
};
}
static DockWorkspace? TryGetDockWorkspace()
{
try
{ return AppComposition.Current.GetExport<DockWorkspace>(); }
catch { return null; }
}
}
}

176
ILSpy/Themes/PreviewTabPinButtonBehavior.cs

@ -0,0 +1,176 @@
// 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 Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Data;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.VisualTree;
using Dock.Avalonia.Controls;
using ILSpy.AppEnv;
using ILSpy.Docking;
using ILSpy.ViewModels;
namespace ILSpy.Themes
{
/// <summary>
/// Injects an inline "pin" Button into the tab header of every <see cref="DocumentTabStripItem"/>.
/// Dock's tab template is compiled into the theme DLL and not directly overridable without
/// reproducing the whole template, so the button is added at runtime by walking the visual
/// tree to the inner title StackPanel and appending a child there. The button's
/// <see cref="Visual.IsVisible"/> is bound to <see cref="ContentTabPage.IsPreview"/> on the
/// tab's underlying viewmodel and the click handler routes to
/// <see cref="DockWorkspace.PinCurrentTab"/>. Styling lives in App.axaml under the
/// <c>Button.preview-pin</c> class selector so the theme's <c>:pointerover</c> states still
/// apply (local Background/BorderBrush values here would block them).
/// </summary>
public static class PreviewTabPinButtonBehavior
{
const string PinButtonTag = "PreviewTabPinButton";
public static readonly AttachedProperty<bool> EnableProperty =
AvaloniaProperty.RegisterAttached<DocumentTabStripItem, bool>(
"Enable",
typeof(PreviewTabPinButtonBehavior));
public static void SetEnable(DocumentTabStripItem element, bool value)
=> element.SetValue(EnableProperty, value);
public static bool GetEnable(DocumentTabStripItem element)
=> element.GetValue(EnableProperty);
static PreviewTabPinButtonBehavior()
{
EnableProperty.Changed.AddClassHandler<DocumentTabStripItem>(OnEnableChanged);
}
static void OnEnableChanged(DocumentTabStripItem item, AvaloniaPropertyChangedEventArgs e)
{
if (e.NewValue is not true)
return;
item.TemplateApplied += (_, _) => TryInject(item);
item.AttachedToVisualTree += (_, _) => TryInject(item);
TryInject(item);
}
static void TryInject(DocumentTabStripItem item)
{
// Idempotent: bail if our button is already in the tree.
if (item.GetVisualDescendants().OfType<Button>().Any(b => (b.Tag as string) == PinButtonTag))
return;
if (item.DataContext is not ContentTabPage)
return;
// Anchor: the Grid that's the parent of the title StackPanel. The Grid also hosts
// the close-button ContentPresenter as a sibling column. We inject pin as a new
// Auto-sized column between the two so the title can't push pin past the tab's
// right edge — appending to the StackPanel puts pin inside the title column,
// where the unconstrained title TextBlock claims all the space and the pin
// overflows behind the close button.
var titleStack = item.GetVisualDescendants().OfType<StackPanel>().FirstOrDefault();
if (titleStack is null)
return;
var grid = titleStack.Parent as Grid;
if (grid is null || grid.ColumnDefinitions.Count == 0)
return;
// Clip the StackPanel's content so the title TextBlock (which measures at its
// full unconstrained text width — 900px+ for long type signatures) doesn't
// render OVER the pin and close buttons that sit in adjacent Grid columns.
// Without this, the pin appears "cut off" because the title's pixels paint
// on top of it.
titleStack.ClipToBounds = true;
// Segoe Fluent Icons / Segoe MDL2 Assets "Pin" glyph (U+E718) — the same
// tilted-pushpin silhouette Visual Studio uses for preview tabs. Foreground
// inherits through TextElement, so the glyph always contrasts the current tab
// background (dark on unselected, light on selected).
var glyph = new TextBlock {
Text = "",
FontFamily = new FontFamily("Segoe Fluent Icons, Segoe MDL2 Assets, Symbols"),
FontSize = 12,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
ClipToBounds = false,
};
glyph.Bind(TextBlock.ForegroundProperty, new Binding(nameof(TemplatedControl.Foreground)) {
Source = item,
Mode = BindingMode.OneWay,
});
var pin = new Button {
Tag = PinButtonTag,
Content = glyph,
// Bumped from 18x18 to 22x22 so the glyph's visual extent has comfortable
// headroom on all sides.
Width = 22,
Height = 22,
Padding = new Thickness(3),
Margin = new Thickness(4, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center,
Focusable = false,
IsTabStop = false,
// Belt-and-suspenders with the glyph's own ClipToBounds=false above; the
// Avalonia Button defaults ClipToBounds=true which clipped the glyph's
// right edge at the content-area boundary.
ClipToBounds = false,
};
// Class-based styling — the App.axaml Style for Button.preview-pin keeps the
// button transparent in normal state and gives it a subtle background tint on
// :pointerover. Local Background/BorderBrush setters here would beat the
// :pointerover style (local > style in Avalonia property precedence), so the
// styling is routed entirely through the class selector instead.
pin.Classes.Add("preview-pin");
ToolTip.SetTip(pin, "Pin tab");
// IsVisible follows IsPreview — the button hides once the tab is pinned.
pin.Bind(Visual.IsVisibleProperty, new Binding(nameof(ContentTabPage.IsPreview)) {
Source = item.DataContext,
Mode = BindingMode.OneWay,
});
pin.Click += (_, _) => TryGetDockWorkspace()?.PinCurrentTab();
// Insert a new Auto-sized column right before the last (close) column, bump any
// existing children at or past that index by +1, and dock pin there. This gives
// pin a dedicated cell the title can't encroach on.
int newColIndex = grid.ColumnDefinitions.Count - 1;
grid.ColumnDefinitions.Insert(newColIndex, new ColumnDefinition(GridLength.Auto));
foreach (var child in grid.Children)
{
var col = Grid.GetColumn(child);
if (col >= newColIndex)
Grid.SetColumn(child, col + 1);
}
Grid.SetColumn(pin, newColIndex);
grid.Children.Add(pin);
}
static DockWorkspace? TryGetDockWorkspace()
{
try
{ return AppComposition.Current.GetExport<DockWorkspace>(); }
catch { return null; }
}
}
}

11
ILSpy/ViewModels/ContentTabPage.cs

@ -69,6 +69,17 @@ namespace ILSpy.ViewModels
[ObservableProperty] [ObservableProperty]
private SharpTreeNode? sourceNode; private SharpTreeNode? sourceNode;
/// <summary>
/// VS-style "preview" / transient tab flag. The persistent <c>MainTab</c> starts as
/// preview (true): every tree-node click replaces its <see cref="Content"/> in
/// place. The user can pin it via <c>DockWorkspace.PinCurrentTab</c>, which flips
/// this to false and creates a new preview-state MainTab beside it. Carved-out
/// tabs (created via <c>OpenNewTab</c> / "Open in new tab" / "Freeze tab") are
/// born pinned — they're never replaced by tree-selection content.
/// </summary>
[ObservableProperty]
private bool isPreview = true;
// Bubble the inner content's Title up to the Document's Title so the tab strip // Bubble the inner content's Title up to the Document's Title so the tab strip
// reflects whatever the active page chose (e.g. the decompiler tab's spinner glyph // reflects whatever the active page chose (e.g. the decompiler tab's spinner glyph
// or "DOS Header" for a metadata grid). // or "DOS Header" for a metadata grid).

Loading…
Cancel
Save