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 @@ - + + + + + + + +