From 1048a8022531c3aa1c105252e15095739c72472f Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 6 May 2026 13:40:28 +0200 Subject: [PATCH] ExportToolPaneAttribute + ToolPaneRegistry Assisted-by: Claude:claude-opus-4-7:Claude Code Assisted-by: Claude:claude-opus-4-7:Claude Code --- .../MainWindow/ToolPaneRegistryTests.cs | 102 ++++++++++++++ ILSpy/Analyzers/AnalyzerTreeViewModel.cs | 2 + ILSpy/AssemblyTree/AssemblyTreeModel.cs | 2 + ILSpy/Commands/ExportToolPaneAttribute.cs | 69 ++++++++++ ILSpy/Commands/ToolPaneRegistry.cs | 56 ++++++++ ILSpy/Docking/DockWorkspace.cs | 16 +-- ILSpy/Docking/ILSpyDockFactory.cs | 127 +++++++++++------- ILSpy/Search/SearchPaneModel.cs | 2 + 8 files changed, 318 insertions(+), 58 deletions(-) create mode 100644 ILSpy.Tests/MainWindow/ToolPaneRegistryTests.cs create mode 100644 ILSpy/Commands/ExportToolPaneAttribute.cs create mode 100644 ILSpy/Commands/ToolPaneRegistry.cs diff --git a/ILSpy.Tests/MainWindow/ToolPaneRegistryTests.cs b/ILSpy.Tests/MainWindow/ToolPaneRegistryTests.cs new file mode 100644 index 000000000..93592eddc --- /dev/null +++ b/ILSpy.Tests/MainWindow/ToolPaneRegistryTests.cs @@ -0,0 +1,102 @@ +// 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 Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using Dock.Model.Controls; + +using ILSpy.Analyzers; +using ILSpy.AppEnv; +using ILSpy.AssemblyTree; +using ILSpy.Commands; +using ILSpy.Docking; +using ILSpy.Search; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +[TestFixture] +public class ToolPaneRegistryTests +{ + [AvaloniaTest] + public void Registry_Lists_All_Three_Built_In_Tool_Panes() + { + // AssemblyTreeModel, SearchPaneModel, AnalyzerTreeViewModel each carry [ExportToolPane] + // so plugins can drop additional panes into the registry without modifying the dock + // factory or DockWorkspace's constructor. + + // Arrange + Act — pull the registry from the composition host. + var registry = AppComposition.Current.GetExport(); + + // Assert — three entries, one per built-in pane type. + var paneTypes = registry.Panes.Select(p => p.Pane.GetType()).ToArray(); + paneTypes.Should().Contain(typeof(AssemblyTreeModel)); + paneTypes.Should().Contain(typeof(SearchPaneModel)); + paneTypes.Should().Contain(typeof(AnalyzerTreeViewModel)); + } + + [AvaloniaTest] + public void Registry_Honours_Alignment_Metadata_From_The_Export_Attribute() + { + // Each pane's [ExportToolPane(Alignment = …)] declares which dock zone it belongs in. + // DockFactory groups panes by Alignment when building the layout. + + // Arrange + Act — read alignments off the registry. + var registry = AppComposition.Current.GetExport(); + var byType = registry.Panes.ToDictionary(p => p.Pane.GetType(), p => p.Metadata); + + // Assert — Assembly tree on the left, Search on top, Analyzer on the bottom. + byType[typeof(AssemblyTreeModel)].Alignment.Should().Be(ToolPaneAlignment.Left); + byType[typeof(SearchPaneModel)].Alignment.Should().Be(ToolPaneAlignment.Top); + byType[typeof(AnalyzerTreeViewModel)].Alignment.Should().Be(ToolPaneAlignment.Bottom); + } + + [AvaloniaTest] + public void DockFactory_Builds_Layout_Zones_Driven_By_The_Registry() + { + // ILSpyDockFactory iterates the registry instead of taking each pane in its ctor — + // verifies via the live DockWorkspace that the same three docks (Left/Top/Bottom) + // still get created and contain the right panes. + + // Arrange + Act — DockWorkspace is constructed by the composition host; its layout is + // built eagerly in the constructor. + var workspace = AppComposition.Current.GetExport(); + var allDockables = FlattenDockables(workspace.Layout).ToArray(); + + // Assert — each pane is registered as a dockable inside the layout. + allDockables.OfType().Should().ContainSingle(); + allDockables.OfType().Should().ContainSingle(); + allDockables.OfType().Should().ContainSingle(); + } + + static System.Collections.Generic.IEnumerable FlattenDockables(Dock.Model.Core.IDockable root) + { + yield return root; + if (root is Dock.Model.Core.IDock dock && dock.VisibleDockables != null) + { + foreach (var child in dock.VisibleDockables) + foreach (var nested in FlattenDockables(child)) + yield return nested; + } + } +} diff --git a/ILSpy/Analyzers/AnalyzerTreeViewModel.cs b/ILSpy/Analyzers/AnalyzerTreeViewModel.cs index c4f638ea3..5b7b198bd 100644 --- a/ILSpy/Analyzers/AnalyzerTreeViewModel.cs +++ b/ILSpy/Analyzers/AnalyzerTreeViewModel.cs @@ -18,11 +18,13 @@ using System.Composition; +using ILSpy.Commands; using ILSpy.ViewModels; namespace ILSpy.Analyzers { [Export] + [ExportToolPane(ContentId = PaneContentId, Alignment = ToolPaneAlignment.Bottom, Order = 0)] [Shared] public class AnalyzerTreeViewModel : ToolPaneModel { diff --git a/ILSpy/AssemblyTree/AssemblyTreeModel.cs b/ILSpy/AssemblyTree/AssemblyTreeModel.cs index 3f300cb3d..20f30acf5 100644 --- a/ILSpy/AssemblyTree/AssemblyTreeModel.cs +++ b/ILSpy/AssemblyTree/AssemblyTreeModel.cs @@ -38,6 +38,7 @@ using ICSharpCode.ILSpyX; using ICSharpCode.ILSpyX.TreeView; using ILSpy; +using ILSpy.Commands; using ILSpy.Languages; using ILSpy.TreeNodes; using ILSpy.ViewModels; @@ -45,6 +46,7 @@ using ILSpy.ViewModels; namespace ILSpy.AssemblyTree { [Export] + [ExportToolPane(ContentId = PaneContentId, Alignment = ToolPaneAlignment.Left, Order = 0)] [Shared] public partial class AssemblyTreeModel : ToolPaneModel { diff --git a/ILSpy/Commands/ExportToolPaneAttribute.cs b/ILSpy/Commands/ExportToolPaneAttribute.cs new file mode 100644 index 000000000..3b6baacba --- /dev/null +++ b/ILSpy/Commands/ExportToolPaneAttribute.cs @@ -0,0 +1,69 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Composition; + +using ILSpy.ViewModels; + +namespace ILSpy.Commands +{ + /// + /// Identifies which dock zone a tool pane belongs to. The dock factory groups exported + /// panes by alignment when building the default layout. + /// + public enum ToolPaneAlignment + { + Left, + Top, + Right, + Bottom, + } + + /// + /// Metadata view consumed by . System.Composition needs a + /// concrete metadata class with public settable properties matching the attribute's. + /// + public class ToolPaneMetadata + { + public string? ContentId { get; set; } + public ToolPaneAlignment Alignment { get; set; } = ToolPaneAlignment.Left; + public double Order { get; set; } + public bool IsVisibleByDefault { get; set; } = true; + } + + /// + /// Marks a -derived class as a discoverable tool pane. The + /// dock factory iterates all such exports to build the default layout, so plugins can + /// drop additional panes in without modifying core wiring. + /// + [MetadataAttribute] + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class ExportToolPaneAttribute : ExportAttribute + { + public ExportToolPaneAttribute() + : base("ToolPane", typeof(ToolPaneModel)) + { + } + + public string? ContentId { get; set; } + public ToolPaneAlignment Alignment { get; set; } = ToolPaneAlignment.Left; + public double Order { get; set; } + public bool IsVisibleByDefault { get; set; } = true; + } +} diff --git a/ILSpy/Commands/ToolPaneRegistry.cs b/ILSpy/Commands/ToolPaneRegistry.cs new file mode 100644 index 000000000..0fc7dfcd2 --- /dev/null +++ b/ILSpy/Commands/ToolPaneRegistry.cs @@ -0,0 +1,56 @@ +// 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.Composition; +using System.Linq; + +using ILSpy.ViewModels; + +namespace ILSpy.Commands +{ + /// + /// Concrete tool-pane entry returned by . Holds the resolved + /// pane instance together with the metadata that describes where in the layout it should + /// land. Each pane is materialised once at registry construction so consumers see the + /// same singleton the rest of the composition host sees (panes are [Shared]). + /// + public sealed record ToolPaneEntry(ToolPaneModel Pane, ToolPaneMetadata Metadata); + + /// + /// MEF aggregator for -tagged tool panes. The dock + /// factory iterates this registry to build the default layout, so adding a new pane + /// reduces to [ExportToolPane] on the model with no central wiring change. + /// + [Export] + [Shared] + public sealed class ToolPaneRegistry + { + [ImportingConstructor] + public ToolPaneRegistry( + [ImportMany("ToolPane")] IEnumerable> panes) + { + Panes = panes + .OrderBy(p => p.Metadata.Order) + .Select(p => new ToolPaneEntry(p.CreateExport().Value, p.Metadata)) + .ToArray(); + } + + public IReadOnlyList Panes { get; } + } +} diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index f93c7f16c..343ec9d10 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -30,11 +30,10 @@ using Dock.Model.Core.Events; using ICSharpCode.ILSpyX.TreeView; -using ILSpy.Analyzers; using ILSpy.AssemblyTree; +using ILSpy.Commands; using ILSpy.Languages; using ILSpy.Navigation; -using ILSpy.Search; using ILSpy.TextView; using ILSpy.TreeNodes; using ILSpy.ViewModels; @@ -72,8 +71,7 @@ namespace ILSpy.Docking [ImportingConstructor] public DockWorkspace( AssemblyTreeModel assemblyTreeModel, - SearchPaneModel searchPaneModel, - AnalyzerTreeViewModel analyzerTreeViewModel, + ToolPaneRegistry toolPaneRegistry, LanguageService languageService) { this.assemblyTreeModel = assemblyTreeModel; @@ -82,7 +80,7 @@ namespace ILSpy.Docking NavigateForwardCommand = new RelayCommand(NavigateForward, () => history.CanNavigateForward); NavigateToHistoryCommand = new RelayCommand(NavigateToHistory, entry => entry != null && (history.BackEntries.Contains(entry) || history.ForwardEntries.Contains(entry))); - factory = new ILSpyDockFactory(assemblyTreeModel, searchPaneModel, analyzerTreeViewModel); + factory = new ILSpyDockFactory(toolPaneRegistry); Layout = factory.CreateLayout(); if (factory.InitialDecompilerTab is { } initialTab) { @@ -96,11 +94,9 @@ namespace ILSpy.Docking // Layout/factory initialization (locators, parent/factory wiring) is done by // the DockControl in MainWindow.axaml via InitializeFactory/InitializeLayout. - ToolPaneMenuItems = new List { - new(assemblyTreeModel, factory), - new(searchPaneModel, factory), - new(analyzerTreeViewModel, factory), - }; + ToolPaneMenuItems = toolPaneRegistry.Panes + .Select(p => new ToolPaneMenuItem(p.Pane, factory)) + .ToList(); factory.DockableAdded += OnDocumentMembershipChanged; factory.DockableRemoved += OnDocumentMembershipChanged; diff --git a/ILSpy/Docking/ILSpyDockFactory.cs b/ILSpy/Docking/ILSpyDockFactory.cs index dbd47b1f5..112b4ee29 100644 --- a/ILSpy/Docking/ILSpyDockFactory.cs +++ b/ILSpy/Docking/ILSpyDockFactory.cs @@ -16,56 +16,35 @@ // 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.Linq; + using Dock.Model.Controls; using Dock.Model.Core; using Dock.Model.Mvvm; using Dock.Model.Mvvm.Controls; -using ILSpy.Analyzers; -using ILSpy.AssemblyTree; -using ILSpy.Search; +using ILSpy.Commands; using ILSpy.TextView; +using ILSpy.ViewModels; namespace ILSpy.Docking { public class ILSpyDockFactory : Factory { - readonly AssemblyTreeModel assemblyTreeModel; - readonly SearchPaneModel searchPaneModel; - readonly AnalyzerTreeViewModel analyzerTreeViewModel; + readonly IReadOnlyList panes; public IDocumentDock? Documents { get; private set; } public DecompilerTabPageModel? InitialDecompilerTab { get; private set; } - public ILSpyDockFactory( - AssemblyTreeModel assemblyTreeModel, - SearchPaneModel searchPaneModel, - AnalyzerTreeViewModel analyzerTreeViewModel) + public ILSpyDockFactory(ToolPaneRegistry registry) { - this.assemblyTreeModel = assemblyTreeModel; - this.searchPaneModel = searchPaneModel; - this.analyzerTreeViewModel = analyzerTreeViewModel; + this.panes = registry.Panes; } public override IRootDock CreateLayout() { - var leftToolDock = new ToolDock { - Id = "LeftTools", - Proportion = 0.25, - VisibleDockables = CreateList(assemblyTreeModel), - ActiveDockable = assemblyTreeModel, - Alignment = Alignment.Left, - }; - - var topToolDock = new ToolDock { - Id = "TopTools", - Proportion = 0.2, - VisibleDockables = CreateList(searchPaneModel), - ActiveDockable = searchPaneModel, - Alignment = Alignment.Top, - }; - var documents = new DocumentDock { Id = "Documents", Title = "Documents", @@ -77,33 +56,52 @@ namespace ILSpy.Docking // Initial decompiler tab is added lazily on first selection (DockWorkspace.ShowSelectedNode). InitialDecompilerTab = new DecompilerTabPageModel { Title = "(no selection)" }; - var bottomToolDock = new ToolDock { - Id = "BottomTools", - Proportion = 0.2, - VisibleDockables = CreateList(analyzerTreeViewModel), - ActiveDockable = analyzerTreeViewModel, - Alignment = Alignment.Bottom, - }; + ToolDock? leftToolDock = BuildToolDock("LeftTools", ToolPaneAlignment.Left, 0.25); + ToolDock? topToolDock = BuildToolDock("TopTools", ToolPaneAlignment.Top, 0.2); + ToolDock? rightToolDock = BuildToolDock("RightTools", ToolPaneAlignment.Right, 0.25); + ToolDock? bottomToolDock = BuildToolDock("BottomTools", ToolPaneAlignment.Bottom, 0.2); + + // Vertical column: top tool dock (if any), documents, bottom tool dock (if any), + // with splitters between siblings. + var verticalChildren = new List(); + if (topToolDock != null) + { + verticalChildren.Add(topToolDock); + verticalChildren.Add(new ProportionalDockSplitter { Id = "TopSplitter" }); + } + verticalChildren.Add(documents); + if (bottomToolDock != null) + { + verticalChildren.Add(new ProportionalDockSplitter { Id = "BottomSplitter" }); + verticalChildren.Add(bottomToolDock); + } var rightVertical = new ProportionalDock { - Id = "RightArea", + Id = "MiddleColumn", Orientation = Orientation.Vertical, - Proportion = 0.75, - VisibleDockables = CreateList( - topToolDock, - new ProportionalDockSplitter { Id = "TopSplitter" }, - documents, - new ProportionalDockSplitter { Id = "BottomSplitter" }, - bottomToolDock), + Proportion = ComputeMiddleColumnProportion(leftToolDock, rightToolDock), + VisibleDockables = CreateList(verticalChildren.ToArray()), }; + // Horizontal row: left tool dock, middle column, right tool dock — splitters + // between siblings only. + var horizontalChildren = new List(); + if (leftToolDock != null) + { + horizontalChildren.Add(leftToolDock); + horizontalChildren.Add(new ProportionalDockSplitter { Id = "LeftSplitter" }); + } + horizontalChildren.Add(rightVertical); + if (rightToolDock != null) + { + horizontalChildren.Add(new ProportionalDockSplitter { Id = "RightSplitter" }); + horizontalChildren.Add(rightToolDock); + } + var horizontal = new ProportionalDock { Id = "MainLayout", Orientation = Orientation.Horizontal, - VisibleDockables = CreateList( - leftToolDock, - new ProportionalDockSplitter { Id = "LeftSplitter" }, - rightVertical), + VisibleDockables = CreateList(horizontalChildren.ToArray()), }; var root = CreateRootDock(); @@ -115,5 +113,38 @@ namespace ILSpy.Docking return root; } + + ToolDock? BuildToolDock(string id, ToolPaneAlignment alignment, double proportion) + { + var dockables = panes + .Where(p => p.Metadata.Alignment == alignment) + .OrderBy(p => p.Metadata.Order) + .Select(p => (IDockable)p.Pane) + .ToArray(); + if (dockables.Length == 0) + return null; + return new ToolDock { + Id = id, + Proportion = proportion, + VisibleDockables = CreateList(dockables), + ActiveDockable = dockables[0], + Alignment = alignment switch { + ToolPaneAlignment.Top => Alignment.Top, + ToolPaneAlignment.Right => Alignment.Right, + ToolPaneAlignment.Bottom => Alignment.Bottom, + _ => Alignment.Left, + }, + }; + } + + static double ComputeMiddleColumnProportion(ToolDock? left, ToolDock? right) + { + double remaining = 1.0; + if (left != null) + remaining -= left.Proportion; + if (right != null) + remaining -= right.Proportion; + return remaining > 0 ? remaining : 0.5; + } } } diff --git a/ILSpy/Search/SearchPaneModel.cs b/ILSpy/Search/SearchPaneModel.cs index ce2b4bef2..ff7f4b614 100644 --- a/ILSpy/Search/SearchPaneModel.cs +++ b/ILSpy/Search/SearchPaneModel.cs @@ -18,11 +18,13 @@ using System.Composition; +using ILSpy.Commands; using ILSpy.ViewModels; namespace ILSpy.Search { [Export] + [ExportToolPane(ContentId = PaneContentId, Alignment = ToolPaneAlignment.Top, Order = 0)] [Shared] public class SearchPaneModel : ToolPaneModel {