Browse Source

Rename the preview-tab 'pin' concept to 'freeze'

Pure rename, no behavior change. 'Pin' conventionally means stuck-at-front-of-
strip + excluded-from-close-all, which is a different (future) feature; the
gesture that makes the preview tab stop following the tree selection is now
called Freeze throughout: FreezeCurrentTab / FreezeCurrentMainTab, the
PreviewTabFreezeButtonBehavior, the 'Freeze tab' menu entry/tooltip, and the
Window_FreezeCurrentTab command + resource. The pushpin glyph and accent colour
are unchanged here -- they move in the following commit.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
1659b8e956
  1. 228
      ILSpy.Tests/Docking/PreviewTabPromotionTests.cs
  2. 4
      ILSpy/App.axaml
  3. 12
      ILSpy/Commands/WindowCommands.cs
  4. 26
      ILSpy/Docking/DockWorkspace.cs
  5. 16
      ILSpy/Docking/ILSpyDockFactory.cs
  6. 4
      ILSpy/Properties/Resources.Designer.cs
  7. 4
      ILSpy/Properties/Resources.resx
  8. 16
      ILSpy/Themes/PreviewTabContextMenuBehavior.cs
  9. 74
      ILSpy/Themes/PreviewTabFreezeButtonBehavior.cs
  10. 4
      ILSpy/ViewModels/ContentTabPage.cs

228
ILSpy.Tests/Docking/PreviewTabPromotionTests.cs

@ -44,21 +44,21 @@ public class PreviewTabPromotionTests @@ -44,21 +44,21 @@ public class PreviewTabPromotionTests
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.
// Content in place until the user explicitly freezes it.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
TestCapture.Step("booted");
var mainTab = ((ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!;
mainTab.IsPreview.Should().BeTrue(
"the freshly-created MainTab is the preview slot until the user pins it");
"the freshly-created MainTab is the preview slot until the user freezes it");
}
[AvaloniaTest]
public async Task Carve_Out_Tabs_Are_Born_Pinned()
public async Task Carve_Out_Tabs_Are_Born_Frozen()
{
// 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).
// by subsequent tree-node selections, so they're born frozen (IsPreview=false).
var (_, vm) = await TestHarness.BootAsync(3);
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
@ -74,19 +74,19 @@ public class PreviewTabPromotionTests @@ -74,19 +74,19 @@ public class PreviewTabPromotionTests
}
[AvaloniaTest]
public async Task PinCurrentTab_Flips_IsPreview_Without_Spawning_A_New_Tab()
public async Task FreezeCurrentTab_Flips_IsPreview_Without_Spawning_A_New_Tab()
{
// New semantics: Pin only flips IsPreview=false on the current MainTab. No new
// preview tab spawns at pin time. A fresh preview tab opens lazily later, when a
// New semantics: Freeze only flips IsPreview=false on the current MainTab. No new
// preview tab spawns at freeze time. A fresh preview tab opens lazily later, when a
// tree-selection change finds the active tab frozen — see
// Selecting_A_Different_Node_After_Pin_Opens_A_New_Preview_Tab below.
// Selecting_A_Different_Node_After_Freeze_Opens_A_New_Preview_Tab below.
var (_, vm) = await TestHarness.BootAsync(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.
// Load content into MainTab so the freeze has something meaningful to keep.
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectNode(typeNode);
@ -97,31 +97,31 @@ public class PreviewTabPromotionTests @@ -97,31 +97,31 @@ public class PreviewTabPromotionTests
var tabCountBefore = vm.DockWorkspace.Documents!.VisibleDockables!.Count;
// Pin.
vm.DockWorkspace.PinCurrentTab();
TestCapture.Step("tab-pinned");
// Freeze.
vm.DockWorkspace.FreezeCurrentTab();
TestCapture.Step("tab-frozen");
// Tab is now pinned, content preserved.
// Tab is now frozen, content preserved.
previousMainTab.IsPreview.Should().BeFalse(
"after pin, the previously-preview MainTab becomes a regular pinned tab");
"after freeze, the previously-preview MainTab becomes a regular frozen tab");
ReferenceEquals(previousMainTab.SourceNode, typeNode).Should().BeTrue(
"pin must not throw away the tab's content");
"freeze must not throw away the tab's content");
// factory.MainTab still points at the same (now-pinned) tab — no rotation.
// factory.MainTab still points at the same (now-frozen) tab — no rotation.
factory.MainTab.Should().BeSameAs(previousMainTab,
"pin alone must not rotate factory.MainTab — the pinned tab keeps the slot until a selection change spawns a new preview");
"freeze alone must not rotate factory.MainTab — the frozen tab keeps the slot until a selection change spawns a new preview");
// And critically: no new tab spawned.
vm.DockWorkspace.Documents!.VisibleDockables!.Count.Should().Be(tabCountBefore,
"pinning alone must not open a new tab; new tabs open lazily on the next selection change");
"freezing alone must not open a new tab; new tabs open lazily on the next selection change");
}
[AvaloniaTest]
public async Task Selecting_A_Different_Node_After_Pin_Opens_A_New_Preview_Tab()
public async Task Selecting_A_Different_Node_After_Freeze_Opens_A_New_Preview_Tab()
{
// Pin holds the current tab's content. Selecting a different tree node while
// the (now-pinned) tab is active must spawn a fresh preview tab beside it and
// route the new content there — never overwrite the pinned tab.
// Freeze holds the current tab's content. Selecting a different tree node while
// the (now-frozen) tab is active must spawn a fresh preview tab beside it and
// route the new content there — never overwrite the frozen tab.
var (_, vm) = await TestHarness.BootAsync(3);
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
@ -132,15 +132,15 @@ public class PreviewTabPromotionTests @@ -132,15 +132,15 @@ public class PreviewTabPromotionTests
vm.AssemblyTreeModel.SelectNode(typeA);
vm.DockWorkspace.SettleSelection();
TestCapture.Step("type-a-selected");
var pinnedTab = factory.MainTab!;
var pinnedContent = pinnedTab.Content;
var frozenTab = factory.MainTab!;
var frozenContent = frozenTab.Content;
// Phase 2: pin. factory.MainTab still points at the same (now-pinned) tab —
// no spawn yet (covered by PinCurrentTab_Flips_IsPreview_Without_Spawning_A_New_Tab).
// Phase 2: freeze. factory.MainTab still points at the same (now-frozen) tab —
// no spawn yet (covered by FreezeCurrentTab_Flips_IsPreview_Without_Spawning_A_New_Tab).
var tabCountBeforeSelection = vm.DockWorkspace.Documents!.VisibleDockables!.Count;
vm.DockWorkspace.PinCurrentTab();
TestCapture.Step("tab-pinned");
factory.MainTab.Should().BeSameAs(pinnedTab, "pin alone keeps the slot");
vm.DockWorkspace.FreezeCurrentTab();
TestCapture.Step("tab-frozen");
factory.MainTab.Should().BeSameAs(frozenTab, "freeze alone keeps the slot");
// Phase 3: select a different type — NOW a new preview tab should spawn AND
// receive the new content.
@ -150,21 +150,21 @@ public class PreviewTabPromotionTests @@ -150,21 +150,21 @@ public class PreviewTabPromotionTests
vm.DockWorkspace.SettleSelection();
TestCapture.Step("type-b-opens-new-preview");
// New tab spawned beside the pinned one.
// New tab spawned beside the frozen one.
vm.DockWorkspace.Documents!.VisibleDockables!.Count.Should().Be(tabCountBeforeSelection + 1,
"selecting a different node while the active tab is pinned must spawn a new preview tab");
"selecting a different node while the active tab is frozen must spawn a new preview tab");
var newMainTab = factory.MainTab!;
newMainTab.Should().NotBeSameAs(pinnedTab,
newMainTab.Should().NotBeSameAs(frozenTab,
"factory.MainTab must rotate to the freshly-spawned preview tab once a selection change forces it");
newMainTab.IsPreview.Should().BeTrue(
"the freshly-spawned tab is itself a preview tab (the user can pin it next)");
"the freshly-spawned tab is itself a preview tab (the user can freeze it next)");
ReferenceEquals(newMainTab.SourceNode, typeB).Should().BeTrue(
"new tree selection must land in the freshly-spawned preview tab");
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");
ReferenceEquals(frozenTab.SourceNode, typeA).Should().BeTrue(
"the frozen tab must keep type A — not be overwritten by the new selection");
frozenTab.Content.Should().BeSameAs(frozenContent,
"frozen tab's Content reference must survive subsequent tree selections");
}
[AvaloniaTest]
@ -172,7 +172,7 @@ public class PreviewTabPromotionTests @@ -172,7 +172,7 @@ public class PreviewTabPromotionTests
{
// 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.
// Frozen tabs and tool-pane tabs fall through to FontStyle.Normal.
var (window, vm) = await TestHarness.BootAsync();
// Wait for the tab strip to realise its items.
@ -213,13 +213,13 @@ public class PreviewTabPromotionTests @@ -213,13 +213,13 @@ public class PreviewTabPromotionTests
}
[AvaloniaTest]
public async Task Pin_Button_Fits_Inside_The_Tab_When_Close_Button_Is_Visible()
public async Task Freeze_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
// CanClose=false), so the freeze 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.
// the freeze's right edge stays inside the tab's right edge.
var (window, vm) = await TestHarness.BootAsync(3);
@ -242,12 +242,12 @@ public class PreviewTabPromotionTests @@ -242,12 +242,12 @@ public class PreviewTabPromotionTests
mainTabItem.Arrange(new global::Avalonia.Rect(mainTabItem.DesiredSize));
global::Avalonia.Threading.Dispatcher.UIThread.RunJobs();
var pinButton = mainTabItem.GetVisualDescendants()
var freezeButton = mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>()
.Single(b => (b.Tag as string) == "PreviewTabPinButton");
.Single(b => (b.Tag as string) == "PreviewTabFreezeButton");
// 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
// mainTabItem — gives the freeze'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)
{
@ -261,36 +261,36 @@ public class PreviewTabPromotionTests @@ -261,36 +261,36 @@ public class PreviewTabPromotionTests
}
return new global::Avalonia.Point(x, y);
}
var pinTopLeft = OriginRelativeTo(pinButton, mainTabItem);
var pinRight = pinTopLeft.X + pinButton.Bounds.Width;
var freezeTopLeft = OriginRelativeTo(freezeButton, mainTabItem);
var freezeRight = freezeTopLeft.X + freezeButton.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")
.Where(b => (b.Tag as string) != "PreviewTabFreezeButton")
.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}");
TestContext.WriteLine($"tab width={tabRight:0}; freeze at x={freezeTopLeft.X:0}-{freezeRight:0}; {closeInfo}");
pinRight.Should().BeLessThanOrEqualTo(tabRight,
"pin button's right edge must fit inside the tab's right edge (cut-off bug)");
freezeRight.Should().BeLessThanOrEqualTo(tabRight,
"freeze 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");
freezeRight.Should().BeLessThanOrEqualTo(closeButton.p.X + 0.5,
"freeze 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()
public async Task Freeze_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
// width if the title isn't capped. The freeze button must still fit inside the tab's
// right edge regardless of title length.
var (window, vm) = await TestHarness.BootAsync(3);
@ -320,9 +320,9 @@ public class PreviewTabPromotionTests @@ -320,9 +320,9 @@ public class PreviewTabPromotionTests
mainTabItem.Arrange(new global::Avalonia.Rect(0, 0, 200, mainTabItem.DesiredSize.Height));
global::Avalonia.Threading.Dispatcher.UIThread.RunJobs();
var pinButton = mainTabItem.GetVisualDescendants()
var freezeButton = mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>()
.Single(b => (b.Tag as string) == "PreviewTabPinButton");
.Single(b => (b.Tag as string) == "PreviewTabFreezeButton");
static global::Avalonia.Point OriginRelativeTo(global::Avalonia.Visual node, global::Avalonia.Visual ancestor)
{
@ -336,29 +336,29 @@ public class PreviewTabPromotionTests @@ -336,29 +336,29 @@ public class PreviewTabPromotionTests
}
return new global::Avalonia.Point(x, y);
}
var pinTopLeft = OriginRelativeTo(pinButton, mainTabItem);
var pinRight = pinTopLeft.X + pinButton.Bounds.Width;
var freezeTopLeft = OriginRelativeTo(freezeButton, mainTabItem);
var freezeRight = freezeTopLeft.X + freezeButton.Bounds.Width;
var tabRight = mainTabItem.Bounds.Width;
TestContext.WriteLine($"long-title scenario: tab={tabRight:0}, pin x={pinTopLeft.X:0}-{pinRight:0}");
TestContext.WriteLine($"long-title scenario: tab={tabRight:0}, freeze x={freezeTopLeft.X:0}-{freezeRight:0}");
pinRight.Should().BeLessThanOrEqualTo(tabRight,
$"pin must fit inside tab width even with long titles (tab={tabRight:0}, pinRight={pinRight:0})");
freezeRight.Should().BeLessThanOrEqualTo(tabRight,
$"freeze must fit inside tab width even with long titles (tab={tabRight:0}, freezeRight={freezeRight: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.
// at its full unconstrained width and paints over the freeze 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
"the title StackPanel must clip its content so a long title TextBlock doesn't render over the freeze button");
// 2. The freeze 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");
freezeButton.ClipToBounds.Should().BeFalse(
"freeze 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()
public async Task Inline_Freeze_Button_Appears_On_Preview_Tab_And_Freezes_When_Clicked()
{
var (window, vm) = await TestHarness.BootAsync();
@ -369,39 +369,39 @@ public class PreviewTabPromotionTests @@ -369,39 +369,39 @@ public class PreviewTabPromotionTests
var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>()
.Single(item => ReferenceEquals(item.DataContext, factory.MainTab));
// Pin button is injected via PreviewTabPinButtonBehavior — find it by Tag.
// Freeze button is injected via PreviewTabFreezeButtonBehavior — find it by Tag.
await Waiters.WaitForAsync(() => mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>()
.Any(b => (b.Tag as string) == "PreviewTabPinButton"),
.Any(b => (b.Tag as string) == "PreviewTabFreezeButton"),
System.TimeSpan.FromSeconds(5));
var pinButton = mainTabItem.GetVisualDescendants()
var freezeButton = 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");
.Single(b => (b.Tag as string) == "PreviewTabFreezeButton");
freezeButton.IsVisible.Should().BeTrue("freeze button must show while the tab is preview");
var previousMainTab = factory.MainTab!;
var tabCountBefore = vm.DockWorkspace.Documents!.VisibleDockables!.Count;
// Simulate a user click on the pin button.
pinButton.RaiseEvent(new global::Avalonia.Interactivity.RoutedEventArgs(
// Simulate a user click on the freeze button.
freezeButton.RaiseEvent(new global::Avalonia.Interactivity.RoutedEventArgs(
global::Avalonia.Controls.Button.ClickEvent));
TestCapture.Step("pin-button-clicked");
TestCapture.Step("freeze-button-clicked");
// New semantics: clicking pin flips IsPreview, doesn't spawn a new tab.
// New semantics: clicking freeze flips IsPreview, doesn't spawn a new tab.
previousMainTab.IsPreview.Should().BeFalse(
"clicking the pin button must flip IsPreview=false on the current MainTab");
"clicking the freeze button must flip IsPreview=false on the current MainTab");
factory.MainTab.Should().BeSameAs(previousMainTab,
"pin alone must not rotate factory.MainTab; the new preview tab opens lazily on the next tree selection");
"freeze alone must not rotate factory.MainTab; the new preview tab opens lazily on the next tree selection");
vm.DockWorkspace.Documents!.VisibleDockables!.Count.Should().Be(tabCountBefore,
"pin alone must not change tab count");
"freeze alone must not change tab count");
}
[AvaloniaTest]
public async Task Pin_Entry_Appears_In_Tab_Context_Menu_Only_When_Preview()
public async Task Freeze_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.
// The right-click context-menu Freeze entry must be visible on the preview MainTab
// and hidden (its IsVisible flips) on frozen carve-out tabs.
var (window, vm) = await TestHarness.BootAsync(3);
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
@ -417,17 +417,17 @@ public class PreviewTabPromotionTests @@ -417,17 +417,17 @@ public class PreviewTabPromotionTests
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 mainFreezeEntry = mainTabItem.DocumentContextMenu!.Items.OfType<global::Avalonia.Controls.MenuItem>().Single();
mainFreezeEntry.Header.Should().Be("Freeze tab");
mainFreezeEntry.IsVisible.Should().BeTrue("MainTab is preview — Freeze 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");
var carveFreezeEntry = carveOutItem.DocumentContextMenu!.Items.OfType<global::Avalonia.Controls.MenuItem>().Single();
carveFreezeEntry.IsVisible.Should().BeFalse(
"carve-out tabs are already frozen — the Freeze entry must hide");
}
[AvaloniaTest]
public async Task Pinned_Carve_Out_Tab_Header_Renders_Upright()
public async Task Frozen_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
@ -447,38 +447,38 @@ public class PreviewTabPromotionTests @@ -447,38 +447,38 @@ public class PreviewTabPromotionTests
var carveOutModel = vm.DockWorkspace.Documents!.VisibleDockables!
.OfType<ContentTabPage>()
.Last(t => t.SourceNode == typeNode);
carveOutModel.IsPreview.Should().BeFalse("baseline: carve-out tab is pinned");
carveOutModel.IsPreview.Should().BeFalse("baseline: carve-out tab is frozen");
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");
"a frozen (IsPreview=false) document tab must render with upright FontStyle");
}
[AvaloniaTest]
public void PinCurrentTab_Is_Noop_When_MainTab_Already_Pinned()
public void FreezeCurrentTab_Is_Noop_When_MainTab_Already_Frozen()
{
// Idempotency: a second pin against an already-pinned MainTab is a no-op. The new
// "pin = flip-only, spawn-lazy" semantics make this simpler than the old version:
// PinCurrentMainTab() returns null when MainTab.IsPreview is already false, and
// PinCurrentTab early-returns.
// Idempotency: a second freeze against an already-frozen MainTab is a no-op. The new
// "freeze = flip-only, spawn-lazy" semantics make this simpler than the old version:
// FreezeCurrentMainTab() returns null when MainTab.IsPreview is already false, and
// FreezeCurrentTab early-returns.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
TestCapture.Step("booted");
// Manually flip the current MainTab to pinned, simulating the post-pin state.
// Manually flip the current MainTab to frozen, simulating the post-freeze state.
factory.MainTab!.IsPreview = false;
var before = factory.MainTab;
vm.DockWorkspace.PinCurrentTab();
TestCapture.Step("pin-noop");
vm.DockWorkspace.FreezeCurrentTab();
TestCapture.Step("freeze-noop");
factory.MainTab.Should().BeSameAs(before,
"with no preview MainTab to flip, PinCurrentTab must leave the factory state untouched");
"with no preview MainTab to flip, FreezeCurrentTab must leave the factory state untouched");
factory.MainTab.IsPreview.Should().BeFalse(
"the already-pinned tab stays pinned");
"the already-frozen tab stays frozen");
}
[AvaloniaTest]
@ -501,15 +501,15 @@ public class PreviewTabPromotionTests @@ -501,15 +501,15 @@ public class PreviewTabPromotionTests
}
[AvaloniaTest]
public async Task Pin_Button_Inherits_The_Close_Button_Theme()
public async Task Freeze_Button_Inherits_The_Close_Button_Theme()
{
// Visual parity: the pin button should look like the close button — same size, same
// Visual parity: the freeze button should look like the close button — same size, same
// hover background. Both are plain Avalonia.Controls.Button instances; the close
// button gets its visual identity from a ControlTheme applied by Dock's tab
// template. Pin should copy that Theme rather than carry its own custom style.
// template. Freeze should copy that Theme rather than carry its own custom style.
var (window, vm) = await TestHarness.BootAsync(3);
// Open a carve-out so the close button is visible alongside the pin (single-tab
// Open a carve-out so the close button is visible alongside the freeze (single-tab
// scenario hides the close button).
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
@ -524,24 +524,24 @@ public class PreviewTabPromotionTests @@ -524,24 +524,24 @@ public class PreviewTabPromotionTests
var mainTabItem = window.GetVisualDescendants().OfType<DocumentTabStripItem>()
.Single(item => ReferenceEquals(item.DataContext, factory.MainTab));
var pinButton = mainTabItem.GetVisualDescendants()
var freezeButton = mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>()
.Single(b => (b.Tag as string) == "PreviewTabPinButton");
.Single(b => (b.Tag as string) == "PreviewTabFreezeButton");
var closeButton = mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>()
.FirstOrDefault(b => (b.Tag as string) != "PreviewTabPinButton");
closeButton.Should().NotBeNull("baseline: close button exists as a sibling of the pin");
.FirstOrDefault(b => (b.Tag as string) != "PreviewTabFreezeButton");
closeButton.Should().NotBeNull("baseline: close button exists as a sibling of the freeze");
pinButton.Theme.Should().BeSameAs(closeButton!.Theme,
"pin button must use the same ControlTheme as the close button so size + hover bg match");
pinButton.Classes.Should().NotContain("preview-pin",
freezeButton.Theme.Should().BeSameAs(closeButton!.Theme,
"freeze button must use the same ControlTheme as the close button so size + hover bg match");
freezeButton.Classes.Should().NotContain("preview-freeze",
"after the Theme-copy refactor the custom class-based styling is unused; the Theme drives all visuals");
}
[AvaloniaTest]
public async Task Tree_Selection_While_Frozen_Tab_Active_Opens_A_New_Preview_Tab()
{
// "Frozen" covers: (a) user-pinned tabs (IsPreview=false via PinCurrentTab),
// "Frozen" covers: (a) user-frozen tabs (IsPreview=false via FreezeCurrentTab),
// (b) carve-out tabs (born IsPreview=false via OpenNodeInNewTab), and (c) static
// content tabs (Options, About) whose Content is a static viewmodel. Tree-node
// selections while any of those are active must spawn a fresh preview tab and
@ -551,7 +551,7 @@ public class PreviewTabPromotionTests @@ -551,7 +551,7 @@ public class PreviewTabPromotionTests
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
// Open a carve-out (born pinned) and let it become the active dockable.
// Open a carve-out (born frozen) and let it become the active dockable.
var typeA = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.DockWorkspace.OpenNodeInNewTab(typeA);

4
ILSpy/App.axaml

@ -387,7 +387,7 @@ @@ -387,7 +387,7 @@
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
<!-- Per-tab right-click "Freeze 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" />
@ -395,7 +395,7 @@ @@ -395,7 +395,7 @@
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" />
<Setter Property="themes:PreviewTabFreezeButtonBehavior.Enable" Value="True" />
</Style>

12
ILSpy/Commands/WindowCommands.cs

@ -42,16 +42,16 @@ namespace ILSpy.Commands @@ -42,16 +42,16 @@ namespace ILSpy.Commands
/// <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
/// (its content is replaced by tree-node selections); freezing 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).
/// tree-selection slot. <see cref="DockWorkspace.FreezeCurrentTab"/> is a no-op when
/// there's nothing freezable (no MainTab, or MainTab already frozen).
/// </summary>
[ExportMainMenuCommand(Header = nameof(Resources.Window_PinCurrentTab), ParentMenuID = nameof(Resources._Window), MenuCategory = "Window", MenuOrder = 0)]
[ExportMainMenuCommand(Header = nameof(Resources.Window_FreezeCurrentTab), ParentMenuID = nameof(Resources._Window), MenuCategory = "Window", MenuOrder = 0)]
[Shared]
[method: ImportingConstructor]
sealed class PinCurrentTabCommand(DockWorkspace dockWorkspace) : SimpleCommand
sealed class FreezeCurrentTabCommand(DockWorkspace dockWorkspace) : SimpleCommand
{
public override void Execute(object? parameter) => dockWorkspace.PinCurrentTab();
public override void Execute(object? parameter) => dockWorkspace.FreezeCurrentTab();
}
}

26
ILSpy/Docking/DockWorkspace.cs

@ -696,8 +696,8 @@ namespace ILSpy.Docking @@ -696,8 +696,8 @@ namespace ILSpy.Docking
// here on a single click, so dedupe to avoid creating two TabPageModels for 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.
// Dedupe is also what makes "re-clicking the same node after pin" a no-op: the
// pinned tab stays active, no new preview tab spawns.
// Dedupe is also what makes "re-clicking the same node after freeze" a no-op: the
// frozen tab stays active, no new preview tab spawns.
if (lastShownNodes is { } prev && prev.SequenceEqual(nodes))
{
if (factory.MainTab is { } existingMain)
@ -708,7 +708,7 @@ namespace ILSpy.Docking @@ -708,7 +708,7 @@ namespace ILSpy.Docking
// Pick or spawn the target tab. If the currently-active document tab is a
// writable preview ContentTabPage we replace its content in place. Anything
// else — pinned tab, Options page, About page, no Documents dock — counts as
// else — frozen tab, Options page, About page, no Documents dock — counts as
// frozen and gets a brand-new preview tab spawned beside it.
ContentTabPage? main;
if (IsWritablePreview(factory.Documents?.ActiveDockable) is ContentTabPage activePreview)
@ -854,7 +854,7 @@ namespace ILSpy.Docking @@ -854,7 +854,7 @@ namespace ILSpy.Docking
/// <see cref="DecompilerTabPageModel"/> is spawned and asked to decompile the
/// node. Shared between the assembly-tree MMB handler, the metadata-grid MMB
/// handler, and the "Decompile to new tab" context-menu entry. The created tab
/// is pinned (born with <see cref="ContentTabPage.IsPreview"/> false) — see
/// is frozen (born with <see cref="ContentTabPage.IsPreview"/> false) — see
/// <see cref="OpenNewTab"/>.
/// </summary>
public void OpenNodeInNewTab(ILSpyTreeNode node)
@ -1093,7 +1093,7 @@ namespace ILSpy.Docking @@ -1093,7 +1093,7 @@ namespace ILSpy.Docking
public ContentTabPage OpenNewTab(object content, SharpTreeNode? sourceNode = null)
{
// Carve-outs are born pinned — they survive tree-node selections instead of
// Carve-outs are born frozen — 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)
@ -1138,7 +1138,7 @@ namespace ILSpy.Docking @@ -1138,7 +1138,7 @@ namespace ILSpy.Docking
/// <summary>
/// Renders <paramref name="content"/> in the reusable MainTab as a transient welcome
/// page (the About page shown at startup when nothing is selected). Unlike Help &gt;
/// About this does not pin a static page: the content is non-static, so the first
/// About this does not freeze a static page: the content is non-static, so the first
/// tree-node selection reuses the MainTab and replaces it, leaving the user with a
/// single tab rather than a stray empty one.
/// </summary>
@ -1162,12 +1162,12 @@ namespace ILSpy.Docking @@ -1162,12 +1162,12 @@ namespace ILSpy.Docking
}
/// <summary>
/// VS-style "pin tab" gesture. The current <c>factory.MainTab</c> keeps its position,
/// content, and active state but flips to pinned
/// (<see cref="ContentTabPage.IsPreview"/> = false). No new tab spawns at pin time:
/// VS-style "freeze tab" gesture. The current <c>factory.MainTab</c> keeps its position,
/// content, and active state but flips to frozen
/// (<see cref="ContentTabPage.IsPreview"/> = false). No new tab spawns at freeze time:
/// the next tree-node selection that finds the active tab frozen will lazily spawn
/// a fresh preview tab inside <see cref="ShowSelectedNode"/>. Re-clicking the same
/// node after pin is a no-op (the dedupe activates the pinned tab).
/// node after freeze is a no-op (the dedupe activates the frozen tab).
/// </summary>
/// <summary>
/// Pump dispatcher jobs so synchronously-set selection state (SourceNode, IsPreview,
@ -1180,11 +1180,11 @@ namespace ILSpy.Docking @@ -1180,11 +1180,11 @@ namespace ILSpy.Docking
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
}
public void PinCurrentTab()
public void FreezeCurrentTab()
{
if (factory.PinCurrentMainTab() is null)
if (factory.FreezeCurrentMainTab() is null)
return;
// Cached decompiler viewmodel was bound to the just-pinned tab — drop the cache
// Cached decompiler viewmodel was bound to the just-frozen tab — drop the cache
// so the next selection-change-into-frozen path materialises a fresh
// DecompilerTabPageModel for the newly-spawned preview tab.
decompilerContent = null;

16
ILSpy/Docking/ILSpyDockFactory.cs

@ -42,21 +42,21 @@ namespace ILSpy.Docking @@ -42,21 +42,21 @@ namespace ILSpy.Docking
/// The single persistent Document the host puts in <see cref="Documents"/>. Its
/// <see cref="ContentTabPage.Content"/> swaps between viewmodels (decompiler text /
/// 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
/// Identity rotates through <see cref="FreezeCurrentMainTab"/> — after a freeze, the
/// just-frozen tab keeps its position and content while MainTab points at a fresh
/// preview tab beside it.
/// </summary>
public ContentTabPage? MainTab { get; internal set; }
/// <summary>
/// Flips the current <see cref="MainTab"/> to pinned
/// (<see cref="ContentTabPage.IsPreview"/> = false). Returns the pinned tab, or
/// <see langword="null"/> when there's nothing to pin (no current MainTab, MainTab
/// already pinned). Does NOT spawn a new preview tab — the next selection change
/// Flips the current <see cref="MainTab"/> to frozen
/// (<see cref="ContentTabPage.IsPreview"/> = false). Returns the frozen tab, or
/// <see langword="null"/> when there's nothing to freeze (no current MainTab, MainTab
/// already frozen). Does NOT spawn a new preview tab — the next selection change
/// that finds the active tab frozen will spawn one lazily via
/// <see cref="CreateAndAttachPreviewTab"/>.
/// </summary>
public ContentTabPage? PinCurrentMainTab()
public ContentTabPage? FreezeCurrentMainTab()
{
if (MainTab is not { IsPreview: true } current)
return null;
@ -67,7 +67,7 @@ namespace ILSpy.Docking @@ -67,7 +67,7 @@ namespace ILSpy.Docking
/// <summary>
/// Creates a fresh preview <see cref="ContentTabPage"/>, attaches it to
/// <see cref="Documents"/>, and sets <see cref="MainTab"/> to it. Used when a
/// tree-node selection changes while the active tab is frozen (pinned, Options,
/// tree-node selection changes while the active tab is frozen (frozen, Options,
/// About, …) — a brand-new preview slot is needed to host the new content.
/// Returns the new tab, or <see langword="null"/> if <see cref="Documents"/> is
/// missing.

4
ILSpy/Properties/Resources.Designer.cs generated

@ -3163,9 +3163,9 @@ namespace ICSharpCode.ILSpy.Properties { @@ -3163,9 +3163,9 @@ namespace ICSharpCode.ILSpy.Properties {
/// <summary>
/// Looks up a localized string similar to _Pin current tab.
/// </summary>
public static string Window_PinCurrentTab {
public static string Window_FreezeCurrentTab {
get {
return ResourceManager.GetString("Window_PinCurrentTab", resourceCulture);
return ResourceManager.GetString("Window_FreezeCurrentTab", resourceCulture);
}
}

4
ILSpy/Properties/Resources.resx

@ -1084,8 +1084,8 @@ Do you want to continue?</value> @@ -1084,8 +1084,8 @@ Do you want to continue?</value>
<data name="Window_CloseAllDocuments" xml:space="preserve">
<value>_Close all documents</value>
</data>
<data name="Window_PinCurrentTab" xml:space="preserve">
<value>_Pin current tab</value>
<data name="Window_FreezeCurrentTab" xml:space="preserve">
<value>_Freeze current tab</value>
</data>
<data name="Window_ResetLayout" xml:space="preserve">
<value>_Reset layout</value>

16
ILSpy/Themes/PreviewTabContextMenuBehavior.cs

@ -31,10 +31,10 @@ namespace ILSpy.Themes @@ -31,10 +31,10 @@ 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="DocumentTabStripItem"/>. The menu hosts a single "Freeze 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
/// <see cref="DockWorkspace.FreezeCurrentTab"/>. 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
@ -75,23 +75,23 @@ namespace ILSpy.Themes @@ -75,23 +75,23 @@ namespace ILSpy.Themes
return;
}
var pinItem = new MenuItem {
Header = "Pin tab",
var freezeItem = new MenuItem {
Header = "Freeze tab",
IsVisible = tab.IsPreview,
};
pinItem.Click += (_, _) => TryGetDockWorkspace()?.PinCurrentTab();
freezeItem.Click += (_, _) => TryGetDockWorkspace()?.FreezeCurrentTab();
// Keep IsVisible in sync with IsPreview so the entry hides once the tab is
// pinned (or after promotion happens through some other code path).
// frozen (freeze is one-way — there's no matching unfreeze entry).
tab.PropertyChanged += OnTabPropertyChanged;
void OnTabPropertyChanged(object? _, PropertyChangedEventArgs args)
{
if (args.PropertyName == nameof(ContentTabPage.IsPreview))
pinItem.IsVisible = tab.IsPreview;
freezeItem.IsVisible = tab.IsPreview;
}
item.DocumentContextMenu = new ContextMenu {
ItemsSource = new[] { pinItem },
ItemsSource = new[] { freezeItem },
};
}

74
ILSpy/Themes/PreviewTabPinButtonBehavior.cs → ILSpy/Themes/PreviewTabFreezeButtonBehavior.cs

@ -36,24 +36,24 @@ using ILSpy.ViewModels; @@ -36,24 +36,24 @@ using ILSpy.ViewModels;
namespace ILSpy.Themes
{
/// <summary>
/// Injects an inline "pin" Button into the tab header of every <see cref="DocumentTabStripItem"/>.
/// Injects an inline "freeze" 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).
/// tab's underlying viewmodel — so it shows only on the One preview tab — and the click
/// handler routes to <see cref="DockWorkspace.FreezeCurrentTab"/>. It reuses the sibling
/// close button's ControlTheme so it renders at the same size and inherits the same
/// <c>:pointerover</c> states.
/// </summary>
public static class PreviewTabPinButtonBehavior
public static class PreviewTabFreezeButtonBehavior
{
const string PinButtonTag = "PreviewTabPinButton";
const string FreezeButtonTag = "PreviewTabFreezeButton";
public static readonly AttachedProperty<bool> EnableProperty =
AvaloniaProperty.RegisterAttached<DocumentTabStripItem, bool>(
"Enable",
typeof(PreviewTabPinButtonBehavior));
typeof(PreviewTabFreezeButtonBehavior));
public static void SetEnable(DocumentTabStripItem element, bool value)
=> element.SetValue(EnableProperty, value);
@ -61,7 +61,7 @@ namespace ILSpy.Themes @@ -61,7 +61,7 @@ namespace ILSpy.Themes
public static bool GetEnable(DocumentTabStripItem element)
=> element.GetValue(EnableProperty);
static PreviewTabPinButtonBehavior()
static PreviewTabFreezeButtonBehavior()
{
EnableProperty.Changed.AddClassHandler<DocumentTabStripItem>(OnEnableChanged);
}
@ -78,16 +78,16 @@ namespace ILSpy.Themes @@ -78,16 +78,16 @@ namespace ILSpy.Themes
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))
if (item.GetVisualDescendants().OfType<Button>().Any(b => (b.Tag as string) == FreezeButtonTag))
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.
// the close-button ContentPresenter as a sibling column. We inject the freeze button
// as a new Auto-sized column between the two so the title can't push it past the tab's
// right edge — appending to the StackPanel puts it inside the title column, where the
// unconstrained title TextBlock claims all the space and the button overflows behind
// the close button.
var titleStack = item.GetVisualDescendants().OfType<StackPanel>().FirstOrDefault();
if (titleStack is null)
return;
@ -97,9 +97,9 @@ namespace ILSpy.Themes @@ -97,9 +97,9 @@ namespace ILSpy.Themes
// 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.
// render OVER the freeze and close buttons that sit in adjacent Grid columns.
// Without this, the freeze button appears "cut off" because the title's pixels
// paint on top of it.
titleStack.ClipToBounds = true;
// Tilted-pushpin silhouette matching the Visual Studio preview-tab affordance.
@ -123,11 +123,11 @@ namespace ILSpy.Themes @@ -123,11 +123,11 @@ namespace ILSpy.Themes
Source = item,
Mode = BindingMode.OneWay,
});
var pin = new Button {
Tag = PinButtonTag,
var freezeButton = new Button {
Tag = FreezeButtonTag,
Content = glyph,
// Left margin preserves the gap between pin and close (~4px) regardless of
// what the inherited ControlTheme decides for its own Margin.
// Left margin preserves the gap between the freeze and close buttons (~4px)
// regardless of what the inherited ControlTheme decides for its own Margin.
Margin = new Thickness(4, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center,
@ -138,28 +138,28 @@ namespace ILSpy.Themes @@ -138,28 +138,28 @@ namespace ILSpy.Themes
// right edge at the content-area boundary.
ClipToBounds = false,
};
// Copy the close button's ControlTheme so pin renders at the same size and uses
// the same :pointerover background. The close button (a plain Avalonia.Controls.Button
// with no classes, theme applied by Dock's tab template) sits as a sibling under
// the same Grid. Without this, pin defaulted to a class-styled custom look that
// was bigger and used a different hover tint than its neighbour.
// Copy the close button's ControlTheme so the freeze button renders at the same size
// and uses the same :pointerover background. The close button (a plain
// Avalonia.Controls.Button with no classes, theme applied by Dock's tab template)
// sits as a sibling under the same Grid. Without this, it defaulted to a class-styled
// custom look that was bigger and used a different hover tint than its neighbour.
var closeButton = item.GetVisualDescendants().OfType<Button>()
.FirstOrDefault(b => (b.Tag as string) != PinButtonTag);
.FirstOrDefault(b => (b.Tag as string) != FreezeButtonTag);
if (closeButton?.Theme is { } closeTheme)
pin.Theme = closeTheme;
ToolTip.SetTip(pin, "Pin tab");
freezeButton.Theme = closeTheme;
ToolTip.SetTip(freezeButton, "Freeze tab");
// IsVisible follows IsPreview — the button hides once the tab is pinned.
pin.Bind(Visual.IsVisibleProperty, new Binding(nameof(ContentTabPage.IsPreview)) {
// IsVisible follows IsPreview — the button hides once the tab is frozen.
freezeButton.Bind(Visual.IsVisibleProperty, new Binding(nameof(ContentTabPage.IsPreview)) {
Source = item.DataContext,
Mode = BindingMode.OneWay,
});
pin.Click += (_, _) => TryGetDockWorkspace()?.PinCurrentTab();
freezeButton.Click += (_, _) => TryGetDockWorkspace()?.FreezeCurrentTab();
// 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.
// existing children at or past that index by +1, and dock the freeze button there.
// This gives it 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)
@ -168,8 +168,8 @@ namespace ILSpy.Themes @@ -168,8 +168,8 @@ namespace ILSpy.Themes
if (col >= newColIndex)
Grid.SetColumn(child, col + 1);
}
Grid.SetColumn(pin, newColIndex);
grid.Children.Add(pin);
Grid.SetColumn(freezeButton, newColIndex);
grid.Children.Add(freezeButton);
}
static DockWorkspace? TryGetDockWorkspace()

4
ILSpy/ViewModels/ContentTabPage.cs

@ -71,10 +71,10 @@ namespace ILSpy.ViewModels @@ -71,10 +71,10 @@ namespace ILSpy.ViewModels
/// <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
/// place. The user can freeze it via <c>DockWorkspace.FreezeCurrentTab</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.
/// born frozen — they're never replaced by tree-selection content.
/// </summary>
[ObservableProperty]
private bool isPreview = true;

Loading…
Cancel
Save