Browse Source

Re-decompile the active tab on output display-setting changes

The Options page is non-modal and live-apply, so -- unlike the WPF host,
which ran a full assembly-list Refresh() when its modal Options dialog
closed -- a setting that changes the decompiler/disassembler output (brace
folding, member/using expansion, debug info, IL detail, indentation) had
nothing to make it take effect; toggling it did nothing until the user
re-navigated.

Classify every DisplaySettings property in one table (DisplaySettingReactions:
editor-live / tree-text / tree-shape / re-decompile / none), grounded in the
actual consumers (ApplyDisplaySettings, GetIndentationString, the IL/mixed
languages). OnSettingsChanged dispatches on it, and a coverage test asserts
the table spans every settable property so a newly-added one can't silently
fall through.

Fixing the re-decompile case exposed that ForceRefreshActiveTab was itself a
no-op for an unchanged node -- ShowSelectedNode re-sets CurrentNodes, whose
setter dedups -- so RefreshDecompiledView (also used after dependency
resolution) never actually re-ran. Add DecompilerTabPageModel.Redecompile()
to force past the dedup and call it from ForceRefreshActiveTab.

Also drop the EnableSmoothScrolling setting: it drove TomsToolbox's
AdvancedScrollWheelBehavior in WPF, which the Avalonia port doesn't use and
never replaced, so the checkbox persisted a value nothing read.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3755/head
Siegfried Pammer 4 weeks ago
parent
commit
4ab76f47e7
  1. 110
      ILSpy.Tests/Options/DisplaySettingsReactionTests.cs
  2. 50
      ILSpy/AssemblyTree/AssemblyTreeModel.cs
  3. 5
      ILSpy/Docking/DockWorkspace.cs
  4. 102
      ILSpy/Options/DisplaySettingReactions.cs
  5. 5
      ILSpy/Options/DisplaySettings.cs
  6. 2
      ILSpy/Options/DisplaySettingsPanel.axaml
  7. 9
      ILSpy/Properties/Resources.Designer.cs
  8. 3
      ILSpy/Properties/Resources.resx
  9. 11
      ILSpy/TextView/DecompilerTabPageModel.cs

110
ILSpy.Tests/Options/DisplaySettingsReactionTests.cs

@ -0,0 +1,110 @@ @@ -0,0 +1,110 @@
// 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.ComponentModel;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Avalonia.Headless.NUnit;
using Avalonia.Threading;
using AwesomeAssertions;
using ILSpy;
using ILSpy.AppEnv;
using ILSpy.Options;
using ILSpy.TextView;
using ILSpy.TreeNodes;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
/// <summary>
/// The Options page is non-modal and live-apply (no "OK" step), so unlike WPF -- which did a full
/// assembly-list Refresh() when the modal dialog closed -- every display setting has to drive its own
/// live reaction. These tests pin that a decompiler-output setting actually re-decompiles the active
/// tab, and that every setting is classified so a newly-added one can't silently fall through.
/// </summary>
[TestFixture]
public class DisplaySettingsReactionTests
{
static async Task Pump(int ticks = 12)
{
for (int i = 0; i < ticks; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
}
[AvaloniaTest]
public async Task Toggling_A_Decompiler_Output_Setting_Re_Decompiles_The_Active_Tab()
{
var (_, vm) = await TestHarness.BootAsync();
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectedItem = typeNode;
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
int decompileStarts = 0;
void OnTab(object? _, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(DecompilerTabPageModel.IsDecompiling) && tab.IsDecompiling)
decompileStarts++;
}
tab.PropertyChanged += OnTab;
try
{
var display = AppComposition.Current.GetExport<SettingsService>().DisplaySettings;
display.DecodeCustomAttributeBlobs = !display.DecodeCustomAttributeBlobs;
await Pump();
}
finally
{
tab.PropertyChanged -= OnTab;
}
decompileStarts.Should().BeGreaterThan(0,
"toggling a decompiler-output display setting must re-decompile the active tab "
+ "(the live Options page has no full-refresh-on-close to fall back on)");
}
[Test]
public void Every_Display_Setting_Is_Classified()
{
// The safety net: each settable DisplaySettings property must appear in the reaction table,
// so adding a new setting forces a deliberate classification (editor / tree / re-decompile /
// none) instead of silently doing nothing. Set equality also catches stale entries left
// behind when a setting is removed.
var settable = typeof(DisplaySettings)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p is { CanRead: true, CanWrite: true })
.Select(p => p.Name)
.ToHashSet();
settable.Should().NotBeEmpty();
var classified = DisplaySettingReactions.ClassifiedProperties.ToHashSet();
settable.Should().BeSubsetOf(classified,
"every settable DisplaySettings property must be classified in DisplaySettingReactions");
classified.Should().BeSubsetOf(settable,
"DisplaySettingReactions must not reference settings that no longer exist");
}
}

50
ILSpy/AssemblyTree/AssemblyTreeModel.cs

@ -254,33 +254,45 @@ namespace ILSpy.AssemblyTree @@ -254,33 +254,45 @@ namespace ILSpy.AssemblyTree
return;
if (Root == null)
return;
switch (e.Inner.PropertyName)
// One classification table (DisplaySettingReactions) drives every reaction, so a
// newly-added setting can't silently fall through -- the coverage test fails until it's
// listed. The Options page is non-modal/live-apply, so there is no full-refresh-on-close
// fallback the way the WPF host had; each bucket must do its own update.
var name = e.Inner.PropertyName;
switch (Options.DisplaySettingReactions.For(name))
{
case nameof(Options.DisplaySettings.ShowMetadataTokens):
case nameof(Options.DisplaySettings.ShowMetadataTokensInBase10):
case Options.DisplaySettingReaction.TreeText:
// Text suffix on member nodes is computed at read time — just fire the
// notification so bound cell templates re-pull.
NotifyTextChanged(Root);
break;
case nameof(Options.DisplaySettings.UseNestedNamespaceNodes):
// Tree shape changes — every loaded AssemblyTreeNode's namespace subtree
// needs rebuilding.
foreach (var asm in Root.Children.OfType<TreeNodes.AssemblyTreeNode>())
asm.ReloadChildren();
// AssemblyListPane caches a snapshot of each expanded node's children via
// the HierarchicalOptions.ChildrenSelector — mid-expand mutations of
// node.Children aren't observed. Re-raising Root forces BindTree to fire,
// which creates a fresh HierarchicalModel that re-reads children on demand.
case Options.DisplaySettingReaction.TreeShape:
if (name == nameof(Options.DisplaySettings.UseNestedNamespaceNodes))
{
// Every loaded AssemblyTreeNode's namespace subtree needs rebuilding.
foreach (var asm in Root.Children.OfType<TreeNodes.AssemblyTreeNode>())
asm.ReloadChildren();
}
else
{
// Visible MetadataTablesTreeNode instances get their children regenerated;
// untouched (lazy) ones already pick up the new value on first expand.
RebuildMetadataTablesIn(Root);
}
// AssemblyListPane caches a snapshot of each expanded node's children via the
// HierarchicalOptions.ChildrenSelector — mid-expand mutations of node.Children
// aren't observed. Re-raising Root forces BindTree to fire, which creates a fresh
// HierarchicalModel that re-reads children on demand.
OnPropertyChanged(nameof(Root));
break;
case nameof(Options.DisplaySettings.HideEmptyMetadataTables):
// Visible MetadataTablesTreeNode instances get their children regenerated;
// untouched (lazy) ones already pick up the new value on first expand.
RebuildMetadataTablesIn(Root);
// Same DataGrid-snapshot problem as the UseNestedNamespaceNodes branch —
// force a re-bind so the rebuilt children make it into the visible grid.
OnPropertyChanged(nameof(Root));
case Options.DisplaySettingReaction.Redecompile:
// Baked into the decompiler/disassembler output (folding, member/using
// expansion, debug info, IL detail, indentation), so it only shows after a
// re-decompile of the active tab.
RefreshDecompiledView();
break;
// EditorLive / None: the text view applies editor settings to AvaloniaEdit itself,
// and the rest have no model-side reaction.
}
}

5
ILSpy/Docking/DockWorkspace.cs

@ -707,6 +707,11 @@ namespace ILSpy.Docking @@ -707,6 +707,11 @@ namespace ILSpy.Docking
{
lastShownNodes = null;
ShowSelectedNode();
// ShowSelectedNode re-projects the selection, but DecompilerTabPageModel.CurrentNodes
// dedups an unchanged node -- so a same-node refresh (a decompiler-output display setting,
// or freshly resolved dependencies) wouldn't actually re-run. Force the active decompiler
// tab to re-decompile so its output reflects the new state.
ActiveDecompilerTab?.Redecompile();
}
void ShowSelectedNode()

102
ILSpy/Options/DisplaySettingReactions.cs

@ -0,0 +1,102 @@ @@ -0,0 +1,102 @@
// 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.Collections.Generic;
namespace ILSpy.Options
{
/// <summary>
/// How a live <see cref="DisplaySettings"/> change has to be surfaced. The Options page is
/// non-modal and applies immediately, so -- unlike the WPF host, which ran a full assembly-list
/// <c>Refresh()</c> when its modal Options dialog closed -- every setting has to drive its own
/// reaction or it silently does nothing.
/// </summary>
public enum DisplaySettingReaction
{
/// <summary>The text view applies it to the editor itself (font, line numbers, word wrap,
/// brace highlighting, ...); the assembly tree and the decompile output are unaffected.</summary>
EditorLive,
/// <summary>Re-pull of member-node text only (the metadata-token suffix).</summary>
TreeText,
/// <summary>In-place rebuild of a tree subtree whose shape the setting changes
/// (nested namespaces, hidden empty metadata tables).</summary>
TreeShape,
/// <summary>Baked into the decompiler / disassembler output, so the active tab must be
/// re-decompiled for the change to show.</summary>
Redecompile,
/// <summary>Deliberately no model-side reaction (window chrome, search ordering).</summary>
None,
}
/// <summary>
/// Single source of truth classifying every <see cref="DisplaySettings"/> property by its live
/// reaction. <c>AssemblyTreeModel</c> dispatches on it; <c>DisplaySettingsReactionTests</c>
/// asserts the table covers every setting, so a newly-added one can't fall through unhandled.
/// </summary>
public static class DisplaySettingReactions
{
static readonly Dictionary<string, DisplaySettingReaction> reactions = new() {
// Tree.
[nameof(DisplaySettings.ShowMetadataTokens)] = DisplaySettingReaction.TreeText,
[nameof(DisplaySettings.ShowMetadataTokensInBase10)] = DisplaySettingReaction.TreeText,
[nameof(DisplaySettings.UseNestedNamespaceNodes)] = DisplaySettingReaction.TreeShape,
[nameof(DisplaySettings.HideEmptyMetadataTables)] = DisplaySettingReaction.TreeShape,
// Decompiler / disassembler output (see DecompilerTabPageModel.ApplyDisplaySettings +
// GetIndentationString, and CSharpILMixedLanguage / ILLanguage for the IL-detail ones).
[nameof(DisplaySettings.FoldBraces)] = DisplaySettingReaction.Redecompile,
[nameof(DisplaySettings.ExpandMemberDefinitions)] = DisplaySettingReaction.Redecompile,
[nameof(DisplaySettings.ExpandUsingDeclarations)] = DisplaySettingReaction.Redecompile,
[nameof(DisplaySettings.ShowDebugInfo)] = DisplaySettingReaction.Redecompile,
[nameof(DisplaySettings.ShowRawOffsetsAndBytesBeforeInstruction)] = DisplaySettingReaction.Redecompile,
[nameof(DisplaySettings.DecodeCustomAttributeBlobs)] = DisplaySettingReaction.Redecompile,
[nameof(DisplaySettings.IndentationUseTabs)] = DisplaySettingReaction.Redecompile,
[nameof(DisplaySettings.IndentationSize)] = DisplaySettingReaction.Redecompile,
[nameof(DisplaySettings.IndentationTabSize)] = DisplaySettingReaction.Redecompile,
// Editor-only (DecompilerTextView applies these directly to the AvaloniaEdit control).
[nameof(DisplaySettings.SelectedFont)] = DisplaySettingReaction.EditorLive,
[nameof(DisplaySettings.SelectedFontSize)] = DisplaySettingReaction.EditorLive,
[nameof(DisplaySettings.ShowLineNumbers)] = DisplaySettingReaction.EditorLive,
[nameof(DisplaySettings.EnableWordWrap)] = DisplaySettingReaction.EditorLive,
[nameof(DisplaySettings.HighlightCurrentLine)] = DisplaySettingReaction.EditorLive,
[nameof(DisplaySettings.HighlightMatchingBraces)] = DisplaySettingReaction.EditorLive,
// No model-side reaction.
[nameof(DisplaySettings.StyleWindowTitleBar)] = DisplaySettingReaction.None,
[nameof(DisplaySettings.SortResults)] = DisplaySettingReaction.None,
};
/// <summary>Every classified property name. The coverage test asserts this equals the set of
/// settable <see cref="DisplaySettings"/> properties.</summary>
public static IReadOnlyCollection<string> ClassifiedProperties => reactions.Keys;
/// <summary>
/// The reaction for <paramref name="propertyName"/>, or <see cref="DisplaySettingReaction.None"/>
/// for an unclassified one (the coverage test prevents that case from reaching production).
/// </summary>
public static DisplaySettingReaction For(string? propertyName)
=> propertyName != null && reactions.TryGetValue(propertyName, out var reaction)
? reaction
: DisplaySettingReaction.None;
}
}

5
ILSpy/Options/DisplaySettings.cs

@ -93,9 +93,6 @@ namespace ILSpy.Options @@ -93,9 +93,6 @@ namespace ILSpy.Options
[ObservableProperty]
bool showRawOffsetsAndBytesBeforeInstruction;
[ObservableProperty]
bool enableSmoothScrolling = true;
[ObservableProperty]
bool decodeCustomAttributeBlobs;
@ -123,7 +120,6 @@ namespace ILSpy.Options @@ -123,7 +120,6 @@ namespace ILSpy.Options
UseNestedNamespaceNodes = (bool?)section.Attribute(nameof(UseNestedNamespaceNodes)) ?? false;
ShowRawOffsetsAndBytesBeforeInstruction = (bool?)section.Attribute(nameof(ShowRawOffsetsAndBytesBeforeInstruction)) ?? false;
StyleWindowTitleBar = (bool?)section.Attribute(nameof(StyleWindowTitleBar)) ?? false;
EnableSmoothScrolling = (bool?)section.Attribute(nameof(EnableSmoothScrolling)) ?? true;
DecodeCustomAttributeBlobs = (bool?)section.Attribute(nameof(DecodeCustomAttributeBlobs)) ?? false;
}
@ -150,7 +146,6 @@ namespace ILSpy.Options @@ -150,7 +146,6 @@ namespace ILSpy.Options
section.SetAttributeValue(nameof(UseNestedNamespaceNodes), UseNestedNamespaceNodes);
section.SetAttributeValue(nameof(ShowRawOffsetsAndBytesBeforeInstruction), ShowRawOffsetsAndBytesBeforeInstruction);
section.SetAttributeValue(nameof(StyleWindowTitleBar), StyleWindowTitleBar);
section.SetAttributeValue(nameof(EnableSmoothScrolling), EnableSmoothScrolling);
section.SetAttributeValue(nameof(DecodeCustomAttributeBlobs), DecodeCustomAttributeBlobs);
return section;
}

2
ILSpy/Options/DisplaySettingsPanel.axaml

@ -81,8 +81,6 @@ @@ -81,8 +81,6 @@
Content="{x:Static res:Resources.ShowRawOffsetsAndBytesBeforeInstruction}" />
<CheckBox IsChecked="{Binding Settings.DecodeCustomAttributeBlobs, Mode=TwoWay}"
Content="{x:Static res:Resources.DecodeCustomAttributeBlobs}" />
<CheckBox IsChecked="{Binding Settings.EnableSmoothScrolling, Mode=TwoWay}"
Content="{x:Static res:Resources.EnableSmoothScrolling}" />
</StackPanel>
</HeaderedContentControl>

9
ILSpy/Properties/Resources.Designer.cs generated

@ -1848,15 +1848,6 @@ namespace ICSharpCode.ILSpy.Properties { @@ -1848,15 +1848,6 @@ namespace ICSharpCode.ILSpy.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Enable smooth scrolling.
/// </summary>
public static string EnableSmoothScrolling {
get {
return ResourceManager.GetString("EnableSmoothScrolling", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Enable word wrap.
/// </summary>

3
ILSpy/Properties/Resources.resx

@ -642,9 +642,6 @@ Are you sure you want to continue?</value> @@ -642,9 +642,6 @@ Are you sure you want to continue?</value>
<data name="EnableFoldingBlocksBraces" xml:space="preserve">
<value>Enable folding on all blocks in braces</value>
</data>
<data name="EnableSmoothScrolling" xml:space="preserve">
<value>Enable smooth scrolling</value>
</data>
<data name="EnableWordWrap" xml:space="preserve">
<value>Enable word wrap</value>
</data>

11
ILSpy/TextView/DecompilerTabPageModel.cs

@ -324,6 +324,17 @@ namespace ILSpy.TextView @@ -324,6 +324,17 @@ namespace ILSpy.TextView
StartDecompile();
}
/// <summary>
/// Forces a fresh decompilation of the current nodes, bypassing the <see cref="CurrentNodes"/>
/// dedup. Used by <c>ForceRefreshActiveTab</c> when a display setting or freshly resolved
/// dependencies changed but the selected node did not. No-op when nothing is being decompiled.
/// </summary>
public void Redecompile()
{
if (CurrentNodes.Count > 0)
StartDecompile();
}
// Built (off the UI thread) when a decompile blows past its output limit: a short message plus
// a "Display code anyway" button (retry at the extended limit, only offered for the normal
// limit) and a "Save code" button. The button click handlers run back on the UI thread.

Loading…
Cancel
Save