diff --git a/ILSpy.Tests/Analyzers/AnalyzerPaneVisibilityTests.cs b/ILSpy.Tests/Analyzers/AnalyzerPaneVisibilityTests.cs new file mode 100644 index 000000000..8dd9d8cb8 --- /dev/null +++ b/ILSpy.Tests/Analyzers/AnalyzerPaneVisibilityTests.cs @@ -0,0 +1,78 @@ +// 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.Collections.Generic; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using Dock.Model.Core; + +using ILSpy.Analyzers; +using ILSpy.AppEnv; +using ILSpy.Docking; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Analyzers; + +[TestFixture] +public class AnalyzerPaneVisibilityTests +{ + static IEnumerable AllDockables(IDockable? root) + { + if (root is null) + yield break; + yield return root; + if (root is IDock dock && dock.VisibleDockables is { } kids) + foreach (var k in kids) + foreach (var d in AllDockables(k)) + yield return d; + } + + [AvaloniaTest] + public async Task Analyze_Reopens_The_Analyzer_Pane_After_The_User_Closed_It() + { + // Repro for "Analyze does not make the Analyzer panel visible": the user closes the + // bottom Analyzer panel, then Analyze must bring it back. ShowToolPane only activated + // panes still in the layout, so a closed pane (removed from VisibleDockables) could + // never be restored -- Analyze became a silent no-op. + var (_, vm) = await TestHarness.BootAsync(3); + var dockWorkspace = AppComposition.Current.GetExport(); + var analyzer = AppComposition.Current.GetExport(); + var factory = (ILSpyDockFactory)dockWorkspace.Factory; + + AllDockables(dockWorkspace.Layout).Should().Contain(analyzer, + "baseline: the analyzer pane is in the default layout"); + + // The user closes the bottom Analyzer panel (its close button calls CloseDockable). + factory.CloseDockable(analyzer); + AllDockables(dockWorkspace.Layout).Should().NotContain(analyzer, + "closing removes the analyzer pane from the layout"); + + // Analyze -> ShowToolPane must restore the closed pane and activate it. + dockWorkspace.ShowToolPane(AnalyzerTreeViewModel.PaneContentId); + + AllDockables(dockWorkspace.Layout).Should().Contain(analyzer, + "Analyze must restore the closed analyzer pane so the user sees the entity they added"); + (analyzer.Owner as IDock)?.ActiveDockable.Should().BeSameAs(analyzer, + "the restored pane must be the active dockable in its dock"); + } +} diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index ff857c61c..f5797949a 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -1045,24 +1045,13 @@ namespace ILSpy.Docking /// selection across — setting it after-the-fact misses the activation event. /// /// - /// Brings the tool pane with the given to focus. Walks - /// every in the layout via and - /// matches on . Silently no-ops when the pane isn't in the - /// layout — e.g. it was closed via its X button and hasn't been re-added. + /// Brings the tool pane with the given into view and + /// activates it. Delegates to , which + /// re-materialises the pane (and recreates its home dock) when it was closed or is hidden + /// by default -- so "Show Search" / "Analyze" reliably reveal the pane even after the user + /// closed it. /// - public void ShowToolPane(string contentId) - { - foreach (var dockable in GetAllDockables(Layout)) - { - if (dockable is ToolPaneModel toolPane && toolPane.Id == contentId) - { - factory.SetActiveDockable(toolPane); - if (toolPane.Owner is IDock owner) - factory.SetFocusedDockable(owner, toolPane); - return; - } - } - } + public void ShowToolPane(string contentId) => factory.ShowToolPane(contentId); void ExecuteShowSearch() { @@ -1084,21 +1073,6 @@ namespace ILSpy.Docking } } - static System.Collections.Generic.IEnumerable GetAllDockables(IDockable? root) - { - if (root == null) - yield break; - yield return root; - if (root is IDock dock && dock.VisibleDockables != null) - { - foreach (var child in dock.VisibleDockables) - { - foreach (var descendant in GetAllDockables(child)) - yield return descendant; - } - } - } - public ContentTabPage OpenNewTab(ContentPageModel content, SharpTreeNode? sourceNode = null) { // Carve-outs are born frozen — they survive tree-node selections instead of diff --git a/ILSpy/Docking/ILSpyDockFactory.cs b/ILSpy/Docking/ILSpyDockFactory.cs index 47981265c..1483fc79e 100644 --- a/ILSpy/Docking/ILSpyDockFactory.cs +++ b/ILSpy/Docking/ILSpyDockFactory.cs @@ -447,5 +447,109 @@ namespace ILSpy.Docking remaining -= right.Proportion; return remaining > 0 ? remaining : 0.5; } + + static (string Id, Alignment DockAlignment, double Proportion) ToolDockSpec(ToolPaneAlignment alignment) + => alignment switch { + ToolPaneAlignment.Left => ("LeftTools", Alignment.Left, 0.25), + ToolPaneAlignment.Right => ("RightTools", Alignment.Right, 0.25), + ToolPaneAlignment.Top => ("TopTools", Alignment.Top, 0.2), + _ => ("BottomTools", Alignment.Bottom, 0.2), + }; + + /// + /// Brings a tool pane into view: activates it if it is already shown, otherwise + /// materialises it -- (re)creating its home at the pane's + /// alignment and splicing that dock back into the layout if it was never built or was + /// removed when the user closed its last pane. This is the single entry point both for + /// "Show Search / Analyze" and for revealing a pane that is hidden by default. + /// Returns the pane, or null when matches no registered pane. + /// + public ToolPaneModel? ShowToolPane(string contentId) + { + var entry = panes.FirstOrDefault(e => string.Equals(e.Pane.Id, contentId, StringComparison.Ordinal)); + if (entry?.Pane is not { } pane) + return null; + + // Already live in the layout -> just activate it where it sits. + if (pane.Owner is IDock current && current.VisibleDockables?.Contains(pane) == true) + { + SetActiveDockable(pane); + SetFocusedDockable(current, pane); + return pane; + } + + var dock = FindOrCreateToolDock(entry.Metadata.Alignment); + if (dock is null) + return pane; + AddDockable(dock, pane); + SetActiveDockable(pane); + SetFocusedDockable(dock, pane); + return pane; + } + + // Finds the live ToolDock for an alignment, or builds an empty one and splices it into the + // layout at the same position CreateLayout would have (top/bottom inside the documents' + // vertical column; left/right inside the outer horizontal row), separated by a splitter. + ToolDock? FindOrCreateToolDock(ToolPaneAlignment alignment) + { + var (id, dockAlignment, proportion) = ToolDockSpec(alignment); + + var root = GetRootDock(); + if (root != null) + { + var existing = Flatten(root).OfType() + .FirstOrDefault(t => t.Alignment == dockAlignment); + if (existing != null) + return existing; + } + + // vertical = the documents' column (top/bottom siblings); horizontal = the outer row. + if (Documents?.Owner is not IDock vertical || vertical.VisibleDockables is null) + return null; + + var dock = new ToolDock { + Id = id, + Proportion = proportion, + Alignment = dockAlignment, + VisibleDockables = CreateList(System.Array.Empty()), + DockCapabilityPolicy = new DockCapabilityPolicy(), + }; + + switch (alignment) + { + case ToolPaneAlignment.Bottom: + AddDockable(vertical, new ProportionalDockSplitter { Id = "BottomSplitter" }); + AddDockable(vertical, dock); + break; + case ToolPaneAlignment.Top: + InsertDockable(vertical, new ProportionalDockSplitter { Id = "TopSplitter" }, 0); + InsertDockable(vertical, dock, 0); + break; + case ToolPaneAlignment.Left: + case ToolPaneAlignment.Right: + if (vertical.Owner is not IDock horizontal) + return null; + if (alignment == ToolPaneAlignment.Left) + { + InsertDockable(horizontal, new ProportionalDockSplitter { Id = "LeftSplitter" }, 0); + InsertDockable(horizontal, dock, 0); + } + else + { + AddDockable(horizontal, new ProportionalDockSplitter { Id = "RightSplitter" }); + AddDockable(horizontal, dock); + } + break; + } + return dock; + } + + IDock? GetRootDock() + { + IDockable? d = Documents; + while (d?.Owner is { } owner) + d = owner; + return d as IDock; + } } }