Browse Source

Preserve Debug Steps tree state across filter sessions

The filter drove every row's IsExpanded from the single IsFiltering flag
via a TreeViewItem style setter. Expansion lived nowhere else: user
gestures (expander arrow, double-tap, arrow keys) all write the property
with SetCurrentValue, which the style binding overwrites on every flip
of the flag. Starting a filter therefore destroyed the expansion state
the user had built up, and clearing it collapsed the whole tree,
burying the still-selected row under collapsed groups.

Row state (visibility + expansion) now lives on a StepNodeViewModel
wrapper per step, two-way bound from the style, so gestures persist in
the view-model. A filter session snapshots expansion on entry, hides
non-matches and opens only the paths to matches while typing, and on
exit restores the snapshot and re-expands the selected step's ancestors
so the selection stays visible. Wrapping is skipped for reference-equal
step lists because step replays re-report the same run and a rebuild
would wipe the state mid-navigation.

Assisted-by: Claude:claude-fable-5:Claude Code
pull/3927/head
Siegfried Pammer 2 months ago committed by Siegfried Pammer
parent
commit
bd6cfbc539
  1. 175
      ILSpy.Tests/Views/DebugStepsFilterStateTests.cs
  2. 53
      ILSpy.Tests/Views/DebugStepsTests.cs
  3. 138
      ILSpy/ViewModels/DebugStepsPaneModel.cs
  4. 83
      ILSpy/ViewModels/StepNodeViewModel.cs
  5. 60
      ILSpy/Views/DebugStepFilterConverter.cs
  6. 19
      ILSpy/Views/DebugSteps.axaml

175
ILSpy.Tests/Views/DebugStepsFilterStateTests.cs

@ -0,0 +1,175 @@ @@ -0,0 +1,175 @@
// Copyright (c) 2026 Siegfried Pammer
//
// 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.
#if DEBUG
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Headless.NUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AwesomeAssertions;
using ICSharpCode.Decompiler.DebugSteps;
using ICSharpCode.ILSpy.ViewModels;
using ICSharpCode.ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Views;
/// <summary>
/// The Debug Steps filter box must be a transient view on the step tree: it may expand and
/// hide rows while active, but expansion states the user established before filtering and
/// the selection made while filtering must survive clearing it. Rows are located through the
/// visual tree (header text -> nearest TreeViewItem) and user gestures are simulated with
/// SetCurrentValue, which is what every real expander gesture (toggle-arrow, double-tap,
/// arrow keys) ends in — a plain local SetValue would mask style-driven bindings and test a
/// gesture that cannot occur.
/// </summary>
[TestFixture]
public class DebugStepsFilterStateTests
{
const string MatchGroupDescription = "CombineQueryExpressions";
const string OtherGroupDescription = "TransformExpressionTrees";
const string MatchingLeafDescription = "3: Introduce query continuation";
const string SiblingLeafDescription = "4: Flatten switch section block";
const string OtherLeafDescription = "7: Copy annotations";
static IList<Stepper.Node> BuildStepTree()
{
var matchGroup = new Stepper.Node(MatchGroupDescription);
matchGroup.Children.Add(new Stepper.Node(MatchingLeafDescription));
matchGroup.Children.Add(new Stepper.Node(SiblingLeafDescription));
var otherGroup = new Stepper.Node(OtherGroupDescription);
otherGroup.Children.Add(new Stepper.Node(OtherLeafDescription));
return new[] { matchGroup, otherGroup };
}
static (Window Window, DebugStepsPaneModel Vm, TreeView Tree) ShowPane()
{
var vm = new DebugStepsPaneModel();
SetSteps(vm, BuildStepTree());
vm.IsAvailable = true;
var window = new Window { Width = 400, Height = 300, Content = new DebugSteps { DataContext = vm } };
window.Show();
Dispatcher.UIThread.RunJobs();
var tree = window.GetVisualDescendants().OfType<TreeView>().First();
return (window, vm, tree);
}
static void SetSteps(DebugStepsPaneModel vm, IList<Stepper.Node> steps)
{
vm.SetStepsSource(steps);
}
// Rows are looked up by DataContext rather than by header text: a row hidden by the filter
// is never measured, so its header TextBlock may not have been templated yet, but its
// container still exists in the visual tree.
static TreeViewItem RowFor(TreeView tree, string description)
{
var row = tree.GetVisualDescendants().OfType<TreeViewItem>()
.FirstOrDefault(item => DescriptionOf(item.DataContext) == description);
row.Should().NotBeNull($"a row with the description '{description}' must be materialised");
return row!;
}
static string? DescriptionOf(object? dataContext) => dataContext switch {
StepNodeViewModel node => node.Description,
_ => null,
};
[AvaloniaTest]
public Task Manual_Expansion_Survives_A_Filter_Round_Trip()
{
var (window, vm, tree) = ShowPane();
var matchGroup = RowFor(tree, MatchGroupDescription);
var otherGroup = RowFor(tree, OtherGroupDescription);
matchGroup.SetCurrentValue(TreeViewItem.IsExpandedProperty, true);
Dispatcher.UIThread.RunJobs();
vm.FilterText = "continuation";
Dispatcher.UIThread.RunJobs();
vm.FilterText = "";
Dispatcher.UIThread.RunJobs();
matchGroup.IsExpanded.Should().BeTrue(
"a group the user expanded before filtering must still be expanded after the filter is cleared");
otherGroup.IsExpanded.Should().BeFalse(
"a group the user never expanded must not stay expanded after the filter is cleared");
window.Close();
return Task.CompletedTask;
}
[AvaloniaTest]
public Task Filtering_Expands_Groups_Leading_To_Matches_And_Hides_The_Rest()
{
var (window, vm, tree) = ShowPane();
vm.FilterText = "continuation";
Dispatcher.UIThread.RunJobs();
var matchGroup = RowFor(tree, MatchGroupDescription);
matchGroup.IsExpanded.Should().BeTrue(
"a group containing a match must expand so the match is revealed");
matchGroup.IsVisible.Should().BeTrue("the path to a match must stay visible");
RowFor(tree, MatchingLeafDescription).IsVisible.Should().BeTrue("the match itself must be visible");
RowFor(tree, SiblingLeafDescription).IsVisible.Should().BeFalse(
"a sibling that does not match must be hidden");
RowFor(tree, OtherGroupDescription).IsVisible.Should().BeFalse(
"a group without any match must be hidden");
window.Close();
return Task.CompletedTask;
}
[AvaloniaTest]
public Task Selection_Made_While_Filtering_Stays_Visible_After_Clearing()
{
var (window, vm, tree) = ShowPane();
vm.FilterText = "continuation";
Dispatcher.UIThread.RunJobs();
var leaf = RowFor(tree, MatchingLeafDescription);
tree.SelectedItem = leaf.DataContext;
Dispatcher.UIThread.RunJobs();
vm.FilterText = "";
Dispatcher.UIThread.RunJobs();
tree.SelectedItem.Should().BeSameAs(leaf.DataContext,
"clearing the filter must not change the selection");
RowFor(tree, MatchGroupDescription).IsExpanded.Should().BeTrue(
"the selected step's group must stay expanded so the selection remains visible");
leaf.IsEffectivelyVisible.Should().BeTrue(
"the step selected while filtering must still be on screen after the filter is cleared");
window.Close();
return Task.CompletedTask;
}
}
#endif

53
ILSpy.Tests/Views/DebugStepsTests.cs

@ -19,7 +19,6 @@ @@ -19,7 +19,6 @@
#if DEBUG
using System;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
@ -173,7 +172,7 @@ public class DebugStepsTests @@ -173,7 +172,7 @@ public class DebugStepsTests
"nested C# debug steps must describe individual AST mutation points");
var collectedSteps = debugStepsVm.Steps;
var replayStep = transformGroupWithChanges.Children.First();
var replayStep = transformGroupWithChanges.Children.First().Step;
var tab = vm.DockWorkspace.ActiveDecompilerTab!;
await tab.RestartDecompileWithStepLimit(replayStep.BeginStep, isDebug: false, replayStep.BeginStep);
@ -240,14 +239,14 @@ public class DebugStepsTests @@ -240,14 +239,14 @@ public class DebugStepsTests
// The first leaf step that acts on a concrete instruction; a step whose Position is null
// (e.g. an empty transform group) has nothing to highlight and is not what a user replays.
static Stepper.Node? FirstLeafStep(System.Collections.Generic.IEnumerable<Stepper.Node> steps)
static Stepper.Node? FirstLeafStep(System.Collections.Generic.IEnumerable<StepNodeViewModel> steps)
{
foreach (var step in steps)
{
if (step.Children.Count == 0)
{
if (step.Position != null)
return step;
if (step.Step.Position != null)
return step.Step;
continue;
}
var leaf = FirstLeafStep(step.Children);
@ -347,40 +346,44 @@ public class DebugStepsTests @@ -347,40 +346,44 @@ public class DebugStepsTests
[AvaloniaTest]
public Task DebugStepFilter_Keeps_Matches_And_The_Path_To_Them()
{
var converter = new DebugStepFilterConverter();
var vm = new DebugStepsPaneModel();
var matchingLeaf = new Stepper.Node("3: Introduce query continuation");
var otherLeaf = new Stepper.Node("4: Flatten switch section block");
var group = new Stepper.Node("CombineQueryExpressions");
group.Children.Add(matchingLeaf);
group.Children.Add(otherLeaf);
// An empty filter shows every row.
Filter(group, "").Should().BeTrue();
Filter(otherLeaf, " ").Should().BeTrue();
// A group survives because a descendant matches, keeping the path to the match.
Filter(group, "continuation").Should().BeTrue();
// The matching leaf survives, case-insensitively.
Filter(matchingLeaf, "CONTINUATION").Should().BeTrue();
// A sibling that neither matches nor leads to a match is hidden.
Filter(otherLeaf, "continuation").Should().BeFalse();
vm.SetStepsSource(new[] { group });
var groupVm = vm.Steps![0];
// Matching is case-insensitive; a group survives because a descendant matches (keeping
// the path to the match open), while a sibling that neither matches nor leads to a
// match is hidden.
vm.FilterText = "CONTINUATION";
groupVm.IsVisible.Should().BeTrue();
groupVm.IsExpanded.Should().BeTrue();
groupVm.Children[0].IsVisible.Should().BeTrue();
groupVm.Children[1].IsVisible.Should().BeFalse();
// A whitespace-only filter counts as empty and shows every row again.
vm.FilterText = " ";
groupVm.IsVisible.Should().BeTrue();
groupVm.Children[0].IsVisible.Should().BeTrue();
groupVm.Children[1].IsVisible.Should().BeTrue();
return Task.CompletedTask;
bool Filter(Stepper.Node node, string filter)
=> (bool)converter.Convert(new object?[] { node, filter }, typeof(bool), null, CultureInfo.InvariantCulture);
}
[AvaloniaTest]
public Task DebugSteps_View_Loads_With_Filter_Applied()
{
// Guards the filter wiring in the XAML -- a MultiBinding inside a TreeViewItem style Setter
// plus the RelativeSource lookups -- against a structural break that x:CompileBindings="False"
// would not catch at build time. Realising the view with a populated tree and a live filter
// must not throw.
// Guards the filter wiring in the XAML -- the per-row style Setter bindings -- against a
// structural break that x:CompileBindings="False" would not catch at build time.
// Realising the view with a populated tree and a live filter must not throw.
var vm = new DebugStepsPaneModel();
var group = new Stepper.Node("CombineQueryExpressions");
group.Children.Add(new Stepper.Node("3: Introduce query continuation"));
group.Children.Add(new Stepper.Node("4: Flatten switch section block"));
vm.Steps = new[] { group };
vm.SetStepsSource(new[] { group });
vm.IsAvailable = true;
var window = new Window { Width = 400, Height = 300, Content = new DebugSteps { DataContext = vm } };
@ -483,7 +486,7 @@ public class DebugStepsTests @@ -483,7 +486,7 @@ public class DebugStepsTests
debugStepsVm.IsAvailable.Should().BeTrue("C# provides debug steps");
// Simulate a populated tree from the C# run, then flip to the disassembler language.
debugStepsVm.Steps = new[] { new Stepper.Node("stale") };
debugStepsVm.SetStepsSource(new[] { new Stepper.Node("stale") });
languageService.CurrentLanguage = languageService.Languages.OfType<ILLanguage>().First(l => l.Name == "IL");
global::Avalonia.Threading.Dispatcher.UIThread.RunJobs();

138
ILSpy/ViewModels/DebugStepsPaneModel.cs

@ -81,16 +81,31 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -81,16 +81,31 @@ namespace ICSharpCode.ILSpy.ViewModels
object? options;
/// <summary>
/// Currently displayed list of recorded transform steps. Re-assigned (not mutated)
/// whenever the active step provider's <see cref="Stepper"/> reports a new run, so
/// late-binding views pick up the latest list via the observable change.
/// The recorded transform steps currently backing <see cref="Steps"/>. Tracked so that
/// re-assignments of the same run (each step replay pumps StepperUpdated with the same
/// list instance) don't rebuild the wrapper tree and thereby wipe its expansion and
/// selection state.
/// </summary>
IList<Stepper.Node>? stepsSource;
/// <summary>
/// True while the wrapper tree holds a pre-filter expansion snapshot, i.e. from the first
/// non-empty <see cref="FilterText"/> until the filter is cleared or the tree is rebuilt.
/// </summary>
bool filterSnapshotTaken;
/// <summary>
/// Currently displayed tree of recorded transform steps, wrapped with per-row UI state.
/// Re-assigned (not mutated) whenever the active step provider's <see cref="Stepper"/>
/// reports a new run, so late-binding views pick up the latest tree via the observable
/// change.
/// </summary>
[ObservableProperty]
IList<Stepper.Node>? steps;
IReadOnlyList<StepNodeViewModel>? steps;
/// <summary>Two-way bound to the TreeView's selected item.</summary>
[ObservableProperty]
Stepper.Node? selectedStep;
StepNodeViewModel? selectedStep;
/// <summary>
/// True while the current language is an <see cref="IDebugStepProvider"/>. When false,
@ -109,8 +124,9 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -109,8 +124,9 @@ namespace ICSharpCode.ILSpy.ViewModels
string? filterText;
/// <summary>
/// True while <see cref="FilterText"/> is non-empty. Drives auto-expansion of the tree so that
/// matches nested under transform groups are revealed rather than hidden in collapsed groups.
/// True while <see cref="FilterText"/> is non-empty. A filter session is transient: the
/// expansion states are snapshotted when it starts and restored when it ends, so filtering
/// never destroys the tree state the user built up manually.
/// </summary>
public bool IsFiltering => !string.IsNullOrWhiteSpace(FilterText);
@ -123,8 +139,8 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -123,8 +139,8 @@ namespace ICSharpCode.ILSpy.ViewModels
{
Id = PaneContentId;
Title = "Debug Steps";
ShowStateBeforeCommand = new RelayCommand(() => RequestRedecompile(SelectedStep?.BeginStep ?? int.MaxValue, isDebug: false, SelectedStep?.BeginStep));
ShowStateAfterCommand = new RelayCommand(() => RequestRedecompile(SelectedStep?.EndStep ?? int.MaxValue, isDebug: false, SelectedStep?.BeginStep));
ShowStateBeforeCommand = new RelayCommand(() => RequestRedecompile(SelectedStep?.Step.BeginStep ?? int.MaxValue, isDebug: false, SelectedStep?.Step.BeginStep));
ShowStateAfterCommand = new RelayCommand(() => RequestRedecompile(SelectedStep?.Step.EndStep ?? int.MaxValue, isDebug: false, SelectedStep?.Step.BeginStep));
DebugStepCommand = new RelayCommand(() => {
// "Debug this step" relies on Stepper.Step calling Debugger.Break() when
// step == StepLimit — which is a silent no-op without a debugger attached.
@ -137,7 +153,7 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -137,7 +153,7 @@ namespace ICSharpCode.ILSpy.ViewModels
if (!System.Diagnostics.Debugger.Launch())
AppEnv.AppLog.Mark("DebugStep: Debugger.Launch returned false; the upcoming Stepper.Step break is a no-op without a debugger attached.");
}
RequestRedecompile(SelectedStep?.BeginStep ?? int.MaxValue, isDebug: true, SelectedStep?.BeginStep);
RequestRedecompile(SelectedStep?.Step.BeginStep ?? int.MaxValue, isDebug: true, SelectedStep?.Step.BeginStep);
});
}
@ -183,14 +199,14 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -183,14 +199,14 @@ namespace ICSharpCode.ILSpy.ViewModels
{
// Same language instance — just refresh the steps in case a decompile happened
// while we were detached.
Steps = activeLanguage!.Stepper.Steps;
SetStepsSource(activeLanguage!.Stepper.Steps);
IsAvailable = true;
return;
}
DetachFromLanguage();
activeLanguage = language;
language.StepperUpdated += OnStepperUpdated;
Steps = language.Stepper.Steps;
SetStepsSource(language.Stepper.Steps);
Options = language.StepOptions;
IsAvailable = true;
}
@ -198,7 +214,7 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -198,7 +214,7 @@ namespace ICSharpCode.ILSpy.ViewModels
void DetachFromLanguage()
{
IsAvailable = false;
Steps = null;
SetStepsSource(null);
if (activeLanguage != null)
{
activeLanguage.StepperUpdated -= OnStepperUpdated;
@ -212,12 +228,104 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -212,12 +228,104 @@ namespace ICSharpCode.ILSpy.ViewModels
Dispatcher.UIThread.Post(() => {
if (activeLanguage != null)
{
Steps = activeLanguage.Stepper.Steps;
SetStepsSource(activeLanguage.Stepper.Steps);
lastSelectedStep = int.MaxValue;
}
});
}
/// <summary>
/// Replaces the displayed step tree with a wrapper tree over <paramref name="source"/>.
/// A reference-equal source is a no-op: step replays re-report the same run, and
/// rebuilding the wrappers then would discard the expansion and selection state the
/// user is navigating with.
/// </summary>
public void SetStepsSource(IList<Stepper.Node>? source)
{
if (ReferenceEquals(stepsSource, source))
return;
stepsSource = source;
filterSnapshotTaken = false;
Steps = source == null ? null : StepNodeViewModel.Wrap(source);
if (Steps != null && IsFiltering)
ApplyFilter();
}
partial void OnFilterTextChanged(string? value)
{
ApplyFilter();
}
/// <summary>
/// Reflects the current <see cref="FilterText"/> in the wrapper tree's per-row state.
/// Entering a filter session snapshots each row's expansion first; every filter change
/// hides non-matching rows and expands the groups on the path to each match; leaving the
/// session restores the snapshot and then re-expands the selected step's ancestors so the
/// selection never vanishes into a collapsed group.
/// </summary>
void ApplyFilter()
{
if (Steps == null)
{
filterSnapshotTaken = false;
return;
}
if (IsFiltering)
{
if (!filterSnapshotTaken)
{
filterSnapshotTaken = true;
foreach (var step in Steps)
SnapshotExpansion(step);
}
string filter = FilterText!.Trim();
foreach (var step in Steps)
ApplyFilterToNode(step, filter);
}
else if (filterSnapshotTaken)
{
filterSnapshotTaken = false;
foreach (var step in Steps)
RestoreExpansion(step);
for (var ancestor = SelectedStep?.Parent; ancestor != null; ancestor = ancestor.Parent)
ancestor.IsExpanded = true;
}
}
static void SnapshotExpansion(StepNodeViewModel node)
{
node.ExpansionBeforeFilter = node.IsExpanded;
foreach (var child in node.Children)
SnapshotExpansion(child);
}
static void RestoreExpansion(StepNodeViewModel node)
{
node.IsVisible = true;
if (node.ExpansionBeforeFilter is bool expanded)
node.IsExpanded = expanded;
node.ExpansionBeforeFilter = null;
foreach (var child in node.Children)
RestoreExpansion(child);
}
/// <summary>
/// Applies the filter to one subtree: a row stays visible when its description — or a
/// descendant's — contains the filter (ordinal, case-insensitive), and a group with a
/// surviving descendant is expanded so the path to every match is open.
/// </summary>
static bool ApplyFilterToNode(StepNodeViewModel node, string filter)
{
bool descendantMatches = false;
foreach (var child in node.Children)
descendantMatches |= ApplyFilterToNode(child, filter);
bool selfMatches = node.Description.Contains(filter, System.StringComparison.OrdinalIgnoreCase);
node.IsVisible = selfMatches || descendantMatches;
if (descendantMatches)
node.IsExpanded = true;
return selfMatches || descendantMatches;
}
void OnSelectionChanged(object? sender, AssemblyTreeSelectionChangedEventArgs e)
{
// User picked a new tree node — the previous run's stepper is stale until the next
@ -237,7 +345,7 @@ namespace ICSharpCode.ILSpy.ViewModels @@ -237,7 +345,7 @@ namespace ICSharpCode.ILSpy.ViewModels
void ClearSteps()
{
Steps = null;
SetStepsSource(null);
lastSelectedStep = int.MaxValue;
}
}

83
ILSpy/ViewModels/StepNodeViewModel.cs

@ -0,0 +1,83 @@ @@ -0,0 +1,83 @@
// Copyright (c) 2026 Siegfried Pammer
//
// 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.
#if DEBUG
using System.Collections.Generic;
using CommunityToolkit.Mvvm.ComponentModel;
using ICSharpCode.Decompiler.DebugSteps;
namespace ICSharpCode.ILSpy.ViewModels
{
/// <summary>
/// Per-row UI state for the Debug Steps tree. <see cref="Stepper.Node"/> is a decompiler-side
/// record with no notion of expansion or visibility, and TreeViewItem containers cannot carry
/// that state reliably either: every expander gesture writes IsExpanded via SetCurrentValue,
/// which any style-driven binding overwrites the next time it produces a value. Keeping the
/// state on a view-model wrapper makes it authoritative — the view binds each row's
/// IsVisible/IsExpanded here, and the pane can snapshot and restore expansion around filter
/// sessions instead of losing it to the containers.
/// </summary>
public sealed partial class StepNodeViewModel : ObservableObject
{
public Stepper.Node Step { get; }
public StepNodeViewModel? Parent { get; }
public IReadOnlyList<StepNodeViewModel> Children { get; }
public string Description => Step.Description;
/// <summary>Two-way bound to the row's TreeViewItem.IsExpanded.</summary>
[ObservableProperty]
bool isExpanded;
/// <summary>Bound to the row's TreeViewItem.IsVisible; false while the filter hides the row.</summary>
[ObservableProperty]
bool isVisible = true;
/// <summary>
/// Expansion state captured when a filter session starts, restored when it ends.
/// Null outside filter sessions.
/// </summary>
internal bool? ExpansionBeforeFilter { get; set; }
StepNodeViewModel(Stepper.Node step, StepNodeViewModel? parent)
{
Step = step;
Parent = parent;
var children = new List<StepNodeViewModel>(step.Children.Count);
foreach (var child in step.Children)
{
children.Add(new StepNodeViewModel(child, this));
}
Children = children;
}
public static IReadOnlyList<StepNodeViewModel> Wrap(IList<Stepper.Node> steps)
{
var wrapped = new List<StepNodeViewModel>(steps.Count);
foreach (var step in steps)
{
wrapped.Add(new StepNodeViewModel(step, null));
}
return wrapped;
}
}
}
#endif

60
ILSpy/Views/DebugStepFilterConverter.cs

@ -1,60 +0,0 @@ @@ -1,60 +0,0 @@
// 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.
#if DEBUG
using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia.Data.Converters;
using ICSharpCode.Decompiler.DebugSteps;
namespace ICSharpCode.ILSpy.Views
{
/// <summary>
/// Decides whether a Debug Steps tree row stays visible under the pane's filter box. A step is
/// shown when the filter is empty, or when its description -- or that of any descendant --
/// contains the filter text, so the path to every match is preserved. Bound per row against
/// [ the step, the filter text ].
/// </summary>
public sealed class DebugStepFilterConverter : IMultiValueConverter
{
public object Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
{
if (values.Count < 2 || values[1] is not string filter || string.IsNullOrWhiteSpace(filter))
return true;
return values[0] is Stepper.Node node && Matches(node, filter.Trim());
}
static bool Matches(Stepper.Node node, string filter)
{
if (node.Description.Contains(filter, StringComparison.OrdinalIgnoreCase))
return true;
foreach (var child in node.Children)
{
if (Matches(child, filter))
return true;
}
return false;
}
}
}
#endif

19
ILSpy/Views/DebugSteps.axaml

@ -3,14 +3,10 @@ @@ -3,14 +3,10 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ICSharpCode.ILSpy.ViewModels"
xmlns:views="using:ICSharpCode.ILSpy.Views"
xmlns:il="using:ICSharpCode.Decompiler.IL"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="300"
x:Class="ICSharpCode.ILSpy.Views.DebugSteps"
x:DataType="vm:DebugStepsPaneModel">
<UserControl.Resources>
<views:DebugStepFilterConverter x:Key="StepFilter" />
</UserControl.Resources>
<Panel>
<TextBlock IsVisible="{Binding !IsAvailable}"
HorizontalAlignment="Center" VerticalAlignment="Center"
@ -47,17 +43,12 @@ @@ -47,17 +43,12 @@
DoubleTapped="OnTreeDoubleTapped"
x:CompileBindings="False">
<TreeView.Styles>
<!-- Hide rows that neither match the filter nor lead to a match, and expand the tree
while filtering so surviving matches under transform groups stay visible. -->
<!-- Each row's visibility and expansion are owned by its StepNodeViewModel, where the
pane's filter logic maintains them; expansion is two-way so the user's expander
gestures flow back into the view-model instead of dying with the container. -->
<Style Selector="TreeViewItem">
<Setter Property="IsVisible">
<MultiBinding Converter="{StaticResource StepFilter}">
<Binding />
<Binding Path="DataContext.FilterText" RelativeSource="{RelativeSource AncestorType=TreeView}" />
</MultiBinding>
</Setter>
<Setter Property="IsExpanded"
Value="{Binding DataContext.IsFiltering, RelativeSource={RelativeSource AncestorType=TreeView}}" />
<Setter Property="IsVisible" Value="{Binding IsVisible}" />
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
</Style>
</TreeView.Styles>
<TreeView.ItemTemplate>

Loading…
Cancel
Save