diff --git a/ILSpy.Tests/AssemblyTree/BackgroundLoadSweepTests.cs b/ILSpy.Tests/AssemblyTree/BackgroundLoadSweepTests.cs
new file mode 100644
index 00000000000..ea4b75b3794
--- /dev/null
+++ b/ILSpy.Tests/AssemblyTree/BackgroundLoadSweepTests.cs
@@ -0,0 +1,102 @@
+// Copyright (c) 2026 Christoph Wille
+//
+// 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.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Avalonia.Headless.NUnit;
+using Avalonia.Threading;
+
+using ICSharpCode.ILSpy.AppEnv;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.AssemblyTree;
+
+///
+/// The background sweep that loads every assembly of the shown list ends by asking the commands
+/// to re-evaluate, which is what enables "Remove assemblies with load errors". That has to happen
+/// when one of the assemblies fails to load, since that is the only case the command is for.
+///
+[TestFixture]
+public class BackgroundLoadSweepTests
+{
+ ///
+ /// Holds a load open until the test lets go of it, then ends without a single byte, which
+ /// no loader accepts. The test thereby chooses the moment the assembly fails to load.
+ ///
+ sealed class GatedEmptyStream : Stream
+ {
+ public ManualResetEventSlim Gate { get; } = new();
+
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => throw new NotSupportedException();
+ public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ Gate.Wait();
+ return 0;
+ }
+
+ public override void Flush() { }
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+ }
+
+ [AvaloniaTest]
+ public async Task Sweep_Requeries_Commands_When_An_Assembly_Fails_To_Load()
+ {
+ var (_, vm) = await TestHarness.BootAsync();
+ await Waiters.WaitForIdleAsync();
+
+ // Show a list whose only assembly cannot finish loading before the gate opens, and let
+ // everything the switch itself queues run first, so that a requery seen afterwards can
+ // only be the one that ends the sweep.
+ var stream = new GatedEmptyStream();
+ var listManager = AppComposition.Current.GetExport().AssemblyListManager;
+ var list = listManager.CreateList("gated-list");
+ var assembly = list.OpenAssembly(Path.Combine(Path.GetTempPath(), "ILSpyGated.dll"), stream);
+ vm.AssemblyTreeModel.ShowAssemblyList(list);
+ await Waiters.WaitForAsync(
+ () => !Dispatcher.UIThread.HasJobsWithPriority(DispatcherPriority.Background),
+ description: "the list switch to settle");
+
+ bool requeried = false;
+ EventHandler onRequery = (_, _) => requeried = true;
+ ICSharpCode.ILSpy.Commands.CommandManager.AddRequerySuggested(onRequery);
+ try
+ {
+ stream.Gate.Set();
+ await Waiters.WaitForAsync(() => assembly.HasLoadError, description: "the gated assembly to fail loading");
+ await Waiters.WaitForAsync(() => requeried,
+ description: "a command requery after the assembly failed to load");
+ }
+ finally
+ {
+ stream.Gate.Set();
+ ICSharpCode.ILSpy.Commands.CommandManager.RemoveRequerySuggested(onRequery);
+ }
+ }
+}
diff --git a/ILSpy.Tests/Controls/SharpTreeViewTests.cs b/ILSpy.Tests/Controls/SharpTreeViewTests.cs
index 32aabfc6b71..4f699490b5f 100644
--- a/ILSpy.Tests/Controls/SharpTreeViewTests.cs
+++ b/ILSpy.Tests/Controls/SharpTreeViewTests.cs
@@ -32,6 +32,7 @@
using ICSharpCode.ILSpyX.TreeView;
+using ICSharpCode.ILSpy.Controls;
using ICSharpCode.ILSpy.Controls.TreeView;
using NUnit.Framework;
@@ -65,6 +66,20 @@ public TestNode(string text, params TestNode[] children)
return (window, tree, root);
}
+ [AvaloniaTest]
+ public void ShowLines_Reaches_The_Connector_Lines_Of_Every_Row()
+ {
+ var (_, tree, _) = Host();
+ var lines = tree.GetVisualDescendants().OfType().ToList();
+ lines.Should().NotBeEmpty("every realized row carries a TreeLines");
+ lines.Should().OnlyContain(l => l.GetValue(TreeLines.ShowLinesProperty), "lines are shown by default");
+
+ tree.ShowLines = false;
+
+ lines.Should().OnlyContain(l => !l.GetValue(TreeLines.ShowLinesProperty),
+ "the rows take the setting from the tree view they are in");
+ }
+
[AvaloniaTest]
public void Flattens_Visible_Nodes_And_Expand_Collapse_Updates_Row_Count()
{
diff --git a/ILSpy.Tests/MainWindow/StartupBindingErrorTests.cs b/ILSpy.Tests/MainWindow/StartupBindingErrorTests.cs
new file mode 100644
index 00000000000..bd55fc565cf
--- /dev/null
+++ b/ILSpy.Tests/MainWindow/StartupBindingErrorTests.cs
@@ -0,0 +1,75 @@
+// Copyright (c) 2026 Christoph Wille
+//
+// 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 Avalonia.Logging;
+
+using AwesomeAssertions;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+///
+/// A binding that fails while the main window comes up is logged by Avalonia and otherwise
+/// goes unnoticed: the bound property silently keeps its default. Booting the window with a
+/// capturing log sink pins that startup produces no such errors.
+///
+[TestFixture]
+public class StartupBindingErrorTests
+{
+ sealed class BindingErrorSink(ILogSink? inner) : ILogSink
+ {
+ public List Errors { get; } = new();
+
+ public bool IsEnabled(LogEventLevel level, string area)
+ => area == LogArea.Binding || (inner?.IsEnabled(level, area) ?? false);
+
+ public void Log(LogEventLevel level, string area, object? source, string messageTemplate)
+ => Log(level, area, source, messageTemplate, []);
+
+ public void Log(LogEventLevel level, string area, object? source, string messageTemplate, params object?[] propertyValues)
+ {
+ if (area == LogArea.Binding && level >= LogEventLevel.Warning)
+ Errors.Add($"{messageTemplate} [{string.Join(", ", propertyValues)}] ({source})");
+ }
+ }
+
+ [AvaloniaTest]
+ public async Task Booting_The_Main_Window_Logs_No_Binding_Errors()
+ {
+ var previous = Logger.Sink;
+ var sink = new BindingErrorSink(previous);
+ Logger.Sink = sink;
+ try
+ {
+ await TestHarness.BootAsync();
+ await Waiters.WaitForIdleAsync();
+ }
+ finally
+ {
+ Logger.Sink = previous;
+ }
+
+ sink.Errors.Should().BeEmpty("every binding in the startup UI must resolve against its data context");
+ }
+}
diff --git a/ILSpy/AssemblyTree/AssemblyTreeModel.cs b/ILSpy/AssemblyTree/AssemblyTreeModel.cs
index b3ffa70c804..4f1a2a14ae1 100644
--- a/ILSpy/AssemblyTree/AssemblyTreeModel.cs
+++ b/ILSpy/AssemblyTree/AssemblyTreeModel.cs
@@ -557,7 +557,7 @@ void ShowAssemblyList(string name)
ShowAssemblyList(list);
}
- void ShowAssemblyList(AssemblyList list)
+ internal void ShowAssemblyList(AssemblyList list)
{
using var _ = AppEnv.AppLog.Phase("ShowAssemblyList(list)");
// Detach the previous list's collection-changed wiring so the MessageBus
@@ -631,6 +631,11 @@ void ScheduleBackgroundLoadSweep(AssemblyList list)
loadTasks.Add(Task.Run(async () => {
try
{ await assembly.GetLoadResultAsync().ConfigureAwait(false); }
+ catch (Exception)
+ {
+ // A file that cannot be loaded is an expected outcome, recorded on the
+ // LoadedAssembly itself (HasLoadError). It must not fault the sweep.
+ }
finally
{ throttle.Release(); }
}));
diff --git a/ILSpy/Controls/TreeLines.cs b/ILSpy/Controls/TreeLines.cs
index 9ef34a303c5..7801a61d0c1 100644
--- a/ILSpy/Controls/TreeLines.cs
+++ b/ILSpy/Controls/TreeLines.cs
@@ -22,6 +22,7 @@
using Avalonia.Controls;
using Avalonia.Media;
+using ICSharpCode.ILSpy.Controls.TreeView;
using ICSharpCode.ILSpyX.TreeView;
namespace ICSharpCode.ILSpy.Controls
@@ -48,12 +49,19 @@ public class TreeLines : Control
public static readonly StyledProperty LevelProperty =
AvaloniaProperty.Register(nameof(Level));
+ ///
+ /// Inherited from the hosting ; stays at its default (lines
+ /// shown) where there is none.
+ ///
+ public static readonly StyledProperty ShowLinesProperty =
+ SharpTreeView.ShowLinesProperty.AddOwner();
+
static TreeLines()
{
// Classic Windows-Explorer dotted connector lines.
var pen = new Pen(Brushes.Gray, 1) { DashStyle = new DashStyle(new double[] { 1, 1 }, 0) };
Pen = pen.ToImmutable();
- AffectsRender(NodeProperty, LevelProperty);
+ AffectsRender(NodeProperty, LevelProperty, ShowLinesProperty);
IsHitTestVisibleProperty.OverrideDefaultValue(false);
}
@@ -73,7 +81,7 @@ public int Level {
public override void Render(DrawingContext context)
{
var node = Node;
- if (node == null || node.IsRoot)
+ if (node == null || node.IsRoot || !GetValue(ShowLinesProperty))
return;
double height = Bounds.Height;
diff --git a/ILSpy/Controls/TreeView/SharpTreeView.axaml b/ILSpy/Controls/TreeView/SharpTreeView.axaml
index 692b4c2fb84..4c03a66ad85 100644
--- a/ILSpy/Controls/TreeView/SharpTreeView.axaml
+++ b/ILSpy/Controls/TreeView/SharpTreeView.axaml
@@ -94,8 +94,7 @@
-
+
diff --git a/ILSpy/Controls/TreeView/SharpTreeView.cs b/ILSpy/Controls/TreeView/SharpTreeView.cs
index 844f2cd35e3..a4cc9830d5a 100644
--- a/ILSpy/Controls/TreeView/SharpTreeView.cs
+++ b/ILSpy/Controls/TreeView/SharpTreeView.cs
@@ -56,7 +56,7 @@ public class SharpTreeView : ListBox
AvaloniaProperty.Register(nameof(ShowRootExpander), defaultValue: false);
public static readonly StyledProperty ShowLinesProperty =
- AvaloniaProperty.Register(nameof(ShowLines), defaultValue: true);
+ AvaloniaProperty.Register(nameof(ShowLines), defaultValue: true, inherits: true);
TreeFlattener? flattener;
string searchBuffer = string.Empty;
diff --git a/ILSpy/Docking/ILSpyDockFactory.cs b/ILSpy/Docking/ILSpyDockFactory.cs
index f275e71a6b4..86d650f4ae6 100644
--- a/ILSpy/Docking/ILSpyDockFactory.cs
+++ b/ILSpy/Docking/ILSpyDockFactory.cs
@@ -199,6 +199,12 @@ public override void InitLayout(IDockable layout)
if (layout is IRootDock { Id: { Length: > 0 } rootId } root)
DockableLocator[rootId] = () => root;
+ // Dock's theme template binds against DockCapabilityOverrides.{CanDrag, CanDrop} on
+ // every dockable, the docks included. A layout restored from disk carries none, so
+ // fill them in here, where both fresh and restored layouts pass through.
+ foreach (var dockable in Flatten(layout))
+ dockable.DockCapabilityOverrides ??= new DockCapabilityOverrides();
+
base.InitLayout(layout);
}
@@ -527,6 +533,7 @@ public void ActivateAndFocus(IDock owner, IDockable dockable)
Alignment = dockAlignment,
VisibleDockables = CreateList(System.Array.Empty()),
DockCapabilityPolicy = new DockCapabilityPolicy(),
+ DockCapabilityOverrides = new DockCapabilityOverrides(),
};
switch (alignment)
diff --git a/ILSpy/Views/MainWindow.axaml b/ILSpy/Views/MainWindow.axaml
index f65eb5d8056..e2add2c8fae 100644
--- a/ILSpy/Views/MainWindow.axaml
+++ b/ILSpy/Views/MainWindow.axaml
@@ -42,7 +42,16 @@
-
+
+
+
+
+
+
+
+