Browse Source

Freeze the preview tab on drag and add Ctrl+W tab close

Pinning the One immovably at index 0 made a drag on it a dead no-op, which reads as a broken tab. Dragging now freezes it instead: pulling the tab out is itself the gesture to keep it, mirroring the snowflake and right-click Freeze, and the next tree selection forges a fresh preview at index 0. Index-0 protection and the self-healing re-assert now apply only while the One is still the preview.

Ctrl+W closes the active document; closing the One drops the cached decompiler viewmodel so the next selection rebuilds it. Long type signatures no longer stretch a tab without bound -- the on-tab title is capped and ellipsised with the full title in a tooltip, and the close button names its shortcut.
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
05c7f02aca
  1. 90
      ILSpy.Tests/Docking/PreviewTabPromotionTests.cs
  2. 10
      ILSpy/App.axaml
  3. 19
      ILSpy/Docking/DockWorkspace.cs
  4. 41
      ILSpy/Docking/ILSpyDockFactory.cs
  5. 16
      ILSpy/Themes/PreviewTabFreezeButtonBehavior.cs
  6. 2
      ILSpy/Views/MainWindow.axaml

90
ILSpy.Tests/Docking/PreviewTabPromotionTests.cs

@ -538,6 +538,82 @@ public class PreviewTabPromotionTests @@ -538,6 +538,82 @@ public class PreviewTabPromotionTests
"after the Theme-copy refactor the custom class-based styling is unused; the Theme drives all visuals");
}
[AvaloniaTest]
public async Task Close_Button_Has_A_Ctrl_W_Tooltip()
{
// Dock's tab template gives the close button no tooltip. PreviewTabFreezeButtonBehavior
// names its Ctrl+W shortcut so the gesture is discoverable on hover.
var (window, vm) = await TestHarness.BootAsync(3);
// Open a carve-out so the close button is realised (single-tab scenario hides it).
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.DockWorkspace.OpenNodeInNewTab(typeNode);
TestCapture.Step("carve-out-tab-opened");
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));
var closeButton = mainTabItem.GetVisualDescendants()
.OfType<global::Avalonia.Controls.Button>()
.FirstOrDefault(b => (b.Tag as string) != "PreviewTabFreezeButton");
closeButton.Should().NotBeNull("baseline: close button exists as a sibling of the freeze");
global::Avalonia.Controls.ToolTip.GetTip(closeButton!).Should().Be("Close (Ctrl+W)",
"the close button must name its Ctrl+W shortcut on hover");
}
[AvaloniaTest]
public async Task Ctrl_W_Closes_The_Active_Document_And_Reforges_The_One()
{
// Ctrl+W (CloseActiveDocumentCommand) closes whatever document is active. Closing a frozen
// carve-out leaves the One untouched; closing the One drops the cached decompiler VM so the
// next tree selection forges a fresh preview at index 0.
var (_, vm) = await TestHarness.BootAsync(3);
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
var docs = vm.DockWorkspace.Documents!;
var typeA = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectNode(typeA);
vm.DockWorkspace.SettleSelection();
var theOne = factory.MainTab!;
theOne.IsPreview.Should().BeTrue("precondition: the One starts as the preview");
// A frozen carve-out keeps the dock non-empty so the One isn't the last tab when closed.
var typeC = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Private.Uri", "System", "System.Uri");
vm.DockWorkspace.OpenNodeInNewTab(typeC);
var frozen = docs.VisibleDockables!.OfType<ContentTabPage>().Last(t => t.SourceNode == typeC);
// Closing the One while it is active removes it from the dock; the frozen tab survives.
factory.SetActiveDockable(theOne);
vm.DockWorkspace.CloseActiveDocument();
docs.VisibleDockables!.Should().NotContain(theOne, "Ctrl+W closes the One when it is active");
docs.VisibleDockables!.Should().Contain(frozen, "the frozen carve-out must survive closing the One");
// The next selection forges a brand-new One (the closed one is not resurrected).
var typeD = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Private.Uri", "System", "System.UriBuilder");
vm.AssemblyTreeModel.SelectNode(typeD);
vm.DockWorkspace.SettleSelection();
var freshOne = factory.MainTab;
freshOne.Should().NotBeNull("a fresh One is forged on the next selection");
freshOne.Should().NotBeSameAs(theOne, "the forged One is a new instance, not the closed tab");
freshOne!.IsPreview.Should().BeTrue("the forged One is a preview tab");
// Closing a frozen carve-out leaves the (fresh) One untouched.
factory.SetActiveDockable(frozen);
vm.DockWorkspace.CloseActiveDocument();
docs.VisibleDockables!.Should().NotContain(frozen, "Ctrl+W closes the active frozen tab");
factory.MainTab.Should().BeSameAs(freshOne, "closing a frozen tab must not disturb the One");
}
[AvaloniaTest]
public async Task Tree_Selection_While_Frozen_Tab_Active_Reuses_The_One()
{
@ -622,10 +698,11 @@ public class PreviewTabPromotionTests @@ -622,10 +698,11 @@ public class PreviewTabPromotionTests
}
[AvaloniaTest]
public async Task Preview_Cannot_Be_Reordered_Out_Of_Slot_0()
public async Task Dragging_The_Preview_Freezes_It()
{
// The in-strip reorder drag commits through the same-dock MoveDockable; it's overridden to
// refuse moving the One out of slot 0. Drive that commit directly (what ItemDragHelper does).
// The in-strip reorder drag commits through the same-dock MoveDockable; it's overridden so
// that dragging the One freezes it (instead of refusing the move). Drive that commit
// directly (what ItemDragHelper does).
var (_, vm) = await TestHarness.BootAsync(3);
var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory;
var docs = vm.DockWorkspace.Documents!;
@ -635,6 +712,7 @@ public class PreviewTabPromotionTests @@ -635,6 +712,7 @@ public class PreviewTabPromotionTests
vm.AssemblyTreeModel.SelectNode(typeA);
vm.DockWorkspace.SettleSelection();
var theOne = factory.MainTab!;
theOne.IsPreview.Should().BeTrue("precondition: the One starts as the preview");
var typeC = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Private.Uri", "System", "System.Uri");
vm.DockWorkspace.OpenNodeInNewTab(typeC);
@ -643,8 +721,10 @@ public class PreviewTabPromotionTests @@ -643,8 +721,10 @@ public class PreviewTabPromotionTests
factory.MoveDockable(docs, theOne, frozen);
docs.VisibleDockables!.IndexOf(theOne).Should().Be(0,
"the One must stay at index 0 -- it is immovable");
theOne.IsPreview.Should().BeFalse(
"dragging the One must freeze it -- it becomes an ordinary, movable tab");
factory.MainTab.Should().BeSameAs(theOne,
"freeze-on-drag does not rotate MainTab; the next tree selection forges a fresh One");
}
[AvaloniaTest]

10
ILSpy/App.axaml

@ -76,7 +76,7 @@ @@ -76,7 +76,7 @@
<SolidColorBrush x:Key="ILSpy.PreviewTabHoverBackground" Color="#BB8FCE" />
<!-- Focused tool pane: a subtle neutral header tint + readable title, instead of
the theme's saturated system accent. -->
<SolidColorBrush x:Key="ILSpy.ToolChromeActiveHeaderBackground" Color="#DCDCDC" />
<SolidColorBrush x:Key="ILSpy.ToolChromeActiveHeaderBackground" Color="#C4C4C4" />
<SolidColorBrush x:Key="ILSpy.ToolChromeActiveTitleForeground" Color="#1E1E1E" />
<SolidColorBrush x:Key="ILSpy.FoldingMarkerBackground" Color="#FFFFE1" />
<SolidColorBrush x:Key="ILSpy.FoldingMarkerForeground" Color="#808080" />
@ -279,6 +279,14 @@ @@ -279,6 +279,14 @@
<Setter Property="Background" Value="{DynamicResource ILSpy.DockTabItemBackground}" />
<Setter Property="Foreground" Value="{DynamicResource ILSpy.DockTabItemForeground}" />
</Style>
<!-- Full title in a tooltip on every document tab. The on-tab ellipsis is applied to the
realised title TextBlock by PreviewTabFreezeButtonBehavior, NOT with a descendant
selector here: a `DocumentTabStripItem TextBlock` selector also matched the tooltip's
own TextBlock and trimmed it to the same ellipsised text. Tool panes have short fixed
names, so this is document-only. -->
<Style Selector="dockControls|DocumentTabStripItem">
<Setter Property="ToolTip.Tip" Value="{Binding Title, Mode=OneWay, x:CompileBindings=False}" />
</Style>
<!-- A merely-selected (but not focused) tab gets the seam-matching fill. The active
tab keeps Dock's accent highlight (see below). Splitting the two states is
required: an application-level Style outranks Dock's DocumentTabStripItem

19
ILSpy/Docking/DockWorkspace.cs

@ -91,6 +91,11 @@ namespace ILSpy.Docking @@ -91,6 +91,11 @@ namespace ILSpy.Docking
/// </summary>
public IRelayCommand ShowSearchCommand { get; }
/// <summary>Closes the active document tab. Wired to Ctrl+W on the main window. The
/// documents dock only ever holds <see cref="ContentTabPage"/>s, so this never touches a
/// tool pane.</summary>
public IRelayCommand CloseActiveDocumentCommand { get; }
// Read-only history snapshots for the Back/Forward split-button dropdowns; oldest-first.
// The UI reverses these for newest-first display.
public IReadOnlyList<NavigationEntry> BackHistory => history.BackEntries;
@ -165,6 +170,7 @@ namespace ILSpy.Docking @@ -165,6 +170,7 @@ namespace ILSpy.Docking
NavigateToHistoryCommand = new RelayCommand<NavigationEntry>(NavigateToHistory,
entry => entry != null && (history.BackEntries.Contains(entry) || history.ForwardEntries.Contains(entry)));
ShowSearchCommand = new RelayCommand(ExecuteShowSearch);
CloseActiveDocumentCommand = new RelayCommand(CloseActiveDocument);
using (ILSpy.AppEnv.AppLog.Phase("ILSpyDockFactory ctor + CreateLayout + InitLayout"))
{
using (ILSpy.AppEnv.AppLog.Phase("ILSpyDockFactory ctor"))
@ -1204,6 +1210,19 @@ namespace ILSpy.Docking @@ -1204,6 +1210,19 @@ namespace ILSpy.Docking
factory.CloseDockable(doc);
}
/// <summary>Closes the active document tab (Ctrl+W). The documents dock only holds
/// <see cref="ContentTabPage"/>s, so tool panes are never affected. Closing the One drops
/// the cached decompiler viewmodel so the next selection forges a fresh preview tab.</summary>
public void CloseActiveDocument()
{
if (factory.Documents?.ActiveDockable is not ContentTabPage active)
return;
bool wasTheOne = ReferenceEquals(active, factory.MainTab);
factory.CloseDockable(active);
if (wasTheOne)
decompilerContent = null;
}
public void ResetLayout()
{
// Rebuild from the factory's default layout. The active tree node, if any, will be

41
ILSpy/Docking/ILSpyDockFactory.cs

@ -83,20 +83,25 @@ namespace ILSpy.Docking @@ -83,20 +83,25 @@ namespace ILSpy.Docking
return fresh;
}
// The One is immovable at documents-dock index 0. A within-strip reorder drag commits
// through this same-dock MoveDockable (Dock's DocumentTabStripItem/ItemDragHelper does NOT
// consult IDockable.CanDrag), so overriding it is the choke point that pins the One to
// slot 0 and keeps frozen tabs to its right.
// The One sits at documents-dock index 0. A within-strip reorder drag commits through this
// same-dock MoveDockable (Dock's DocumentTabStripItem/ItemDragHelper does NOT consult
// IDockable.CanDrag), so this override is the choke point: dragging the One FREEZES it (it
// then moves like any other tab), and while it is still the preview no frozen tab may slip
// in front of it.
public override void MoveDockable(IDock dock, IDockable sourceDockable, IDockable targetDockable)
{
if (ReferenceEquals(dock, Documents))
{
// Never drag the One out of slot 0.
if (ReferenceEquals(sourceDockable, MainTab))
return;
// A frozen tab must not land at or before the One: retarget to the tab at index 1
// so the source lands at position 1, not 0.
if (ReferenceEquals(targetDockable, MainTab))
// Dragging the One freezes it (replacing the old "refuse the move" behaviour): it
// stops being the preview slot and lands wherever it's dropped; the next tree
// selection forges a fresh One at index 0.
if (ReferenceEquals(sourceDockable, MainTab) && MainTab is { IsPreview: true })
{
FreezeCurrentMainTab();
}
// While the One is still the preview, a frozen tab must not land at or before it:
// retarget to the tab at index 1 so the source lands at position 1, not 0.
else if (ReferenceEquals(targetDockable, MainTab) && MainTab is { IsPreview: true })
{
var kids = dock.VisibleDockables;
if (kids is { Count: > 1 })
@ -108,21 +113,23 @@ namespace ILSpy.Docking @@ -108,21 +113,23 @@ namespace ILSpy.Docking
base.MoveDockable(dock, sourceDockable, targetDockable);
}
// Cross-dock move: never pull the One into another document dock (only reachable if the
// document area is ever split).
// Cross-dock move (only reachable if the document area is ever split): dragging the One out
// freezes it too, consistent with the in-strip case.
public override void MoveDockable(IDock sourceDock, IDock targetDock, IDockable sourceDockable, IDockable? targetDockable)
{
if (ReferenceEquals(sourceDockable, MainTab))
return;
if (ReferenceEquals(sourceDockable, MainTab) && MainTab is { IsPreview: true })
FreezeCurrentMainTab();
base.MoveDockable(sourceDock, targetDock, sourceDockable, targetDockable);
}
// Self-healing insurance: whatever drag path ran, re-assert the One at index 0. Idempotent
// and cheap -- guards against a future Dock version routing a reorder past MoveDockable.
// Self-healing insurance: re-assert the One at index 0 after any drag -- but ONLY while it is
// still the preview. Once dragged (which freezes it) it's an ordinary tab and must stay where
// the user dropped it. Idempotent; guards against a future Dock version routing a reorder
// past MoveDockable.
public override void OnDockableMoved(IDockable? dockable)
{
base.OnDockableMoved(dockable);
if (MainTab is not { } one || Documents?.VisibleDockables is not { } kids)
if (MainTab is not { IsPreview: true } one || Documents?.VisibleDockables is not { } kids)
return;
int idx = kids.IndexOf(one);
if (idx > 0)

16
ILSpy/Themes/PreviewTabFreezeButtonBehavior.cs

@ -102,6 +102,17 @@ namespace ILSpy.Themes @@ -102,6 +102,17 @@ namespace ILSpy.Themes
// paint on top of it.
titleStack.ClipToBounds = true;
// Cap the title and ellipsise overruns so a long type signature can't make a giant tab;
// the full title is surfaced in the tab tooltip (App.axaml). Applied to the specific
// realised title TextBlock here rather than via a `DocumentTabStripItem TextBlock` style
// selector, which also matched -- and trimmed -- the tooltip's own TextBlock.
var titleText = titleStack.GetVisualDescendants().OfType<TextBlock>().FirstOrDefault();
if (titleText is not null)
{
titleText.MaxWidth = 240;
titleText.TextTrimming = TextTrimming.CharacterEllipsis;
}
// Snowflake glyph for "freeze" (Font Awesome Free v7 "snowflake", CC-BY-4.0). A filled
// vector Path (not a font glyph): a Segoe Fluent fallback was Windows-only and rendered
// as tofu on Linux / macOS. Fill is bound to the tab Foreground so the glyph inherits
@ -147,6 +158,11 @@ namespace ILSpy.Themes @@ -147,6 +158,11 @@ namespace ILSpy.Themes
if (closeButton?.Theme is { } closeTheme)
freezeButton.Theme = closeTheme;
ToolTip.SetTip(freezeButton, "Freeze tab");
// The close button carries no tooltip from Dock's template; name its Ctrl+W shortcut.
// TryInject runs for every document tab (the freeze button is injected on all of them,
// just hidden when not the One), so this covers preview and frozen tabs alike.
if (closeButton is not null)
ToolTip.SetTip(closeButton, "Close (Ctrl+W)");
// IsVisible follows IsPreview — the button hides once the tab is frozen.
freezeButton.Bind(Visual.IsVisibleProperty, new Binding(nameof(ContentTabPage.IsPreview)) {

2
ILSpy/Views/MainWindow.axaml

@ -28,6 +28,8 @@ @@ -28,6 +28,8 @@
"search box" gesture VS uses. Both bring the bottom search pane to focus. -->
<KeyBinding Gesture="Ctrl+Shift+F" Command="{Binding DockWorkspace.ShowSearchCommand}" />
<KeyBinding Gesture="Ctrl+E" Command="{Binding DockWorkspace.ShowSearchCommand}" />
<!-- Close the active document tab (documents only; tool panes are unaffected). -->
<KeyBinding Gesture="Ctrl+W" Command="{Binding DockWorkspace.CloseActiveDocumentCommand}" />
</Window.KeyBindings>
<DockPanel>

Loading…
Cancel
Save