diff --git a/ILSpy.Tests/Views/DebugStepsFilterStateTests.cs b/ILSpy.Tests/Views/DebugStepsFilterStateTests.cs
new file mode 100644
index 000000000..7b380630f
--- /dev/null
+++ b/ILSpy.Tests/Views/DebugStepsFilterStateTests.cs
@@ -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;
+
+///
+/// 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.
+///
+[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 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().First();
+ return (window, vm, tree);
+ }
+
+ static void SetSteps(DebugStepsPaneModel vm, IList 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()
+ .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
diff --git a/ILSpy.Tests/Views/DebugStepsTests.cs b/ILSpy.Tests/Views/DebugStepsTests.cs
index ef49b192b..251201477 100644
--- a/ILSpy.Tests/Views/DebugStepsTests.cs
+++ b/ILSpy.Tests/Views/DebugStepsTests.cs
@@ -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
"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
// 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 steps)
+ static Stepper.Node? FirstLeafStep(System.Collections.Generic.IEnumerable 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
[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
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().First(l => l.Name == "IL");
global::Avalonia.Threading.Dispatcher.UIThread.RunJobs();
diff --git a/ILSpy/ViewModels/DebugStepsPaneModel.cs b/ILSpy/ViewModels/DebugStepsPaneModel.cs
index 013841852..2f7603f22 100644
--- a/ILSpy/ViewModels/DebugStepsPaneModel.cs
+++ b/ILSpy/ViewModels/DebugStepsPaneModel.cs
@@ -81,16 +81,31 @@ namespace ICSharpCode.ILSpy.ViewModels
object? options;
///
- /// Currently displayed list of recorded transform steps. Re-assigned (not mutated)
- /// whenever the active step provider's reports a new run, so
- /// late-binding views pick up the latest list via the observable change.
+ /// The recorded transform steps currently backing . 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.
+ ///
+ IList? stepsSource;
+
+ ///
+ /// True while the wrapper tree holds a pre-filter expansion snapshot, i.e. from the first
+ /// non-empty until the filter is cleared or the tree is rebuilt.
+ ///
+ bool filterSnapshotTaken;
+
+ ///
+ /// Currently displayed tree of recorded transform steps, wrapped with per-row UI state.
+ /// Re-assigned (not mutated) whenever the active step provider's
+ /// reports a new run, so late-binding views pick up the latest tree via the observable
+ /// change.
///
[ObservableProperty]
- IList? steps;
+ IReadOnlyList? steps;
/// Two-way bound to the TreeView's selected item.
[ObservableProperty]
- Stepper.Node? selectedStep;
+ StepNodeViewModel? selectedStep;
///
/// True while the current language is an . When false,
@@ -109,8 +124,9 @@ namespace ICSharpCode.ILSpy.ViewModels
string? filterText;
///
- /// True while 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 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.
///
public bool IsFiltering => !string.IsNullOrWhiteSpace(FilterText);
@@ -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
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
{
// 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
void DetachFromLanguage()
{
IsAvailable = false;
- Steps = null;
+ SetStepsSource(null);
if (activeLanguage != null)
{
activeLanguage.StepperUpdated -= OnStepperUpdated;
@@ -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;
}
});
}
+ ///
+ /// Replaces the displayed step tree with a wrapper tree over .
+ /// 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.
+ ///
+ public void SetStepsSource(IList? 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();
+ }
+
+ ///
+ /// Reflects the current 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.
+ ///
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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
void ClearSteps()
{
- Steps = null;
+ SetStepsSource(null);
lastSelectedStep = int.MaxValue;
}
}
diff --git a/ILSpy/ViewModels/StepNodeViewModel.cs b/ILSpy/ViewModels/StepNodeViewModel.cs
new file mode 100644
index 000000000..ed09b1138
--- /dev/null
+++ b/ILSpy/ViewModels/StepNodeViewModel.cs
@@ -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
+{
+ ///
+ /// Per-row UI state for the Debug Steps tree. 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.
+ ///
+ public sealed partial class StepNodeViewModel : ObservableObject
+ {
+ public Stepper.Node Step { get; }
+ public StepNodeViewModel? Parent { get; }
+ public IReadOnlyList Children { get; }
+ public string Description => Step.Description;
+
+ /// Two-way bound to the row's TreeViewItem.IsExpanded.
+ [ObservableProperty]
+ bool isExpanded;
+
+ /// Bound to the row's TreeViewItem.IsVisible; false while the filter hides the row.
+ [ObservableProperty]
+ bool isVisible = true;
+
+ ///
+ /// Expansion state captured when a filter session starts, restored when it ends.
+ /// Null outside filter sessions.
+ ///
+ internal bool? ExpansionBeforeFilter { get; set; }
+
+ StepNodeViewModel(Stepper.Node step, StepNodeViewModel? parent)
+ {
+ Step = step;
+ Parent = parent;
+ var children = new List(step.Children.Count);
+ foreach (var child in step.Children)
+ {
+ children.Add(new StepNodeViewModel(child, this));
+ }
+ Children = children;
+ }
+
+ public static IReadOnlyList Wrap(IList steps)
+ {
+ var wrapped = new List(steps.Count);
+ foreach (var step in steps)
+ {
+ wrapped.Add(new StepNodeViewModel(step, null));
+ }
+ return wrapped;
+ }
+ }
+}
+
+#endif
diff --git a/ILSpy/Views/DebugStepFilterConverter.cs b/ILSpy/Views/DebugStepFilterConverter.cs
deleted file mode 100644
index 5e78a9ccf..000000000
--- a/ILSpy/Views/DebugStepFilterConverter.cs
+++ /dev/null
@@ -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
-{
- ///
- /// 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 ].
- ///
- public sealed class DebugStepFilterConverter : IMultiValueConverter
- {
- public object Convert(IList