mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
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 Codepull/3755/head
13 changed files with 1116 additions and 3 deletions
@ -0,0 +1,474 @@
@@ -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"); |
||||
} |
||||
} |
||||
@ -0,0 +1,50 @@
@@ -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; |
||||
} |
||||
} |
||||
@ -0,0 +1,47 @@
@@ -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; |
||||
} |
||||
} |
||||
@ -0,0 +1,48 @@
@@ -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; |
||||
} |
||||
} |
||||
@ -0,0 +1,105 @@
@@ -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; } |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue