From 045f9a66f33e3079c7e3ea441dc6be7d90d44ce0 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 22 May 2026 09:17:37 +0200 Subject: [PATCH] VS-style preview tabs + Options polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Docking/PreviewTabPromotionTests.cs | 474 ++++++++++++++++++ ILSpy.Tests/Options/OptionsTabTests.cs | 65 +++ ILSpy/App.axaml | 45 ++ ILSpy/Commands/WindowCommands.cs | 15 + ILSpy/Docking/DockWorkspace.cs | 51 +- ILSpy/Docking/ILSpyDockFactory.cs | 28 ++ ILSpy/Options/OptionsPageModel.cs | 4 +- ILSpy/Themes/BoolToBrushConverter.cs | 50 ++ ILSpy/Themes/BoolToFontStyleConverter.cs | 47 ++ ILSpy/Themes/BoolToThicknessConverter.cs | 48 ++ ILSpy/Themes/PreviewTabContextMenuBehavior.cs | 105 ++++ ILSpy/Themes/PreviewTabPinButtonBehavior.cs | 176 +++++++ ILSpy/ViewModels/ContentTabPage.cs | 11 + 13 files changed, 1116 insertions(+), 3 deletions(-) create mode 100644 ILSpy.Tests/Docking/PreviewTabPromotionTests.cs create mode 100644 ILSpy/Themes/BoolToBrushConverter.cs create mode 100644 ILSpy/Themes/BoolToFontStyleConverter.cs create mode 100644 ILSpy/Themes/BoolToThicknessConverter.cs create mode 100644 ILSpy/Themes/PreviewTabContextMenuBehavior.cs create mode 100644 ILSpy/Themes/PreviewTabPinButtonBehavior.cs diff --git a/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs b/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs new file mode 100644 index 000000000..4192cdc99 --- /dev/null +++ b/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(); + 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(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + vm.DockWorkspace.OpenNodeInNewTab(typeNode); + + var carveOut = vm.DockWorkspace.Documents!.VisibleDockables! + .OfType() + .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(); + 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( + "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().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(); + 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( + "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( + "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(); + 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().Any(), + System.TimeSpan.FromSeconds(10)); + + var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; + var mainTabItem = window.GetVisualDescendants().OfType() + .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(); + window.Show(); + await ((MainWindowViewModel)window.DataContext!).AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType().Any(), + System.TimeSpan.FromSeconds(10)); + + var factory = (ILSpyDockFactory)((MainWindowViewModel)window.DataContext!).DockWorkspace.Factory; + var mainTabItem = window.GetVisualDescendants().OfType() + .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(); + 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( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + vm.DockWorkspace.OpenNodeInNewTab(typeNode); + + await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType().Count() >= 2, + System.TimeSpan.FromSeconds(10)); + global::Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + + var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; + var mainTabItem = window.GetVisualDescendants().OfType() + .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() + .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() + .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(); + 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( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + vm.DockWorkspace.OpenNodeInNewTab(typeNode); + + await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType().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"; + + global::Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + var mainTabItem = window.GetVisualDescendants().OfType() + .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() + .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().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(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType().Any(), + System.TimeSpan.FromSeconds(10)); + + var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; + var mainTabItem = window.GetVisualDescendants().OfType() + .Single(item => ReferenceEquals(item.DataContext, factory.MainTab)); + + // Pin button is injected via PreviewTabPinButtonBehavior — find it by Tag. + await Waiters.WaitForAsync(() => mainTabItem.GetVisualDescendants() + .OfType() + .Any(b => (b.Tag as string) == "PreviewTabPinButton"), + System.TimeSpan.FromSeconds(5)); + + var pinButton = mainTabItem.GetVisualDescendants() + .OfType() + .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(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + vm.DockWorkspace.OpenNodeInNewTab(typeNode); + + await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType().Count() >= 2, + System.TimeSpan.FromSeconds(10)); + + var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; + var mainTabItem = window.GetVisualDescendants().OfType() + .Single(item => ReferenceEquals(item.DataContext, factory.MainTab)); + var carveOutItem = window.GetVisualDescendants().OfType() + .Single(item => item.DataContext is ContentTabPage t && t.SourceNode == typeNode); + + var mainPinEntry = mainTabItem.DocumentContextMenu!.Items.OfType().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().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(); + 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( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + vm.DockWorkspace.OpenNodeInNewTab(typeNode); + + await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType().Any(), + System.TimeSpan.FromSeconds(10)); + + var carveOutModel = vm.DockWorkspace.Documents!.VisibleDockables! + .OfType() + .Last(t => t.SourceNode == typeNode); + carveOutModel.IsPreview.Should().BeFalse("baseline: carve-out tab is pinned"); + + var carveOutItem = window.GetVisualDescendants().OfType() + .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(); + 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"); + } +} diff --git a/ILSpy.Tests/Options/OptionsTabTests.cs b/ILSpy.Tests/Options/OptionsTabTests.cs index 4a66c040d..f4551b63d 100644 --- a/ILSpy.Tests/Options/OptionsTabTests.cs +++ b/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"); } + [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(); + 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(); + registry.Commands.Single(c => c.Metadata.Header == nameof(Resources._Options)) + .CreateExport().Value.Execute(null); + + var documents = vm.DockWorkspace.Documents!; + var optionsTab = documents.VisibleDockables!.OfType() + .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( + "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() + .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(); + window.Show(); + var registry = AppComposition.Current.GetExport(); + 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().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] public void OptionsPageModel_Surfaces_The_Three_Panels_In_MEF_Order() { diff --git a/ILSpy/App.axaml b/ILSpy/App.axaml index 7cda7895e..b5152843d 100644 --- a/ILSpy/App.axaml +++ b/ILSpy/App.axaml @@ -9,9 +9,11 @@ xmlns:textView="using:ILSpy.TextView" xmlns:metaViews="using:ILSpy.Views" xmlns:dockTheme="using:Dock.Avalonia.Themes.Simple" + xmlns:dockControls="using:Dock.Avalonia.Controls" xmlns:recycling="using:Avalonia.Controls.Recycling" xmlns:docking="using:ILSpy.Docking" xmlns:controls="using:ILSpy.Controls" + xmlns:themes="using:ILSpy.Themes" RequestedThemeVariant="Default"> @@ -116,6 +118,49 @@ + + + + + + + +