diff --git a/ILSpy.Tests/MainWindow/AboutPageUpdateSectionTests.cs b/ILSpy.Tests/MainWindow/AboutPageUpdateSectionTests.cs
new file mode 100644
index 000000000..2b4e631b6
--- /dev/null
+++ b/ILSpy.Tests/MainWindow/AboutPageUpdateSectionTests.cs
@@ -0,0 +1,78 @@
+// 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.Linq;
+using System.Threading.Tasks;
+
+using Avalonia.Controls;
+
+using Avalonia.Headless.NUnit;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpy.Properties;
+
+using ILSpy;
+using ILSpy.AppEnv;
+using ILSpy.Commands;
+using ILSpy.Docking;
+using ILSpy.ViewModels;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+///
+/// The About page exposes the inline update controls the previous version had: a Check-for-updates /
+/// Download button and the "automatically check for updates every week" toggle bound to UpdateSettings.
+///
+[TestFixture]
+public class AboutPageUpdateSectionTests
+{
+ [AvaloniaTest]
+ public async Task About_Page_Has_The_AutoCheck_Toggle_And_Update_Button()
+ {
+ await TestHarness.BootAsync(1);
+ var settingsService = AppComposition.Current.GetExport();
+ var updatePanel = AppComposition.Current.GetExport();
+ var dockWorkspace = AppComposition.Current.GetExport();
+ var about = new AboutCommand(dockWorkspace, Array.Empty(), settingsService, updatePanel);
+
+ var section = (StackPanel)about.BuildUpdateSection();
+
+ var checkBox = section.Children.OfType().Single();
+ ((string?)checkBox.Content).Should().Be(Resources.AutomaticallyCheckUpdatesEveryWeek);
+
+ var button = section.Children.OfType().Single().Children.OfType().Single();
+ button.Command.Should().BeSameAs(updatePanel.DownloadOrCheckUpdateCommand,
+ "the button runs the same update check as the toolbar banner");
+
+ var original = settingsService.UpdateSettings.AutomaticUpdateCheckEnabled;
+ try
+ {
+ checkBox.IsChecked = !original;
+ settingsService.UpdateSettings.AutomaticUpdateCheckEnabled.Should().Be(!original,
+ "the toggle is two-way bound to UpdateSettings.AutomaticUpdateCheckEnabled");
+ }
+ finally
+ {
+ settingsService.UpdateSettings.AutomaticUpdateCheckEnabled = original;
+ }
+ }
+}
diff --git a/ILSpy.Tests/Resources/ResourceFactoryTests.cs b/ILSpy.Tests/Resources/ResourceFactoryTests.cs
index e857eb956..ac9ba594f 100644
--- a/ILSpy.Tests/Resources/ResourceFactoryTests.cs
+++ b/ILSpy.Tests/Resources/ResourceFactoryTests.cs
@@ -131,7 +131,7 @@ public class ResourceFactoryTests
// and they all materialise as Avalonia Controls.
output.UIElements.Should().HaveCount(3,
".resources should render a string table + object table inline alongside the Save button");
- var realised = output.UIElements.Select(kv => kv.Value.Value).ToList();
+ var realised = output.UIElements.Select(kv => kv.Value()).ToList();
realised.Should().ContainItemsAssignableTo();
}
diff --git a/ILSpy/Commands/AboutCommand.cs b/ILSpy/Commands/AboutCommand.cs
index 3f93f1e07..9c5f4e448 100644
--- a/ILSpy/Commands/AboutCommand.cs
+++ b/ILSpy/Commands/AboutCommand.cs
@@ -23,6 +23,11 @@ using System.Diagnostics;
using System.IO;
using System.Text.RegularExpressions;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Data;
+using Avalonia.Layout;
+
using AvaloniaEdit.Rendering;
using ICSharpCode.Decompiler;
@@ -48,12 +53,17 @@ namespace ILSpy.Commands
readonly DockWorkspace dockWorkspace;
readonly IEnumerable additions;
+ readonly SettingsService settingsService;
+ readonly UpdatePanelViewModel updatePanel;
[ImportingConstructor]
- public AboutCommand(DockWorkspace dockWorkspace, [ImportMany] IEnumerable additions)
+ public AboutCommand(DockWorkspace dockWorkspace, [ImportMany] IEnumerable additions,
+ SettingsService settingsService, UpdatePanelViewModel updatePanel)
{
this.dockWorkspace = dockWorkspace;
this.additions = additions;
+ this.settingsService = settingsService;
+ this.updatePanel = updatePanel;
}
public override void Execute(object? parameter)
@@ -92,6 +102,9 @@ namespace ILSpy.Commands
output.WriteLine(Resources.ILSpyVersion + DecompilerVersionInfo.FullVersionWithCommitHash);
output.WriteLine(Resources.NETFrameworkVersion + GetDotnetProductVersion());
output.WriteLine();
+ output.AddUIElement(BuildUpdateSection);
+ output.WriteLine();
+ output.WriteLine();
foreach (var addition in additions)
addition.Write(output);
@@ -114,6 +127,46 @@ namespace ILSpy.Commands
return output;
}
+ // The inline update controls the previous version showed on the About page: a Check-for-updates
+ // / Download button + status message (sharing the toolbar banner's UpdatePanelViewModel state),
+ // and the "automatically check for updates every week" toggle bound to UpdateSettings.
+ internal Control BuildUpdateSection()
+ {
+ var button = new Button {
+ Command = updatePanel.DownloadOrCheckUpdateCommand,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ button.Bind(ContentControl.ContentProperty,
+ new Binding(nameof(UpdatePanelViewModel.ButtonText)) { Source = updatePanel });
+
+ var message = new TextBlock {
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ message.Bind(TextBlock.TextProperty,
+ new Binding(nameof(UpdatePanelViewModel.Message)) { Source = updatePanel });
+
+ var row = new StackPanel {
+ Orientation = Orientation.Horizontal,
+ Spacing = 8,
+ };
+ row.Children.Add(button);
+ row.Children.Add(message);
+
+ var autoCheck = new CheckBox {
+ Content = Resources.AutomaticallyCheckUpdatesEveryWeek,
+ };
+ autoCheck.Bind(ToggleButton.IsCheckedProperty,
+ new Binding(nameof(Updates.UpdateSettings.AutomaticUpdateCheckEnabled)) {
+ Source = settingsService.UpdateSettings,
+ Mode = BindingMode.TwoWay,
+ });
+
+ return new StackPanel {
+ Spacing = 4,
+ Children = { row, autoCheck },
+ };
+ }
+
DecompilerTabPageModel CreateTabContent(string title, AvaloniaEditTextOutput output, string syntaxExtension, bool isStaticContent)
{
var content = new DecompilerTabPageModel {
diff --git a/ILSpy/TextView/AvaloniaEditTextOutput.cs b/ILSpy/TextView/AvaloniaEditTextOutput.cs
index db129d256..b77e191b0 100644
--- a/ILSpy/TextView/AvaloniaEditTextOutput.cs
+++ b/ILSpy/TextView/AvaloniaEditTextOutput.cs
@@ -83,11 +83,11 @@ namespace ILSpy.TextView
/// Maps reference targets to their definition offsets in the rendered text.
public DefinitionLookup DefinitionLookup { get; } = new();
- readonly List>> uiElements = new();
+ readonly List>> uiElements = new();
/// Inline UI elements collected during writing, in offset order. Fed to
/// by the text view.
- public IReadOnlyList>> UIElements => uiElements;
+ public IReadOnlyList>> UIElements => uiElements;
readonly List elementGenerators = new();
@@ -231,7 +231,11 @@ namespace ILSpy.TextView
return;
if (uiElements.Count > 0 && uiElements[uiElements.Count - 1].Key == builder.Length)
throw new InvalidOperationException("Only one UIElement is allowed for each position in the document.");
- uiElements.Add(new KeyValuePair>(builder.Length, new Lazy(element)));
+ // Store the factory itself (not a cached Lazy): each editor's UIElementGenerator
+ // builds and caches its own control instance, so the same control is never shared
+ // across two editors -- which would make AvaloniaEdit throw "already has a visual
+ // parent" when a reused/reopened tab renders into a different TextView.
+ uiElements.Add(new KeyValuePair>(builder.Length, element));
}
public void AddVisualLineElementGenerator(VisualLineElementGenerator generator)
diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs
index 7fcaf18c8..e8a4073b8 100644
--- a/ILSpy/TextView/DecompilerTabPageModel.cs
+++ b/ILSpy/TextView/DecompilerTabPageModel.cs
@@ -132,7 +132,7 @@ namespace ILSpy.TextView
/// Fed to by the text view.
///
[ObservableProperty]
- private IReadOnlyList>>? uIElements;
+ private IReadOnlyList>>? uIElements;
///
/// Custom s the writer attached (e.g. a
diff --git a/ILSpy/TextView/UIElementGenerator.cs b/ILSpy/TextView/UIElementGenerator.cs
index e10fd8147..83464596b 100644
--- a/ILSpy/TextView/UIElementGenerator.cs
+++ b/ILSpy/TextView/UIElementGenerator.cs
@@ -25,33 +25,58 @@ using AvaloniaEdit.Rendering;
namespace ILSpy.TextView
{
- using Pair = KeyValuePair>;
+ using Pair = KeyValuePair>;
///
/// Embeds inline UI elements produced by in
- /// the rendered text. The element factory is stored as a so
- /// the actual control is constructed on the UI thread when the row is first realised.
+ /// the rendered text. Each element is stored as a factory; this generator builds and caches
+ /// one control instance per offset locally, so the control belongs to a single editor. The
+ /// model only carries the factory, never a shared control -- otherwise a reused or reopened
+ /// tab rendering into a different TextView would hit AvaloniaEdit's "already has a visual
+ /// parent" guard.
///
sealed class UIElementGenerator : VisualLineElementGenerator, IComparer
{
- public IReadOnlyList? UIElements;
+ // Controls realised so far, keyed by document offset, so re-laying out the same line in
+ // THIS editor reuses the instance (preserving its state, and letting AvaloniaEdit's
+ // per-TextView dedup handle it) rather than building a fresh one each time.
+ readonly Dictionary realised = new();
+
+ IReadOnlyList? uiElements;
+
+ public IReadOnlyList? UIElements {
+ get => uiElements;
+ set {
+ // Rebinding to a new document: drop controls built for the previous one so they
+ // are rebuilt from the new model's factories.
+ realised.Clear();
+ uiElements = value;
+ }
+ }
public override int GetFirstInterestedOffset(int startOffset)
{
- if (UIElements == null)
+ if (uiElements == null)
return -1;
- int r = BinarySearch(UIElements, new Pair(startOffset, null!));
+ int r = BinarySearch(uiElements, new Pair(startOffset, null!));
if (r < 0)
r = ~r;
- return r < UIElements.Count ? UIElements[r].Key : -1;
+ return r < uiElements.Count ? uiElements[r].Key : -1;
}
public override VisualLineElement? ConstructElement(int offset)
{
- if (UIElements == null)
+ if (uiElements == null)
return null;
- int r = BinarySearch(UIElements, new Pair(offset, null!));
- return r >= 0 ? new InlineObjectElement(0, UIElements[r].Value.Value) : null;
+ int r = BinarySearch(uiElements, new Pair(offset, null!));
+ if (r < 0)
+ return null;
+ if (!realised.TryGetValue(offset, out var control))
+ {
+ control = uiElements[r].Value();
+ realised[offset] = control;
+ }
+ return new InlineObjectElement(0, control);
}
int IComparer.Compare(Pair x, Pair y) => x.Key.CompareTo(y.Key);