Browse Source

ExportToolPaneAttribute + ToolPaneRegistry

Assisted-by: Claude:claude-opus-4-7:Claude Code

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
1048a80225
  1. 102
      ILSpy.Tests/MainWindow/ToolPaneRegistryTests.cs
  2. 2
      ILSpy/Analyzers/AnalyzerTreeViewModel.cs
  3. 2
      ILSpy/AssemblyTree/AssemblyTreeModel.cs
  4. 69
      ILSpy/Commands/ExportToolPaneAttribute.cs
  5. 56
      ILSpy/Commands/ToolPaneRegistry.cs
  6. 16
      ILSpy/Docking/DockWorkspace.cs
  7. 127
      ILSpy/Docking/ILSpyDockFactory.cs
  8. 2
      ILSpy/Search/SearchPaneModel.cs

102
ILSpy.Tests/MainWindow/ToolPaneRegistryTests.cs

@ -0,0 +1,102 @@ @@ -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<ToolPaneRegistry>();
// 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<ToolPaneRegistry>();
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<DockWorkspace>();
var allDockables = FlattenDockables(workspace.Layout).ToArray();
// Assert — each pane is registered as a dockable inside the layout.
allDockables.OfType<AssemblyTreeModel>().Should().ContainSingle();
allDockables.OfType<SearchPaneModel>().Should().ContainSingle();
allDockables.OfType<AnalyzerTreeViewModel>().Should().ContainSingle();
}
static System.Collections.Generic.IEnumerable<Dock.Model.Core.IDockable> 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;
}
}
}

2
ILSpy/Analyzers/AnalyzerTreeViewModel.cs

@ -18,11 +18,13 @@ @@ -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
{

2
ILSpy/AssemblyTree/AssemblyTreeModel.cs

@ -38,6 +38,7 @@ using ICSharpCode.ILSpyX; @@ -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; @@ -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
{

69
ILSpy/Commands/ExportToolPaneAttribute.cs

@ -0,0 +1,69 @@ @@ -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
{
/// <summary>
/// Identifies which dock zone a tool pane belongs to. The dock factory groups exported
/// panes by alignment when building the default layout.
/// </summary>
public enum ToolPaneAlignment
{
Left,
Top,
Right,
Bottom,
}
/// <summary>
/// Metadata view consumed by <see cref="ToolPaneRegistry"/>. System.Composition needs a
/// concrete metadata class with public settable properties matching the attribute's.
/// </summary>
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;
}
/// <summary>
/// Marks a <see cref="ToolPaneModel"/>-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.
/// </summary>
[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;
}
}

56
ILSpy/Commands/ToolPaneRegistry.cs

@ -0,0 +1,56 @@ @@ -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
{
/// <summary>
/// Concrete tool-pane entry returned by <see cref="ToolPaneRegistry"/>. 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 <c>[Shared]</c>).
/// </summary>
public sealed record ToolPaneEntry(ToolPaneModel Pane, ToolPaneMetadata Metadata);
/// <summary>
/// MEF aggregator for <see cref="ExportToolPaneAttribute"/>-tagged tool panes. The dock
/// factory iterates this registry to build the default layout, so adding a new pane
/// reduces to <c>[ExportToolPane]</c> on the model with no central wiring change.
/// </summary>
[Export]
[Shared]
public sealed class ToolPaneRegistry
{
[ImportingConstructor]
public ToolPaneRegistry(
[ImportMany("ToolPane")] IEnumerable<ExportFactory<ToolPaneModel, ToolPaneMetadata>> panes)
{
Panes = panes
.OrderBy(p => p.Metadata.Order)
.Select(p => new ToolPaneEntry(p.CreateExport().Value, p.Metadata))
.ToArray();
}
public IReadOnlyList<ToolPaneEntry> Panes { get; }
}
}

16
ILSpy/Docking/DockWorkspace.cs

@ -30,11 +30,10 @@ using Dock.Model.Core.Events; @@ -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 @@ -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 @@ -82,7 +80,7 @@ namespace ILSpy.Docking
NavigateForwardCommand = new RelayCommand(NavigateForward, () => history.CanNavigateForward);
NavigateToHistoryCommand = new RelayCommand<NavigationEntry>(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 @@ -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<ToolPaneMenuItem> {
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;

127
ILSpy/Docking/ILSpyDockFactory.cs

@ -16,56 +16,35 @@ @@ -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<ToolPaneEntry> 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<IDockable>(assemblyTreeModel),
ActiveDockable = assemblyTreeModel,
Alignment = Alignment.Left,
};
var topToolDock = new ToolDock {
Id = "TopTools",
Proportion = 0.2,
VisibleDockables = CreateList<IDockable>(searchPaneModel),
ActiveDockable = searchPaneModel,
Alignment = Alignment.Top,
};
var documents = new DocumentDock {
Id = "Documents",
Title = "Documents",
@ -77,33 +56,52 @@ namespace ILSpy.Docking @@ -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<IDockable>(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<IDockable>();
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<IDockable>(
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<IDockable>();
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<IDockable>(
leftToolDock,
new ProportionalDockSplitter { Id = "LeftSplitter" },
rightVertical),
VisibleDockables = CreateList(horizontalChildren.ToArray()),
};
var root = CreateRootDock();
@ -115,5 +113,38 @@ namespace ILSpy.Docking @@ -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;
}
}
}

2
ILSpy/Search/SearchPaneModel.cs

@ -18,11 +18,13 @@ @@ -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
{

Loading…
Cancel
Save