Browse Source

Dockables own their view so slot-sharing panes render their own content

Without per-dockable view resolution the dock chrome caches one view per
slot and reuses it for every dockable that lands there, so a second pane
sharing an Alignment (Analyzer + Debug Steps both Bottom) renders the
first pane's content under the second pane's tab. Each dockable instead
owns its view (IDockableViewOwner) and DockableViewRecycling hands that
instance back keyed by dockable identity -- there is no app-lifetime
global view cache to leak every transient document tab's view subtree.

Resolution runs through the single application-wide ViewLocator (its
dispatch map is introduced here rather than in the later DataTemplate
cleanup) so the dock never re-resolves an owned view through the template
machinery on a cache hit.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
8a00383c12
  1. 1
      Directory.Packages.props
  2. 16
      ILSpy/App.axaml
  3. 137
      ILSpy/Docking/DockableViewRecycling.cs
  4. 41
      ILSpy/Docking/IDockableViewOwner.cs
  5. 1
      ILSpy/ILSpy.csproj
  6. 57
      ILSpy/ViewLocator.cs
  7. 9
      ILSpy/ViewModels/ContentTabPage.cs
  8. 9
      ILSpy/ViewModels/ToolPaneModel.cs

1
Directory.Packages.props

@ -28,6 +28,7 @@ @@ -28,6 +28,7 @@
<PackageVersion Include="Dock.Avalonia" Version="$(DockVersion)" />
<PackageVersion Include="Dock.Avalonia.Themes.Fluent" Version="$(DockVersion)" />
<PackageVersion Include="Dock.Avalonia.Themes.Simple" Version="$(DockVersion)" />
<PackageVersion Include="Dock.Controls.Recycling" Version="$(DockVersion)" />
<PackageVersion Include="Dock.Model" Version="$(DockVersion)" />
<PackageVersion Include="Dock.Model.Mvvm" Version="$(DockVersion)" />
<PackageVersion Include="Dock.Serializer.SystemTextJson" Version="$(DockVersion)" />

16
ILSpy/App.axaml

@ -10,11 +10,21 @@ @@ -10,11 +10,21 @@
xmlns:metaViews="using:ILSpy.Views"
xmlns:vm="using:ILSpy.ViewModels"
xmlns:dockTheme="using:Dock.Avalonia.Themes.Simple"
xmlns:recycling="using:Avalonia.Controls.Recycling"
xmlns:docking="using:ILSpy.Docking"
xmlns:controls="using:ILSpy.Controls"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.Resources>
<!-- Every dockable owns its view (IDockableViewOwner): the dock chrome must render one
view per dockable identity, not one per slot, or two panes sharing a ToolDock slot
(e.g. Analyzer + Debug Steps both Bottom-aligned) render each other's content. This
resolver hands back the dockable's own view instead of pinning views in a global,
never-evicting cache (a leak for transient document tabs). Wired to every DockControl
via the style below. -->
<docking:DockableViewRecycling x:Key="ControlRecyclingKey" />
<!-- Override ProDataGrid's hierarchical expander to a classic Windows-Explorer-sized 9x9 box,
plus 4px of left padding (so the ToggleButton itself is 13x9). -->
<x:Double x:Key="DataGridHierarchicalExpanderSize">13</x:Double>
@ -70,6 +80,12 @@ @@ -70,6 +80,12 @@
<StyleInclude Source="avares://AvaloniaEdit/Themes/Simple/AvaloniaEdit.xaml" />
<dockTheme:DockSimpleTheme />
<!-- Hook the ControlRecycling instance onto every DockControl so the dock chrome
resolves views per dockable identity instead of caching one view per dock slot. -->
<Style Selector="DockControl">
<Setter Property="(recycling:ControlRecyclingDataTemplate.ControlRecycling)" Value="{StaticResource ControlRecyclingKey}" />
</Style>
<!-- Suppress the column header's hover / pressed fill while the user is interacting
with content INSIDE the header (e.g. typing into a per-column filter TextBox).
Without this, clicking the TextBox briefly flashes the header background blue —

137
ILSpy/Docking/DockableViewRecycling.cs

@ -0,0 +1,137 @@ @@ -0,0 +1,137 @@
// 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 Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Recycling;
using Avalonia.Controls.Recycling.Model;
using Avalonia.VisualTree;
namespace ILSpy.Docking
{
/// <summary>
/// View resolver for the dock chrome. The dock must render one view per dockable identity
/// (not one per slot) or two dockables sharing a slot render each other's content. Rather
/// than pinning views in an app-lifetime, never-evicting global cache (a leak for transient
/// document tabs), every dockable owns its view via <see cref="IDockableViewOwner"/>: this
/// resolver builds the view once through the <see cref="ViewLocator"/> on first resolution,
/// stores it on the dockable, and hands the same instance back on every later resolution
/// (tab switch, drag to a new slot). The view then lives and dies with the dockable.
///
/// <para>
/// Because the owned view is returned directly, the dock never re-resolves it through the
/// data-template machinery on a cache hit, so the recursive template re-entry that the stock
/// <see cref="ControlRecycling"/> is prone to cannot occur. Non-owner dockables (if any) fall
/// through to a stock <see cref="ControlRecycling"/>.
/// </para>
/// </summary>
public sealed class DockableViewRecycling : IControlRecycling
{
readonly ControlRecycling fallback = new();
readonly ViewLocator viewLocator = new();
// Exposed for tests: the stock recycler used only for non-owner dockables. Owners must
// never end up here -- that global, never-evicting cache is the leak the owned-view model
// replaces.
internal ControlRecycling FallbackCache => fallback;
public bool TryToUseIdAsKey {
get => fallback.TryToUseIdAsKey;
set => fallback.TryToUseIdAsKey = value;
}
public bool TryGetValue(object? data, out object? control)
{
if (data is IDockableViewOwner { OwnedView: { } view })
{
control = view;
return true;
}
return fallback.TryGetValue(data, out control);
}
public void Add(object data, object control)
{
if (data is IDockableViewOwner owner && control is Control view)
owner.OwnedView = view;
else
fallback.Add(data, control);
}
public void Clear() => fallback.Clear();
public object? Build(object? data, object? existing, object? parent)
{
if (data is not IDockableViewOwner owner)
return fallback.Build(data, existing, parent);
var view = owner.OwnedView ??= viewLocator.Build(data);
if (view is null)
return null;
if (ReferenceEquals(existing, view) || TryDetachFromParent(view))
return view;
// Detach failed: the owned view is still attached to its previous slot -- e.g. mid-drag
// the dock shows it in the source slot while this resolution targets the new slot, and
// the source's DeferredContentPresenter rebuilds its child synchronously so it won't
// release. Adopt a fresh view as the owned instance (the stuck one is discarded when its
// slot tears down) so the dockable actually moves. Mirrors stock ControlRecycling
// caching its BuildFallback result -- without re-adopting, OwnedView stays pinned to the
// stuck view and the dockable never relocates.
var fresh = viewLocator.Build(data);
owner.OwnedView = fresh;
return fresh;
}
// Faithful port of the stock ControlRecycling's TryDetachFromParent: detach the control
// from whatever container currently holds it so it can be re-parented into a new dock
// slot. Returns false when the control could not be released (its visual parent is still
// set afterwards) -- the caller then builds a fresh view instead of moving this one. The
// logical Parent is null when the control sits inside a templated host (Dock's
// DeferredContentPresenter is a ContentPresenter), so fall back to the visual parent.
static bool TryDetachFromParent(Control control)
{
var parent = control.Parent ?? control.GetVisualParent() as StyledElement;
switch (parent)
{
case null:
return true;
case Panel panel:
return panel.Children.Remove(control);
case ContentPresenter presenter:
if (!ReferenceEquals(presenter.Child, control))
return false;
presenter.SetCurrentValue(ContentPresenter.ContentProperty, null);
presenter.UpdateChild();
return control.GetVisualParent() is null;
case ContentControl contentControl:
if (!ReferenceEquals(contentControl.Content, control))
return false;
contentControl.SetCurrentValue(ContentControl.ContentProperty, null);
return true;
case Decorator decorator:
if (!ReferenceEquals(decorator.Child, control))
return false;
decorator.Child = null;
return true;
default:
return false;
}
}
}
}

41
ILSpy/Docking/IDockableViewOwner.cs

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
// 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 Avalonia.Controls;
namespace ILSpy.Docking
{
/// <summary>
/// A dockable view-model that owns its realised view instance. The dock chrome must render
/// one view per dockable identity (not one per dock slot) or two dockables sharing a slot
/// render each other's content; <see cref="DockableViewRecycling"/> satisfies that by handing
/// back the owner's <see cref="OwnedView"/> instead of pinning views in a global cache. The
/// view then lives and dies with the dockable: singleton dockables (tool panes, the Options /
/// About pages) keep one view for the app's life, and transient document tabs release theirs
/// for GC the moment the tab is dropped.
/// </summary>
public interface IDockableViewOwner
{
/// <summary>
/// The realised view for this dockable, created lazily by <see cref="DockableViewRecycling"/>
/// on first resolution and reused for every subsequent resolution (tab switch, drag to a new
/// slot). Null until first realised.
/// </summary>
Control? OwnedView { get; set; }
}
}

1
ILSpy/ILSpy.csproj

@ -56,6 +56,7 @@ @@ -56,6 +56,7 @@
<PackageReference Include="Avalonia.Xaml.Behaviors" />
<PackageReference Include="Dock.Avalonia" />
<PackageReference Include="Dock.Avalonia.Themes.Simple" />
<PackageReference Include="Dock.Controls.Recycling" />
<PackageReference Include="Dock.Model.Mvvm" />
<PackageReference Include="Dock.Serializer.SystemTextJson" />
<PackageReference Include="ProDataGrid" />

57
ILSpy/ViewLocator.cs

@ -17,42 +17,81 @@ @@ -17,42 +17,81 @@
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Dock.Model.Core;
using ILSpy.Analyzers;
using ILSpy.AssemblyTree;
using ILSpy.Compare;
using ILSpy.Search;
using ILSpy.TextView;
using ILSpy.ViewModels;
using ILSpy.Views;
namespace ILSpy
{
/// <summary>
/// Given a view model, returns the corresponding view if possible.
/// Single application-wide IDataTemplate that resolves a view-model to its view. Two
/// resolution paths:
/// 1. Explicit <see cref="s_views"/> map (Dock-hosted view-models). Dispatched via
/// a parameter-less ctor delegate — equivalent to MvvmSample's compile-time
/// <c>[StaticViewLocator]</c>-generated dictionary.
/// 2. Convention fallback for any <see cref="ViewModelBase"/>-derived viewmodel:
/// <c>SomeViewModel</c> → <c>SomeView</c> resolved via <see cref="Type.GetType(string)"/>.
///
/// <para>
/// One locator instance handles every <see cref="IDockable"/> (and any
/// <see cref="ViewModelBase"/>) rather than N typed
/// <c>&lt;DataTemplate DataType="..."/&gt;</c> blocks in App.axaml: the owned-view
/// resolver (<c>DockableViewRecycling</c>) builds each dockable's view through this map
/// once, and a single shared template keeps view resolution uniform across the dock
/// chrome. Mirrors <c>samples/DockMvvmSample</c>'s StaticViewLocator shape.
/// </para>
/// </summary>
[RequiresUnreferencedCode(
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
"Convention fallback uses reflection (Type.GetType) which may be trimmed.",
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
public class ViewLocator : IDataTemplate
{
static readonly Dictionary<Type, Func<Control>> s_views = new() {
{ typeof(AssemblyTreeModel), () => new AssemblyListPane() },
{ typeof(SearchPaneModel), () => new SearchPane() },
{ typeof(AnalyzerTreeViewModel), () => new AnalyzerTreeView() },
{ typeof(ContentTabPage), () => new ContentTabPageView() },
{ typeof(DecompilerTabPageModel), () => new DecompilerTextView() },
{ typeof(MetadataTablePageModel), () => new MetadataTablePage() },
{ typeof(CompareTabPageModel), () => new CompareView() },
#if DEBUG
{ typeof(DebugStepsPaneModel), () => new DebugSteps() },
#endif
};
public Control? Build(object? param)
{
if (param is null)
return null;
if (s_views.TryGetValue(param.GetType(), out var ctor))
return ctor();
// Convention fallback for ViewModelBase-derived VMs not in the explicit map.
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(name);
if (type != null)
{
return (Control)Activator.CreateInstance(type)!;
}
return new TextBlock { Text = "Not Found: " + name };
}
public bool Match(object? data)
{
return data is ViewModelBase;
if (data is null)
return false;
return data is IDockable
|| data is ViewModelBase
|| s_views.ContainsKey(data.GetType());
}
}
}
}

9
ILSpy/ViewModels/ContentTabPage.cs

@ -18,6 +18,8 @@ @@ -18,6 +18,8 @@
using System.ComponentModel;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using Dock.Controls.DeferredContentControl;
@ -25,6 +27,8 @@ using Dock.Model.Core; @@ -25,6 +27,8 @@ using Dock.Model.Core;
using ICSharpCode.ILSpyX.TreeView;
using ILSpy.Docking;
namespace ILSpy.ViewModels
{
/// <summary>
@ -34,8 +38,11 @@ namespace ILSpy.ViewModels @@ -34,8 +38,11 @@ namespace ILSpy.ViewModels
/// which is visible — Dock.Avalonia's add+close-in-the-same-tick semantics otherwise
/// leave the previous view rendered when the tab type changes.
/// </summary>
public sealed partial class ContentTabPage : TabPageModel, IDeferredContentPresentation
public sealed partial class ContentTabPage : TabPageModel, IDeferredContentPresentation, IDockableViewOwner
{
// Each document tab owns its view; it is released for GC when the tab is dropped.
public Control? OwnedView { get; set; }
public ContentTabPage()
{
// Same reason as ToolPaneModel — Dock's chrome template binds against

9
ILSpy/ViewModels/ToolPaneModel.cs

@ -16,10 +16,14 @@ @@ -16,10 +16,14 @@
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using Avalonia.Controls;
using Dock.Controls.DeferredContentControl;
using Dock.Model.Core;
using Dock.Model.Mvvm.Controls;
using ILSpy.Docking;
namespace ILSpy.ViewModels
{
/// <summary>
@ -37,7 +41,7 @@ namespace ILSpy.ViewModels @@ -37,7 +41,7 @@ namespace ILSpy.ViewModels
/// startup logs a <c>[Binding]</c> error per property — adds up to ~30 errors per
/// launch.
/// </summary>
public abstract class ToolPaneModel : Tool, IDeferredContentPresentation
public abstract class ToolPaneModel : Tool, IDeferredContentPresentation, IDockableViewOwner
{
protected ToolPaneModel()
{
@ -45,5 +49,8 @@ namespace ILSpy.ViewModels @@ -45,5 +49,8 @@ namespace ILSpy.ViewModels
}
bool IDeferredContentPresentation.DeferContentPresentation => false;
// Each tool pane is a singleton, so this holds its one view for the app's life.
public Control? OwnedView { get; set; }
}
}

Loading…
Cancel
Save