Browse Source

Restore the inline update controls on the About page

The previous version's About page carried a Check-for-updates / Download
button and an "automatically check for updates every week" toggle. Embed
both back into the About output: the button reuses the toolbar banner's
shared UpdatePanelViewModel (same check, same state), and the toggle is
two-way bound to UpdateSettings.AutomaticUpdateCheckEnabled.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
23417a0b4b
  1. 78
      ILSpy.Tests/MainWindow/AboutPageUpdateSectionTests.cs
  2. 2
      ILSpy.Tests/Resources/ResourceFactoryTests.cs
  3. 55
      ILSpy/Commands/AboutCommand.cs
  4. 10
      ILSpy/TextView/AvaloniaEditTextOutput.cs
  5. 2
      ILSpy/TextView/DecompilerTabPageModel.cs
  6. 45
      ILSpy/TextView/UIElementGenerator.cs

78
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;
/// <summary>
/// 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.
/// </summary>
[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<SettingsService>();
var updatePanel = AppComposition.Current.GetExport<UpdatePanelViewModel>();
var dockWorkspace = AppComposition.Current.GetExport<DockWorkspace>();
var about = new AboutCommand(dockWorkspace, Array.Empty<IAboutPageAddition>(), settingsService, updatePanel);
var section = (StackPanel)about.BuildUpdateSection();
var checkBox = section.Children.OfType<CheckBox>().Single();
((string?)checkBox.Content).Should().Be(Resources.AutomaticallyCheckUpdatesEveryWeek);
var button = section.Children.OfType<StackPanel>().Single().Children.OfType<Button>().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;
}
}
}

2
ILSpy.Tests/Resources/ResourceFactoryTests.cs

@ -131,7 +131,7 @@ public class ResourceFactoryTests
// and they all materialise as Avalonia Controls. // and they all materialise as Avalonia Controls.
output.UIElements.Should().HaveCount(3, output.UIElements.Should().HaveCount(3,
".resources should render a string table + object table inline alongside the Save button"); ".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<Control>(); realised.Should().ContainItemsAssignableTo<Control>();
} }

55
ILSpy/Commands/AboutCommand.cs

@ -23,6 +23,11 @@ using System.Diagnostics;
using System.IO; using System.IO;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Data;
using Avalonia.Layout;
using AvaloniaEdit.Rendering; using AvaloniaEdit.Rendering;
using ICSharpCode.Decompiler; using ICSharpCode.Decompiler;
@ -48,12 +53,17 @@ namespace ILSpy.Commands
readonly DockWorkspace dockWorkspace; readonly DockWorkspace dockWorkspace;
readonly IEnumerable<IAboutPageAddition> additions; readonly IEnumerable<IAboutPageAddition> additions;
readonly SettingsService settingsService;
readonly UpdatePanelViewModel updatePanel;
[ImportingConstructor] [ImportingConstructor]
public AboutCommand(DockWorkspace dockWorkspace, [ImportMany] IEnumerable<IAboutPageAddition> additions) public AboutCommand(DockWorkspace dockWorkspace, [ImportMany] IEnumerable<IAboutPageAddition> additions,
SettingsService settingsService, UpdatePanelViewModel updatePanel)
{ {
this.dockWorkspace = dockWorkspace; this.dockWorkspace = dockWorkspace;
this.additions = additions; this.additions = additions;
this.settingsService = settingsService;
this.updatePanel = updatePanel;
} }
public override void Execute(object? parameter) public override void Execute(object? parameter)
@ -92,6 +102,9 @@ namespace ILSpy.Commands
output.WriteLine(Resources.ILSpyVersion + DecompilerVersionInfo.FullVersionWithCommitHash); output.WriteLine(Resources.ILSpyVersion + DecompilerVersionInfo.FullVersionWithCommitHash);
output.WriteLine(Resources.NETFrameworkVersion + GetDotnetProductVersion()); output.WriteLine(Resources.NETFrameworkVersion + GetDotnetProductVersion());
output.WriteLine(); output.WriteLine();
output.AddUIElement(BuildUpdateSection);
output.WriteLine();
output.WriteLine();
foreach (var addition in additions) foreach (var addition in additions)
addition.Write(output); addition.Write(output);
@ -114,6 +127,46 @@ namespace ILSpy.Commands
return output; 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) DecompilerTabPageModel CreateTabContent(string title, AvaloniaEditTextOutput output, string syntaxExtension, bool isStaticContent)
{ {
var content = new DecompilerTabPageModel { var content = new DecompilerTabPageModel {

10
ILSpy/TextView/AvaloniaEditTextOutput.cs

@ -83,11 +83,11 @@ namespace ILSpy.TextView
/// <summary>Maps reference targets to their definition offsets in the rendered text.</summary> /// <summary>Maps reference targets to their definition offsets in the rendered text.</summary>
public DefinitionLookup DefinitionLookup { get; } = new(); public DefinitionLookup DefinitionLookup { get; } = new();
readonly List<KeyValuePair<int, Lazy<Control>>> uiElements = new(); readonly List<KeyValuePair<int, Func<Control>>> uiElements = new();
/// <summary>Inline UI elements collected during writing, in offset order. Fed to /// <summary>Inline UI elements collected during writing, in offset order. Fed to
/// <see cref="UIElementGenerator"/> by the text view.</summary> /// <see cref="UIElementGenerator"/> by the text view.</summary>
public IReadOnlyList<KeyValuePair<int, Lazy<Control>>> UIElements => uiElements; public IReadOnlyList<KeyValuePair<int, Func<Control>>> UIElements => uiElements;
readonly List<VisualLineElementGenerator> elementGenerators = new(); readonly List<VisualLineElementGenerator> elementGenerators = new();
@ -231,7 +231,11 @@ namespace ILSpy.TextView
return; return;
if (uiElements.Count > 0 && uiElements[uiElements.Count - 1].Key == builder.Length) 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."); throw new InvalidOperationException("Only one UIElement is allowed for each position in the document.");
uiElements.Add(new KeyValuePair<int, Lazy<Control>>(builder.Length, new Lazy<Control>(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<int, Func<Control>>(builder.Length, element));
} }
public void AddVisualLineElementGenerator(VisualLineElementGenerator generator) public void AddVisualLineElementGenerator(VisualLineElementGenerator generator)

2
ILSpy/TextView/DecompilerTabPageModel.cs

@ -132,7 +132,7 @@ namespace ILSpy.TextView
/// Fed to <see cref="UIElementGenerator"/> by the text view. /// Fed to <see cref="UIElementGenerator"/> by the text view.
/// </summary> /// </summary>
[ObservableProperty] [ObservableProperty]
private IReadOnlyList<KeyValuePair<int, Lazy<Control>>>? uIElements; private IReadOnlyList<KeyValuePair<int, Func<Control>>>? uIElements;
/// <summary> /// <summary>
/// Custom <see cref="VisualLineElementGenerator"/>s the writer attached (e.g. a /// Custom <see cref="VisualLineElementGenerator"/>s the writer attached (e.g. a

45
ILSpy/TextView/UIElementGenerator.cs

@ -25,33 +25,58 @@ using AvaloniaEdit.Rendering;
namespace ILSpy.TextView namespace ILSpy.TextView
{ {
using Pair = KeyValuePair<int, Lazy<Control>>; using Pair = KeyValuePair<int, Func<Control>>;
/// <summary> /// <summary>
/// Embeds inline UI elements produced by <see cref="ISmartTextOutput.AddUIElement"/> in /// Embeds inline UI elements produced by <see cref="ISmartTextOutput.AddUIElement"/> in
/// the rendered text. The element factory is stored as a <see cref="Lazy{Control}"/> so /// the rendered text. Each element is stored as a factory; this generator builds and caches
/// the actual control is constructed on the UI thread when the row is first realised. /// 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.
/// </summary> /// </summary>
sealed class UIElementGenerator : VisualLineElementGenerator, IComparer<Pair> sealed class UIElementGenerator : VisualLineElementGenerator, IComparer<Pair>
{ {
public IReadOnlyList<Pair>? 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<int, Control> realised = new();
IReadOnlyList<Pair>? uiElements;
public IReadOnlyList<Pair>? 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) public override int GetFirstInterestedOffset(int startOffset)
{ {
if (UIElements == null) if (uiElements == null)
return -1; return -1;
int r = BinarySearch(UIElements, new Pair(startOffset, null!)); int r = BinarySearch(uiElements, new Pair(startOffset, null!));
if (r < 0) if (r < 0)
r = ~r; r = ~r;
return r < UIElements.Count ? UIElements[r].Key : -1; return r < uiElements.Count ? uiElements[r].Key : -1;
} }
public override VisualLineElement? ConstructElement(int offset) public override VisualLineElement? ConstructElement(int offset)
{ {
if (UIElements == null) if (uiElements == null)
return null; return null;
int r = BinarySearch(UIElements, new Pair(offset, null!)); int r = BinarySearch(uiElements, new Pair(offset, null!));
return r >= 0 ? new InlineObjectElement(0, UIElements[r].Value.Value) : 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<Pair>.Compare(Pair x, Pair y) => x.Key.CompareTo(y.Key); int IComparer<Pair>.Compare(Pair x, Pair y) => x.Key.CompareTo(y.Key);

Loading…
Cancel
Save